@@ -18,13 +18,17 @@ import 'package:flutter/widgets.dart';
18
18
import 'colors.dart' ;
19
19
20
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.
21
22
22
23
// Minimum padding from edges of the segmented control to edges of
23
24
// encompassing widget.
24
25
const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets .symmetric (vertical: 2 , horizontal: 3 );
25
26
27
+ // The corner radius of the segmented control.
28
+ const Radius _kCornerRadius = Radius .circular (9 );
29
+
26
30
// The corner radius of the thumb.
27
- const Radius _kThumbRadius = Radius .circular (6.93 );
31
+ const Radius _kThumbRadius = Radius .circular (7 );
28
32
// The amount of space by which to expand the thumb from the size of the currently
29
33
// selected child.
30
34
const EdgeInsets _kThumbInsets = EdgeInsets .symmetric (horizontal: 1 );
@@ -40,17 +44,17 @@ const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness(
40
44
);
41
45
42
46
// 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 );
44
48
const double _kSeparatorWidth = 1 ;
45
- const Radius _kSeparatorRadius = Radius .circular (_kSeparatorWidth/ 2 );
49
+ const Radius _kSeparatorRadius = Radius .circular (_kSeparatorWidth / 2 );
46
50
47
51
// The minimum scale factor of the thumb, when being pressed on for a sufficient
48
52
// amount of time.
49
53
const double _kMinThumbScale = 0.95 ;
50
54
51
55
// The minimum horizontal distance between the edges of the separator and the
52
56
// closest child.
53
- const double _kSegmentMinPadding = 9.25 ;
57
+ const double _kSegmentMinPadding = 10 ;
54
58
55
59
// The threshold value used in hasDraggedTooFar, for checking against the square
56
60
// L2 distance from the location of the current drag pointer, to the closest
@@ -59,17 +63,24 @@ const double _kSegmentMinPadding = 9.25;
59
63
// Both the mechanism and the value are speculated.
60
64
const double _kTouchYDistanceThreshold = 50.0 * 50.0 ;
61
65
62
- // The corner radius of the segmented control.
63
- //
64
- // Inspected from iOS 13.2 simulator.
65
- const double _kCornerRadius = 8 ;
66
-
67
66
// The minimum opacity of an unselected segment, when the user presses on the
68
67
// segment and it starts to fadeout.
69
68
//
70
- // Inspected from iOS 13.2 simulator.
69
+ // Inspected from iOS 17.5 simulator.
71
70
const double _kContentPressedMinOpacity = 0.2 ;
72
71
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
+
73
84
// The spring animation used when the thumb changes its rect.
74
85
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation (
75
86
const SpringDescription (mass: 1 , stiffness: 503.551 , damping: 44.8799 ),
@@ -91,19 +102,23 @@ class _Segment<T> extends StatefulWidget {
91
102
required this .pressed,
92
103
required this .highlighted,
93
104
required this .isDragging,
105
+ required this .enabled,
106
+ required this .segmentLocation,
94
107
}) : super (key: key);
95
108
96
109
final Widget child;
97
110
98
111
final bool pressed;
99
112
final bool highlighted;
113
+ final bool enabled;
114
+ final _SegmentLocation segmentLocation;
100
115
101
116
// Whether the thumb of the parent widget (CupertinoSlidingSegmentedControl)
102
117
// is currently being dragged.
103
118
final bool isDragging;
104
119
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 ;
107
122
108
123
@override
109
124
_SegmentState <T > createState () => _SegmentState <T >();
@@ -151,6 +166,12 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
151
166
152
167
@override
153
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
+
154
175
return MetaData (
155
176
// Expand the hitTest area of this widget.
156
177
behavior: HitTestBehavior .opaque,
@@ -164,10 +185,15 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
164
185
child: AnimatedDefaultTextStyle (
165
186
style: DefaultTextStyle .of (context)
166
187
.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
+ )),
168
193
duration: _kHighlightAnimationDuration,
169
194
curve: Curves .ease,
170
195
child: ScaleTransition (
196
+ alignment: scaleAlignment,
171
197
scale: highlightPressScaleAnimation,
172
198
child: widget.child,
173
199
),
@@ -178,11 +204,12 @@ class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<
178
204
// the same and will always be greater than equal to that of the
179
205
// visible child (at index 0), to keep the size of the entire
180
206
// 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 ,
185
211
),
212
+ child: widget.child
186
213
),
187
214
],
188
215
),
@@ -321,6 +348,7 @@ class CupertinoSlidingSegmentedControl<T extends Object> extends StatefulWidget
321
348
super .key,
322
349
required this .children,
323
350
required this .onValueChanged,
351
+ this .disabledChildren = const < Never > {},
324
352
this .groupValue,
325
353
this .thumbColor = _kThumbColor,
326
354
this .padding = _kHorizontalItemPadding,
@@ -341,6 +369,20 @@ class CupertinoSlidingSegmentedControl<T extends Object> extends StatefulWidget
341
369
/// The map must have more than one entry.
342
370
final Map <T , Widget > children;
343
371
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
+
344
386
/// The identifier of the widget that is currently selected.
345
387
///
346
388
/// This must be one of the keys in the [Map] of [children] .
@@ -499,11 +541,15 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
499
541
// Otherwise the thumb can be dragged around in an ongoing drag gesture.
500
542
bool ? _startedOnSelectedSegment;
501
543
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
+
502
548
// Whether an ongoing horizontal drag gesture that started on the thumb is
503
549
// present. When true, defer/ignore changes to the `highlighted` variable
504
550
// from other sources (except for semantics) until the gesture ends, preventing
505
551
// them from interfering with the active drag gesture.
506
- bool get isThumbDragging => _startedOnSelectedSegment ?? false ;
552
+ bool get isThumbDragging => ( _startedOnSelectedSegment ?? false ) && ! _startedOnDisabledSegment ;
507
553
508
554
// Converts local coordinate to segments.
509
555
T segmentForXPosition (double dx) {
@@ -554,6 +600,7 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
554
600
if (highlighted == newValue) {
555
601
return ;
556
602
}
603
+
557
604
setState (() { highlighted = newValue; });
558
605
// Additionally, start the thumb animation if the highlighted segment
559
606
// changes. If the thumbController is already running, the render object's
@@ -578,14 +625,18 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
578
625
}
579
626
final T segment = segmentForXPosition (details.localPosition.dx);
580
627
onPressedChangedByGesture (null );
581
- if (segment != widget.groupValue) {
628
+ if (segment != widget.groupValue && ! widget.disabledChildren. contains (segment) ) {
582
629
widget.onValueChanged (segment);
583
630
}
584
631
}
585
632
586
633
void onDown (DragDownDetails details) {
587
634
final T touchDownSegment = segmentForXPosition (details.localPosition.dx);
588
635
_startedOnSelectedSegment = touchDownSegment == highlighted;
636
+ _startedOnDisabledSegment = widget.disabledChildren.contains (touchDownSegment);
637
+ if (widget.disabledChildren.contains (touchDownSegment)) {
638
+ return ;
639
+ }
589
640
onPressedChangedByGesture (touchDownSegment);
590
641
591
642
if (isThumbDragging) {
@@ -594,10 +645,20 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
594
645
}
595
646
596
647
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
+ }
597
659
if (isThumbDragging) {
598
- final T segment = segmentForXPosition (details.localPosition.dx);
599
- onPressedChangedByGesture (segment);
600
- onHighlightChangedByGesture (segment);
660
+ onPressedChangedByGesture (touchDownSegment);
661
+ onHighlightChangedByGesture (touchDownSegment);
601
662
} else {
602
663
final T ? segment = _hasDraggedTooFar (details)
603
664
? null
@@ -629,7 +690,6 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
629
690
if (isThumbDragging) {
630
691
_playThumbScaleAnimation (isExpanding: true );
631
692
}
632
-
633
693
onPressedChangedByGesture (null );
634
694
_startedOnSelectedSegment = null ;
635
695
}
@@ -670,10 +730,23 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
670
730
);
671
731
}
672
732
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
+ };
673
741
children.add (
674
742
Semantics (
675
743
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
+ },
677
750
inMutuallyExclusiveGroup: true ,
678
751
selected: widget.groupValue == entry.key,
679
752
child: MouseRegion (
@@ -683,6 +756,8 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
683
756
highlighted: isHighlighted,
684
757
pressed: pressed == entry.key,
685
758
isDragging: isThumbDragging,
759
+ enabled: ! widget.disabledChildren.contains (entry.key),
760
+ segmentLocation: segmentLocation,
686
761
child: entry.value,
687
762
),
688
763
),
@@ -708,9 +783,12 @@ class _SegmentedControlState<T extends Object> extends State<CupertinoSlidingSeg
708
783
return UnconstrainedBox (
709
784
constrainedAxis: Axis .horizontal,
710
785
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,
711
789
padding: widget.padding.resolve (Directionality .of (context)),
712
790
decoration: BoxDecoration (
713
- borderRadius: const BorderRadius .all (Radius . circular ( _kCornerRadius) ),
791
+ borderRadius: const BorderRadius .all (_kCornerRadius),
714
792
color: CupertinoDynamicColor .resolve (widget.backgroundColor, context),
715
793
),
716
794
child: AnimatedBuilder (
@@ -773,6 +851,12 @@ class _SegmentedControlRenderWidget<T extends Object> extends MultiChildRenderOb
773
851
774
852
class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData <RenderBox > { }
775
853
854
+ enum _SegmentLocation {
855
+ leftmost,
856
+ rightmost,
857
+ inbetween;
858
+ }
859
+
776
860
// The behavior of a UISegmentedControl as observed on iOS 13.1:
777
861
//
778
862
// 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
1131
1215
void paint (PaintingContext context, Offset offset) {
1132
1216
final List <RenderBox > children = getChildrenAsList ();
1133
1217
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.
1134
1221
for (int index = 1 ; index < childCount; index += 2 ) {
1135
1222
_paintSeparator (context, offset, children[index]);
1136
1223
}
@@ -1164,8 +1251,24 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
1164
1251
1165
1252
final Rect unscaledThumbRect = state.thumbAnimatable? .evaluate (state.thumbController) ?? newThumbRect;
1166
1253
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
+
1167
1270
final Rect thumbRect = Rect .fromCenter (
1168
- center: unscaledThumbRect.center,
1271
+ center: unscaledThumbRect.center - Offset (delta / 2 , 0 ) ,
1169
1272
width: unscaledThumbRect.width * thumbScale,
1170
1273
height: unscaledThumbRect.height * thumbScale,
1171
1274
);
@@ -1176,6 +1279,9 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
1176
1279
}
1177
1280
1178
1281
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.
1179
1285
_paintChild (context, offset, children[index]);
1180
1286
}
1181
1287
}
0 commit comments