blob: 2c9365722beb242b9084eb89ba3da1974bb50daa [file] [log] [blame]
// 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;
break;
}
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;
}
break;
}
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 {
_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;
}
}