// 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 // vertice 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 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, // Everytime 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 _FontWeightTween extends Tween<FontWeight> { _FontWeightTween({ FontWeight begin, FontWeight end }) : super(begin: begin, end: end); @override FontWeight lerp(double t) => FontWeight.lerp(begin, end, t); } /// An iOS 13 style segmented control. /// /// Displays the widgets provided in the [Map] of [children] in a horizontal list. /// Used to select between a number of mutually exclusive options. When one option /// in the segmented control is selected, the other options in the segmented /// control cease to be selected. /// /// 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. /// /// When the state of the segmented control changes, the widget calls the /// [onValueChanged] callback. 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]. /// The height of the segmented control is determined by the height of the /// tallest widget provided as a value in the [Map] of [children]. /// The width of each child in the segmented control will be equal to the width /// of widest child, unless the combined width of the children is wider than /// the available horizontal space. In this case, the available horizontal space /// is divided by the number of provided [children] to determine the width of /// each widget. The selection area for each of the widgets in the [Map] of /// [children] will then be expanded to fill the calculated space, so each /// widget will appear to have the same dimensions. /// /// 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. /// /// See also: /// /// * [CupertinoSlidingSegmentedControl], a segmented control widget in the /// style introduced in iOS 13. /// * <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({ Key key, @required this.children, @required this.onValueChanged, this.groupValue, this.thumbColor = _kThumbColor, this.padding = _kHorizontalItemPadding, this.backgroundColor = CupertinoColors.tertiarySystemFill, }) : assert(children != null), assert(children.length >= 2), assert(padding != null), assert(onValueChanged != null), assert( groupValue == null || children.keys.contains(groupValue), 'The groupValue must be either null or one of the keys in the children map.', ), super(key: key); /// The identifying keys and corresponding widget values in the /// segmented control. /// /// The map must have more than one entry. /// This attribute must be an ordered [Map] such as a [LinkedHashMap]. 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 { /// @override /// State createState() => SegmentedControlExampleState(); /// } /// /// class SegmentedControlExampleState extends State<SegmentedControlExample> { /// final Map<int, Widget> children = const { /// 0: Text('Child 1'), /// 1: Text('Child 2'), /// }; /// /// int currentValue; /// /// @override /// Widget build(BuildContext context) { /// return Container( /// child: 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 _SegmentedControlState<T> createState() => _SegmentedControlState<T>(); } class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T>> with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> { final Map<T, AnimationController> _highlightControllers = <T, AnimationController>{}; final Tween<FontWeight> _highlightTween = _FontWeightTween(begin: FontWeight.normal, end: FontWeight.w500); final Map<T, AnimationController> _pressControllers = <T, AnimationController>{}; final Tween<double> _pressTween = Tween<double>(begin: 1, end: 0.2); List<T> keys; AnimationController thumbController; AnimationController separatorOpacityController; AnimationController thumbScaleController; final TapGestureRecognizer tap = TapGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); AnimationController _createHighlightAnimationController({ bool isCompleted = false }) { return AnimationController( duration: _kHighlightAnimationDuration, value: isCompleted ? 1 : 0, vsync: this, ); } AnimationController _createFadeoutAnimationController() { return AnimationController( duration: _kOpacityAnimationDuration, vsync: this, ); } @override void initState() { super.initState(); final GestureArenaTeam team = GestureArenaTeam(); // 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. longPress.team = team; drag.team = team; team.captain = drag; _highlighted = widget.groupValue; thumbController = AnimationController( duration: _kSpringAnimationDuration, value: 0, vsync: this, ); thumbScaleController = AnimationController( duration: _kSpringAnimationDuration, value: 1, vsync: this, ); separatorOpacityController = AnimationController( duration: _kSpringAnimationDuration, value: 0, vsync: this, ); for (final T currentKey in widget.children.keys) { _highlightControllers[currentKey] = _createHighlightAnimationController( isCompleted: currentKey == widget.groupValue, // Highlight the current selection. ); _pressControllers[currentKey] = _createFadeoutAnimationController(); } } @override void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) { super.didUpdateWidget(oldWidget); // Update animation controllers. for (final T oldKey in oldWidget.children.keys) { if (!widget.children.containsKey(oldKey)) { _highlightControllers[oldKey].dispose(); _pressControllers[oldKey].dispose(); _highlightControllers.remove(oldKey); _pressControllers.remove(oldKey); } } for (final T newKey in widget.children.keys) { if (!_highlightControllers.keys.contains(newKey)) { _highlightControllers[newKey] = _createHighlightAnimationController(); _pressControllers[newKey] = _createFadeoutAnimationController(); } } highlighted = widget.groupValue; } @override void dispose() { for (final AnimationController animationController in _highlightControllers.values) { animationController.dispose(); } for (final AnimationController animationController in _pressControllers.values) { animationController.dispose(); } thumbScaleController.dispose(); thumbController.dispose(); separatorOpacityController.dispose(); drag.dispose(); tap.dispose(); longPress.dispose(); super.dispose(); } // Play highlight animation for the child located at _highlightControllers[at]. void _animateHighlightController({ T at, bool forward }) { if (at == null) return; final AnimationController controller = _highlightControllers[at]; assert(!forward || controller != null); controller?.animateTo(forward ? 1 : 0, duration: _kHighlightAnimationDuration, curve: Curves.ease); } T _highlighted; set highlighted(T newValue) { if (_highlighted == newValue) return; _animateHighlightController(at: newValue, forward: true); _animateHighlightController(at: _highlighted, forward: false); _highlighted = newValue; } T _pressed; set pressed(T newValue) { if (_pressed == newValue) return; if (_pressed != null) { _pressControllers[_pressed]?.animateTo(0, duration: _kOpacityAnimationDuration, curve: Curves.ease); } if (newValue != _highlighted && newValue != null) { _pressControllers[newValue].animateTo(1, duration: _kOpacityAnimationDuration, curve: Curves.ease); } _pressed = newValue; } void didChangeSelectedViaGesture() { widget.onValueChanged(_highlighted); } T indexToKey(int index) => index == null ? null : keys[index]; @override Widget build(BuildContext context) { debugCheckHasDirectionality(context); switch (Directionality.of(context)) { case TextDirection.ltr: keys = widget.children.keys.toList(growable: false); break; case TextDirection.rtl: keys = widget.children.keys.toList().reversed.toList(growable: false); break; } return AnimatedBuilder( animation: Listenable.merge(<Listenable>[ ..._highlightControllers.values, ..._pressControllers.values, ]), builder: (BuildContext context, Widget child) { final List<Widget> children = <Widget>[]; for (final T currentKey in keys) { final TextStyle textStyle = DefaultTextStyle.of(context).style.copyWith( fontWeight: _highlightTween.evaluate(_highlightControllers[currentKey]), ); final Widget child = DefaultTextStyle( style: textStyle, child: Semantics( button: true, onTap: () { widget.onValueChanged(currentKey); }, inMutuallyExclusiveGroup: true, selected: widget.groupValue == currentKey, child: Opacity( opacity: _pressTween.evaluate(_pressControllers[currentKey]), // Expand the hitTest area to be as large as the Opacity widget. child: MetaData( behavior: HitTestBehavior.opaque, child: Center(child: widget.children[currentKey]), ), ), ), ); children.add(child); } final int selectedIndex = widget.groupValue == null ? null : keys.indexOf(widget.groupValue); final Widget box = _SegmentedControlRenderWidget<T>( children: children, selectedIndex: selectedIndex, thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context), state: this, ); 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: box, ), ); }, ); } } class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget { _SegmentedControlRenderWidget({ Key key, List<Widget> children = const <Widget>[], @required this.selectedIndex, @required this.thumbColor, @required this.state, }) : super(key: key, children: children); final int selectedIndex; final Color thumbColor; final _SegmentedControlState<T> state; @override RenderObject createRenderObject(BuildContext context) { return _RenderSegmentedControl<T>( selectedIndex: selectedIndex, thumbColor: CupertinoDynamicColor.resolve(thumbColor, context), state: state, ); } @override void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) { renderObject ..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context) ..guardedSetHighlightedIndex(selectedIndex); } } class _ChildAnimationManifest { _ChildAnimationManifest({ this.opacity = 1, @required this.separatorOpacity, }) : assert(separatorOpacity != null), assert(opacity != null), separatorTween = Tween<double>(begin: separatorOpacity, end: separatorOpacity), opacityTween = Tween<double>(begin: opacity, end: opacity); double opacity; Tween<double> opacityTween; double separatorOpacity; Tween<double> separatorTween; } 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. // // 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, // 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 selectedIndex, @required Color thumbColor, @required this.state, }) : _highlightedIndex = selectedIndex, _thumbColor = thumbColor, assert(state != null) { state.drag ..onDown = _onDown ..onUpdate = _onUpdate ..onEnd = _onEnd ..onCancel = _onCancel; state.tap.onTapUp = _onTapUp; // Empty callback to enable the long press recognizer. state.longPress.onLongPress = () { }; } final _SegmentedControlState<T> state; Map<RenderBox, _ChildAnimationManifest> _childAnimations = <RenderBox, _ChildAnimationManifest>{}; // The current **Unscaled** Thumb Rect. Rect currentThumbRect; Tween<Rect> _currentThumbTween; Tween<double> _thumbScaleTween = Tween<double>(begin: _kMinThumbScale, end: 1); double currentThumbScale = 1; // The current position of the active drag pointer. Offset _localDragOffset; // Whether the current drag gesture started on a selected segment. bool _startedOnSelectedSegment; @override void insert(RenderBox child, { RenderBox after }) { super.insert(child, after: after); if (_childAnimations == null) return; assert(_childAnimations[child] == null); _childAnimations[child] = _ChildAnimationManifest(separatorOpacity: 1); } @override void remove(RenderBox child) { super.remove(child); _childAnimations?.remove(child); } @override void attach(PipelineOwner owner) { super.attach(owner); state.thumbController.addListener(markNeedsPaint); state.thumbScaleController.addListener(markNeedsPaint); state.separatorOpacityController.addListener(markNeedsPaint); } @override void detach() { state.thumbController.removeListener(markNeedsPaint); state.thumbScaleController.removeListener(markNeedsPaint); state.separatorOpacityController.removeListener(markNeedsPaint); super.detach(); } // Indicates whether selectedIndex has changed and animations need to be updated. // when true some animation tweens will be updated in paint phase. bool _needsThumbAnimationUpdate = false; int get highlightedIndex => _highlightedIndex; int _highlightedIndex; set highlightedIndex(int value) { if (_highlightedIndex == value) { return; } _needsThumbAnimationUpdate = true; _highlightedIndex = value; state.thumbController.animateWith(_kThumbSpringAnimationSimulation); state.separatorOpacityController.reset(); state.separatorOpacityController.animateTo( 1, duration: _kSpringAnimationDuration, curve: Curves.ease, ); state.highlighted = state.indexToKey(value); markNeedsPaint(); markNeedsSemanticsUpdate(); } void guardedSetHighlightedIndex(int value) { // Ignore set highlightedIndex when the user is dragging the thumb around. if (_startedOnSelectedSegment == true) return; highlightedIndex = value; } int get pressedIndex => _pressedIndex; int _pressedIndex; set pressedIndex(int value) { if (_pressedIndex == value) { return; } assert(value == null || (value >= 0 && value < childCount)); _pressedIndex = value; state.pressed = state.indexToKey(value); } Color get thumbColor => _thumbColor; Color _thumbColor; set thumbColor(Color value) { if (_thumbColor == value) { return; } _thumbColor = value; markNeedsPaint(); } double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount - 1); @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent) { state.tap.addPointer(event); state.longPress.addPointer(event); state.drag.addPointer(event); } } int indexFromLocation(Offset location) { return childCount == 0 ? null // This assumes all children have the same width. : ((location.dx / (size.width / childCount)) .floor() .clamp(0, childCount - 1) as int); } void _onTapUp(TapUpDetails details) { highlightedIndex = indexFromLocation(details.localPosition); state.didChangeSelectedViaGesture(); } void _onDown(DragDownDetails details) { assert(size.contains(details.localPosition)); _localDragOffset = details.localPosition; final int index = indexFromLocation(_localDragOffset); _startedOnSelectedSegment = index == highlightedIndex; pressedIndex = index; if (_startedOnSelectedSegment) { _playThumbScaleAnimation(isExpanding: false); } } void _onUpdate(DragUpdateDetails details) { _localDragOffset = details.localPosition; final int newIndex = indexFromLocation(_localDragOffset); if (_startedOnSelectedSegment) { highlightedIndex = newIndex; pressedIndex = newIndex; } else { pressedIndex = _hasDraggedTooFar(details) ? null : newIndex; } } void _onEnd(DragEndDetails details) { if (_startedOnSelectedSegment) { _playThumbScaleAnimation(isExpanding: true); state.didChangeSelectedViaGesture(); } if (pressedIndex != null) { highlightedIndex = pressedIndex; state.didChangeSelectedViaGesture(); } pressedIndex = null; _localDragOffset = null; _startedOnSelectedSegment = null; } void _onCancel() { if (_startedOnSelectedSegment) { _playThumbScaleAnimation(isExpanding: true); } _localDragOffset = null; pressedIndex = null; _startedOnSelectedSegment = null; } void _playThumbScaleAnimation({ @required bool isExpanding }) { assert(isExpanding != null); _thumbScaleTween = Tween<double>(begin: currentThumbScale, end: isExpanding ? 1 : _kMinThumbScale); state.thumbScaleController.animateWith(_kThumbSpringAnimationSimulation); } bool _hasDraggedTooFar(DragUpdateDetails details) { final Offset offCenter = details.localPosition - Offset(size.width/2, size.height/2); return math.pow(math.max(0, offCenter.dx.abs() - size.width/2), 2) + math.pow(math.max(0, offCenter.dy.abs() - size.height/2), 2) > _kTouchYDistanceThreshold; } @override double computeMinIntrinsicWidth(double height) { RenderBox child = firstChild; double maxMinChildWidth = 0; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final double childWidth = child.getMinIntrinsicWidth(height); maxMinChildWidth = math.max(maxMinChildWidth, childWidth); child = childParentData.nextSibling; } return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; } @override double computeMaxIntrinsicWidth(double height) { RenderBox child = firstChild; double maxMaxChildWidth = 0; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final double childWidth = child.getMaxIntrinsicWidth(height); maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth); child = childParentData.nextSibling; } return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; } @override double computeMinIntrinsicHeight(double width) { RenderBox child = firstChild; double maxMinChildHeight = 0; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final double childHeight = child.getMinIntrinsicHeight(width); maxMinChildHeight = math.max(maxMinChildHeight, childHeight); child = childParentData.nextSibling; } return maxMinChildHeight; } @override double computeMaxIntrinsicHeight(double width) { RenderBox child = firstChild; double maxMaxChildHeight = 0; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final double childHeight = child.getMaxIntrinsicHeight(width); maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight); child = childParentData.nextSibling; } return maxMaxChildHeight; } @override double computeDistanceToActualBaseline(TextBaseline baseline) { return defaultComputeDistanceToHighestActualBaseline(baseline); } @override void setupParentData(RenderBox child) { if (child.parentData is! _SegmentedControlContainerBoxParentData) { child.parentData = _SegmentedControlContainerBoxParentData(); } } @override void performLayout() { final BoxConstraints constraints = this.constraints; double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount; double maxHeight = _kMinSegmentedControlHeight; for (final RenderBox child in getChildrenAsList()) { childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding); } childWidth = math.min( childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount, ); RenderBox child = firstChild; while (child != null) { final double boxHeight = child.getMaxIntrinsicHeight(childWidth); maxHeight = math.max(maxHeight, boxHeight); child = childAfter(child); } constraints.constrainHeight(maxHeight); final BoxConstraints childConstraints = BoxConstraints.tightFor( width: childWidth, height: maxHeight, ); // Layout children. child = firstChild; while (child != null) { child.layout(childConstraints, parentUsesSize: true); child = childAfter(child); } double start = 0; child = firstChild; while (child != null) { final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final Offset childOffset = Offset(start, 0); childParentData.offset = childOffset; start += child.size.width + _kSeparatorWidth + _kSeparatorInset.horizontal; child = childAfter(child); } size = constraints.constrain(Size(childWidth * childCount + totalSeparatorWidth, maxHeight)); } @override void paint(PaintingContext context, Offset offset) { final List<RenderBox> children = getChildrenAsList(); // Paint thumb if highlightedIndex is not null. if (highlightedIndex != null) { if (_childAnimations == null) { _childAnimations = <RenderBox, _ChildAnimationManifest> { }; for (int i = 0; i < childCount - 1; i += 1) { // The separator associated with the last child will not be painted (unless // a new trailing segment is added), and its opacity will always be 1. final bool shouldFadeOut = i == highlightedIndex || i == highlightedIndex - 1; final RenderBox child = children[i]; _childAnimations[child] = _ChildAnimationManifest(separatorOpacity: shouldFadeOut ? 0 : 1); } } final RenderBox selectedChild = children[highlightedIndex]; final _SegmentedControlContainerBoxParentData childParentData = selectedChild.parentData as _SegmentedControlContainerBoxParentData; final Rect unscaledThumbTargetRect = _kThumbInsets.inflateRect(childParentData.offset & selectedChild.size); // Update related Tweens before animation update phase. if (_needsThumbAnimationUpdate) { // Needs to ensure _currentThumbRect is valid. _currentThumbTween = RectTween(begin: currentThumbRect ?? unscaledThumbTargetRect, end: unscaledThumbTargetRect); for (int i = 0; i < childCount - 1; i += 1) { // The separator associated with the last child will not be painted (unless // a new segment is appended to the child list), and its opacity will always be 1. final bool shouldFadeOut = i == highlightedIndex || i == highlightedIndex - 1; final RenderBox child = children[i]; final _ChildAnimationManifest manifest = _childAnimations[child]; assert(manifest != null); manifest.separatorTween = Tween<double>( begin: manifest.separatorOpacity, end: shouldFadeOut ? 0 : 1, ); } _needsThumbAnimationUpdate = false; } else if (_currentThumbTween != null && unscaledThumbTargetRect != _currentThumbTween.begin) { _currentThumbTween = RectTween(begin: _currentThumbTween.begin, end: unscaledThumbTargetRect); } for (int index = 0; index < childCount - 1; index += 1) { _paintSeparator(context, offset, children[index]); } currentThumbRect = _currentThumbTween?.evaluate(state.thumbController) ?? unscaledThumbTargetRect; currentThumbScale = _thumbScaleTween.evaluate(state.thumbScaleController); final Rect thumbRect = Rect.fromCenter( center: currentThumbRect.center, width: currentThumbRect.width * currentThumbScale, height: currentThumbRect.height * currentThumbScale, ); _paintThumb(context, offset, thumbRect); } else { // Reset all animations when there's no thumb. currentThumbRect = null; _childAnimations = null; for (int index = 0; index < childCount - 1; index += 1) { _paintSeparator(context, offset, children[index]); } } for (int index = 0; index < children.length; index++) { _paintChild(context, offset, children[index], index); } } // Paint the separator to the right of the given child. void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) { assert(child != null); final _SegmentedControlContainerBoxParentData childParentData = child.parentData as _SegmentedControlContainerBoxParentData; final Paint paint = Paint(); final _ChildAnimationManifest manifest = _childAnimations == null ? null : _childAnimations[child]; final double opacity = manifest?.separatorTween?.evaluate(state.separatorOpacityController) ?? 1; manifest?.separatorOpacity = opacity; paint.color = _kSeparatorColor.withOpacity(_kSeparatorColor.opacity * opacity); final Rect childRect = (childParentData.offset + offset) & child.size; final Rect separatorRect = _kSeparatorInset.deflateRect( childRect.topRight & Size(_kSeparatorInset.horizontal + _kSeparatorWidth, child.size.height), ); context.canvas.drawRRect( RRect.fromRectAndRadius(separatorRect, _kSeparatorRadius), paint, ); } void _paintChild(PaintingContext context, Offset offset, RenderBox child, int childIndex) { assert(child != null); 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 }) { assert(position != null); 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; } }