1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'dart:collection';
6library;
7
8import 'dart:math' as math;
9import 'dart:math';
10
11import 'package:collection/collection.dart';
12import 'package:flutter/foundation.dart';
13import 'package:flutter/gestures.dart';
14import 'package:flutter/physics.dart';
15import 'package:flutter/rendering.dart';
16import 'package:flutter/widgets.dart';
17
18import 'colors.dart';
19
20// Extracted from https://developer.apple.com/design/resources/.
21// Default values have been updated to match iOS 17 figma file: https://www.figma.com/community/file/1248375255495415511.
22
23// Minimum padding from edges of the segmented control to edges of
24// encompassing widget.
25const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(vertical: 2, horizontal: 3);
26
27// The corner radius of the segmented control.
28const Radius _kCornerRadius = Radius.circular(9);
29
30// The corner radius of the thumb.
31const Radius _kThumbRadius = Radius.circular(7);
32// The amount of space by which to expand the thumb from the size of the currently
33// selected child.
34const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: 1);
35
36// Minimum height of the segmented control.
37const double _kMinSegmentedControlHeight = 28.0;
38
39const Color _kSeparatorColor = Color(0x4D8E8E93);
40
41const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness(
42 color: Color(0xFFFFFFFF),
43 darkColor: Color(0xFF636366),
44);
45
46// The amount of space by which to inset each separator.
47const EdgeInsets _kSeparatorInset = EdgeInsets.symmetric(vertical: 5);
48const double _kSeparatorWidth = 1;
49const Radius _kSeparatorRadius = Radius.circular(_kSeparatorWidth / 2);
50
51// The minimum scale factor of the thumb, when being pressed on for a sufficient
52// amount of time.
53const double _kMinThumbScale = 0.95;
54
55// The minimum horizontal distance between the edges of the separator and the
56// closest child.
57const double _kSegmentMinPadding = 10;
58
59// The threshold value used in hasDraggedTooFar, for checking against the square
60// L2 distance from the location of the current drag pointer, to the closest
61// vertex of the CupertinoSlidingSegmentedControl's Rect.
62//
63// Both the mechanism and the value are speculated.
64const double _kTouchYDistanceThreshold = 50.0 * 50.0;
65
66// The minimum opacity of an unselected segment, when the user presses on the
67// segment and it starts to fadeout.
68//
69// Inspected from iOS 17.5 simulator.
70const double _kContentPressedMinOpacity = 0.2;
71
72// Inspected from iOS 17.5 simulator.
73const double _kFontSize = 13.0;
74
75// Inspected from iOS 17.5 simulator.
76const FontWeight _kFontWeight = FontWeight.w500;
77
78// Inspected from iOS 17.5 simulator.
79const FontWeight _kHighlightedFontWeight = FontWeight.w600;
80
81// Inspected from iOS 17.5 simulator
82const Color _kDisabledContentColor = Color.fromARGB(115, 122, 122, 122);
83
84// The spring animation used when the thumb changes its rect.
85final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
86 const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
87 0,
88 1,
89 0, // Every time a new spring animation starts the previous animation stops.
90);
91
92const Duration _kSpringAnimationDuration = Duration(milliseconds: 412);
93
94const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470);
95
96const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200);
97
98class _Segment<T> extends StatefulWidget {
99 const _Segment({
100 required ValueKey<T> key,
101 required this.child,
102 required this.pressed,
103 required this.highlighted,
104 required this.isDragging,
105 required this.enabled,
106 required this.segmentLocation,
107 }) : super(key: key);
108
109 final Widget child;
110
111 final bool pressed;
112 final bool highlighted;
113 final bool enabled;
114 final _SegmentLocation segmentLocation;
115
116 // Whether the thumb of the parent widget (CupertinoSlidingSegmentedControl)
117 // is currently being dragged.
118 final bool isDragging;
119
120 bool get shouldFadeoutContent => pressed && !highlighted && enabled;
121 bool get shouldScaleContent => pressed && highlighted && isDragging && enabled;
122
123 @override
124 _SegmentState<T> createState() => _SegmentState<T>();
125}
126
127class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<_Segment<T>> {
128 late final AnimationController highlightPressScaleController;
129 late Animation<double> highlightPressScaleAnimation;
130
131 @override
132 void initState() {
133 super.initState();
134 highlightPressScaleController = AnimationController(
135 duration: _kOpacityAnimationDuration,
136 value: widget.shouldScaleContent ? 1 : 0,
137 vsync: this,
138 );
139
140 highlightPressScaleAnimation = highlightPressScaleController.drive(
141 Tween<double>(begin: 1.0, end: _kMinThumbScale),
142 );
143 }
144
145 @override
146 void didUpdateWidget(_Segment<T> oldWidget) {
147 super.didUpdateWidget(oldWidget);
148 assert(oldWidget.key == widget.key);
149
150 if (oldWidget.shouldScaleContent != widget.shouldScaleContent) {
151 highlightPressScaleAnimation = highlightPressScaleController.drive(
152 Tween<double>(
153 begin: highlightPressScaleAnimation.value,
154 end: widget.shouldScaleContent ? _kMinThumbScale : 1.0,
155 ),
156 );
157 highlightPressScaleController.animateWith(_kThumbSpringAnimationSimulation);
158 }
159 }
160
161 @override
162 void dispose() {
163 highlightPressScaleController.dispose();
164 super.dispose();
165 }
166
167 @override
168 Widget build(BuildContext context) {
169 final Alignment scaleAlignment = switch (widget.segmentLocation) {
170 _SegmentLocation.leftmost => Alignment.centerLeft,
171 _SegmentLocation.rightmost => Alignment.centerRight,
172 _SegmentLocation.inbetween => Alignment.center,
173 };
174
175 return MetaData(
176 // Expand the hitTest area of this widget.
177 behavior: HitTestBehavior.opaque,
178 child: IndexedStack(
179 alignment: Alignment.center,
180 children: <Widget>[
181 AnimatedOpacity(
182 opacity: widget.shouldFadeoutContent ? _kContentPressedMinOpacity : 1,
183 duration: _kOpacityAnimationDuration,
184 curve: Curves.ease,
185 child: AnimatedDefaultTextStyle(
186 style: DefaultTextStyle.of(context).style.merge(
187 TextStyle(
188 fontWeight: widget.highlighted ? _kHighlightedFontWeight : _kFontWeight,
189 fontSize: _kFontSize,
190 color: widget.enabled ? null : _kDisabledContentColor,
191 ),
192 ),
193 duration: _kHighlightAnimationDuration,
194 curve: Curves.ease,
195 child: ScaleTransition(
196 alignment: scaleAlignment,
197 scale: highlightPressScaleAnimation,
198 child: widget.child,
199 ),
200 ),
201 ),
202 // The entire widget will assume the size of this widget, so when a
203 // segment's "highlight" animation plays the size of the parent stays
204 // the same and will always be greater than equal to that of the
205 // visible child (at index 0), to keep the size of the entire
206 // SegmentedControl widget consistent throughout the animation.
207 DefaultTextStyle.merge(
208 style: const TextStyle(fontWeight: _kHighlightedFontWeight, fontSize: _kFontSize),
209 child: widget.child,
210 ),
211 ],
212 ),
213 );
214 }
215}
216
217// Fadeout the separator when either adjacent segment is highlighted.
218class _SegmentSeparator extends StatefulWidget {
219 const _SegmentSeparator({required ValueKey<int> key, required this.highlighted})
220 : super(key: key);
221
222 final bool highlighted;
223
224 @override
225 _SegmentSeparatorState createState() => _SegmentSeparatorState();
226}
227
228class _SegmentSeparatorState extends State<_SegmentSeparator>
229 with TickerProviderStateMixin<_SegmentSeparator> {
230 late final AnimationController separatorOpacityController;
231
232 @override
233 void initState() {
234 super.initState();
235
236 separatorOpacityController = AnimationController(
237 duration: _kSpringAnimationDuration,
238 value: widget.highlighted ? 0 : 1,
239 vsync: this,
240 );
241 }
242
243 @override
244 void didUpdateWidget(_SegmentSeparator oldWidget) {
245 super.didUpdateWidget(oldWidget);
246 assert(oldWidget.key == widget.key);
247
248 if (oldWidget.highlighted != widget.highlighted) {
249 separatorOpacityController.animateTo(
250 widget.highlighted ? 0 : 1,
251 duration: _kSpringAnimationDuration,
252 curve: Curves.ease,
253 );
254 }
255 }
256
257 @override
258 void dispose() {
259 separatorOpacityController.dispose();
260 super.dispose();
261 }
262
263 @override
264 Widget build(BuildContext context) {
265 return AnimatedBuilder(
266 animation: separatorOpacityController,
267 child: const SizedBox(width: _kSeparatorWidth),
268 builder: (BuildContext context, Widget? child) {
269 return Padding(
270 padding: _kSeparatorInset,
271 child: DecoratedBox(
272 decoration: BoxDecoration(
273 color: _kSeparatorColor.withOpacity(
274 _kSeparatorColor.opacity * separatorOpacityController.value,
275 ),
276 // Use RRect instead of RSuperellipse here since the radius is too
277 // small to make enough visual difference.
278 borderRadius: const BorderRadius.all(_kSeparatorRadius),
279 ),
280 child: child,
281 ),
282 );
283 },
284 );
285 }
286}
287
288/// An iOS 13 style segmented control.
289///
290/// {@youtube 560 315 https://www.youtube.com/watch?v=esnBf6V4C34}
291///
292/// Displays the widgets provided in the [Map] of [children] in a horizontal list.
293/// It allows the user to select between a number of mutually exclusive options,
294/// by tapping or dragging within the segmented control.
295///
296/// A segmented control can feature any [Widget] as one of the values in its
297/// [Map] of [children]. The type T is the type of the [Map] keys used to identify
298/// each widget and determine which widget is selected. As required by the [Map]
299/// class, keys must be of consistent types and must be comparable. The [children]
300/// argument must be an ordered [Map] such as a [LinkedHashMap], the ordering of
301/// the keys will determine the order of the widgets in the segmented control.
302///
303/// The widget calls the [onValueChanged] callback *when a valid user gesture
304/// completes on an unselected segment*. The map key associated with the newly
305/// selected widget is returned in the [onValueChanged] callback. Typically,
306/// widgets that use a segmented control will listen for the [onValueChanged]
307/// callback and rebuild the segmented control with a new [groupValue] to update
308/// which option is currently selected.
309///
310/// The [children] will be displayed in the order of the keys in the [Map],
311/// along the current [TextDirection]. Each child widget will have the same size.
312/// The height of the segmented control is determined by the height of the
313/// tallest child widget. The width of each child will be the intrinsic width of
314/// the widest child, or the available horizontal space divided by the number of
315/// [children], which ever is smaller.
316///
317/// A segmented control may optionally be created with custom colors. The
318/// [thumbColor], [backgroundColor] arguments can be used to override the
319/// segmented control's colors from its defaults.
320///
321/// {@tool dartpad}
322/// This example shows a [CupertinoSlidingSegmentedControl] with an enum type.
323///
324/// The callback provided to [onValueChanged] should update the state of
325/// the parent [StatefulWidget] using the [State.setState] method, so that
326/// the parent gets rebuilt; for example:
327///
328/// ** See code in examples/api/lib/cupertino/segmented_control/cupertino_sliding_segmented_control.0.dart **
329/// {@end-tool}
330/// See also:
331///
332/// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/segmented-controls/>
333class CupertinoSlidingSegmentedControl<T extends Object> extends StatefulWidget {
334 /// Creates an iOS-style segmented control bar.
335 ///
336 /// The [children] argument must be an ordered [Map] such as a
337 /// [LinkedHashMap]. Further, the length of the [children] list must be
338 /// greater than one.
339 ///
340 /// Each widget value in the map of [children] must have an associated key
341 /// that uniquely identifies this widget. This key is what will be returned
342 /// in the [onValueChanged] callback when a new value from the [children] map
343 /// is selected.
344 ///
345 /// The [groupValue] is the currently selected value for the segmented control.
346 /// If no [groupValue] is provided, or the [groupValue] is null, no widget will
347 /// appear as selected. The [groupValue] must be either null or one of the keys
348 /// in the [children] map.
349 CupertinoSlidingSegmentedControl({
350 super.key,
351 required this.children,
352 required this.onValueChanged,
353 this.disabledChildren = const <Never>{},
354 this.groupValue,
355 this.thumbColor = _kThumbColor,
356 this.padding = _kHorizontalItemPadding,
357 this.backgroundColor = CupertinoColors.tertiarySystemFill,
358 this.proportionalWidth = false,
359 }) : assert(children.length >= 2),
360 assert(
361 groupValue == null || children.keys.contains(groupValue),
362 'The groupValue must be either null or one of the keys in the children map.',
363 );
364
365 /// The identifying keys and corresponding widget values in the
366 /// segmented control.
367 ///
368 /// This attribute must be an ordered [Map] such as a [LinkedHashMap]. Each
369 /// widget is typically a single-line [Text] widget or an [Icon] widget.
370 ///
371 /// The map must have more than one entry.
372 final Map<T, Widget> children;
373
374 /// The set of identifying keys that correspond to the segments that should be
375 /// disabled.
376 ///
377 /// Disabled children cannot be selected by dragging, but they can be selected
378 /// programmatically. For example, if the [groupValue] is set to a disabled
379 /// segment, the segment is still selected but the segment content looks disabled.
380 ///
381 /// If an enabled segment is selected by dragging gesture and becomes disabled
382 /// before dragging finishes, [onValueChanged] will be triggered when finger is
383 /// released and the disabled segment is selected.
384 ///
385 /// By default, all segments are selectable.
386 final Set<T> disabledChildren;
387
388 /// The identifier of the widget that is currently selected.
389 ///
390 /// This must be one of the keys in the [Map] of [children].
391 /// If this attribute is null, no widget will be initially selected.
392 final T? groupValue;
393
394 /// The callback that is called when a new option is tapped.
395 ///
396 /// The segmented control passes the newly selected widget's associated key
397 /// to the callback but does not actually change state until the parent
398 /// widget rebuilds the segmented control with the new [groupValue].
399 ///
400 /// The callback provided to [onValueChanged] should update the state of
401 /// the parent [StatefulWidget] using the [State.setState] method, so that
402 /// the parent gets rebuilt; for example:
403 ///
404 /// {@tool snippet}
405 ///
406 /// ```dart
407 /// class SegmentedControlExample extends StatefulWidget {
408 /// const SegmentedControlExample({super.key});
409 ///
410 /// @override
411 /// State createState() => SegmentedControlExampleState();
412 /// }
413 ///
414 /// class SegmentedControlExampleState extends State<SegmentedControlExample> {
415 /// final Map<int, Widget> children = const <int, Widget>{
416 /// 0: Text('Child 1'),
417 /// 1: Text('Child 2'),
418 /// };
419 ///
420 /// int? currentValue;
421 ///
422 /// @override
423 /// Widget build(BuildContext context) {
424 /// return CupertinoSlidingSegmentedControl<int>(
425 /// children: children,
426 /// onValueChanged: (int? newValue) {
427 /// setState(() {
428 /// currentValue = newValue;
429 /// });
430 /// },
431 /// groupValue: currentValue,
432 /// );
433 /// }
434 /// }
435 /// ```
436 /// {@end-tool}
437 final ValueChanged<T?> onValueChanged;
438
439 /// The color used to paint the rounded rect behind the [children] and the separators.
440 ///
441 /// The default value is [CupertinoColors.tertiarySystemFill]. The background
442 /// will not be painted if null is specified.
443 final Color backgroundColor;
444
445 /// Determine whether segments have proportional widths based on their content.
446 ///
447 /// If false, all segments will have the same width, determined by the longest
448 /// segment. If true, each segment's width will be determined by its individual
449 /// content.
450 ///
451 /// If the max width of parent constraints is smaller than the width that the
452 /// segmented control needs, The segment widths will scale down proportionally
453 /// to ensure the segment control fits within the boundaries; similarly, if
454 /// the min width of parent constraints is larger, the segment width will scales
455 /// up to meet the min width requirement.
456 ///
457 /// Defaults to false.
458 final bool proportionalWidth;
459
460 /// The color used to paint the interior of the thumb that appears behind the
461 /// currently selected item.
462 ///
463 /// The default value is a [CupertinoDynamicColor] that appears white in light
464 /// mode and becomes a gray color in dark mode.
465 final Color thumbColor;
466
467 /// The amount of space by which to inset the [children].
468 ///
469 /// Defaults to `EdgeInsets.symmetric(vertical: 2, horizontal: 3)`.
470 final EdgeInsetsGeometry padding;
471
472 @override
473 State<CupertinoSlidingSegmentedControl<T>> createState() => _SegmentedControlState<T>();
474}
475
476class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSegmentedControl<T>>
477 with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> {
478 late final AnimationController thumbController = AnimationController(
479 duration: _kSpringAnimationDuration,
480 value: 0,
481 vsync: this,
482 );
483 Animatable<Rect?>? thumbAnimatable;
484
485 late final AnimationController thumbScaleController = AnimationController(
486 duration: _kSpringAnimationDuration,
487 value: 0,
488 vsync: this,
489 );
490 late Animation<double> thumbScaleAnimation = thumbScaleController.drive(
491 Tween<double>(begin: 1, end: _kMinThumbScale),
492 );
493
494 final TapGestureRecognizer tap = TapGestureRecognizer();
495 final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
496 final LongPressGestureRecognizer longPress = LongPressGestureRecognizer();
497 final GlobalKey segmentedControlRenderWidgetKey = GlobalKey();
498
499 @override
500 void initState() {
501 super.initState();
502
503 // If the long press or horizontal drag recognizer gets accepted, we know for
504 // sure the gesture is meant for the segmented control. Hand everything to
505 // the drag gesture recognizer.
506 final GestureArenaTeam team = GestureArenaTeam();
507 longPress.team = team;
508 drag.team = team;
509 team.captain = drag;
510
511 drag
512 ..onDown = onDown
513 ..onUpdate = onUpdate
514 ..onEnd = onEnd
515 ..onCancel = onCancel;
516
517 tap.onTapUp = onTapUp;
518
519 // Empty callback to enable the long press recognizer.
520 longPress.onLongPress = () {};
521
522 highlighted = widget.groupValue;
523 }
524
525 @override
526 void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) {
527 super.didUpdateWidget(oldWidget);
528
529 // Temporarily ignore highlight changes from the widget when the thumb is
530 // being dragged. When the drag gesture finishes the widget will be forced
531 // to build (see the onEnd method), and didUpdateWidget will be called again.
532 if (!isThumbDragging && highlighted != widget.groupValue) {
533 thumbController.animateWith(_kThumbSpringAnimationSimulation);
534 thumbAnimatable = null;
535 highlighted = widget.groupValue;
536 }
537 }
538
539 @override
540 void dispose() {
541 thumbScaleController.dispose();
542 thumbController.dispose();
543
544 drag.dispose();
545 tap.dispose();
546 longPress.dispose();
547
548 super.dispose();
549 }
550
551 // Whether the current drag gesture started on a selected segment. When this
552 // flag is false, the `onUpdate` method does not update `highlighted`.
553 // Otherwise the thumb can be dragged around in an ongoing drag gesture.
554 bool? _startedOnSelectedSegment;
555
556 // Whether the current drag gesture started on a disabled segment. When this
557 // flag is true, drag gestures will be ignored.
558 bool _startedOnDisabledSegment = false;
559
560 // Whether an ongoing horizontal drag gesture that started on the thumb is
561 // present. When true, defer/ignore changes to the `highlighted` variable
562 // from other sources (except for semantics) until the gesture ends, preventing
563 // them from interfering with the active drag gesture.
564 bool get isThumbDragging => (_startedOnSelectedSegment ?? false) && !_startedOnDisabledSegment;
565
566 // Converts local coordinate to segments.
567 T segmentForXPosition(double dx) {
568 final BuildContext currentContext = segmentedControlRenderWidgetKey.currentContext!;
569 final _RenderSegmentedControl<T> renderBox =
570 currentContext.findRenderObject()! as _RenderSegmentedControl<T>;
571
572 final int numOfChildren = widget.children.length;
573 assert(renderBox.hasSize);
574 assert(numOfChildren >= 2);
575
576 int segmentIndex = renderBox.getClosestSegmentIndex(dx);
577
578 switch (Directionality.of(context)) {
579 case TextDirection.ltr:
580 break;
581 case TextDirection.rtl:
582 segmentIndex = numOfChildren - 1 - segmentIndex;
583 }
584 return widget.children.keys.elementAt(segmentIndex);
585 }
586
587 bool _hasDraggedTooFar(DragUpdateDetails details) {
588 final RenderBox renderBox = context.findRenderObject()! as RenderBox;
589 assert(renderBox.hasSize);
590 final Size size = renderBox.size;
591 final Offset offCenter = details.localPosition - Offset(size.width / 2, size.height / 2);
592 final double l2 =
593 math.pow(math.max(0.0, offCenter.dx.abs() - size.width / 2), 2) +
594 math.pow(math.max(0.0, offCenter.dy.abs() - size.height / 2), 2)
595 as double;
596 return l2 > _kTouchYDistanceThreshold;
597 }
598
599 // The thumb shrinks when the user presses on it, and starts expanding when
600 // the user lets go.
601 // This animation must be synced with the segment scale animation (see the
602 // _Segment widget) to make the overall animation look natural when the thumb
603 // is not sliding.
604 void _playThumbScaleAnimation({required bool isExpanding}) {
605 thumbScaleAnimation = thumbScaleController.drive(
606 Tween<double>(begin: thumbScaleAnimation.value, end: isExpanding ? 1 : _kMinThumbScale),
607 );
608 thumbScaleController.animateWith(_kThumbSpringAnimationSimulation);
609 }
610
611 void onHighlightChangedByGesture(T newValue) {
612 if (highlighted == newValue) {
613 return;
614 }
615
616 setState(() {
617 highlighted = newValue;
618 });
619 // Additionally, start the thumb animation if the highlighted segment
620 // changes. If the thumbController is already running, the render object's
621 // paint method will create a new tween to drive the animation with.
622 // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/74356:
623 // the current thumb will be painted at the same location twice (before and
624 // after the new animation starts).
625 thumbController.animateWith(_kThumbSpringAnimationSimulation);
626 thumbAnimatable = null;
627 }
628
629 void onPressedChangedByGesture(T? newValue) {
630 if (pressed != newValue) {
631 setState(() {
632 pressed = newValue;
633 });
634 }
635 }
636
637 void onTapUp(TapUpDetails details) {
638 // No gesture should interfere with an ongoing thumb drag.
639 if (isThumbDragging) {
640 return;
641 }
642 final T segment = segmentForXPosition(details.localPosition.dx);
643 onPressedChangedByGesture(null);
644 if (segment != widget.groupValue && !widget.disabledChildren.contains(segment)) {
645 widget.onValueChanged(segment);
646 }
647 }
648
649 void onDown(DragDownDetails details) {
650 final T touchDownSegment = segmentForXPosition(details.localPosition.dx);
651 _startedOnSelectedSegment = touchDownSegment == highlighted;
652 _startedOnDisabledSegment = widget.disabledChildren.contains(touchDownSegment);
653 if (widget.disabledChildren.contains(touchDownSegment)) {
654 return;
655 }
656 onPressedChangedByGesture(touchDownSegment);
657
658 if (isThumbDragging) {
659 _playThumbScaleAnimation(isExpanding: false);
660 }
661 }
662
663 void onUpdate(DragUpdateDetails details) {
664 // If drag gesture starts on disabled segment, no update needed.
665 if (_startedOnDisabledSegment) {
666 return;
667 }
668
669 // If drag gesture starts on enabled segment and dragging on disabled segment,
670 // no update needed.
671 final T touchDownSegment = segmentForXPosition(details.localPosition.dx);
672 if (widget.disabledChildren.contains(touchDownSegment)) {
673 return;
674 }
675 if (isThumbDragging) {
676 onPressedChangedByGesture(touchDownSegment);
677 onHighlightChangedByGesture(touchDownSegment);
678 } else {
679 final T? segment = _hasDraggedTooFar(details)
680 ? null
681 : segmentForXPosition(details.localPosition.dx);
682 onPressedChangedByGesture(segment);
683 }
684 }
685
686 void onEnd(DragEndDetails details) {
687 final T? pressed = this.pressed;
688 if (isThumbDragging) {
689 _playThumbScaleAnimation(isExpanding: true);
690 if (highlighted != widget.groupValue) {
691 widget.onValueChanged(highlighted);
692 }
693 } else if (pressed != null) {
694 onHighlightChangedByGesture(pressed);
695 assert(pressed == highlighted);
696 if (highlighted != widget.groupValue) {
697 widget.onValueChanged(highlighted);
698 }
699 }
700
701 onPressedChangedByGesture(null);
702 _startedOnSelectedSegment = null;
703 }
704
705 void onCancel() {
706 if (isThumbDragging) {
707 _playThumbScaleAnimation(isExpanding: true);
708 }
709 onPressedChangedByGesture(null);
710 _startedOnSelectedSegment = null;
711 }
712
713 // The segment the sliding thumb is currently located at, or animating to. It
714 // may have a different value from widget.groupValue, since this widget does
715 // not report a selection change via `onValueChanged` until the user stops
716 // interacting with the widget (onTapUp). For example, the user can drag the
717 // thumb around, and the `onValueChanged` callback will not be invoked until
718 // the thumb is let go.
719 T? highlighted;
720
721 // The segment the user is currently pressing.
722 T? pressed;
723
724 @override
725 Widget build(BuildContext context) {
726 assert(widget.children.length >= 2);
727 List<Widget> children = <Widget>[];
728 bool isPreviousSegmentHighlighted = false;
729
730 int index = 0;
731 int? highlightedIndex;
732 for (final MapEntry<T, Widget> entry in widget.children.entries) {
733 final bool isHighlighted = highlighted == entry.key;
734 if (isHighlighted) {
735 highlightedIndex = index;
736 }
737
738 if (index != 0) {
739 children.add(
740 _SegmentSeparator(
741 // Let separators be TextDirection-invariant. If the TextDirection
742 // changes, the separators should mostly stay where they were.
743 key: ValueKey<int>(index),
744 highlighted: isPreviousSegmentHighlighted || isHighlighted,
745 ),
746 );
747 }
748
749 final TextDirection textDirection = Directionality.of(context);
750 final _SegmentLocation segmentLocation = switch (textDirection) {
751 TextDirection.ltr when index == 0 => _SegmentLocation.leftmost,
752 TextDirection.ltr when index == widget.children.length - 1 => _SegmentLocation.rightmost,
753 TextDirection.rtl when index == widget.children.length - 1 => _SegmentLocation.leftmost,
754 TextDirection.rtl when index == 0 => _SegmentLocation.rightmost,
755 TextDirection.ltr || TextDirection.rtl => _SegmentLocation.inbetween,
756 };
757 children.add(
758 Semantics(
759 button: true,
760 onTap: () {
761 if (widget.disabledChildren.contains(entry.key)) {
762 return;
763 }
764 widget.onValueChanged(entry.key);
765 },
766 inMutuallyExclusiveGroup: true,
767 selected: widget.groupValue == entry.key,
768 child: MouseRegion(
769 cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
770 child: _Segment<T>(
771 key: ValueKey<T>(entry.key),
772 highlighted: isHighlighted,
773 pressed: pressed == entry.key,
774 isDragging: isThumbDragging,
775 enabled: !widget.disabledChildren.contains(entry.key),
776 segmentLocation: segmentLocation,
777 child: entry.value,
778 ),
779 ),
780 ),
781 );
782
783 index += 1;
784 isPreviousSegmentHighlighted = isHighlighted;
785 }
786
787 assert((highlightedIndex == null) == (highlighted == null));
788
789 switch (Directionality.of(context)) {
790 case TextDirection.ltr:
791 break;
792 case TextDirection.rtl:
793 children = children.reversed.toList(growable: false);
794 if (highlightedIndex != null) {
795 highlightedIndex = index - 1 - highlightedIndex;
796 }
797 }
798
799 return UnconstrainedBox(
800 constrainedAxis: Axis.horizontal,
801 child: Container(
802 // Clip the thumb shadow if it is outside of the segmented control. This
803 // behavior is eyeballed by the iOS 17.5 simulator.
804 clipBehavior: Clip.antiAlias,
805 padding: widget.padding.resolve(Directionality.of(context)),
806 decoration: ShapeDecoration(
807 shape: const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(_kCornerRadius)),
808 color: CupertinoDynamicColor.resolve(widget.backgroundColor, context),
809 ),
810 child: AnimatedBuilder(
811 animation: thumbScaleAnimation,
812 builder: (BuildContext context, Widget? child) {
813 return _SegmentedControlRenderWidget<T>(
814 key: segmentedControlRenderWidgetKey,
815 highlightedIndex: highlightedIndex,
816 thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context),
817 thumbScale: thumbScaleAnimation.value,
818 proportionalWidth: widget.proportionalWidth,
819 state: this,
820 children: children,
821 );
822 },
823 ),
824 ),
825 );
826 }
827}
828
829class _SegmentedControlRenderWidget<T extends Object> extends MultiChildRenderObjectWidget {
830 const _SegmentedControlRenderWidget({
831 super.key,
832 super.children,
833 required this.highlightedIndex,
834 required this.thumbColor,
835 required this.thumbScale,
836 required this.proportionalWidth,
837 required this.state,
838 });
839
840 final int? highlightedIndex;
841 final Color thumbColor;
842 final double thumbScale;
843 final bool proportionalWidth;
844 final _SegmentedControlState<T> state;
845
846 @override
847 RenderObject createRenderObject(BuildContext context) {
848 return _RenderSegmentedControl<T>(
849 highlightedIndex: highlightedIndex,
850 thumbColor: thumbColor,
851 thumbScale: thumbScale,
852 proportionalWidth: proportionalWidth,
853 state: state,
854 );
855 }
856
857 @override
858 void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) {
859 assert(renderObject.state == state);
860 renderObject
861 ..thumbColor = thumbColor
862 ..thumbScale = thumbScale
863 ..highlightedIndex = highlightedIndex
864 ..proportionalWidth = proportionalWidth;
865 }
866}
867
868class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<RenderBox> {}
869
870enum _SegmentLocation { leftmost, rightmost, inbetween }
871
872// The behavior of a UISegmentedControl as observed on iOS 13.1:
873//
874// 1. Tap up inside events will set the current selected index to the index of the
875// segment at the tap up location instantaneously (there might be animation but
876// the index change seems to happen before animation finishes), unless the tap
877// down event from the same touch event didn't happen within the segmented
878// control, in which case the touch event will be ignored entirely (will be
879// referring to these touch events as invalid touch events below).
880//
881// 2. A valid tap up event will also trigger the sliding CASpringAnimation (even
882// when it lands on the current segment), starting from the current `frame`
883// of the thumb. The previous sliding animation, if still playing, will be
884// removed and its velocity reset to 0. The sliding animation has a fixed
885// duration, regardless of the distance or transform.
886//
887// 3. When the sliding animation plays two other animations take place. In one animation
888// the content of the current segment gradually becomes "highlighted", turning the
889// font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2).
890// The other is the separator fadein/fadeout animation (duration = 0.41).
891//
892// 4. A tap down event on the segment pointed to by the current selected
893// index will trigger a CABasicAnimation that shrinks the thumb to 95% of its
894// original size, even if the sliding animation is still playing. The
895/// corresponding tap up event inverts the process (eyeballed).
896//
897// 5. A tap down event on other segments will trigger a CABasicAnimation
898// (timingFunction = default, duration = 0.47.) that fades out the content
899// from its current alpha, eventually reducing the alpha of that segment to
900// 20% unless interrupted by a tap up event or the pointer moves out of the
901// region (either outside of the segmented control's vicinity or to a
902// different segment). The reverse animation has the same duration and timing
903// function.
904class _RenderSegmentedControl<T extends Object> extends RenderBox
905 with
906 ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>,
907 RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> {
908 _RenderSegmentedControl({
909 required int? highlightedIndex,
910 required Color thumbColor,
911 required double thumbScale,
912 required bool proportionalWidth,
913 required this.state,
914 }) : _highlightedIndex = highlightedIndex,
915 _thumbColor = thumbColor,
916 _thumbScale = thumbScale,
917 _proportionalWidth = proportionalWidth;
918
919 final _SegmentedControlState<T> state;
920
921 // The current **Unscaled** Thumb Rect in this RenderBox's coordinate space.
922 Rect? currentThumbRect;
923
924 @override
925 void attach(PipelineOwner owner) {
926 super.attach(owner);
927 state.thumbController.addListener(markNeedsPaint);
928 }
929
930 @override
931 void detach() {
932 state.thumbController.removeListener(markNeedsPaint);
933 super.detach();
934 }
935
936 double get thumbScale => _thumbScale;
937 double _thumbScale;
938 set thumbScale(double value) {
939 if (_thumbScale == value) {
940 return;
941 }
942
943 _thumbScale = value;
944 if (state.highlighted != null) {
945 markNeedsPaint();
946 }
947 }
948
949 int? get highlightedIndex => _highlightedIndex;
950 int? _highlightedIndex;
951 set highlightedIndex(int? value) {
952 if (_highlightedIndex == value) {
953 return;
954 }
955
956 _highlightedIndex = value;
957 markNeedsPaint();
958 }
959
960 Color get thumbColor => _thumbColor;
961 Color _thumbColor;
962 set thumbColor(Color value) {
963 if (_thumbColor == value) {
964 return;
965 }
966 _thumbColor = value;
967 markNeedsPaint();
968 }
969
970 bool get proportionalWidth => _proportionalWidth;
971 bool _proportionalWidth;
972 set proportionalWidth(bool value) {
973 if (_proportionalWidth == value) {
974 return;
975 }
976 _proportionalWidth = value;
977 markNeedsLayout();
978 }
979
980 @override
981 void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
982 assert(debugHandleEvent(event, entry));
983 // No gesture should interfere with an ongoing thumb drag.
984 if (event is PointerDownEvent && !state.isThumbDragging) {
985 state.tap.addPointer(event);
986 state.longPress.addPointer(event);
987 state.drag.addPointer(event);
988 }
989 }
990
991 // Intrinsic Dimensions
992 double get separatorWidth => _kSeparatorInset.horizontal + _kSeparatorWidth;
993 double get totalSeparatorWidth => separatorWidth * (childCount ~/ 2);
994
995 int getClosestSegmentIndex(double dx) {
996 int index = 0;
997 RenderBox? child = firstChild;
998 while (child != null) {
999 final _SegmentedControlContainerBoxParentData childParentData =
1000 child.parentData! as _SegmentedControlContainerBoxParentData;
1001 final double clampX = clampDouble(
1002 dx,
1003 childParentData.offset.dx,
1004 child.size.width + childParentData.offset.dx,
1005 );
1006
1007 if (dx <= clampX) {
1008 break;
1009 }
1010
1011 index++;
1012 child = nonSeparatorChildAfter(child);
1013 }
1014
1015 final int segmentCount = childCount ~/ 2 + 1;
1016 // When the thumb is dragging out of bounds, the return result must be
1017 // smaller than segment count.
1018 return min(index, segmentCount - 1);
1019 }
1020
1021 RenderBox? nonSeparatorChildAfter(RenderBox child) {
1022 final RenderBox? nextChild = childAfter(child);
1023 return nextChild == null ? null : childAfter(nextChild);
1024 }
1025
1026 @override
1027 double computeMinIntrinsicWidth(double height) {
1028 final int childCount = this.childCount ~/ 2 + 1;
1029 RenderBox? child = firstChild;
1030 double maxMinChildWidth = 0;
1031 while (child != null) {
1032 final double childWidth = child.getMinIntrinsicWidth(height);
1033 maxMinChildWidth = math.max(maxMinChildWidth, childWidth);
1034 child = nonSeparatorChildAfter(child);
1035 }
1036 return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
1037 }
1038
1039 @override
1040 double computeMaxIntrinsicWidth(double height) {
1041 final int childCount = this.childCount ~/ 2 + 1;
1042 RenderBox? child = firstChild;
1043 double maxMaxChildWidth = 0;
1044 while (child != null) {
1045 final double childWidth = child.getMaxIntrinsicWidth(height);
1046 maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth);
1047 child = nonSeparatorChildAfter(child);
1048 }
1049 return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
1050 }
1051
1052 @override
1053 double computeMinIntrinsicHeight(double width) {
1054 RenderBox? child = firstChild;
1055 double maxMinChildHeight = _kMinSegmentedControlHeight;
1056 while (child != null) {
1057 final double childHeight = child.getMinIntrinsicHeight(width);
1058 maxMinChildHeight = math.max(maxMinChildHeight, childHeight);
1059 child = nonSeparatorChildAfter(child);
1060 }
1061 return maxMinChildHeight;
1062 }
1063
1064 @override
1065 double computeMaxIntrinsicHeight(double width) {
1066 RenderBox? child = firstChild;
1067 double maxMaxChildHeight = _kMinSegmentedControlHeight;
1068 while (child != null) {
1069 final double childHeight = child.getMaxIntrinsicHeight(width);
1070 maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight);
1071 child = nonSeparatorChildAfter(child);
1072 }
1073 return maxMaxChildHeight;
1074 }
1075
1076 @override
1077 double? computeDistanceToActualBaseline(TextBaseline baseline) {
1078 return defaultComputeDistanceToHighestActualBaseline(baseline);
1079 }
1080
1081 @override
1082 void setupParentData(RenderBox child) {
1083 if (child.parentData is! _SegmentedControlContainerBoxParentData) {
1084 child.parentData = _SegmentedControlContainerBoxParentData();
1085 }
1086 }
1087
1088 double _getMaxChildWidth(BoxConstraints constraints) {
1089 final int childCount = this.childCount ~/ 2 + 1;
1090 double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount;
1091 RenderBox? child = firstChild;
1092 while (child != null) {
1093 childWidth = math.max(
1094 childWidth,
1095 child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding,
1096 );
1097 child = nonSeparatorChildAfter(child);
1098 }
1099 return math.min(childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount);
1100 }
1101
1102 double _getMaxChildHeight(BoxConstraints constraints, double childWidth) {
1103 double maxHeight = _kMinSegmentedControlHeight;
1104 RenderBox? child = firstChild;
1105 while (child != null) {
1106 final double boxHeight = child.getMaxIntrinsicHeight(childWidth);
1107 maxHeight = math.max(maxHeight, boxHeight);
1108 child = nonSeparatorChildAfter(child);
1109 }
1110 return maxHeight;
1111 }
1112
1113 List<double> _getChildWidths(BoxConstraints constraints) {
1114 if (!proportionalWidth) {
1115 final double maxChildWidth = _getMaxChildWidth(constraints);
1116 final int segmentCount = childCount ~/ 2 + 1;
1117 return List<double>.filled(segmentCount, maxChildWidth);
1118 }
1119
1120 final List<double> segmentWidths = <double>[];
1121 RenderBox? child = firstChild;
1122 while (child != null) {
1123 final double childWidth =
1124 child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding;
1125 child = nonSeparatorChildAfter(child);
1126 segmentWidths.add(childWidth);
1127 }
1128
1129 final double totalWidth = segmentWidths.sum;
1130
1131 // If the sum of the children's width is larger than the allowed max width,
1132 // each segment width should scale down until the overall size can fit in
1133 // the parent constraints; similarly, if the sum of the children's width is
1134 // smaller than the allowed min width, each segment width should scale up
1135 // until the overall size can fit in the parent constraints.
1136 final double allowedMaxWidth = constraints.maxWidth - totalSeparatorWidth;
1137 final double allowedMinWidth = constraints.minWidth - totalSeparatorWidth;
1138
1139 final double scale = clampDouble(totalWidth, allowedMinWidth, allowedMaxWidth) / totalWidth;
1140 if (scale != 1) {
1141 for (int i = 0; i < segmentWidths.length; i++) {
1142 segmentWidths[i] = segmentWidths[i] * scale;
1143 }
1144 }
1145 return segmentWidths;
1146 }
1147
1148 Size _computeOverallSize(BoxConstraints constraints) {
1149 final double maxChildHeight = _getMaxChildHeight(constraints, constraints.maxWidth);
1150 return constraints.constrain(
1151 Size(_getChildWidths(constraints).sum + totalSeparatorWidth, maxChildHeight),
1152 );
1153 }
1154
1155 @override
1156 double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
1157 final List<double> segmentWidths = _getChildWidths(constraints);
1158 final double childHeight = _getMaxChildHeight(constraints, constraints.maxWidth);
1159
1160 int index = 0;
1161 BaselineOffset baselineOffset = BaselineOffset.noBaseline;
1162 RenderBox? child = firstChild;
1163 while (child != null) {
1164 final BoxConstraints childConstraints = BoxConstraints.tight(
1165 Size(segmentWidths[index], childHeight),
1166 );
1167 baselineOffset = baselineOffset.minOf(
1168 BaselineOffset(child.getDryBaseline(childConstraints, baseline)),
1169 );
1170
1171 child = nonSeparatorChildAfter(child);
1172 index++;
1173 }
1174
1175 return baselineOffset.offset;
1176 }
1177
1178 @override
1179 Size computeDryLayout(BoxConstraints constraints) {
1180 return _computeOverallSize(constraints);
1181 }
1182
1183 @override
1184 void performLayout() {
1185 final BoxConstraints constraints = this.constraints;
1186 final List<double> segmentWidths = _getChildWidths(constraints);
1187
1188 final double childHeight = _getMaxChildHeight(constraints, double.infinity);
1189 final BoxConstraints separatorConstraints = BoxConstraints(
1190 minHeight: childHeight,
1191 maxHeight: childHeight,
1192 );
1193 RenderBox? child = firstChild;
1194 int index = 0;
1195 double start = 0;
1196 while (child != null) {
1197 final BoxConstraints childConstraints = BoxConstraints.tight(
1198 Size(segmentWidths[index ~/ 2], childHeight),
1199 );
1200 child.layout(index.isEven ? childConstraints : separatorConstraints, parentUsesSize: true);
1201 final _SegmentedControlContainerBoxParentData childParentData =
1202 child.parentData! as _SegmentedControlContainerBoxParentData;
1203 final Offset childOffset = Offset(start, 0);
1204 childParentData.offset = childOffset;
1205 start += child.size.width;
1206 assert(
1207 index.isEven || child.size.width == _kSeparatorWidth + _kSeparatorInset.horizontal,
1208 '${child.size.width} != ${_kSeparatorWidth + _kSeparatorInset.horizontal}',
1209 );
1210 child = childAfter(child);
1211 index += 1;
1212 }
1213 size = _computeOverallSize(constraints);
1214 }
1215
1216 // This method is used to convert the original unscaled thumb rect painted in
1217 // the previous frame, to a Rect that is within the valid boundary defined by
1218 // the child segments.
1219 //
1220 // The overall size does not include that of the thumb. That is, if the thumb
1221 // is located at the first or the last segment, the thumb can get cut off if
1222 // one of the values in _kThumbInsets is positive.
1223 Rect? moveThumbRectInBound(Rect? thumbRect, List<RenderBox> children) {
1224 assert(hasSize);
1225 assert(children.length >= 2);
1226 if (thumbRect == null) {
1227 return null;
1228 }
1229
1230 final Offset firstChildOffset =
1231 (children.first.parentData! as _SegmentedControlContainerBoxParentData).offset;
1232 final double leftMost = firstChildOffset.dx;
1233 final double rightMost =
1234 (children.last.parentData! as _SegmentedControlContainerBoxParentData).offset.dx +
1235 children.last.size.width;
1236 assert(rightMost > leftMost);
1237
1238 // Ignore the horizontal position and the height of `thumbRect`, and
1239 // calculates them from `children`.
1240 return Rect.fromLTRB(
1241 math.max(thumbRect.left, leftMost - _kThumbInsets.left),
1242 firstChildOffset.dy - _kThumbInsets.top,
1243 math.min(thumbRect.right, rightMost + _kThumbInsets.right),
1244 firstChildOffset.dy + children.first.size.height + _kThumbInsets.bottom,
1245 );
1246 }
1247
1248 @override
1249 void paint(PaintingContext context, Offset offset) {
1250 final List<RenderBox> children = getChildrenAsList();
1251
1252 // Children contains both segment and separator and the order is segment ->
1253 // separator -> segment. So to paint separators, index should start from 1 and
1254 // the step should be 2.
1255 for (int index = 1; index < childCount; index += 2) {
1256 _paintSeparator(context, offset, children[index]);
1257 }
1258
1259 final int? highlightedChildIndex = highlightedIndex;
1260 // Paint thumb if there's a highlighted segment.
1261 if (highlightedChildIndex != null) {
1262 final RenderBox selectedChild = children[highlightedChildIndex * 2];
1263
1264 final _SegmentedControlContainerBoxParentData childParentData =
1265 selectedChild.parentData! as _SegmentedControlContainerBoxParentData;
1266 final Rect newThumbRect = _kThumbInsets.inflateRect(
1267 childParentData.offset & selectedChild.size,
1268 );
1269
1270 // Update thumb animation's tween, in case the end rect changed (e.g., a
1271 // new segment is added during the animation).
1272 if (state.thumbController.isAnimating) {
1273 final Animatable<Rect?>? thumbTween = state.thumbAnimatable;
1274 if (thumbTween == null) {
1275 // This is the first frame of the animation.
1276 final Rect startingRect =
1277 moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect;
1278 state.thumbAnimatable = RectTween(begin: startingRect, end: newThumbRect);
1279 } else if (newThumbRect != thumbTween.transform(1)) {
1280 // The thumbTween of the running sliding animation needs updating,
1281 // without restarting the animation.
1282 final Rect startingRect =
1283 moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect;
1284 state.thumbAnimatable = RectTween(
1285 begin: startingRect,
1286 end: newThumbRect,
1287 ).chain(CurveTween(curve: Interval(state.thumbController.value, 1)));
1288 }
1289 } else {
1290 state.thumbAnimatable = null;
1291 }
1292
1293 final Rect unscaledThumbRect =
1294 state.thumbAnimatable?.evaluate(state.thumbController) ?? newThumbRect;
1295 currentThumbRect = unscaledThumbRect;
1296
1297 final _SegmentLocation childLocation;
1298 if (highlightedChildIndex == 0) {
1299 childLocation = _SegmentLocation.leftmost;
1300 } else if (highlightedChildIndex == children.length ~/ 2) {
1301 childLocation = _SegmentLocation.rightmost;
1302 } else {
1303 childLocation = _SegmentLocation.inbetween;
1304 }
1305
1306 final double delta = switch (childLocation) {
1307 _SegmentLocation.leftmost => unscaledThumbRect.width - unscaledThumbRect.width * thumbScale,
1308 _SegmentLocation.rightmost =>
1309 unscaledThumbRect.width * thumbScale - unscaledThumbRect.width,
1310 _SegmentLocation.inbetween => 0,
1311 };
1312
1313 final Rect thumbRect = Rect.fromCenter(
1314 center: unscaledThumbRect.center - Offset(delta / 2, 0),
1315 width: unscaledThumbRect.width * thumbScale,
1316 height: unscaledThumbRect.height * thumbScale,
1317 );
1318
1319 _paintThumb(context, offset, thumbRect);
1320 } else {
1321 currentThumbRect = null;
1322 }
1323
1324 for (int index = 0; index < children.length; index += 2) {
1325 // Children contains both segment and separator and the order is segment ->
1326 // separator -> segment. So to paint separators, index should start from 0 and
1327 // the step should be 2.
1328 _paintChild(context, offset, children[index]);
1329 }
1330 }
1331
1332 // Paint the separator to the right of the given child.
1333 final Paint separatorPaint = Paint();
1334 void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) {
1335 final _SegmentedControlContainerBoxParentData childParentData =
1336 child.parentData! as _SegmentedControlContainerBoxParentData;
1337 context.paintChild(child, offset + childParentData.offset);
1338 }
1339
1340 void _paintChild(PaintingContext context, Offset offset, RenderBox child) {
1341 final _SegmentedControlContainerBoxParentData childParentData =
1342 child.parentData! as _SegmentedControlContainerBoxParentData;
1343 context.paintChild(child, childParentData.offset + offset);
1344 }
1345
1346 void _paintThumb(PaintingContext context, Offset offset, Rect thumbRect) {
1347 // Colors extracted from https://developer.apple.com/design/resources/.
1348 const List<BoxShadow> thumbShadow = <BoxShadow>[
1349 BoxShadow(color: Color(0x1F000000), offset: Offset(0, 3), blurRadius: 8),
1350 BoxShadow(color: Color(0x0A000000), offset: Offset(0, 3), blurRadius: 1),
1351 ];
1352
1353 final RSuperellipse thumbShape = RSuperellipse.fromRectAndRadius(
1354 thumbRect.shift(offset),
1355 _kThumbRadius,
1356 );
1357
1358 for (final BoxShadow shadow in thumbShadow) {
1359 context.canvas.drawRSuperellipse(thumbShape.shift(shadow.offset), shadow.toPaint());
1360 }
1361
1362 context.canvas.drawRSuperellipse(
1363 thumbShape.inflate(0.5),
1364 Paint()..color = const Color(0x0A000000),
1365 );
1366
1367 context.canvas.drawRSuperellipse(thumbShape, Paint()..color = thumbColor);
1368 }
1369
1370 @override
1371 bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
1372 RenderBox? child = lastChild;
1373 while (child != null) {
1374 final _SegmentedControlContainerBoxParentData childParentData =
1375 child.parentData! as _SegmentedControlContainerBoxParentData;
1376 if ((childParentData.offset & child.size).contains(position)) {
1377 return result.addWithPaintOffset(
1378 offset: childParentData.offset,
1379 position: position,
1380 hitTest: (BoxHitTestResult result, Offset localOffset) {
1381 assert(localOffset == position - childParentData.offset);
1382 return child!.hitTest(result, position: localOffset);
1383 },
1384 );
1385 }
1386 child = childParentData.previousSibling;
1387 }
1388 return false;
1389 }
1390}
1391