// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; // Extracted from https://developer.apple.com/design/resources/. // Minimum padding from edges of the segmented control to edges of // encompassing widget. const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(vertical: 2, horizontal: 3); // The corner radius of the thumb. const Radius _kThumbRadius = Radius.circular(6.93); // The amount of space by which to expand the thumb from the size of the currently // selected child. const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: 1); // Minimum height of the segmented control. const double _kMinSegmentedControlHeight = 28.0; const Color _kSeparatorColor = Color(0x4D8E8E93); const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness( color: Color(0xFFFFFFFF), darkColor: Color(0xFF636366), ); // The amount of space by which to inset each separator. const EdgeInsets _kSeparatorInset = EdgeInsets.symmetric(vertical: 6); const double _kSeparatorWidth = 1; const Radius _kSeparatorRadius = Radius.circular(_kSeparatorWidth/2); // The minimum scale factor of the thumb, when being pressed on for a sufficient // amount of time. const double _kMinThumbScale = 0.95; // The minimum horizontal distance between the edges of the separator and the // closest child. const double _kSegmentMinPadding = 9.25; // The threshold value used in hasDraggedTooFar, for checking against the square // L2 distance from the location of the current drag pointer, to the closest // vertex of the CupertinoSlidingSegmentedControl's Rect. // // Both the mechanism and the value are speculated. const double _kTouchYDistanceThreshold = 50.0 * 50.0; // The corner radius of the segmented control. // // Inspected from iOS 13.2 simulator. const double _kCornerRadius = 8; // The minimum opacity of an unselected segment, when the user presses on the // segment and it starts to fadeout. // // Inspected from iOS 13.2 simulator. const double _kContentPressedMinOpacity = 0.2; // The spring animation used when the thumb changes its rect. final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation( const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799), 0, 1, 0, // Every time a new spring animation starts the previous animation stops. ); const Duration _kSpringAnimationDuration = Duration(milliseconds: 412); const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470); const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200); class _Segment<T> extends StatefulWidget { const _Segment({ required ValueKey<T> key, required this.child, required this.pressed, required this.highlighted, required this.isDragging, }) : super(key: key); final Widget child; final bool pressed; final bool highlighted; // Whether the thumb of the parent widget (CupertinoSlidingSegmentedControl) // is currently being dragged. final bool isDragging; bool get shouldFadeoutContent => pressed && !highlighted; bool get shouldScaleContent => pressed && highlighted && isDragging; @override _SegmentState<T> createState() => _SegmentState<T>(); } class _SegmentState<T> extends State<_Segment<T>> with TickerProviderStateMixin<_Segment<T>> { late final AnimationController highlightPressScaleController; late Animation<double> highlightPressScaleAnimation; @override void initState() { super.initState(); highlightPressScaleController = AnimationController( duration: _kOpacityAnimationDuration, value: widget.shouldScaleContent ? 1 : 0, vsync: this, ); highlightPressScaleAnimation = highlightPressScaleController.drive( Tween<double>(begin: 1.0, end: _kMinThumbScale), ); } @override void didUpdateWidget(_Segment<T> oldWidget) { super.didUpdateWidget(oldWidget); assert(oldWidget.key == widget.key); if (oldWidget.shouldScaleContent != widget.shouldScaleContent) { highlightPressScaleAnimation = highlightPressScaleController.drive( Tween<double>( begin: highlightPressScaleAnimation.value, end: widget.shouldScaleContent ? _kMinThumbScale : 1.0, ), ); highlightPressScaleController.animateWith(_kThumbSpringAnimationSimulation); } } @override void dispose() { highlightPressScaleController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MetaData( // Expand the hitTest area of this widget. behavior: HitTestBehavior.opaque, child: IndexedStack( alignment: Alignment.center, children: <Widget>[ AnimatedOpacity( opacity: widget.shouldFadeoutContent ? _kContentPressedMinOpacity : 1, duration: _kOpacityAnimationDuration, curve: Curves.ease, child: AnimatedDefaultTextStyle( style: DefaultTextStyle.of(context) .style .merge(TextStyle(fontWeight: widget.highlighted ? FontWeight.w500 : FontWeight.normal)), duration: _kHighlightAnimationDuration, curve: Curves.ease, child: ScaleTransition( scale: highlightPressScaleAnimation, child: widget.child, ), ), ), // The entire widget will assume the size of this widget, so when a // segment's "highlight" animation plays the size of the parent stays // the same and will always be greater than equal to that of the // visible child (at index 0), to keep the size of the entire // SegmentedControl widget consistent throughout the animation. Offstage( child: DefaultTextStyle.merge( style: const TextStyle(fontWeight: FontWeight.w500), child: widget.child, ), ), ], ), ); } } // Fadeout the separator when either adjacent segment is highlighted. class _SegmentSeparator extends StatefulWidget { const _SegmentSeparator({ required ValueKey<int> key, required this.highlighted, }) : super(key: key); final bool highlighted; @override _SegmentSeparatorState createState() => _SegmentSeparatorState(); } class _SegmentSeparatorState extends State<_SegmentSeparator> with TickerProviderStateMixin<_SegmentSeparator> { late final AnimationController separatorOpacityController; @override void initState() { super.initState(); separatorOpacityController = AnimationController( duration: _kSpringAnimationDuration, value: widget.highlighted ? 0 : 1, vsync: this, ); } @override void didUpdateWidget(_SegmentSeparator oldWidget) { super.didUpdateWidget(oldWidget); assert(oldWidget.key == widget.key); if (oldWidget.highlighted != widget.highlighted) { separatorOpacityController.animateTo( widget.highlighted ? 0 : 1, duration: _kSpringAnimationDuration, curve: Curves.ease, ); } } @override void dispose() { separatorOpacityController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: separatorOpacityController, child: const SizedBox(width: _kSeparatorWidth), builder: (BuildContext context, Widget? child) { return Padding( padding: _kSeparatorInset, child: DecoratedBox( decoration: BoxDecoration( color: _kSeparatorColor.withOpacity(_kSeparatorColor.opacity * separatorOpacityController.value), borderRadius: const BorderRadius.all(_kSeparatorRadius), ), child: child, ), ); }, ); } } /// An iOS 13 style segmented control. /// /// Displays the widgets provided in the [Map] of [children] in a horizontal list. /// It allows the user to select between a number of mutually exclusive options, /// by tapping or dragging within the segmented control. /// /// A segmented control can feature any [Widget] as one of the values in its /// [Map] of [children]. The type T is the type of the [Map] keys used to identify /// each widget and determine which widget is selected. As required by the [Map] /// class, keys must be of consistent types and must be comparable. The [children] /// argument must be an ordered [Map] such as a [LinkedHashMap], the ordering of /// the keys will determine the order of the widgets in the segmented control. /// /// The widget calls the [onValueChanged] callback *when a valid user gesture /// completes on an unselected segment*. The map key associated with the newly /// selected widget is returned in the [onValueChanged] callback. Typically, /// widgets that use a segmented control will listen for the [onValueChanged] /// callback and rebuild the segmented control with a new [groupValue] to update /// which option is currently selected. /// /// The [children] will be displayed in the order of the keys in the [Map], /// along the current [TextDirection]. Each child widget will have the same size. /// The height of the segmented control is determined by the height of the /// tallest child widget. The width of each child will be the intrinsic width of /// the widest child, or the available horizontal space divided by the number of /// [children], which ever is smaller. /// /// A segmented control may optionally be created with custom colors. The /// [thumbColor], [backgroundColor] arguments can be used to override the /// segmented control's colors from its defaults. /// /// {@tool dartpad} /// This example shows a [CupertinoSlidingSegmentedControl] with an enum type. /// /// The callback provided to [onValueChanged] should update the state of /// the parent [StatefulWidget] using the [State.setState] method, so that /// the parent gets rebuilt; for example: /// /// ** See code in examples/api/lib/cupertino/segmented_control/cupertino_sliding_segmented_control.0.dart ** /// {@end-tool} /// See also: /// /// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/segmented-controls/> class CupertinoSlidingSegmentedControl<T> extends StatefulWidget { /// Creates an iOS-style segmented control bar. /// /// The [children] and [onValueChanged] arguments must not be null. The /// [children] argument must be an ordered [Map] such as a [LinkedHashMap]. /// Further, the length of the [children] list must be greater than one. /// /// Each widget value in the map of [children] must have an associated key /// that uniquely identifies this widget. This key is what will be returned /// in the [onValueChanged] callback when a new value from the [children] map /// is selected. /// /// The [groupValue] is the currently selected value for the segmented control. /// If no [groupValue] is provided, or the [groupValue] is null, no widget will /// appear as selected. The [groupValue] must be either null or one of the keys /// in the [children] map. CupertinoSlidingSegmentedControl({ super.key, required this.children, required this.onValueChanged, this.groupValue, this.thumbColor = _kThumbColor, this.padding = _kHorizontalItemPadding, this.backgroundColor = CupertinoColors.tertiarySystemFill, }) : assert(children.length >= 2), assert( groupValue == null || children.keys.contains(groupValue), 'The groupValue must be either null or one of the keys in the children map.', ); /// The identifying keys and corresponding widget values in the /// segmented control. /// /// This attribute must be an ordered [Map] such as a [LinkedHashMap]. Each /// widget is typically a single-line [Text] widget or an [Icon] widget. /// /// The map must have more than one entry. final Map<T, Widget> children; /// The identifier of the widget that is currently selected. /// /// This must be one of the keys in the [Map] of [children]. /// If this attribute is null, no widget will be initially selected. final T? groupValue; /// The callback that is called when a new option is tapped. /// /// This attribute must not be null. /// /// The segmented control passes the newly selected widget's associated key /// to the callback but does not actually change state until the parent /// widget rebuilds the segmented control with the new [groupValue]. /// /// The callback provided to [onValueChanged] should update the state of /// the parent [StatefulWidget] using the [State.setState] method, so that /// the parent gets rebuilt; for example: /// /// {@tool snippet} /// /// ```dart /// class SegmentedControlExample extends StatefulWidget { /// const SegmentedControlExample({super.key}); /// /// @override /// State createState() => SegmentedControlExampleState(); /// } /// /// class SegmentedControlExampleState extends State<SegmentedControlExample> { /// final Map<int, Widget> children = const <int, Widget>{ /// 0: Text('Child 1'), /// 1: Text('Child 2'), /// }; /// /// int? currentValue; /// /// @override /// Widget build(BuildContext context) { /// return CupertinoSlidingSegmentedControl<int>( /// children: children, /// onValueChanged: (int? newValue) { /// setState(() { /// currentValue = newValue; /// }); /// }, /// groupValue: currentValue, /// ); /// } /// } /// ``` /// {@end-tool} final ValueChanged<T?> onValueChanged; /// The color used to paint the rounded rect behind the [children] and the separators. /// /// The default value is [CupertinoColors.tertiarySystemFill]. The background /// will not be painted if null is specified. final Color backgroundColor; /// The color used to paint the interior of the thumb that appears behind the /// currently selected item. /// /// The default value is a [CupertinoDynamicColor] that appears white in light /// mode and becomes a gray color in dark mode. final Color thumbColor; /// The amount of space by which to inset the [children]. /// /// Must not be null. Defaults to EdgeInsets.symmetric(vertical: 2, horizontal: 3). final EdgeInsetsGeometry padding; @override State<CupertinoSlidingSegmentedControl<T>> createState() => _SegmentedControlState<T>(); } class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T>> with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> { late final AnimationController thumbController = AnimationController(duration: _kSpringAnimationDuration, value: 0, vsync: this); Animatable<Rect?>? thumbAnimatable; late final AnimationController thumbScaleController = AnimationController(duration: _kSpringAnimationDuration, value: 0, vsync: this); late Animation<double> thumbScaleAnimation = thumbScaleController.drive(Tween<double>(begin: 1, end: _kMinThumbScale)); final TapGestureRecognizer tap = TapGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); @override void initState() { super.initState(); // If the long press or horizontal drag recognizer gets accepted, we know for // sure the gesture is meant for the segmented control. Hand everything to // the drag gesture recognizer. final GestureArenaTeam team = GestureArenaTeam(); longPress.team = team; drag.team = team; team.captain = drag; drag ..onDown = onDown ..onUpdate = onUpdate ..onEnd = onEnd ..onCancel = onCancel; tap.onTapUp = onTapUp; // Empty callback to enable the long press recognizer. longPress.onLongPress = () { }; highlighted = widget.groupValue; } @override void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) { super.didUpdateWidget(oldWidget); // Temporarily ignore highlight changes from the widget when the thumb is // being dragged. When the drag gesture finishes the widget will be forced // to build (see the onEnd method), and didUpdateWidget will be called again. if (!isThumbDragging && highlighted != widget.groupValue) { thumbController.animateWith(_kThumbSpringAnimationSimulation); thumbAnimatable = null; highlighted = widget.groupValue; } } @override void dispose() { thumbScaleController.dispose(); thumbController.dispose(); drag.dispose(); tap.dispose(); longPress.dispose(); super.dispose(); } // Whether the current drag gesture started on a selected segment. When this // flag is false, the `onUpdate` method does not update `highlighted`. // Otherwise the thumb can be dragged around in an ongoing drag gesture. bool? _startedOnSelectedSegment; // Whether an ongoing horizontal drag gesture that started on the thumb is // present. When true, defer/ignore changes to the `highlighted` variable // from other sources (except for semantics) until the gesture ends, preventing // them from interfering with the active drag gesture. bool get isThumbDragging => _startedOnSelectedSegment ?? false; // Converts local coordinate to segments. This method assumes each segment has // the same width. T segmentForXPosition(double dx) { final RenderBox renderBox = context.findRenderObject()! as RenderBox; final int numOfChildren = widget.children.length; assert(renderBox.hasSize); assert(numOfChildren >= 2); int index = (dx ~/ (renderBox.size.width / numOfChildren)).clamp(0, numOfChildren - 1); // ignore_clamp_double_lint switch (Directionality.of(context)) { case TextDirection.ltr: break; case TextDirection.rtl: index = numOfChildren - 1 - index; } return widget.children.keys.elementAt(index); } bool _hasDraggedTooFar(DragUpdateDetails details) { final RenderBox renderBox = context.findRenderObject()! as RenderBox; assert(renderBox.hasSize); final Size size = renderBox.size; final Offset offCenter = details.localPosition - Offset(size.width/2, size.height/2); final double l2 = math.pow(math.max(0.0, offCenter.dx.abs() - size.width/2), 2) + math.pow(math.max(0.0, offCenter.dy.abs() - size.height/2), 2) as double; return l2 > _kTouchYDistanceThreshold; } // The thumb shrinks when the user presses on it, and starts expanding when // the user lets go. // This animation must be synced with the segment scale animation (see the // _Segment widget) to make the overall animation look natural when the thumb // is not sliding. void _playThumbScaleAnimation({ required bool isExpanding }) { thumbScaleAnimation = thumbScaleController.drive( Tween<double>( begin: thumbScaleAnimation.value, end: isExpanding ? 1 : _kMinThumbScale, ), ); thumbScaleController.animateWith(_kThumbSpringAnimationSimulation); } void onHighlightChangedByGesture(T newValue) { if (highlighted == newValue) { return; } setState(() { highlighted = newValue; }); // Additionally, start the thumb animation if the highlighted segment // changes. If the thumbController is already running, the render object's // paint method will create a new tween to drive the animation with. // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/74356: // the current thumb will be painted at the same location twice (before and // after the new animation starts). thumbController.animateWith(_kThumbSpringAnimationSimulation); thumbAnimatable = null; } void onPressedChangedByGesture(T? newValue) { if (pressed != newValue) { setState(() { pressed = newValue; }); } } void onTapUp(TapUpDetails details) { // No gesture should interfere with an ongoing thumb drag. if (isThumbDragging) { return; } final T segment = segmentForXPosition(details.localPosition.dx); onPressedChangedByGesture(null); if (segment != widget.groupValue) { widget.onValueChanged(segment); } } void onDown(DragDownDetails details) { final T touchDownSegment = segmentForXPosition(details.localPosition.dx); _startedOnSelectedSegment = touchDownSegment == highlighted; onPressedChangedByGesture(touchDownSegment); if (isThumbDragging) { _playThumbScaleAnimation(isExpanding: false); } } void onUpdate(DragUpdateDetails details) { if (isThumbDragging) { final T segment = segmentForXPosition(details.localPosition.dx); onPressedChangedByGesture(segment); onHighlightChangedByGesture(segment); } else { final T? segment = _hasDraggedTooFar(details) ? null : segmentForXPosition(details.localPosition.dx); onPressedChangedByGesture(segment); } } void onEnd(DragEndDetails details) { final T? pressed = this.pressed; if (isThumbDragging) { _playThumbScaleAnimation(isExpanding: true); if (highlighted != widget.groupValue) { widget.onValueChanged(highlighted); } } else if (pressed != null) { onHighlightChangedByGesture(pressed); assert(pressed == highlighted); if (highlighted != widget.groupValue) { widget.onValueChanged(highlighted); } } onPressedChangedByGesture(null); _startedOnSelectedSegment = null; } void onCancel() { if (isThumbDragging) { _playThumbScaleAnimation(isExpanding: true); } onPressedChangedByGesture(null); _startedOnSelectedSegment = null; } // The segment the sliding thumb is currently located at, or animating to. It // may have a different value from widget.groupValue, since this widget does // not report a selection change via `onValueChanged` until the user stops // interacting with the widget (onTapUp). For example, the user can drag the // thumb around, and the `onValueChanged` callback will not be invoked until // the thumb is let go. T? highlighted; // The segment the user is currently pressing. T? pressed; @override Widget build(BuildContext context) { assert(widget.children.length >= 2); List<Widget> children = <Widget>[]; bool isPreviousSegmentHighlighted = false; int index = 0; int? highlightedIndex; for (final MapEntry<T, Widget> entry in widget.children.entries) { final bool isHighlighted = highlighted == entry.key; if (isHighlighted) { highlightedIndex = index; } if (index != 0) { children.add( _SegmentSeparator( // Let separators be TextDirection-invariant. If the TextDirection // changes, the separators should mostly stay where they were. key: ValueKey<int>(index), highlighted: isPreviousSegmentHighlighted || isHighlighted, ), ); } children.add( Semantics( button: true, onTap: () { widget.onValueChanged(entry.key); }, inMutuallyExclusiveGroup: true, selected: widget.groupValue == entry.key, child: MouseRegion( cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, child: _Segment<T>( key: ValueKey<T>(entry.key), highlighted: isHighlighted, pressed: pressed == entry.key, isDragging: isThumbDragging, child: entry.value, ), ), ), ); index += 1; isPreviousSegmentHighlighted = isHighlighted; } assert((highlightedIndex == null) == (highlighted == null)); switch (Directionality.of(context)) { case TextDirection.ltr: break; case TextDirection.rtl: children = children.reversed.toList(growable: false); if (highlightedIndex != null) { highlightedIndex = index - 1 - highlightedIndex; } } return UnconstrainedBox( constrainedAxis: Axis.horizontal, child: Container( padding: widget.padding.resolve(Directionality.of(context)), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(_kCornerRadius)), color: CupertinoDynamicColor.resolve(widget.backgroundColor, context), ), child: AnimatedBuilder( animation: thumbScaleAnimation, builder: (BuildContext context, Widget? child) { return _SegmentedControlRenderWidget<T>( highlightedIndex: highlightedIndex, thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context), thumbScale: thumbScaleAnimation.value, state: this, children: children, ); }, ), ), ); } } class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget { const _SegmentedControlRenderWidget({ super.key, super.children, required this.highlightedIndex, required this.thumbColor, required this.thumbScale, required this.state, }); final int? highlightedIndex; final Color thumbColor; final double thumbScale; final _SegmentedControlState<T> state; @override RenderObject createRenderObject(BuildContext context) { return _RenderSegmentedControl<T>( highlightedIndex: highlightedIndex, thumbColor: thumbColor, thumbScale: thumbScale, state: state, ); } @override void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) { assert(renderObject.state == state); renderObject ..thumbColor = thumbColor ..thumbScale = thumbScale ..highlightedIndex = highlightedIndex; } } class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<RenderBox> { } // The behavior of a UISegmentedControl as observed on iOS 13.1: // // 1. Tap up inside events will set the current selected index to the index of the // segment at the tap up location instantaneously (there might be animation but // the index change seems to happen before animation finishes), unless the tap // down event from the same touch event didn't happen within the segmented // control, in which case the touch event will be ignored entirely (will be // referring to these touch events as invalid touch events below). // // 2. A valid tap up event will also trigger the sliding CASpringAnimation (even // when it lands on the current segment), starting from the current `frame` // of the thumb. The previous sliding animation, if still playing, will be // removed and its velocity reset to 0. The sliding animation has a fixed // duration, regardless of the distance or transform. // // 3. When the sliding animation plays two other animations take place. In one animation // the content of the current segment gradually becomes "highlighted", turning the // font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2). // The other is the separator fadein/fadeout animation (duration = 0.41). // // 4. A tap down event on the segment pointed to by the current selected // index will trigger a CABasicAnimation that shrinks the thumb to 95% of its // original size, even if the sliding animation is still playing. The /// corresponding tap up event inverts the process (eyeballed). // // 5. A tap down event on other segments will trigger a CABasicAnimation // (timingFunction = default, duration = 0.47.) that fades out the content // from its current alpha, eventually reducing the alpha of that segment to // 20% unless interrupted by a tap up event or the pointer moves out of the // region (either outside of the segmented control's vicinity or to a // different segment). The reverse animation has the same duration and timing // function. class _RenderSegmentedControl<T> extends RenderBox with ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>, RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> { _RenderSegmentedControl({ required int? highlightedIndex, required Color thumbColor, required double thumbScale, required this.state, }) : _highlightedIndex = highlightedIndex, _thumbColor = thumbColor, _thumbScale = thumbScale; final _SegmentedControlState<T> state; // The current **Unscaled** Thumb Rect in this RenderBox's coordinate space. Rect? currentThumbRect; @override void attach(PipelineOwner owner) { super.attach(owner); state.thumbController.addListener(markNeedsPaint); } @override void detach() { state.thumbController.removeListener(markNeedsPaint); super.detach(); } double get thumbScale => _thumbScale; double _thumbScale; set thumbScale(double value) { if (_thumbScale == value) { return; } _thumbScale = value; if (state.highlighted != null) { markNeedsPaint(); } } int? get highlightedIndex => _highlightedIndex; int? _highlightedIndex; set highlightedIndex(int? value) { if (_highlightedIndex == value) { return; } _highlightedIndex = value; markNeedsPaint(); } Color get thumbColor => _thumbColor; Color _thumbColor; set thumbColor(Color value) { if (_thumbColor == value) { return; } _thumbColor = value; markNeedsPaint(); } @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); // No gesture should interfere with an ongoing thumb drag. if (event is PointerDownEvent && !state.isThumbDragging) { state.tap.addPointer(event); state.longPress.addPointer(event); state.drag.addPointer(event); } } // Intrinsic Dimensions double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount ~/ 2); RenderBox? nonSeparatorChildAfter(RenderBox child) { final RenderBox? nextChild = childAfter(child); return nextChild == null ? null : childAfter(nextChild); } @override double computeMinIntrinsicWidth(double height) { final int childCount = this.childCount ~/ 2 + 1; RenderBox? child = firstChild; double maxMinChildWidth = 0; while (child != null) { final double childWidth = child.getMinIntrinsicWidth(height); maxMinChildWidth = math.max(maxMinChildWidth, childWidth); child = nonSeparatorChildAfter(child); } return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; } @override double computeMaxIntrinsicWidth(double height) { final int childCount = this.childCount ~/ 2 + 1; RenderBox? child = firstChild; double maxMaxChildWidth = 0; while (child != null) { final double childWidth = child.getMaxIntrinsicWidth(height); maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth); child = nonSeparatorChildAfter(child); } return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; } @override double computeMinIntrinsicHeight(double width) { RenderBox? child = firstChild; double maxMinChildHeight = _kMinSegmentedControlHeight; while (child != null) { final double childHeight = child.getMinIntrinsicHeight(width); maxMinChildHeight = math.max(maxMinChildHeight, childHeight); child = nonSeparatorChildAfter(child); } return maxMinChildHeight; } @override double computeMaxIntrinsicHeight(double width) { RenderBox? child = firstChild; double maxMaxChildHeight = _kMinSegmentedControlHeight; while (child != null) { final double childHeight = child.getMaxIntrinsicHeight(width); maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight); child = nonSeparatorChildAfter(child); } return maxMaxChildHeight; } @override double? computeDistanceToActualBaseline(TextBaseline baseline) { return defaultComputeDistanceToHighestActualBaseline(baseline); } @override void setupParentData(RenderBox child) { if (child.parentData is! _SegmentedControlContainerBoxParentData) { child.parentData = _SegmentedControlContainerBoxParentData(); } } Size _calculateChildSize(BoxConstraints constraints) { final int childCount = this.childCount ~/ 2 + 1; double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount; double maxHeight = _kMinSegmentedControlHeight; RenderBox? child = firstChild; while (child != null) { childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding); child = nonSeparatorChildAfter(child); } childWidth = math.min( childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount, ); child = firstChild; while (child != null) { final double boxHeight = child.getMaxIntrinsicHeight(childWidth); maxHeight = math.max(maxHeight, boxHeight); child = nonSeparatorChildAfter(child); } return Size(childWidth, maxHeight); } Size _computeOverallSizeFromChildSize(Size childSize, BoxConstraints constraints) { final int childCount = this.childCount ~/ 2 + 1; return constraints.constrain(Size(childSize.width * childCount + totalSeparatorWidth, childSize.height)); } @override Size computeDryLayout(BoxConstraints constraints) { final Size childSize = _calculateChildSize(constraints); return _computeOverallSizeFromChildSize(childSize, constraints); } @override void performLayout() { final BoxConstraints constraints = this.constraints; final Size childSize = _calculateChildSize(constraints); final BoxConstraints childConstraints = BoxConstraints.tight(childSize); final BoxConstraints separatorConstraints = childConstraints.heightConstraints(); RenderBox? child = firstChild; int index = 0; double start = 0; while (child != null) { child.layout(index.isEven ? childConstraints : separatorConstraints, parentUsesSize: true); final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; final Offset childOffset = Offset(start, 0); childParentData.offset = childOffset; start += child.size.width; assert( index.isEven || child.size.width == _kSeparatorWidth + _kSeparatorInset.horizontal, '${child.size.width} != ${_kSeparatorWidth + _kSeparatorInset.horizontal}', ); child = childAfter(child); index += 1; } size = _computeOverallSizeFromChildSize(childSize, constraints); } // This method is used to convert the original unscaled thumb rect painted in // the previous frame, to a Rect that is within the valid boundary defined by // the child segments. // // The overall size does not include that of the thumb. That is, if the thumb // is located at the first or the last segment, the thumb can get cut off if // one of the values in _kThumbInsets is positive. Rect? moveThumbRectInBound(Rect? thumbRect, List<RenderBox> children) { assert(hasSize); assert(children.length >= 2); if (thumbRect == null) { return null; } final Offset firstChildOffset = (children.first.parentData! as _SegmentedControlContainerBoxParentData).offset; final double leftMost = firstChildOffset.dx; final double rightMost = (children.last.parentData! as _SegmentedControlContainerBoxParentData).offset.dx + children.last.size.width; assert(rightMost > leftMost); // Ignore the horizontal position and the height of `thumbRect`, and // calculates them from `children`. return Rect.fromLTRB( math.max(thumbRect.left, leftMost - _kThumbInsets.left), firstChildOffset.dy - _kThumbInsets.top, math.min(thumbRect.right, rightMost + _kThumbInsets.right), firstChildOffset.dy + children.first.size.height + _kThumbInsets.bottom, ); } @override void paint(PaintingContext context, Offset offset) { final List<RenderBox> children = getChildrenAsList(); for (int index = 1; index < childCount; index += 2) { _paintSeparator(context, offset, children[index]); } final int? highlightedChildIndex = highlightedIndex; // Paint thumb if there's a highlighted segment. if (highlightedChildIndex != null) { final RenderBox selectedChild = children[highlightedChildIndex * 2]; final _SegmentedControlContainerBoxParentData childParentData = selectedChild.parentData! as _SegmentedControlContainerBoxParentData; final Rect newThumbRect = _kThumbInsets.inflateRect(childParentData.offset & selectedChild.size); // Update thumb animation's tween, in case the end rect changed (e.g., a // new segment is added during the animation). if (state.thumbController.isAnimating) { final Animatable<Rect?>? thumbTween = state.thumbAnimatable; if (thumbTween == null) { // This is the first frame of the animation. final Rect startingRect = moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect; state.thumbAnimatable = RectTween(begin: startingRect, end: newThumbRect); } else if (newThumbRect != thumbTween.transform(1)) { // The thumbTween of the running sliding animation needs updating, // without restarting the animation. final Rect startingRect = moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect; state.thumbAnimatable = RectTween(begin: startingRect, end: newThumbRect) .chain(CurveTween(curve: Interval(state.thumbController.value, 1))); } } else { state.thumbAnimatable = null; } final Rect unscaledThumbRect = state.thumbAnimatable?.evaluate(state.thumbController) ?? newThumbRect; currentThumbRect = unscaledThumbRect; final Rect thumbRect = Rect.fromCenter( center: unscaledThumbRect.center, width: unscaledThumbRect.width * thumbScale, height: unscaledThumbRect.height * thumbScale, ); _paintThumb(context, offset, thumbRect); } else { currentThumbRect = null; } for (int index = 0; index < children.length; index += 2) { _paintChild(context, offset, children[index]); } } // Paint the separator to the right of the given child. final Paint separatorPaint = Paint(); void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; context.paintChild(child, offset + childParentData.offset); } void _paintChild(PaintingContext context, Offset offset, RenderBox child) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; context.paintChild(child, childParentData.offset + offset); } void _paintThumb(PaintingContext context, Offset offset, Rect thumbRect) { // Colors extracted from https://developer.apple.com/design/resources/. const List<BoxShadow> thumbShadow = <BoxShadow> [ BoxShadow( color: Color(0x1F000000), offset: Offset(0, 3), blurRadius: 8, ), BoxShadow( color: Color(0x0A000000), offset: Offset(0, 3), blurRadius: 1, ), ]; final RRect thumbRRect = RRect.fromRectAndRadius(thumbRect.shift(offset), _kThumbRadius); for (final BoxShadow shadow in thumbShadow) { context.canvas.drawRRect(thumbRRect.shift(shadow.offset), shadow.toPaint()); } context.canvas.drawRRect( thumbRRect.inflate(0.5), Paint()..color = const Color(0x0A000000), ); context.canvas.drawRRect( thumbRRect, Paint()..color = thumbColor, ); } @override bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { RenderBox? child = lastChild; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; if ((childParentData.offset & child.size).contains(position)) { return result.addWithPaintOffset( offset: childParentData.offset, position: position, hitTest: (BoxHitTestResult result, Offset localOffset) { assert(localOffset == position - childParentData.offset); return child!.hitTest(result, position: localOffset); }, ); } child = childParentData.previousSibling; } return false; } }