Skip to content

Commit b872a27

Browse files
authored
CupertinoSlidingSegmentedControl update (flutter#152976)
This PR is to improve `CupertinoSlidingSegmentedControl` fidelity and add a new property `setEnabled` so that segments can be disabled now. Fidelity update includes: * small change on default thumb radius (Based on iOS 17 Figma file) * separator height (Based on the comparison with SegmentedControl example in Xcode) * segment min padding (Based on iOS 17 Figma file) * thumb scale alignment (Based on the comparison with SegmentedControl example in Xcode). If the thumb is on the first or last position in SegmentedControl, the alignment should be `Alignment.leftCenter` and `Alignment.rightCenter` respectively, assuming the text direction is left to right. For segments in middle, they should have center alignment as previously. * TextStyle update (Based on both the Figma file and the Xcode example) * Clipped thumb shadow if the shadow is outside of the segmented control (Based on the Xcode example) * Fixed the overall size shaking issue during segment switching on macOS and iOS <details><summary>Alignment update demo</summary> https://github.com/user-attachments/assets/6de3986f-6810-4dc4-8688-f87120557d89 </details> <details><summary>Comparison before and after</summary> Before: ![Screenshot 2024-08-08 at 1 42 57�PM](https://github.com/user-attachments/assets/fe2e49a8-4bbd-4d54-9aba-1f47ab9bd9d9) After: ![Screenshot 2024-08-08 at 1 43 50�PM](https://github.com/user-attachments/assets/d3292f74-8d04-40ed-ae72-bf2e9b1751a4) </details> <details><summary>Shaking issue</summary> Before: https://github.com/user-attachments/assets/910d1271-75ea-4cec-8666-24090f9d4fb0 After: https://github.com/user-attachments/assets/bc5ae3e1-600d-49a6-95d9-b1f5d1bd9f6d </details> The `disabledChildren` feature can be used to disable segments. The default disabled color comes from the inspection in the Xcode example. <details><summary>Disabled feature demos</summary> Disabled segment cannot be highlighted: https://github.com/user-attachments/assets/70339364-23d5-41c7-b071-c7abe92abd62 `groupValue` can still be highlighted programmatically if it is disabled. ![Screenshot 2024-09-04 at 5 45 25�PM](https://github.com/user-attachments/assets/99d37903-3605-475f-b87a-ae118a23e94d) </details>
1 parent 55af75d commit b872a27

File tree

2 files changed

+553
-75
lines changed

2 files changed

+553
-75
lines changed

packages/flutter/lib/src/cupertino/sliding_segmented_control.dart

Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ import 'package:flutter/widgets.dart';
1818
import '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.
2425
const 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.
3034
const 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);
4448
const 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.
4953
const 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.
6064
const 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.
7170
const 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.
7485
final 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

774852
class _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

Comments
 (0)