Created
November 3, 2022 20:46
-
-
Save HansMuller/8fd61db8c4ea690f19ee4dea006055cd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:math' as math; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/services.dart'; | |
| typedef _NextChild = RenderBox? Function(RenderBox child); | |
| class _SplitViewParentData extends ContainerBoxParentData<RenderBox> { | |
| bool? isActiveSplitter; | |
| double? weight; | |
| double minExtent = 0; | |
| double maxExtent = double.infinity; | |
| double inputPadding = 0; | |
| MouseCursor cursor = MouseCursor.defer; | |
| @override | |
| String toString() => '${super.toString()}; weight=$weight'; | |
| } | |
| class SplitConstraints extends ParentDataWidget<_SplitViewParentData> { | |
| const SplitConstraints({ | |
| Key? key, | |
| this.weight, | |
| this.minExtent = 0, | |
| this.maxExtent = double.infinity, | |
| this.inputPadding = 0, | |
| this.cursor = MouseCursor.defer, | |
| required Widget child, | |
| }) : assert(weight == null || weight >= 0), | |
| assert(minExtent >= 0), | |
| assert(maxExtent >= minExtent), | |
| super(key: key, child: child); | |
| final double? weight; | |
| final double minExtent; | |
| final double maxExtent; | |
| final double inputPadding; | |
| final MouseCursor cursor; | |
| @override | |
| void applyParentData(RenderObject renderObject) { | |
| final _SplitViewParentData parentData = renderObject.parentData! as _SplitViewParentData; | |
| bool needsLayout = false; | |
| bool needsPaint = false; | |
| if (parentData.weight != weight) { | |
| parentData.weight = weight; | |
| } | |
| if (parentData.minExtent != minExtent) { | |
| parentData.minExtent = minExtent; | |
| needsLayout = true; | |
| } | |
| if (parentData.maxExtent != maxExtent) { | |
| parentData.maxExtent = maxExtent; | |
| needsLayout = true; | |
| } | |
| if (parentData.inputPadding != inputPadding) { | |
| parentData.inputPadding = inputPadding; | |
| } | |
| if (parentData.cursor != cursor) { | |
| parentData.cursor = cursor; | |
| } | |
| if (needsLayout || needsPaint) { | |
| final AbstractNode? targetParent = renderObject.parent; | |
| if (targetParent is RenderObject) { | |
| if (needsLayout) { | |
| targetParent.markNeedsLayout(); | |
| } else { | |
| targetParent.markNeedsPaint(); | |
| } | |
| } | |
| } | |
| } | |
| @override | |
| Type get debugTypicalAncestorWidgetClass => SplitConstraints; | |
| } | |
| class _SplitViewRenderWidget<T> extends MultiChildRenderObjectWidget { | |
| _SplitViewRenderWidget({ | |
| Key? key, | |
| required this.axis, | |
| required List<Widget> children, | |
| }) : super(key: key, children: children); | |
| final Axis axis; | |
| @override | |
| RenderObject createRenderObject(BuildContext context) { | |
| return _RenderSplitView<T>( | |
| textDirection: Directionality.of(context), | |
| axis: axis, | |
| ); | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, _RenderSplitView<T> renderObject) { | |
| renderObject | |
| ..textDirection = Directionality.of(context) | |
| ..axis = axis; | |
| } | |
| } | |
| class _RenderSplitView<T> extends RenderBox with | |
| ContainerRenderObjectMixin<RenderBox, _SplitViewParentData>, | |
| RenderBoxContainerDefaultsMixin<RenderBox, _SplitViewParentData> | |
| implements MouseTrackerAnnotation { | |
| _RenderSplitView({ | |
| required TextDirection textDirection, | |
| required Axis axis, | |
| }) : _textDirection = textDirection, | |
| _axis = axis | |
| { | |
| initDragRecognizer(axis); | |
| } | |
| Size? initialSize; | |
| late DragGestureRecognizer dragRecognizer; | |
| bool dragUnderway = false; | |
| late Offset dragAnchor; | |
| late Offset dragPosition; | |
| bool splitLayoutNeeded = false; | |
| late Offset splitAnchor; | |
| late Offset splitOffset; | |
| @override | |
| MouseCursor get cursor => _cursor; | |
| MouseCursor _cursor = MouseCursor.defer; | |
| set cursor(MouseCursor value) { | |
| if (_cursor != value) { | |
| _cursor = value; | |
| // A repaint is needed in order to trigger a device update of | |
| // [MouseTracker] so that this new value can be found. | |
| markNeedsPaint(); | |
| } | |
| } | |
| @override | |
| PointerEnterEventListener? onEnter; | |
| @override | |
| PointerExitEventListener? onExit; | |
| @override | |
| bool get validForMouseTracker => _validForMouseTracker; | |
| bool _validForMouseTracker = true; | |
| @override | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| _validForMouseTracker = true; | |
| } | |
| @override | |
| void detach() { | |
| // It's possible that the renderObject be detached during mouse events | |
| // dispatching, set the [MouseTrackerAnnotation.validForMouseTracker] false to prevent | |
| // the callbacks from being called. | |
| _validForMouseTracker = false; | |
| super.detach(); | |
| } | |
| TextDirection get textDirection => _textDirection; | |
| TextDirection _textDirection; | |
| set textDirection(TextDirection value) { | |
| if (_textDirection == value) { | |
| return; | |
| } | |
| _textDirection = value; | |
| markNeedsLayout(); | |
| } | |
| Axis get axis => _axis; | |
| Axis _axis; | |
| set axis(Axis value) { | |
| if (_axis == value) { | |
| return; | |
| } | |
| _axis = value; | |
| initialSize = null; | |
| initDragRecognizer(_axis); | |
| markNeedsLayout(); | |
| } | |
| @override | |
| void setupParentData(RenderBox child) { | |
| if (child.parentData is! _SplitViewParentData) { | |
| child.parentData = _SplitViewParentData(); | |
| } | |
| } | |
| double? getChildWeight(RenderBox child) => (child.parentData! as _SplitViewParentData).weight; | |
| double getChildMinExtent(RenderBox child) => (child.parentData! as _SplitViewParentData).minExtent; | |
| double getChildMaxExtent(RenderBox child) => (child.parentData! as _SplitViewParentData).maxExtent; | |
| Offset getChildOffset(RenderBox child)=> (child.parentData! as _SplitViewParentData).offset; | |
| void setChildOffset(RenderBox child, Offset offset) { | |
| (child.parentData! as _SplitViewParentData).offset = offset; | |
| } | |
| BoxConstraints getChildConstraints(RenderBox child) { | |
| final _SplitViewParentData parentData = child.parentData! as _SplitViewParentData; | |
| switch (axis) { | |
| case Axis.horizontal: | |
| final double minWidth = math.min(constraints.minWidth, parentData.minExtent); | |
| final double maxWidth = math.min(constraints.maxWidth, parentData.maxExtent); | |
| return constraints | |
| .tighten(height: constraints.maxHeight) | |
| .copyWith(minWidth: minWidth, maxWidth: maxWidth); | |
| case Axis.vertical: | |
| final double minHeight = math.min(constraints.minHeight, parentData.minExtent); | |
| final double maxHeight = math.min(constraints.maxHeight, parentData.maxExtent); | |
| return constraints | |
| .tighten(width: constraints.maxWidth) | |
| .copyWith(minHeight: minHeight, maxHeight: maxHeight); | |
| } | |
| } | |
| RenderBox? splitterAt(Offset position) { | |
| RenderBox? child = firstChild; | |
| RenderBox? splitter; | |
| double splitterDistance = double.infinity; // input-padded splitters can overlap | |
| int index = 0; | |
| while (child != null) { | |
| final bool isSplitter = index.isOdd; | |
| index += 1; | |
| if (isSplitter) { | |
| final _SplitViewParentData parentData = child.parentData! as _SplitViewParentData; | |
| final double inputPadding = parentData.inputPadding; | |
| switch (axis) { | |
| case Axis.horizontal: | |
| final double childX = parentData.offset.dx; | |
| if (position.dx >= childX - inputPadding && position.dx < childX + child.size.width + inputPadding) { | |
| double distance = (position.dx - (childX + child.size.width / 2)).abs(); | |
| if (distance < splitterDistance) { | |
| splitterDistance = distance; | |
| splitter = child; | |
| } | |
| } | |
| break; | |
| case Axis.vertical: | |
| final double childY = parentData.offset.dy; | |
| if (position.dy >= childY - inputPadding && position.dy < childY + child.size.height + inputPadding) { | |
| double distance = (position.dy - (childY + child.size.height / 2)).abs(); | |
| if (distance < splitterDistance) { | |
| splitterDistance = distance; | |
| splitter = child; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| child = childAfter(child); | |
| } | |
| return splitter; | |
| } | |
| RenderBox? getActiveSplitter() { | |
| RenderBox? child = firstChild; | |
| while (child != null) { | |
| final _SplitViewParentData parentData = child.parentData! as _SplitViewParentData; | |
| if (parentData.isActiveSplitter == true) { | |
| return child; | |
| } | |
| child = childAfter(child); | |
| } | |
| return null; | |
| } | |
| void setActiveSplitter(RenderBox splitter) { | |
| RenderBox? child = firstChild; | |
| while (child != null) { | |
| final _SplitViewParentData parentData = child.parentData! as _SplitViewParentData; | |
| parentData.isActiveSplitter = child == splitter; | |
| child = childAfter(child); | |
| } | |
| } | |
| void clearActiveSplitter() { | |
| RenderBox? child = firstChild; | |
| while (child != null) { | |
| final _SplitViewParentData parentData = child.parentData! as _SplitViewParentData; | |
| parentData.isActiveSplitter = false; | |
| child = childAfter(child); | |
| } | |
| } | |
| @override | |
| bool hitTestSelf(Offset position) => splitterAt(position) != null; | |
| @override | |
| bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { | |
| if (splitterAt(position) == null) { | |
| return defaultHitTestChildren(result, position: position); | |
| } | |
| return false; | |
| } | |
| void initDragRecognizer(Axis axis) { | |
| switch (axis) { | |
| case Axis.horizontal: | |
| dragRecognizer = HorizontalDragGestureRecognizer(); | |
| break; | |
| case Axis.vertical: | |
| dragRecognizer = HorizontalDragGestureRecognizer(); | |
| break; | |
| } | |
| dragRecognizer | |
| ..dragStartBehavior = DragStartBehavior.down | |
| ..onStart = handleDragStart | |
| ..onUpdate = handleDragUpdate | |
| ..onEnd = handleDragEnd | |
| ..onCancel = handleDragCancel; | |
| } | |
| void handleDragStart(DragStartDetails details) { | |
| final RenderBox? splitter = splitterAt(details.localPosition); | |
| if (splitter == null) { | |
| dragUnderway = false; | |
| } else { | |
| dragUnderway = true; | |
| setActiveSplitter(splitter); | |
| splitAnchor = getChildOffset(splitter); | |
| splitOffset = splitAnchor; | |
| dragAnchor = details.localPosition; | |
| } | |
| } | |
| void handleDragUpdate(DragUpdateDetails details) { | |
| if (dragUnderway) { | |
| splitOffset = splitAnchor + details.localPosition - dragAnchor; | |
| splitLayoutNeeded = true; | |
| markNeedsLayout(); | |
| } | |
| } | |
| void handleDragEnd(DragEndDetails details) { | |
| dragUnderway = false; | |
| } | |
| void handleDragCancel() { | |
| dragUnderway = false; | |
| } | |
| @override | |
| void handleEvent(PointerEvent event, BoxHitTestEntry entry) { | |
| assert(debugHandleEvent(event, entry)); | |
| if (event is PointerDownEvent) { | |
| dragRecognizer.addPointer(event); | |
| } | |
| if (event is PointerHoverEvent && !event.down) { | |
| final RenderBox? splitter = splitterAt(event.localPosition); | |
| if (splitter != null) { | |
| final _SplitViewParentData parentData = splitter.parentData! as _SplitViewParentData; | |
| cursor = parentData.cursor; | |
| } | |
| } | |
| } | |
| double redistributeUnderflow(double underflow, RenderBox? startChild, _NextChild nextChild) { | |
| void updateHorizontalLayout() { | |
| double x = 0; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| if (underflow != 0) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| final BoxConstraints childConstraints = getChildConstraints(child); | |
| final double childWidth = child.size.width; | |
| child.layout(childConstraints.tighten(width: child.size.width + underflow), parentUsesSize: true); | |
| underflow -= child.size.width - childWidth; | |
| } | |
| } | |
| setChildOffset(child, Offset(x, 0)); | |
| x += child.size.width; | |
| } | |
| } | |
| void updateVerticalLayout() { | |
| double y = 0; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| if (underflow != 0) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| final BoxConstraints childConstraints = getChildConstraints(child); | |
| final double childHeight = child.size.height; | |
| child.layout(childConstraints.tighten(height: child.size.height + underflow), parentUsesSize: true); | |
| underflow -= child.size.height - childHeight; | |
| } | |
| } | |
| setChildOffset(child, Offset(0, y)); | |
| y += child.size.height; | |
| } | |
| } | |
| switch (axis) { | |
| case Axis.horizontal: | |
| updateHorizontalLayout(); | |
| break; | |
| case Axis.vertical: | |
| updateVerticalLayout(); | |
| break; | |
| } | |
| return underflow; | |
| } | |
| // Layout the renderer's children based on their weights the first | |
| // time this renderer is laid out, or when its axis changes. | |
| Size initialLayout(RenderBox? startChild, _NextChild nextChild) { | |
| // Layout each unweighted child and compute the available "extent" | |
| // - the width or height along the main axis - that remains when | |
| // it's reduced by the extents of the unweighted children. The | |
| // extent of the remaining children be proportional to their weight. | |
| double totalWeight = 0; | |
| late double availableExtent; | |
| switch (axis) { | |
| case Axis.horizontal: | |
| availableExtent = constraints.maxWidth; | |
| break; | |
| case Axis.vertical: | |
| availableExtent = constraints.maxHeight; | |
| break; | |
| } | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| totalWeight += weight; | |
| } else { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| child.layout(childConstraints, parentUsesSize: true); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| availableExtent -= child.size.width; | |
| break; | |
| case Axis.vertical: | |
| availableExtent -= child.size.height; | |
| break; | |
| } | |
| } | |
| } | |
| // Layout each weighted child with its width or height constrained to | |
| // its share of the available extent. | |
| double width = 0; | |
| double height = 0; | |
| switch (axis) { | |
| case Axis.horizontal: | |
| height = constraints.maxHeight; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| final double weightedWidth = availableExtent * weight / totalWeight; // TBD availableExtent == 0 | |
| BoxConstraints weightedConstraints = childConstraints.tighten(width: weightedWidth); | |
| child.layout(weightedConstraints, parentUsesSize: true); | |
| } | |
| setChildOffset(child, Offset(width, 0)); | |
| width += child.size.width; | |
| } | |
| double underflow = constraints.biggest.width - width; | |
| if (underflow != 0) { | |
| width -= redistributeUnderflow(underflow, startChild, nextChild); | |
| } | |
| break; | |
| case Axis.vertical: | |
| width = constraints.maxWidth; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| final double weightedHeight = availableExtent * weight / totalWeight; // TBD availableExtent == 0 | |
| BoxConstraints weightedConstraints = childConstraints.tighten(height: weightedHeight); | |
| child.layout(weightedConstraints, parentUsesSize: true); | |
| } | |
| setChildOffset(child, Offset(0, height)); | |
| height += child.size.height; | |
| } | |
| double underflow = constraints.biggest.height - height; | |
| if (underflow != 0) { | |
| height -= redistributeUnderflow(underflow, startChild, nextChild); | |
| } | |
| break; | |
| } | |
| initialSize = constraints.constrain(Size(width, height)); | |
| return initialSize!; | |
| } | |
| // Layout the renderer's children when a splitter is moved. | |
| Size splitLayout(RenderBox? startChild, _NextChild nextChild, _NextChild previousChild) { | |
| final RenderBox activeSplitter = getActiveSplitter()!; | |
| switch (axis) { | |
| case Axis.horizontal: | |
| final double splitExtent = previousChild(activeSplitter)!.size.width + nextChild(activeSplitter)!.size.width; | |
| double width = 0; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| if (activeSplitter == nextChild(child)) { | |
| final RenderBox next = nextChild(activeSplitter)!; | |
| final double minWidth = math.max(getChildMinExtent(child), splitExtent - getChildMaxExtent(next)); | |
| final double maxWidth = math.min(getChildMaxExtent(child), splitExtent - getChildMinExtent(next)); | |
| childConstraints = childConstraints.tighten( | |
| width: (splitOffset.dx - getChildOffset(child).dx).clamp(minWidth, maxWidth), | |
| ); | |
| } else if (activeSplitter == previousChild(child)) { | |
| final double splitterWidth = activeSplitter.size.width; | |
| final RenderBox previous = previousChild(activeSplitter)!; | |
| final double minWidth = math.max(getChildMinExtent(child), splitExtent - getChildMaxExtent(previous)); | |
| final double maxWidth = math.min(getChildMaxExtent(child), splitExtent - getChildMinExtent(previous)); | |
| childConstraints = childConstraints.tighten( | |
| width: (getChildOffset(child).dx + child.size.width - splitOffset.dx - splitterWidth).clamp(minWidth, maxWidth), | |
| ); | |
| } else { | |
| childConstraints = childConstraints.tighten( | |
| width: child.size.width, | |
| ); | |
| } | |
| child.layout(childConstraints, parentUsesSize: true); | |
| setChildOffset(child, Offset(width, 0)); | |
| width += child.size.width; | |
| } | |
| break; | |
| case Axis.vertical: | |
| double height = 0; | |
| final double splitExtent = previousChild(activeSplitter)!.size.height + nextChild(activeSplitter)!.size.height; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| if (activeSplitter == nextChild(child)) { | |
| final RenderBox next = nextChild(activeSplitter)!; | |
| final double minHeight = math.max(getChildMinExtent(child), splitExtent - getChildMaxExtent(next)); | |
| final double maxHeight = math.min(getChildMaxExtent(child), splitExtent - getChildMinExtent(next)); | |
| childConstraints = childConstraints.tighten( | |
| height: (splitOffset.dy - getChildOffset(child).dy).clamp(minHeight, maxHeight), | |
| ); | |
| } else if (activeSplitter == previousChild(child)) { | |
| final double splitterHeight = activeSplitter.size.height; | |
| final RenderBox previous = previousChild(activeSplitter)!; | |
| final double minHeight = math.max(getChildMinExtent(child), splitExtent - getChildMaxExtent(previous)); | |
| final double maxHeight = math.min(getChildMaxExtent(child), splitExtent - getChildMinExtent(previous)); | |
| childConstraints = childConstraints.tighten( | |
| height: (getChildOffset(child).dy + child.size.height - splitOffset.dy - splitterHeight).clamp(minHeight, maxHeight), | |
| ); | |
| } else { | |
| childConstraints = childConstraints.tighten( | |
| height: child.size.height, | |
| ); | |
| } | |
| child.layout(childConstraints, parentUsesSize: true); | |
| setChildOffset(child, Offset(0, height)); | |
| height += child.size.height; | |
| } | |
| break; | |
| } | |
| splitLayoutNeeded = false; | |
| return constraints.biggest; | |
| } | |
| // Layout the renderer's children when its size constraints change. | |
| // The additional space is distributed to the weighted children. | |
| Size updateLayout(RenderBox? startChild, _NextChild nextChild) { | |
| // Layout each unweighted child and compute the available "extent" | |
| // - the width or height along the main axis - that remains when | |
| // it's reduced by the extents of the unweighted children. | |
| double totalWeight = 0; | |
| for (RenderBox? child = startChild; child != null; ) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| totalWeight += weight; | |
| } else { | |
| BoxConstraints childConstraints = getChildConstraints(child); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| child.layout(childConstraints.tighten(width: child.size.width), parentUsesSize: true); | |
| break; | |
| case Axis.vertical: | |
| child.layout(childConstraints.tighten(height: child.size.height), parentUsesSize: true); | |
| break; | |
| } | |
| } | |
| child = nextChild(child); | |
| } | |
| void updateHorizontalLayout() { | |
| final double deltaWidth = constraints.biggest.width - size.width; | |
| double x = 0; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| final double? weight = getChildWeight(child); | |
| final BoxConstraints childConstraints = getChildConstraints(child); | |
| if (weight != null) { | |
| final double updatedWidth = child.size.width + deltaWidth * weight / totalWeight; | |
| child.layout(childConstraints.tighten(width: updatedWidth), parentUsesSize: true); | |
| } | |
| setChildOffset(child, Offset(x, 0)); | |
| x += child.size.width; | |
| } | |
| double underflow = constraints.biggest.width - x; | |
| if (underflow != 0) { | |
| underflow = redistributeUnderflow(underflow, startChild, nextChild); | |
| } | |
| } | |
| void updateVerticalLayout() { | |
| // Allocate deltaHeight to the weighted children in proportion to their weight. | |
| final double deltaHeight = constraints.biggest.height - size.height; | |
| double y = 0; | |
| for (RenderBox? child = startChild; child != null; child = nextChild(child)) { | |
| final double? weight = getChildWeight(child); | |
| if (weight != null) { | |
| final BoxConstraints childConstraints = getChildConstraints(child); | |
| final double updatedHeight = child.size.height + deltaHeight * weight / totalWeight; | |
| child.layout(childConstraints.tighten(height: updatedHeight), parentUsesSize: true); | |
| } | |
| setChildOffset(child, Offset(0, y)); | |
| y += child.size.height; | |
| } | |
| // If we were unable to allocate all of deltaHeight, then allocate the over/underflow | |
| // to the first weighted child (children) that can accept it per their min,maxExtent | |
| // constraints. In this case the allocations are not proportional the child's weight. | |
| double underflow = constraints.biggest.height - y; | |
| if (underflow != 0) { | |
| underflow = redistributeUnderflow(underflow, startChild, nextChild); | |
| } | |
| } | |
| switch (axis) { | |
| case Axis.horizontal: | |
| updateHorizontalLayout(); | |
| break; | |
| case Axis.vertical: | |
| updateVerticalLayout(); | |
| break; | |
| } | |
| return constraints.biggest; | |
| } | |
| @override | |
| void performLayout() { | |
| late final RenderBox? startChild; | |
| late final _NextChild nextChild; | |
| late final _NextChild previousChild; | |
| // RTL order only applies to horizontal layouts | |
| switch (textDirection) { | |
| case TextDirection.ltr: | |
| startChild = firstChild; | |
| nextChild = childAfter; | |
| previousChild = childBefore; | |
| break; | |
| case TextDirection.rtl: | |
| switch(axis) { | |
| case Axis.vertical: | |
| startChild = firstChild; | |
| nextChild = childAfter; | |
| previousChild = childBefore; | |
| break; | |
| case Axis.horizontal: | |
| startChild = lastChild; | |
| nextChild = childBefore; | |
| previousChild = childAfter; | |
| break; | |
| } | |
| } | |
| // If children were added (not laid out yet) then the layout can't | |
| // be updated, so we force an initialLayout(). | |
| for (RenderBox? child = startChild; child != null && initialSize != null; child = nextChild(child)) { | |
| if (!child.hasSize) { | |
| initialSize = null; | |
| } | |
| } | |
| if (initialSize == null) { | |
| size = initialLayout(startChild, nextChild); | |
| } else if (splitLayoutNeeded) { | |
| size = splitLayout(startChild, nextChild, previousChild); | |
| } else { | |
| size = updateLayout(startChild, nextChild); | |
| } | |
| } | |
| @override | |
| double computeMinIntrinsicWidth(double height) { | |
| double minWidth = 0.0; | |
| for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { | |
| final double minChildWidth = child.getMinIntrinsicWidth(height); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| minWidth += minChildWidth; | |
| break; | |
| case Axis.vertical: | |
| minWidth = math.max(minWidth, minChildWidth); | |
| break; | |
| } | |
| } | |
| return minWidth; | |
| } | |
| @override | |
| double computeMaxIntrinsicWidth(double height) { | |
| double maxWidth = 0.0; | |
| for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { | |
| final double maxChildWidth = child.getMaxIntrinsicWidth(height); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| maxWidth += maxChildWidth; | |
| break; | |
| case Axis.vertical: | |
| maxWidth = math.max(maxWidth, maxChildWidth); | |
| break; | |
| } | |
| } | |
| return maxWidth; | |
| } | |
| @override | |
| double computeMinIntrinsicHeight(double width) { | |
| double minHeight = 0.0; | |
| for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { | |
| final double minChildHeight = child.getMinIntrinsicHeight(width); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| minHeight += minChildHeight; | |
| break; | |
| case Axis.vertical: | |
| minHeight = math.max(minHeight, minChildHeight); | |
| break; | |
| } | |
| } | |
| return minHeight; | |
| } | |
| @override | |
| double computeMaxIntrinsicHeight(double width) { | |
| double maxHeight = 0.0; | |
| for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { | |
| final double maxChildHeight = child.getMaxIntrinsicHeight(width); | |
| switch (axis) { | |
| case Axis.horizontal: | |
| maxHeight += maxChildHeight; | |
| break; | |
| case Axis.vertical: | |
| maxHeight = math.max(maxHeight, maxChildHeight); | |
| break; | |
| } | |
| } | |
| return maxHeight; | |
| } | |
| @override | |
| double? computeDistanceToActualBaseline(TextBaseline baseline) { | |
| return defaultComputeDistanceToHighestActualBaseline(baseline); | |
| } | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) { | |
| return constraints.biggest; | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| RenderBox? child = firstChild; | |
| while (child != null) { | |
| final _SplitViewParentData childParentData = child.parentData! as _SplitViewParentData; | |
| context.paintChild(child, childParentData.offset + offset); | |
| child = childAfter(child); | |
| } | |
| } | |
| } | |
| class SplitView extends StatelessWidget { | |
| const SplitView({ | |
| Key? key, | |
| this.axis = Axis.horizontal, | |
| required this.children, | |
| }) : super(key: key); | |
| final Axis axis; | |
| final List<Widget> children; | |
| @override | |
| Widget build(BuildContext context) { | |
| return _SplitViewRenderWidget( | |
| axis: axis, | |
| children: children, | |
| ); | |
| } | |
| } | |
| // ------- DEMO ------- | |
| enum DemoId { splitView, nestedSplitViews } | |
| class DemoItemLabel extends StatelessWidget { | |
| const DemoItemLabel({ Key? key, this.index }) : super(key: key); | |
| final int? index; | |
| @override | |
| Widget build(BuildContext context) { | |
| return CircleAvatar( | |
| minRadius: 4, | |
| maxRadius: 20, | |
| backgroundColor: Colors.grey.shade800, | |
| foregroundColor: Colors.white, | |
| child: Text(index == null ? '-' : '$index'), | |
| ); | |
| } | |
| } | |
| class DemoItem extends StatefulWidget { | |
| const DemoItem({ | |
| Key? key, | |
| required this.index, | |
| required this.color, | |
| required this.isSelected, | |
| required this.nestedSplitViews, | |
| this.onTap, | |
| }) : super(key: key); | |
| final int index; | |
| final Color color; | |
| final bool isSelected; | |
| final VoidCallback? onTap; | |
| final bool nestedSplitViews; | |
| @override | |
| State<DemoItem> createState() => _DemoItemState(); | |
| } | |
| class _DemoItemState extends State<DemoItem> with TickerProviderStateMixin { | |
| late AnimationController colorOpacityController; | |
| late AnimationController borderOpacityController; | |
| late Animation<double> colorOpacity; | |
| late Animation<double> borderOpacity; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| colorOpacityController = AnimationController( | |
| duration: const Duration(milliseconds: 150), | |
| vsync: this | |
| ); | |
| borderOpacityController = AnimationController( | |
| duration: const Duration(milliseconds: 150), | |
| vsync: this | |
| ); | |
| colorOpacity = colorOpacityController | |
| .drive(CurveTween(curve: Curves.easeIn)) | |
| .drive(Tween<double>(begin: 0.5, end: 0.85)); | |
| borderOpacity = borderOpacityController | |
| .drive(CurveTween(curve: Curves.easeIn)) | |
| .drive(Tween<double>(begin: 0, end: 1)); | |
| } | |
| @override | |
| void dispose() { | |
| colorOpacityController.dispose(); | |
| borderOpacityController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| void didUpdateWidget(DemoItem oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.isSelected != oldWidget.isSelected) { | |
| if (widget.isSelected) { | |
| colorOpacityController.forward(); | |
| } else { | |
| colorOpacityController.reverse(); | |
| } | |
| } | |
| } | |
| void handleEnter(PointerEnterEvent event) { | |
| borderOpacityController.forward(); | |
| } | |
| void handleExit(PointerExitEvent event) { | |
| borderOpacityController.reverse(); | |
| } | |
| Widget animatedBuild(BuildContext context, Widget? child) { | |
| return DecoratedBox( | |
| decoration: BoxDecoration( | |
| color: widget.color.withOpacity(colorOpacity.value), | |
| border: Border.all(color: Colors.grey.shade800.withOpacity(borderOpacity.value)), | |
| ), | |
| child: child, | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return MouseRegion( | |
| opaque: false, | |
| onEnter: handleEnter, | |
| onExit: handleExit, | |
| child: GestureDetector( | |
| onTap: widget.onTap, | |
| child: AnimatedBuilder( | |
| animation: Listenable.merge(<Listenable>[ | |
| colorOpacityController.view, | |
| borderOpacityController.view | |
| ]), | |
| builder: animatedBuild, | |
| child: Center(child: DemoItemLabel(index: widget.index)), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| @immutable | |
| class SplitConstraintsData { | |
| const SplitConstraintsData({ | |
| this.weight = 1, | |
| this.minExtent = 0, | |
| this.maxExtent = double.infinity, | |
| this.inputPadding = 0, | |
| }); | |
| final double? weight; | |
| final double minExtent; | |
| final double maxExtent; | |
| final double inputPadding; | |
| SplitConstraintsData copyWith({ | |
| double? weight, | |
| double? minExtent, | |
| double? maxExtent, | |
| double? inputPadding, | |
| }) { | |
| return SplitConstraintsData( | |
| weight: weight ?? this.weight, | |
| minExtent: minExtent ?? this.minExtent, | |
| maxExtent: maxExtent ?? this.maxExtent, | |
| inputPadding: inputPadding ?? this.inputPadding, | |
| ); | |
| } | |
| @override | |
| String toString() { | |
| String maxExtentString = maxExtent.isInfinite ? ' ${String.fromCharCode(0x221e)}' : '${maxExtent.floor()}'; | |
| return 'SplitConstraintsData[weight=${weight?.floor()}, minExtent=${minExtent.floor()}, maxExtent=$maxExtentString, inputPadding=${inputPadding.floor()}]'; | |
| } | |
| } | |
| class SelectedItemConstraintsView extends StatefulWidget { | |
| const SelectedItemConstraintsView({ | |
| Key? key, | |
| required this.index, | |
| required this.axis, | |
| required this.data, | |
| required this.onDataChanged, | |
| }) : super(key: key); | |
| final int index; | |
| final Axis axis; | |
| final SplitConstraintsData data; | |
| final ValueChanged<SplitConstraintsData> onDataChanged; | |
| @override | |
| State<SelectedItemConstraintsView> createState() => _SelectedItemConstraintsViewState(); | |
| } | |
| class _SelectedItemConstraintsViewState extends State<SelectedItemConstraintsView> { | |
| static const Widget spacer = SizedBox(width: 24); | |
| late final TextEditingController minController; | |
| late final TextEditingController maxController; | |
| late final TextEditingController weightController; | |
| String? minErrorText; | |
| String? maxErrorText; | |
| String? weightErrorText; | |
| String get maxExtentString => widget.data.maxExtent.isInfinite ? String.fromCharCode(0x221e) : '${widget.data.maxExtent.floor()}'; | |
| String get weightString => widget.data.weight == null ? '<none>' : '${widget.data.weight!.floor()}'; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| minController = TextEditingController(text: '${widget.data.minExtent.floor()}'); | |
| maxController = TextEditingController(text: maxExtentString); | |
| weightController = TextEditingController(text: weightString); | |
| } | |
| @override | |
| void dispose() { | |
| minController.dispose(); | |
| maxController.dispose(); | |
| weightController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| void didUpdateWidget(SelectedItemConstraintsView oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.index != oldWidget.index) { | |
| minController.text = '${widget.data.minExtent.floor()}'; | |
| maxController.text = maxExtentString; | |
| weightController.text = weightString; | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| late final String dimension; | |
| switch (widget.axis) { | |
| case Axis.horizontal: | |
| dimension = 'Width'; | |
| break; | |
| case Axis.vertical: | |
| dimension = 'Height'; | |
| break; | |
| } | |
| return Row( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.baseline, | |
| textBaseline: TextBaseline.alphabetic, | |
| children: <Widget>[ | |
| spacer, | |
| SizedBox( | |
| width: 96, | |
| child: TextField( | |
| controller: minController, | |
| decoration: InputDecoration( | |
| border: const OutlineInputBorder(), | |
| labelText: 'Min $dimension', | |
| errorText: minErrorText, | |
| ), | |
| onSubmitted: (String? stringValue) { | |
| if (stringValue != null) { | |
| final double? value = double.tryParse(stringValue); | |
| if (value != null && value >= 0 && value <= widget.data.maxExtent) { | |
| if (minErrorText != null) { | |
| setState(() { minErrorText = null; }); | |
| } | |
| widget.onDataChanged(widget.data.copyWith(minExtent: value)); | |
| } else { | |
| if (minErrorText == null) { | |
| setState(() { minErrorText = 'invalid'; }); | |
| } | |
| } | |
| } | |
| }, | |
| ), | |
| ), | |
| spacer, | |
| SizedBox( | |
| width: 96, | |
| child: TextField( | |
| controller: maxController, | |
| decoration: InputDecoration( | |
| border: const OutlineInputBorder(), | |
| labelText: 'Max $dimension', | |
| ), | |
| onSubmitted: (String? stringValue) { | |
| if (stringValue != null) { | |
| final double? value = double.tryParse(stringValue); | |
| if (value != null && value >= widget.data.minExtent) { | |
| if (maxErrorText != null) { | |
| setState(() { maxErrorText = null; }); | |
| } | |
| widget.onDataChanged(widget.data.copyWith(maxExtent: value)); | |
| } else { | |
| if (maxErrorText == null) { | |
| setState(() { maxErrorText = 'invalid'; }); | |
| } | |
| } | |
| } | |
| }, | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| class SelectedItemView extends StatefulWidget { | |
| const SelectedItemView({ | |
| Key? key, | |
| required this.itemCount, | |
| required this.index, | |
| required this.data, | |
| required this.axis, | |
| required this.textDirection, | |
| required this.onItemCountChanged, | |
| required this.onAxisChanged, | |
| required this.onTextDirectionChanged, | |
| required this.onDataChanged, | |
| }) : super(key: key); | |
| final int itemCount; | |
| final int? index; | |
| final SplitConstraintsData? data; | |
| final Axis axis; | |
| final TextDirection textDirection; | |
| final ValueChanged<int?> onItemCountChanged; | |
| final ValueChanged<Axis?> onAxisChanged; | |
| final ValueChanged<TextDirection?> onTextDirectionChanged; | |
| final ValueChanged<SplitConstraintsData> onDataChanged; | |
| @override | |
| State<SelectedItemView> createState() => _SelectedItemViewState(); | |
| } | |
| class _SelectedItemViewState extends State<SelectedItemView> { | |
| static const Widget spacer = SizedBox(width: 24); | |
| @override | |
| Widget build(BuildContext context) { | |
| return Material( | |
| elevation: 8, | |
| child: Container( | |
| color: Theme.of(context).colorScheme.background, | |
| height: 128, | |
| padding: const EdgeInsets.symmetric( | |
| horizontal: 16, | |
| vertical: 24, | |
| ), | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.start, | |
| crossAxisAlignment: CrossAxisAlignment.baseline, | |
| textBaseline: TextBaseline.alphabetic, | |
| children: <Widget>[ | |
| DemoItemLabel(index: widget.index), | |
| if (widget.index != null && widget.data != null) | |
| SelectedItemConstraintsView( | |
| index: widget.index!, | |
| axis: widget.axis, | |
| data: widget.data!, | |
| onDataChanged: widget.onDataChanged, | |
| ), | |
| Expanded( | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.end, | |
| children: <Widget>[ | |
| DropdownButton<int>( | |
| value: widget.itemCount, | |
| onChanged: widget.onItemCountChanged, | |
| items: <int>[2, 3, 5, 8].map<DropdownMenuItem<int>>((int value) { | |
| return DropdownMenuItem<int>( | |
| value: value, | |
| child: Text(' $value items'), | |
| ); | |
| }).toList(), | |
| ), | |
| spacer, | |
| DropdownButton<TextDirection>( | |
| value: widget.textDirection, | |
| onChanged: widget.onTextDirectionChanged, | |
| items: const <DropdownMenuItem<TextDirection>>[ | |
| DropdownMenuItem<TextDirection>( | |
| value: TextDirection.ltr, | |
| child: Text('LTR'), | |
| ), | |
| DropdownMenuItem<TextDirection>( | |
| value: TextDirection.rtl, | |
| child: Text('RTL'), | |
| ), | |
| ], | |
| ), | |
| spacer, | |
| DropdownButton<Axis>( | |
| value: widget.axis, | |
| onChanged: widget.onAxisChanged, | |
| items: const <DropdownMenuItem<Axis>>[ | |
| DropdownMenuItem<Axis>( | |
| value: Axis.horizontal, | |
| child: Text('Horizontal'), | |
| ), | |
| DropdownMenuItem<Axis>( | |
| value: Axis.vertical, | |
| child: Text('Vertical'), | |
| ), | |
| ], | |
| ), | |
| const SizedBox(width: 16), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class DemoSplitter extends StatelessWidget { | |
| const DemoSplitter({ | |
| Key? key, | |
| required this.axis, | |
| }) : super(key: key); | |
| final Axis axis; | |
| @override | |
| Widget build(BuildContext context) { | |
| final MouseCursor cursor = axis == Axis.horizontal | |
| ? SystemMouseCursors.resizeLeftRight | |
| : SystemMouseCursors.resizeUpDown; | |
| return SplitConstraints( | |
| inputPadding: 0, | |
| cursor: cursor, | |
| child: Container( | |
| color: Theme.of(context).colorScheme.secondary.withOpacity(.75), | |
| width: 16, | |
| height: 16, | |
| ), | |
| ); | |
| } | |
| } | |
| class Demo extends StatefulWidget { | |
| const Demo({ Key? key }) : super(key: key); | |
| @override | |
| State<Demo> createState() => _DemoState(); | |
| } | |
| class _DemoState extends State<Demo> { | |
| int itemCount = 5; | |
| bool nestedSplitViews = false; | |
| int? selectedIndex; | |
| Axis axis = Axis.horizontal; | |
| TextDirection textDirection = TextDirection.ltr; | |
| late List<SplitConstraintsData> splitConstraintsData; | |
| void initSplitConstraintsData() { | |
| splitConstraintsData = <SplitConstraintsData>[ | |
| for(int index = 0; index < itemCount; index += 1) | |
| const SplitConstraintsData() | |
| ]; | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| initSplitConstraintsData(); | |
| } | |
| void handleTap(int index) { | |
| setState(() { | |
| selectedIndex = index == selectedIndex ? null : index; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| Widget buildSplitter(int index) { | |
| return DemoSplitter( | |
| axis: axis, | |
| ); | |
| } | |
| Widget buildItem(int index) { | |
| final SplitConstraintsData data = splitConstraintsData[index]; | |
| return SplitConstraints( | |
| weight: data.weight, | |
| minExtent: data.minExtent, | |
| maxExtent: data.maxExtent, | |
| inputPadding: data.inputPadding, | |
| child: DemoItem( | |
| index: index, | |
| color: Color.lerp(Colors.green, Colors.indigo, index / itemCount)!, | |
| isSelected: index == selectedIndex, | |
| onTap: () { handleTap(index); }, | |
| nestedSplitViews: nestedSplitViews, | |
| ), | |
| ); | |
| } | |
| return Scaffold( | |
| body: Column( | |
| children: <Widget>[ | |
| SelectedItemView( | |
| itemCount: itemCount, | |
| index: selectedIndex, | |
| data: selectedIndex == null ? null : splitConstraintsData[selectedIndex!], | |
| axis: axis, | |
| textDirection: textDirection, | |
| onItemCountChanged: (int? newValue) { | |
| if (newValue != null) { | |
| setState(() { | |
| itemCount = newValue; | |
| selectedIndex = null; | |
| initSplitConstraintsData(); | |
| }); | |
| } | |
| }, | |
| onAxisChanged: (Axis? newValue) { | |
| if (newValue != null) { | |
| setState(() { axis = newValue; }); | |
| } | |
| }, | |
| onTextDirectionChanged: (TextDirection? newValue) { | |
| if (newValue != null) { | |
| setState(() { textDirection = newValue; }); | |
| } | |
| }, | |
| onDataChanged: (SplitConstraintsData newValue) { | |
| setState(() { | |
| splitConstraintsData[selectedIndex!] = newValue; | |
| print('selectedIndex=$selectedIndex $newValue'); | |
| }); | |
| }, | |
| ), | |
| Expanded( | |
| child: Directionality( | |
| textDirection: textDirection, | |
| child: SplitView( | |
| axis: axis, | |
| children: <Widget>[ | |
| for (int index = 0; index < itemCount * 2 - 1; index += 1) | |
| index.isOdd ? buildSplitter(index) : buildItem(index ~/ 2) | |
| ], | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| class NestedSplitDemo extends StatefulWidget { | |
| const NestedSplitDemo({ Key? key }) : super(key: key); | |
| @override | |
| State<NestedSplitDemo> createState() => _NestedSplitDemoState(); | |
| } | |
| class _NestedSplitDemoState extends State<NestedSplitDemo> { | |
| @override | |
| Widget build(BuildContext context) { | |
| return SplitView( | |
| axis: Axis.horizontal, | |
| children: <Widget>[ | |
| SplitConstraints( | |
| weight: 1, | |
| minExtent: 48, | |
| child: Container( | |
| color: Colors.orange.withOpacity(0.15), | |
| child: SplitView( | |
| axis: Axis.vertical, | |
| children: <Widget>[ | |
| SplitConstraints( | |
| weight: 1, | |
| minExtent: 48, | |
| child: Container( | |
| color: Colors.orange.withOpacity(0.15), | |
| child: const Placeholder(color: Colors.orange), | |
| ), | |
| ), | |
| SplitConstraints( | |
| inputPadding: 20, | |
| cursor: SystemMouseCursors.resizeUpDown, | |
| child: Container( | |
| color: Colors.white, | |
| width: 8, | |
| height: 8, | |
| ), | |
| ), | |
| SplitConstraints( | |
| weight: 1, | |
| minExtent: 48, | |
| child: Container( | |
| color: Colors.orange.withOpacity(0.15), | |
| child: const Placeholder(color: Colors.orange), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| SplitConstraints( | |
| inputPadding: 20, | |
| cursor: SystemMouseCursors.resizeLeftRight, | |
| child: Container( | |
| color: Colors.white, | |
| width: 8, | |
| height: 8, | |
| ), | |
| ), | |
| SplitConstraints( | |
| weight: 1, | |
| minExtent: 48, | |
| child: Container( | |
| color: Colors.indigo.withOpacity(0.15), | |
| child: const Placeholder(color: Colors.indigo), | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| class DemoNavigator extends StatefulWidget { | |
| const DemoNavigator({ Key? key }) : super(key: key); | |
| @override | |
| State<DemoNavigator> createState() => _DemoNavigatorState(); | |
| } | |
| class _DemoNavigatorState extends State<DemoNavigator> { | |
| DemoId demoId = DemoId.splitView; | |
| @override | |
| Widget build(BuildContext context) { | |
| late final String title; | |
| late final Widget demo; | |
| switch(demoId) { | |
| case DemoId.splitView: | |
| title = 'SplitView Demo'; | |
| demo = const Demo(); | |
| break; | |
| case DemoId.nestedSplitViews: | |
| title = 'Nested SplitViews Demo'; | |
| demo = const NestedSplitDemo(); | |
| break; | |
| } | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: Text(title), | |
| actions: <Widget>[ | |
| PopupMenuButton( | |
| onSelected: (DemoId id) { | |
| setState(() { demoId = id; }); | |
| }, | |
| itemBuilder: (BuildContext context) { | |
| return const [ | |
| PopupMenuItem<DemoId>( | |
| value: DemoId.splitView, | |
| child: Text('SplitView Demo'), | |
| ), | |
| PopupMenuItem<DemoId>( | |
| value: DemoId.nestedSplitViews, | |
| child: Text('Nested SplitViews Demo'), | |
| ), | |
| ]; | |
| }, | |
| ), | |
| const SizedBox(width: 16), | |
| ], | |
| ), | |
| body: demo, | |
| ); | |
| } | |
| } | |
| void main() { | |
| runApp( | |
| MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData(colorSchemeSeed: Colors.blue), | |
| home: const DemoNavigator(), | |
| ), | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment