@@ -18,13 +18,17 @@ import 'package:flutter/widgets.dart';
1818import 'colors.dart' ;
1919
2020// 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.
2122
2223// Minimum padding from edges of the segmented control to edges of
2324// encompassing widget.
2425const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets .symmetric (vertical: 2 , horizontal: 3 );
2526
27+ // The corner radius of the segmented control.
28+ const Radius _kCornerRadius = Radius .circular (9 );
29+
2630// The corner radius of the thumb.
27- const Radius _kThumbRadius = Radius .circular (6.93 );
31+ const Radius _kThumbRadius = Radius .circular (7 );
2832// The amount of space by which to expand the thumb from the size of the currently
2933// selected child.
3034const EdgeInsets _kThumbInsets = EdgeInsets .symmetric (horizontal: 1 );
@@ -40,17 +44,17 @@ const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness(
4044);
4145
4246// The amount of space by which to inset each separator.
43- const EdgeInsets _kSeparatorInset = EdgeInsets .symmetric (vertical: 6 );
47+ const EdgeInsets _kSeparatorInset = EdgeInsets .symmetric (vertical: 5 );
4448const double _kSeparatorWidth = 1 ;
45- const Radius _kSeparatorRadius = Radius .circular (_kSeparatorWidth/ 2 );
49+ const Radius _kSeparatorRadius = Radius .circular (_kSeparatorWidth / 2 );
4650
4751// The minimum scale factor of the thumb, when being pressed on for a sufficient
4852// amount of time.
4953const double _kMinThumbScale = 0.95 ;
5054
5155// The minimum horizontal distance between the edges of the separator and the
5256// closest child.
53- const double _kSegmentMinPadding = 9.25 ;
57+ const double _kSegmentMinPadding = 10 ;
5458
5559// The threshold value used in hasDraggedTooFar, for checking against the square
5660// L2 distance from the location of the current drag pointer, to the closest
@@ -59,17 +63,24 @@ const double _kSegmentMinPadding = 9.25;
5963// Both the mechanism and the value are speculated.
6064const double _kTouchYDistanceThreshold = 50.0 * 50.0 ;
6165
62- // The corner radius of the segmented control.
63- //
64- // Inspected from iOS 13.2 simulator.
65- const double _kCornerRadius = 8 ;
66-
6766// The minimum opacity of an unselected segment, when the user presses on the
6867// segment and it starts to fadeout.
6968//
70- // Inspected from iOS 13.2 simulator.
69+ // Inspected from iOS 17.5 simulator.
7170const double _kContentPressedMinOpacity = 0.2 ;
7271
72+ // Inspected from iOS 17.5 simulator.
73+ const double _kFontSize = 13.0 ;
74+
75+ // Inspected from iOS 17.5 simulator.
76+ const FontWeight _kFontWeight = FontWeight .w500;
77+
78+ // Inspected from iOS 17.5 simulator.
79+ const FontWeight _kHighlightedFontWeight = FontWeight .w600;
80+
81+ // Inspected from iOS 17.5 simulator
82+ const Color _kDisabledContentColor = Color .fromARGB (115 , 122 , 122 , 122 );
83+
7384// The spring animation used when the thumb changes its rect.
7485final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation (
7586 const SpringDescription (mass: 1 , stiffness: 503.551 , damping: 44.8799 ),
@@ -91,19 +102,23 @@ class _Segment<T> extends StatefulWidget {
91102 required this .pressed,
92103 required this .highlighted,
93104 required this .isDragging,
105+ required this .enabled,
106+ required this .segmentLocation,
94107 }) : super (key: key);
95108
96109 final Widget child;
97110
98111 final bool pressed;
99112 final bool highlighted;
113+ final bool enabled;
114+ final _SegmentLocation segmentLocation;
100115
101116 // Whether the thumb of the parent widget (CupertinoSlidingSegmentedControl)
102117 // is currently being dragged.
103118 final bool isDragging;
104119
105- bool get shouldFadeoutContent => pressed && ! highlighted;
106- bool get shouldScaleContent => pressed && highlighted && isDragging;
120+ bool get shouldFadeoutContent => pressed && ! highlighted && enabled ;
121+ bool get shouldScaleContent => pressed && highlighted && isDragging && enabled ;
107122
108123 @override
109124 _SegmentState <T > createState () => _SegmentState <T >();
@@ -151,6 +166,12 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
151166
152167 @override
153168 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+
154175 return MetaData (
155176 // Expand the hitTest area of this widget.
156177 behavior: HitTestBehavior .opaque,
@@ -164,10 +185,15 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
164185 child: AnimatedDefaultTextStyle (
165186 style: DefaultTextStyle .of (context)
166187 .style
167- .merge (TextStyle (fontWeight: widget.highlighted ? FontWeight .w500 : FontWeight .normal)),
188+ .merge (TextStyle (
189+ fontWeight: widget.highlighted ? _kHighlightedFontWeight : _kFontWeight,
190+ fontSize: _kFontSize,
191+ color: widget.enabled ? null : _kDisabledContentColor,
192+ )),
168193 duration: _kHighlightAnimationDuration,
169194 curve: Curves .ease,
170195 child: ScaleTransition (
196+ alignment: scaleAlignment,
171197 scale: highlightPressScaleAnimation,
172198 child: widget.child,
173199 ),
@@ -178,11 +204,12 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
178204 // the same and will always be greater than equal to that of the
179205 // visible child (at index 0), to keep the size of the entire
180206 // SegmentedControl widget consistent throughout the animation.
181- Offstage (
182- child : DefaultTextStyle . merge (
183- style : const TextStyle ( fontWeight: FontWeight .w500) ,
184- child : widget.child ,
207+ DefaultTextStyle . merge (
208+ style : const TextStyle (
209+ fontWeight: _kHighlightedFontWeight ,
210+ fontSize : _kFontSize ,
185211 ),
212+ child: widget.child
186213 ),
187214 ],
188215 ),
@@ -321,6 +348,7 @@ class CupertinoSlidingSegmentedControl<T extends Object> extends StatefulWidget
321348 super .key,
322349 required this .children,
323350 required this .onValueChanged,
351+ this .disabledChildren = const < Never > {},
324352 this .groupValue,
325353 this .thumbColor = _kThumbColor,
326354 this .padding = _kHorizontalItemPadding,
@@ -341,6 +369,20 @@ class CupertinoSlidingSegmentedControl<T extends Object> extends StatefulWidget
341369 /// The map must have more than one entry.
342370 final Map <T , Widget > children;
343371
372+ /// The set of identifying keys that correspond to the segments that should be
373+ /// disabled.
374+ ///
375+ /// Disabled children cannot be selected by dragging, but they can be selected
376+ /// programmatically. For example, if the [groupValue] is set to a disabled
377+ /// segment, the segment is still selected but the segment content looks disabled.
378+ ///
379+ /// If an enabled segment is selected by dragging gesture and becomes disabled
380+ /// before dragging finishes, [onValueChanged] will be triggered when finger is
381+ /// released and the disabled segment is selected.
382+ ///
383+ /// By default, all segments are selectable.
384+ final Set <T > disabledChildren;
385+
344386 /// The identifier of the widget that is currently selected.
345387 ///
346388 /// This must be one of the keys in the [Map] of [children] .
@@ -499,11 +541,15 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
499541 // Otherwise the thumb can be dragged around in an ongoing drag gesture.
500542 bool ? _startedOnSelectedSegment;
501543
544+ // Whether the current drag gesture started on a disabled segment. When this
545+ // flag is true, drag gestures will be ignored.
546+ bool _startedOnDisabledSegment = false ;
547+
502548 // Whether an ongoing horizontal drag gesture that started on the thumb is
503549 // present. When true, defer/ignore changes to the `highlighted` variable
504550 // from other sources (except for semantics) until the gesture ends, preventing
505551 // them from interfering with the active drag gesture.
506- bool get isThumbDragging => _startedOnSelectedSegment ?? false ;
552+ bool get isThumbDragging => ( _startedOnSelectedSegment ?? false ) && ! _startedOnDisabledSegment ;
507553
508554 // Converts local coordinate to segments.
509555 T segmentForXPosition (double dx) {
@@ -554,6 +600,7 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
554600 if (highlighted == newValue) {
555601 return ;
556602 }
603+
557604 setState (() { highlighted = newValue; });
558605 // Additionally, start the thumb animation if the highlighted segment
559606 // changes. If the thumbController is already running, the render object's
@@ -578,14 +625,18 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
578625 }
579626 final T segment = segmentForXPosition (details.localPosition.dx);
580627 onPressedChangedByGesture (null );
581- if (segment != widget.groupValue) {
628+ if (segment != widget.groupValue && ! widget.disabledChildren. contains (segment) ) {
582629 widget.onValueChanged (segment);
583630 }
584631 }
585632
586633 void onDown (DragDownDetails details) {
587634 final T touchDownSegment = segmentForXPosition (details.localPosition.dx);
588635 _startedOnSelectedSegment = touchDownSegment == highlighted;
636+ _startedOnDisabledSegment = widget.disabledChildren.contains (touchDownSegment);
637+ if (widget.disabledChildren.contains (touchDownSegment)) {
638+ return ;
639+ }
589640 onPressedChangedByGesture (touchDownSegment);
590641
591642 if (isThumbDragging) {
@@ -594,10 +645,20 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
594645 }
595646
596647 void onUpdate (DragUpdateDetails details) {
648+ // If drag gesture starts on disabled segment, no update needed.
649+ if (_startedOnDisabledSegment) {
650+ return ;
651+ }
652+
653+ // If drag gesture starts on enabled segment and dragging on disabled segment,
654+ // no update needed.
655+ final T touchDownSegment = segmentForXPosition (details.localPosition.dx);
656+ if (widget.disabledChildren.contains (touchDownSegment)) {
657+ return ;
658+ }
597659 if (isThumbDragging) {
598- final T segment = segmentForXPosition (details.localPosition.dx);
599- onPressedChangedByGesture (segment);
600- onHighlightChangedByGesture (segment);
660+ onPressedChangedByGesture (touchDownSegment);
661+ onHighlightChangedByGesture (touchDownSegment);
601662 } else {
602663 final T ? segment = _hasDraggedTooFar (details)
603664 ? null
@@ -629,7 +690,6 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
629690 if (isThumbDragging) {
630691 _playThumbScaleAnimation (isExpanding: true );
631692 }
632-
633693 onPressedChangedByGesture (null );
634694 _startedOnSelectedSegment = null ;
635695 }
@@ -670,10 +730,23 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
670730 );
671731 }
672732
733+ final TextDirection textDirection = Directionality .of (context);
734+ final _SegmentLocation segmentLocation = switch (textDirection) {
735+ TextDirection .ltr when index == 0 => _SegmentLocation .leftmost,
736+ TextDirection .ltr when index == widget.children.length - 1 => _SegmentLocation .rightmost,
737+ TextDirection .rtl when index == widget.children.length - 1 => _SegmentLocation .leftmost,
738+ TextDirection .rtl when index == 0 => _SegmentLocation .rightmost,
739+ TextDirection .ltr || TextDirection .rtl => _SegmentLocation .inbetween,
740+ };
673741 children.add (
674742 Semantics (
675743 button: true ,
676- onTap: () { widget.onValueChanged (entry.key); },
744+ onTap: () {
745+ if (widget.disabledChildren.contains (entry.key)) {
746+ return ;
747+ }
748+ widget.onValueChanged (entry.key);
749+ },
677750 inMutuallyExclusiveGroup: true ,
678751 selected: widget.groupValue == entry.key,
679752 child: MouseRegion (
@@ -683,6 +756,8 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
683756 highlighted: isHighlighted,
684757 pressed: pressed == entry.key,
685758 isDragging: isThumbDragging,
759+ enabled: ! widget.disabledChildren.contains (entry.key),
760+ segmentLocation: segmentLocation,
686761 child: entry.value,
687762 ),
688763 ),
@@ -708,9 +783,12 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
708783 return UnconstrainedBox (
709784 constrainedAxis: Axis .horizontal,
710785 child: Container (
786+ // Clip the thumb shadow if it is outside of the segmented control. This
787+ // behavior is eyeballed by the iOS 17.5 simulator.
788+ clipBehavior: Clip .antiAlias,
711789 padding: widget.padding.resolve (Directionality .of (context)),
712790 decoration: BoxDecoration (
713- borderRadius: const BorderRadius .all (Radius . circular ( _kCornerRadius) ),
791+ borderRadius: const BorderRadius .all (_kCornerRadius),
714792 color: CupertinoDynamicColor .resolve (widget.backgroundColor, context),
715793 ),
716794 child: AnimatedBuilder (
@@ -773,6 +851,12 @@ class _SegmentedControlRenderWidget<T extends Object> extends MultiChildRenderOb
773851
774852class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData <RenderBox > { }
775853
854+ enum _SegmentLocation {
855+ leftmost,
856+ rightmost,
857+ inbetween;
858+ }
859+
776860// The behavior of a UISegmentedControl as observed on iOS 13.1:
777861//
778862// 1. Tap up inside events will set the current selected index to the index of the
@@ -1131,6 +1215,9 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
11311215 void paint (PaintingContext context, Offset offset) {
11321216 final List <RenderBox > children = getChildrenAsList ();
11331217
1218+ // Children contains both segment and separator and the order is segment ->
1219+ // separator -> segment. So to paint separators, index should start from 1 and
1220+ // the step should be 2.
11341221 for (int index = 1 ; index < childCount; index += 2 ) {
11351222 _paintSeparator (context, offset, children[index]);
11361223 }
@@ -1164,8 +1251,24 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
11641251
11651252 final Rect unscaledThumbRect = state.thumbAnimatable? .evaluate (state.thumbController) ?? newThumbRect;
11661253 currentThumbRect = unscaledThumbRect;
1254+
1255+ final _SegmentLocation childLocation;
1256+ if (highlightedChildIndex == 0 ) {
1257+ childLocation = _SegmentLocation .leftmost;
1258+ } else if (highlightedChildIndex == children.length ~ / 2 ) {
1259+ childLocation = _SegmentLocation .rightmost;
1260+ } else {
1261+ childLocation = _SegmentLocation .inbetween;
1262+ }
1263+
1264+ final double delta = switch (childLocation) {
1265+ _SegmentLocation .leftmost => unscaledThumbRect.width - unscaledThumbRect.width * thumbScale,
1266+ _SegmentLocation .rightmost => unscaledThumbRect.width * thumbScale - unscaledThumbRect.width,
1267+ _SegmentLocation .inbetween => 0 ,
1268+ };
1269+
11671270 final Rect thumbRect = Rect .fromCenter (
1168- center: unscaledThumbRect.center,
1271+ center: unscaledThumbRect.center - Offset (delta / 2 , 0 ) ,
11691272 width: unscaledThumbRect.width * thumbScale,
11701273 height: unscaledThumbRect.height * thumbScale,
11711274 );
@@ -1176,6 +1279,9 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
11761279 }
11771280
11781281 for (int index = 0 ; index < children.length; index += 2 ) {
1282+ // Children contains both segment and separator and the order is segment ->
1283+ // separator -> segment. So to paint separators, indes should start from 0 and
1284+ // the step should be 2.
11791285 _paintChild (context, offset, children[index]);
11801286 }
11811287 }
0 commit comments