`CupertinoSlidingSegmentedControl` is able to have proportional layout based on segment content (#153125)
diff --git a/packages/flutter/lib/src/cupertino/sliding_segmented_control.dart b/packages/flutter/lib/src/cupertino/sliding_segmented_control.dart index f760f68..3d56616 100644 --- a/packages/flutter/lib/src/cupertino/sliding_segmented_control.dart +++ b/packages/flutter/lib/src/cupertino/sliding_segmented_control.dart
@@ -6,7 +6,9 @@ library; import 'dart:math' as math; +import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; @@ -323,6 +325,7 @@ this.thumbColor = _kThumbColor, this.padding = _kHorizontalItemPadding, this.backgroundColor = CupertinoColors.tertiarySystemFill, + this.proportionalWidth = false, }) : assert(children.length >= 2), assert( groupValue == null || children.keys.contains(groupValue), @@ -395,6 +398,21 @@ /// will not be painted if null is specified. final Color backgroundColor; + /// Determine whether segments have proportional widths based on their content. + /// + /// If false, all segments will have the same width, determined by the longest + /// segment. If true, each segment's width will be determined by its individual + /// content. + /// + /// If the max width of parent constraints is smaller than the width that the + /// segmented control needs, The segment widths will scale down proportionally + /// to ensure the segment control fits within the boundaries; similarly, if + /// the min width of parent constraints is larger, the segment width will scales + /// up to meet the min width requirement. + /// + /// Defaults to false. + final bool proportionalWidth; + /// The color used to paint the interior of the thumb that appears behind the /// currently selected item. /// @@ -422,10 +440,12 @@ final TapGestureRecognizer tap = TapGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); + final GlobalKey segmentedControlRenderWidgetKey = GlobalKey(); @override void initState() { super.initState(); + // If the long press or horizontal drag recognizer gets accepted, we know for // sure the gesture is meant for the segmented control. Hand everything to // the drag gesture recognizer. @@ -485,23 +505,24 @@ // them from interfering with the active drag gesture. bool get isThumbDragging => _startedOnSelectedSegment ?? false; - // Converts local coordinate to segments. This method assumes each segment has - // the same width. + // Converts local coordinate to segments. T segmentForXPosition(double dx) { - final RenderBox renderBox = context.findRenderObject()! as RenderBox; + final BuildContext currentContext = segmentedControlRenderWidgetKey.currentContext!; + final _RenderSegmentedControl<T> renderBox = currentContext.findRenderObject()! as _RenderSegmentedControl<T>; + final int numOfChildren = widget.children.length; assert(renderBox.hasSize); assert(numOfChildren >= 2); - int index = (dx ~/ (renderBox.size.width / numOfChildren)).clamp(0, numOfChildren - 1); + + int segmentIndex = renderBox.getClosestSegmentIndex(dx); switch (Directionality.of(context)) { case TextDirection.ltr: break; case TextDirection.rtl: - index = numOfChildren - 1 - index; + segmentIndex = numOfChildren - 1 - segmentIndex; } - - return widget.children.keys.elementAt(index); + return widget.children.keys.elementAt(segmentIndex); } bool _hasDraggedTooFar(DragUpdateDetails details) { @@ -696,9 +717,11 @@ animation: thumbScaleAnimation, builder: (BuildContext context, Widget? child) { return _SegmentedControlRenderWidget<T>( + key: segmentedControlRenderWidgetKey, highlightedIndex: highlightedIndex, thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context), thumbScale: thumbScaleAnimation.value, + proportionalWidth: widget.proportionalWidth, state: this, children: children, ); @@ -716,12 +739,14 @@ required this.highlightedIndex, required this.thumbColor, required this.thumbScale, + required this.proportionalWidth, required this.state, }); final int? highlightedIndex; final Color thumbColor; final double thumbScale; + final bool proportionalWidth; final _SegmentedControlState<T> state; @override @@ -730,6 +755,7 @@ highlightedIndex: highlightedIndex, thumbColor: thumbColor, thumbScale: thumbScale, + proportionalWidth: proportionalWidth, state: state, ); } @@ -740,7 +766,8 @@ renderObject ..thumbColor = thumbColor ..thumbScale = thumbScale - ..highlightedIndex = highlightedIndex; + ..highlightedIndex = highlightedIndex + ..proportionalWidth = proportionalWidth; } } @@ -785,10 +812,12 @@ required int? highlightedIndex, required Color thumbColor, required double thumbScale, + required bool proportionalWidth, required this.state, }) : _highlightedIndex = highlightedIndex, _thumbColor = thumbColor, - _thumbScale = thumbScale; + _thumbScale = thumbScale, + _proportionalWidth = proportionalWidth; final _SegmentedControlState<T> state; @@ -841,6 +870,16 @@ markNeedsPaint(); } + bool get proportionalWidth => _proportionalWidth; + bool _proportionalWidth; + set proportionalWidth(bool value) { + if (_proportionalWidth == value) { + return; + } + _proportionalWidth = value; + markNeedsLayout(); + } + @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); @@ -853,8 +892,29 @@ } // Intrinsic Dimensions + double get separatorWidth => _kSeparatorInset.horizontal + _kSeparatorWidth; + double get totalSeparatorWidth => separatorWidth * (childCount ~/ 2); - double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount ~/ 2); + int getClosestSegmentIndex(double dx) { + int index = 0; + RenderBox? child = firstChild; + while (child != null) { + final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double clampX = clampDouble(dx, childParentData.offset.dx, child.size.width + childParentData.offset.dx); + + if (dx <= clampX) { + break; + } + + index++; + child = nonSeparatorChildAfter(child); + } + + final int segmentCount = childCount ~/ 2 + 1; + // When the thumb is dragging out of bounds, the return result must be + // smaller than segment count. + return min(index, segmentCount - 1); + } RenderBox? nonSeparatorChildAfter(RenderBox child) { final RenderBox? nextChild = childAfter(child); @@ -923,62 +983,106 @@ } } - Size _calculateChildSize(BoxConstraints constraints) { + double _getMaxChildWidth(BoxConstraints constraints) { final int childCount = this.childCount ~/ 2 + 1; double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount; - double maxHeight = _kMinSegmentedControlHeight; RenderBox? child = firstChild; while (child != null) { childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding); child = nonSeparatorChildAfter(child); } - childWidth = math.min( + return math.min( childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount, ); - child = firstChild; + } + + double _getMaxChildHeight(BoxConstraints constraints, double childWidth) { + double maxHeight = _kMinSegmentedControlHeight; + RenderBox? child = firstChild; while (child != null) { final double boxHeight = child.getMaxIntrinsicHeight(childWidth); maxHeight = math.max(maxHeight, boxHeight); child = nonSeparatorChildAfter(child); } - return Size(childWidth, maxHeight); + return maxHeight; } - Size _computeOverallSizeFromChildSize(Size childSize, BoxConstraints constraints) { - final int childCount = this.childCount ~/ 2 + 1; - return constraints.constrain(Size(childSize.width * childCount + totalSeparatorWidth, childSize.height)); + List<double> _getChildWidths(BoxConstraints constraints) { + if (!proportionalWidth) { + final double maxChildWidth = _getMaxChildWidth(constraints); + final int segmentCount = childCount ~/ 2 + 1; + return List<double>.filled(segmentCount, maxChildWidth); + } + + final List<double> segmentWidths = <double>[]; + RenderBox? child = firstChild; + while (child != null) { + final double childWidth = child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding; + child = nonSeparatorChildAfter(child); + segmentWidths.add(childWidth); + } + + final double totalWidth = segmentWidths.sum; + + // If the sum of the children's width is larger than the allowed max width, + // each segment width should scale down until the overall size can fit in + // the parent constraints; similarly, if the sum of the children's width is + // smaller than the allowed min width, each segment width should scale up + // until the overall size can fit in the parent constraints. + final double allowedMaxWidth = constraints.maxWidth - totalSeparatorWidth; + final double allowedMinWidth = constraints.minWidth - totalSeparatorWidth; + + final double scale = clampDouble(totalWidth, allowedMinWidth, allowedMaxWidth) / totalWidth; + if (scale != 1) { + for (int i = 0; i < segmentWidths.length; i++) { + segmentWidths[i] = segmentWidths[i] * scale; + } + } + return segmentWidths; + } + + Size _computeOverallSize(BoxConstraints constraints) { + final double maxChildHeight = _getMaxChildHeight(constraints, constraints.maxWidth); + return constraints.constrain(Size(_getChildWidths(constraints).sum + totalSeparatorWidth, maxChildHeight)); } @override double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { - final Size childSize = _calculateChildSize(constraints); - final BoxConstraints childConstraints = BoxConstraints.tight(childSize); + final List<double> segmentWidths = _getChildWidths(constraints); + final double childHeight = _getMaxChildHeight(constraints, constraints.maxWidth); + int index = 0; BaselineOffset baselineOffset = BaselineOffset.noBaseline; - for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { + RenderBox? child = firstChild; + while (child != null) { + final BoxConstraints childConstraints = BoxConstraints.tight(Size(segmentWidths[index], childHeight)); baselineOffset = baselineOffset.minOf(BaselineOffset(child.getDryBaseline(childConstraints, baseline))); + + child = nonSeparatorChildAfter(child); + index++; } + return baselineOffset.offset; } @override Size computeDryLayout(BoxConstraints constraints) { - final Size childSize = _calculateChildSize(constraints); - return _computeOverallSizeFromChildSize(childSize, constraints); + return _computeOverallSize(constraints); } @override void performLayout() { final BoxConstraints constraints = this.constraints; - final Size childSize = _calculateChildSize(constraints); - final BoxConstraints childConstraints = BoxConstraints.tight(childSize); - final BoxConstraints separatorConstraints = childConstraints.heightConstraints(); + final List<double> segmentWidths = _getChildWidths(constraints); + final double childHeight = _getMaxChildHeight(constraints, double.infinity); + final BoxConstraints separatorConstraints = BoxConstraints(minHeight: childHeight, maxHeight: childHeight); RenderBox? child = firstChild; int index = 0; double start = 0; while (child != null) { + final BoxConstraints childConstraints = BoxConstraints.tight(Size(segmentWidths[index ~/ 2], childHeight)); child.layout(index.isEven ? childConstraints : separatorConstraints, parentUsesSize: true); final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; final Offset childOffset = Offset(start, 0); @@ -991,8 +1095,7 @@ child = childAfter(child); index += 1; } - - size = _computeOverallSizeFromChildSize(childSize, constraints); + size = _computeOverallSize(constraints); } // This method is used to convert the original unscaled thumb rect painted in
diff --git a/packages/flutter/test/cupertino/sliding_segmented_control_test.dart b/packages/flutter/test/cupertino/sliding_segmented_control_test.dart index 166257b..a5975b7 100644 --- a/packages/flutter/test/cupertino/sliding_segmented_control_test.dart +++ b/packages/flutter/test/cupertino/sliding_segmented_control_test.dart
@@ -510,7 +510,7 @@ ); }); - testWidgets('Width of each segmented control segment is determined by widest widget', (WidgetTester tester) async { + testWidgets('Width of each segmented control segment is determined by widest widget by default', (WidgetTester tester) async { final Map<int, Widget> children = <int, Widget>{ 0: Container(constraints: const BoxConstraints.tightFor(width: 50.0)), 1: Container(constraints: const BoxConstraints.tightFor(width: 100.0)), @@ -541,6 +541,266 @@ expect(childWidth, 200.0 + 9.25 * 2); }); + testWidgets('If proportionalWidth is true, the width of each segmented ' + 'control segment is determined by its own content', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + final Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 9.25 * 2); + + final Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 100 + 9.25 * 2); + + final Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 9.25 * 2); + + // Overall segment control width is the sum of the segment widths + horizontal paddings + 2 separator width. + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + final double childWidthSum = firstChildSize.width + secondChildSize.width + thirdChildSize.width; + expect(segmentedControl.size.width, childWidthSum + 6.0 + 2.0); + }); + + testWidgets('proportionalWidth rebuild', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 200, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + bool proportionalWidth = false; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + proportionalWidth: proportionalWidth, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 200 + 9.25 * 2); + + Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 200 + 9.25 * 2); + + Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 200 + 9.25 * 2); + + setState!(() { proportionalWidth = true; }); + await tester.pump(); + + firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 9.25 * 2); + + secondChildSize = getChildSize(1); + expect(secondChildSize.width, 200 + 9.25 * 2); + + thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 9.25 * 2); + }); + + testWidgets('If proportionalWidth is true, the width of each segmented ' + 'control segment is updated when children change', (WidgetTester tester) async { + Map<int, Widget> children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 9.25 * 2); + + Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 100 + 9.25 * 2); + + Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 9.25 * 2); + + setState!(() { + children = <int, Widget>{ + 0: const SizedBox(), + 1: const SizedBox(width: 220, child: Text('Second')), + 2: const SizedBox(width: 170, child: Text('Third')), + }; + }); + await tester.pump(); + + firstChildSize = getChildSize(0); + expect(firstChildSize.width, 0 + 9.25 * 2); + + secondChildSize = getChildSize(1); + expect(secondChildSize.width, 220 + 9.25 * 2); + + thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 170 + 9.25 * 2); + }); + + + testWidgets('If proportionalWidth is true and the overall segment control width ' + 'is larger than the max width of the parent constraints, each segment scales down', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 200, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ), + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + // Without constraints, the overall size should be 405.5: 50 + 100 + 200 + // + 9.25 * 6(horizontal padding). To fit in 194(allowed max width - padding), + // each segment width should scale down to original width * (194 - separator) / 413.5. + final Size firstChildSize = getChildSize(0); + const double maxAllowedTotal = 200 - 6 - 2; + const double originalTotal = 405.5; + expect(firstChildSize.width, (50 + 9.25 * 2) * maxAllowedTotal / originalTotal); + + final Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, (100 + 9.25 * 2) * maxAllowedTotal / originalTotal); + + final Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, (200 + 9.25 * 2) * maxAllowedTotal / originalTotal); + }); + + testWidgets('If proportionalWidth is true and the overall segment control width ' + 'is smaller than the min width of the parent constraints, each segment scales up', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{ + 0: const SizedBox(width: 20, child: Text('First')), + 1: const SizedBox(width: 30, child: Text('Second')), + 2: const SizedBox(width: 50, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 200), + child: CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ), + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + // Without constraints, the overall size should be 155.5: 20 + 30 + 50 + // + 9.25 * 6(horizontal padding). To fit in 194(allowed max width - padding), + // each segment width should scale up to original width * (194 - separator) / 155.5. + final Size firstChildSize = getChildSize(0); + const double constraintsMinWidth = 200 - 6 - 2; + const double originalTotal = 155.5; + expect(firstChildSize.width, moreOrLessEquals((20 + 9.25 * 2) * constraintsMinWidth / originalTotal)); + + final Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, moreOrLessEquals((30 + 9.25 * 2) * constraintsMinWidth / originalTotal)); + + final Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, moreOrLessEquals((50 + 9.25 * 2) * constraintsMinWidth / originalTotal)); + }); + testWidgets('Width is finite in unbounded space', (WidgetTester tester) async { const Map<int, Widget> children = <int, Widget>{ 0: SizedBox(width: 50), @@ -759,6 +1019,46 @@ expect(groupValue, 1); }); + testWidgets('Non-centered taps work on propotional segments', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{}; + children[0] = const SizedBox(width: 50, height: 30); + children[1] = const SizedBox(); + children[2] = const SizedBox(width: 100, height: 30); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Rect firstChild = tester.getRect(find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData))); + expect(firstChild.width, 50.0 + 9.25 * 2); + + final Rect secondChild = tester.getRect(find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData))); + expect(secondChild.width, 0.0 + 9.25 * 2); + + final Rect thirdChild = tester.getRect(find.ancestor(of: find.byWidget(children[2]!), matching: find.byType(MetaData))); + expect(thirdChild.width, 100.0 + 9.25 * 2); + + final Finder child0 = find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData)); + final Offset centerOfChild0 = tester.getCenter(child0); + await tester.tapAt(centerOfChild0 + Offset(firstChild.width / 2 + 1, 0)); + expect(groupValue, 1); + + await tester.tapAt(centerOfChild0 + Offset(firstChild.width / 2 + 1 + secondChild.width + 1, 0)); + expect(groupValue, 2); + }); + testWidgets('Hit-tests report accurate local position in segments', (WidgetTester tester) async { final Map<int, Widget> children = <int, Widget>{}; late TapDownDetails tapDownDetails; @@ -791,6 +1091,39 @@ expect(tapDownDetails.globalPosition, segment0GlobalOffset + const Offset(7, 11)); }); + testWidgets('Hit-tests report accurate local position in proportional segments', (WidgetTester tester) async { + final Map<int, Widget> children = <int, Widget>{}; + late TapDownDetails tapDownDetails; + children[0] = GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) { tapDownDetails = details; }, + child: const SizedBox(width: 200, height: 200), + ); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Offset segment0GlobalOffset = tester.getTopLeft(find.byWidget(children[0]!)); + await tester.tapAt(segment0GlobalOffset + const Offset(7, 11)); + + expect(tapDownDetails.localPosition, const Offset(7, 11)); + expect(tapDownDetails.globalPosition, segment0GlobalOffset + const Offset(7, 11)); + }); + testWidgets('Thumb animation is correct when the selected segment changes', (WidgetTester tester) async { await tester.pumpWidget(setupSimpleSegmentedControl()); @@ -1084,6 +1417,64 @@ expect(callbackCalled, isFalse); }); + testWidgets('Dragging out of bound does not cause out of range exception', (WidgetTester tester) async { + const Map<int, Widget> children = <int, Widget>{ + 0: Text('A'), + 1: Text('BB'), + 2: Text('CCC'), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor( + of: find.byWidget(children[index]!), + matching: find.byType(MetaData) + ) + ); + } + + expect(getChildSize(0).width, 32.5); + expect(getChildSize(2).width, 60.5); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Dragging to left until out of bound. + await gesture.moveTo(const Offset(-100, 0)); + await tester.pump(); + expect(getHighlightedIndex(tester), 0); + + // Move the pointer to the last child and continue dragging until out of bound. + final Offset thirdChild = tester.getCenter(find.text('CCC')); + await gesture.moveTo(thirdChild); + await tester.pump(); + + await gesture.moveTo(thirdChild + const Offset(100, 0)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 2); + + expect(tester.takeException(), isNull); + }); + testWidgets('Disallow new gesture when dragging', (WidgetTester tester) async { const Map<int, Widget> children = <int, Widget>{ 0: Text('A'),