Unverified Commit a4b27cbf authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Update CupertinoSlidingSegmentedControl (#73772)

parent e21344fa
...@@ -58,6 +58,12 @@ const double _kTouchYDistanceThreshold = 50.0 * 50.0; ...@@ -58,6 +58,12 @@ const double _kTouchYDistanceThreshold = 50.0 * 50.0;
// Inspected from iOS 13.2 simulator. // Inspected from iOS 13.2 simulator.
const double _kCornerRadius = 8; 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. // The spring animation used when the thumb changes its rect.
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation( final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799), const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
...@@ -72,19 +78,186 @@ const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470); ...@@ -72,19 +78,186 @@ const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470);
const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200); const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200);
class _FontWeightTween extends Tween<FontWeight> { class _Segment<T> extends StatefulWidget {
_FontWeightTween({ required FontWeight begin, required FontWeight end }) : super(begin: begin, end: end); 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 @override
FontWeight lerp(double t) => FontWeight.lerp(begin, end, t)!; _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) {
assert(oldWidget.key == widget.key);
super.didUpdateWidget(oldWidget);
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(
index: 0,
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) {
assert(oldWidget.key == widget.key);
super.didUpdateWidget(oldWidget);
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. /// An iOS 13 style segmented control.
/// ///
/// Displays the widgets provided in the [Map] of [children] in a horizontal list. /// 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 /// It allows the user to select between a number of mutually exclusive options,
/// in the segmented control is selected, the other options in the segmented /// by tapping or dragging within the segmented control.
/// control cease to be selected.
/// ///
/// A segmented control can feature any [Widget] as one of the values in its /// 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 /// [Map] of [children]. The type T is the type of the [Map] keys used to identify
...@@ -93,32 +266,26 @@ class _FontWeightTween extends Tween<FontWeight> { ...@@ -93,32 +266,26 @@ class _FontWeightTween extends Tween<FontWeight> {
/// argument must be an ordered [Map] such as a [LinkedHashMap], the ordering of /// 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 keys will determine the order of the widgets in the segmented control.
/// ///
/// When the state of the segmented control changes, the widget calls the /// The widget calls the [onValueChanged] callback *when a valid user gesture
/// [onValueChanged] callback. The map key associated with the newly selected /// completes on an unselected segment*. The map key associated with the newly
/// widget is returned in the [onValueChanged] callback. Typically, widgets /// selected widget is returned in the [onValueChanged] callback. Typically,
/// that use a segmented control will listen for the [onValueChanged] callback /// widgets that use a segmented control will listen for the [onValueChanged]
/// and rebuild the segmented control with a new [groupValue] to update which /// callback and rebuild the segmented control with a new [groupValue] to update
/// option is currently selected. /// which option is currently selected.
/// ///
/// The [children] will be displayed in the order of the keys in the [Map]. /// 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 /// The height of the segmented control is determined by the height of the
/// tallest widget provided as a value in the [Map] of [children]. /// tallest child widget. The width of each child will be the intrinsic width of
/// The width of each child in the segmented control will be equal to the width /// the widest child, or the available horizontal space divided by the number of
/// of widest child, unless the combined width of the children is wider than /// [children], which ever is smaller.
/// 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 /// A segmented control may optionally be created with custom colors. The
/// [thumbColor], [backgroundColor] arguments can be used to override the segmented /// [thumbColor], [backgroundColor] arguments can be used to override the
/// control's colors from its defaults. /// segmented control's colors from its defaults.
/// ///
/// See also: /// 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/> /// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/segmented-controls/>
class CupertinoSlidingSegmentedControl<T> extends StatefulWidget { class CupertinoSlidingSegmentedControl<T> extends StatefulWidget {
/// Creates an iOS-style segmented control bar. /// Creates an iOS-style segmented control bar.
...@@ -157,8 +324,10 @@ class CupertinoSlidingSegmentedControl<T> extends StatefulWidget { ...@@ -157,8 +324,10 @@ class CupertinoSlidingSegmentedControl<T> extends StatefulWidget {
/// The identifying keys and corresponding widget values in the /// The identifying keys and corresponding widget values in the
/// segmented control. /// 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. /// The map must have more than one entry.
/// This attribute must be an ordered [Map] such as a [LinkedHashMap].
final Map<T, Widget> children; final Map<T, Widget> children;
/// The identifier of the widget that is currently selected. /// The identifier of the widget that is currently selected.
...@@ -238,116 +407,59 @@ class CupertinoSlidingSegmentedControl<T> extends StatefulWidget { ...@@ -238,116 +407,59 @@ class CupertinoSlidingSegmentedControl<T> extends StatefulWidget {
class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T>> class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T>>
with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> { with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> {
late final AnimationController thumbController = AnimationController(duration: _kSpringAnimationDuration, value: 0, vsync: this);
Animatable<Rect?>? thumbAnimatable;
final Map<T, AnimationController> _highlightControllers = <T, AnimationController>{}; late final AnimationController thumbScaleController = AnimationController(duration: _kSpringAnimationDuration, value: 0, vsync: this);
final Tween<FontWeight> _highlightTween = _FontWeightTween(begin: FontWeight.normal, end: FontWeight.w500); late Animation<double> thumbScaleAnimation = thumbScaleController.drive(Tween<double>(begin: 1, end: _kMinThumbScale));
final Map<T, AnimationController> _pressControllers = <T, AnimationController>{};
final Tween<double> _pressTween = Tween<double>(begin: 1, end: 0.2);
late List<T> keys;
late AnimationController thumbController;
late AnimationController separatorOpacityController;
late AnimationController thumbScaleController;
final TapGestureRecognizer tap = TapGestureRecognizer(); final TapGestureRecognizer tap = TapGestureRecognizer();
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); 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 @override
void initState() { void initState() {
super.initState(); super.initState();
final GestureArenaTeam team = GestureArenaTeam();
// If the long press or horizontal drag recognizer gets accepted, we know for // 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 // sure the gesture is meant for the segmented control. Hand everything to
// the drag gesture recognizer. // the drag gesture recognizer.
final GestureArenaTeam team = GestureArenaTeam();
longPress.team = team; longPress.team = team;
drag.team = team; drag.team = team;
team.captain = drag; team.captain = drag;
_highlighted = widget.groupValue; drag
..onDown = onDown
..onUpdate = onUpdate
..onEnd = onEnd
..onCancel = onCancel;
thumbController = AnimationController( tap.onTapUp = onTapUp;
duration: _kSpringAnimationDuration,
value: 0,
vsync: this,
);
thumbScaleController = AnimationController( // Empty callback to enable the long press recognizer.
duration: _kSpringAnimationDuration, longPress.onLongPress = () { };
value: 1,
vsync: this,
);
separatorOpacityController = AnimationController( highlighted = widget.groupValue;
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 @override
void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) { void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Update animation controllers. // Temporarily ignore highlight changes from the widget when the thumb is
for (final T oldKey in oldWidget.children.keys) { // being dragged. When the drag gesture finishes the widget will be forced
if (!widget.children.containsKey(oldKey)) { // to build (see the onEnd method), and didUpdateWidget will be called again.
_highlightControllers[oldKey]!.dispose(); if (!isThumbDragging && highlighted != widget.groupValue) {
_pressControllers[oldKey]!.dispose(); thumbController.animateWith(_kThumbSpringAnimationSimulation);
thumbAnimatable = null;
_highlightControllers.remove(oldKey); highlighted = widget.groupValue;
_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 @override
void dispose() { void dispose() {
for (final AnimationController animationController in _highlightControllers.values) {
animationController.dispose();
}
for (final AnimationController animationController in _pressControllers.values) {
animationController.dispose();
}
thumbScaleController.dispose(); thumbScaleController.dispose();
thumbController.dispose(); thumbController.dispose();
separatorOpacityController.dispose();
drag.dispose(); drag.dispose();
tap.dispose(); tap.dispose();
...@@ -356,111 +468,234 @@ class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T ...@@ -356,111 +468,234 @@ class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T
super.dispose(); super.dispose();
} }
// Play highlight animation for the child located at _highlightControllers[at]. // Whether the current drag gesture started on a selected segment. When this
void _animateHighlightController({ T? at, required bool forward }) { // flag is false, the `onUpdate` method does not update `highlighted`.
if (at == null) // Otherwise the thumb can be dragged around in an ongoing drag gesture.
return; bool? _startedOnSelectedSegment;
final AnimationController? controller = _highlightControllers[at];
assert(!forward || controller != null); // Whether an ongoing horizontal drag gesture that started on the thumb is
controller?.animateTo(forward ? 1 : 0, duration: _kHighlightAnimationDuration, curve: Curves.ease); // 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);
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;
} }
T? _highlighted; // The thumb shrinks when the user presses on it, and starts expanding when
set highlighted(T? newValue) { // the user lets go.
if (_highlighted == newValue) // 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 }) {
assert(isExpanding != null);
thumbScaleAnimation = thumbScaleController.drive(
Tween<double>(
begin: thumbScaleAnimation.value,
end: isExpanding ? 1 : _kMinThumbScale,
)
);
thumbScaleController.animateWith(_kThumbSpringAnimationSimulation);
}
void onHighlightChangedByGesture(T newValue) {
if (highlighted == newValue)
return; return;
_animateHighlightController(at: newValue, forward: true); setState(() { highlighted = newValue; });
_animateHighlightController(at: _highlighted, forward: false); // Additionally, start the thumb animation if the highlighted segment
_highlighted = newValue; // 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; });
} }
T? _pressed; void onTapUp(TapUpDetails details) {
set pressed(T? newValue) { // No gesture should interfere with an ongoing thumb drag.
if (_pressed == newValue) if (isThumbDragging)
return; return;
final T segment = segmentForXPosition(details.localPosition.dx);
onPressedChangedByGesture(null);
if (segment != widget.groupValue) {
widget.onValueChanged(segment);
}
}
if (_pressed != null) { void onDown(DragDownDetails details) {
_pressControllers[_pressed]?.animateTo(0, duration: _kOpacityAnimationDuration, curve: Curves.ease); final T touchDownSegment = segmentForXPosition(details.localPosition.dx);
_startedOnSelectedSegment = touchDownSegment == highlighted;
onPressedChangedByGesture(touchDownSegment);
if (isThumbDragging) {
_playThumbScaleAnimation(isExpanding: false);
} }
if (newValue != _highlighted && newValue != null) { }
_pressControllers[newValue]!.animateTo(1, duration: _kOpacityAnimationDuration, curve: Curves.ease);
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);
} }
_pressed = newValue;
} }
void didChangeSelectedViaGesture() { void onEnd(DragEndDetails details) {
widget.onValueChanged(_highlighted); 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;
} }
T? indexToKey(int? index) => index == null ? null : keys[index]; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
debugCheckHasDirectionality(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: _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)) { switch (Directionality.of(context)) {
case TextDirection.ltr: case TextDirection.ltr:
keys = widget.children.keys.toList(growable: false);
break; break;
case TextDirection.rtl: case TextDirection.rtl:
keys = widget.children.keys.toList().reversed.toList(growable: false); children = children.reversed.toList(growable: false);
if (highlightedIndex != null) {
highlightedIndex = index - 1 - highlightedIndex;
}
break; break;
} }
return AnimatedBuilder( return UnconstrainedBox(
animation: Listenable.merge(<Listenable>[ constrainedAxis: Axis.horizontal,
..._highlightControllers.values, child: Container(
..._pressControllers.values, padding: widget.padding.resolve(Directionality.of(context)),
]), decoration: BoxDecoration(
builder: (BuildContext context, Widget? child) { borderRadius: const BorderRadius.all(Radius.circular(_kCornerRadius)),
final List<Widget> children = <Widget>[]; color: CupertinoDynamicColor.resolve(widget.backgroundColor, context),
for (final T currentKey in keys) { ),
final TextStyle textStyle = DefaultTextStyle.of(context).style.copyWith( child: AnimatedBuilder(
fontWeight: _highlightTween.evaluate(_highlightControllers[currentKey]!), animation: thumbScaleAnimation,
); builder: (BuildContext context, Widget? child) {
return _SegmentedControlRenderWidget<T>(
final Widget child = DefaultTextStyle( children: children,
style: textStyle, highlightedIndex: highlightedIndex,
child: Semantics( thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context),
button: true, thumbScale: thumbScaleAnimation.value,
onTap: () { widget.onValueChanged(currentKey); }, state: this,
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 as T);
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,
),
);
},
); );
} }
} }
...@@ -469,48 +704,38 @@ class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget { ...@@ -469,48 +704,38 @@ class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget {
_SegmentedControlRenderWidget({ _SegmentedControlRenderWidget({
Key? key, Key? key,
List<Widget> children = const <Widget>[], List<Widget> children = const <Widget>[],
required this.selectedIndex, required this.highlightedIndex,
required this.thumbColor, required this.thumbColor,
required this.thumbScale,
required this.state, required this.state,
}) : super(key: key, children: children); }) : super(key: key, children: children);
final int? selectedIndex; final int? highlightedIndex;
final Color? thumbColor; final Color thumbColor;
final double thumbScale;
final _SegmentedControlState<T> state; final _SegmentedControlState<T> state;
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return _RenderSegmentedControl<T>( return _RenderSegmentedControl<T>(
selectedIndex: selectedIndex, highlightedIndex: highlightedIndex,
thumbColor: CupertinoDynamicColor.maybeResolve(thumbColor, context), thumbColor: thumbColor,
thumbScale: thumbScale,
state: state, state: state,
); );
} }
@override @override
void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) { void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) {
assert(renderObject.state == state);
renderObject renderObject
..thumbColor = CupertinoDynamicColor.maybeResolve(thumbColor, context) ..thumbColor = thumbColor
..guardedSetHighlightedIndex(selectedIndex); ..thumbScale = thumbScale
..highlightedIndex = highlightedIndex;
} }
} }
class _ChildAnimationManifest { class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<RenderBox> {}
_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: // The behavior of a UISegmentedControl as observed on iOS 13.1:
// //
...@@ -530,7 +755,7 @@ class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<Ren ...@@ -530,7 +755,7 @@ class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<Ren
// 3. When the sliding animation plays two other animations take place. In one animation // 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 // the content of the current segment gradually becomes "highlighted", turning the
// font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2). // font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2).
// The other is the separator fadein/fadeout animation. // 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 // 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 // index will trigger a CABasicAnimation that shrinks the thumb to 95% of its
...@@ -538,84 +763,53 @@ class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<Ren ...@@ -538,84 +763,53 @@ class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<Ren
/// corresponding tap up event inverts the process (eyeballed). /// corresponding tap up event inverts the process (eyeballed).
// //
// 5. A tap down event on other segments will trigger a CABasicAnimation // 5. A tap down event on other segments will trigger a CABasicAnimation
// (timingFunction = default, duration = 0.47.) that fades out the content, // (timingFunction = default, duration = 0.47.) that fades out the content
// eventually reducing the alpha of that segment to 20% unless interrupted by // from its current alpha, eventually reducing the alpha of that segment to
// a tap up event or the pointer moves out of the region (either outside of the // 20% unless interrupted by a tap up event or the pointer moves out of the
// segmented control's vicinity or to a different segment). The reverse animation // region (either outside of the segmented control's vicinity or to a
// has the same duration and timing function. // different segment). The reverse animation has the same duration and timing
// function.
class _RenderSegmentedControl<T> extends RenderBox class _RenderSegmentedControl<T> extends RenderBox
with ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>, with ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>,
RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> { RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> {
_RenderSegmentedControl({ _RenderSegmentedControl({
required int? selectedIndex, required int? highlightedIndex,
required Color? thumbColor, required Color thumbColor,
required double thumbScale,
required this.state, required this.state,
}) : _highlightedIndex = selectedIndex, }) : _highlightedIndex = highlightedIndex,
_thumbColor = thumbColor, _thumbColor = thumbColor,
assert(state != null) { _thumbScale = thumbScale,
state.drag assert(state != null);
..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; final _SegmentedControlState<T> state;
Map<RenderBox, _ChildAnimationManifest>? _childAnimations = <RenderBox, _ChildAnimationManifest>{}; // The current **Unscaled** Thumb Rect in this RenderBox's coordinate space.
// The current **Unscaled** Thumb Rect.
Rect? currentThumbRect; 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 @override
void attach(PipelineOwner owner) { void attach(PipelineOwner owner) {
super.attach(owner); super.attach(owner);
state.thumbController.addListener(markNeedsPaint); state.thumbController.addListener(markNeedsPaint);
state.thumbScaleController.addListener(markNeedsPaint);
state.separatorOpacityController.addListener(markNeedsPaint);
} }
@override @override
void detach() { void detach() {
state.thumbController.removeListener(markNeedsPaint); state.thumbController.removeListener(markNeedsPaint);
state.thumbScaleController.removeListener(markNeedsPaint);
state.separatorOpacityController.removeListener(markNeedsPaint);
super.detach(); super.detach();
} }
// Indicates whether selectedIndex has changed and animations need to be updated. double get thumbScale => _thumbScale;
// when true some animation tweens will be updated in paint phase. double _thumbScale;
bool _needsThumbAnimationUpdate = false; set thumbScale(double value) {
if (_thumbScale == value) {
return;
}
_thumbScale = value;
if (state.highlighted != null)
markNeedsPaint();
}
int? get highlightedIndex => _highlightedIndex; int? get highlightedIndex => _highlightedIndex;
int? _highlightedIndex; int? _highlightedIndex;
...@@ -624,46 +818,13 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -624,46 +818,13 @@ class _RenderSegmentedControl<T> extends RenderBox
return; return;
} }
_needsThumbAnimationUpdate = true;
_highlightedIndex = value; _highlightedIndex = value;
state.thumbController.animateWith(_kThumbSpringAnimationSimulation);
state.separatorOpacityController.reset();
state.separatorOpacityController.animateTo(
1,
duration: _kSpringAnimationDuration,
curve: Curves.ease,
);
state.highlighted = state.indexToKey(value);
markNeedsPaint(); 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 get thumbColor => _thumbColor;
Color? _thumbColor; Color _thumbColor;
set thumbColor(Color? value) { set thumbColor(Color value) {
if (_thumbColor == value) { if (_thumbColor == value) {
return; return;
} }
...@@ -671,116 +832,48 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -671,116 +832,48 @@ class _RenderSegmentedControl<T> extends RenderBox
markNeedsPaint(); markNeedsPaint();
} }
double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount - 1);
@override @override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) { void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry)); assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) { // No gesture should interfere with an ongoing thumb drag.
if (event is PointerDownEvent && !state.isThumbDragging) {
state.tap.addPointer(event); state.tap.addPointer(event);
state.longPress.addPointer(event); state.longPress.addPointer(event);
state.drag.addPointer(event); state.drag.addPointer(event);
} }
} }
int? indexFromLocation(Offset location) { // Intrinsic Dimensions
return childCount == 0
? null
// This assumes all children have the same width.
: (location.dx / (size.width / childCount))
.floor()
.clamp(0, childCount - 1);
}
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; double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount ~/ 2);
pressedIndex = null;
_startedOnSelectedSegment = null;
}
void _playThumbScaleAnimation({ required bool isExpanding }) { RenderBox? nonSeparatorChildAfter(RenderBox child) {
assert(isExpanding != null); final RenderBox? nextChild = childAfter(child);
_thumbScaleTween = Tween<double>(begin: currentThumbScale, end: isExpanding ? 1 : _kMinThumbScale); return nextChild == null ? null : childAfter(nextChild);
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 @override
double computeMinIntrinsicWidth(double height) { double computeMinIntrinsicWidth(double height) {
final int childCount = this.childCount ~/ 2 + 1;
RenderBox? child = firstChild; RenderBox? child = firstChild;
double maxMinChildWidth = 0; double maxMinChildWidth = 0;
while (child != null) { while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData =
child.parentData! as _SegmentedControlContainerBoxParentData;
final double childWidth = child.getMinIntrinsicWidth(height); final double childWidth = child.getMinIntrinsicWidth(height);
maxMinChildWidth = math.max(maxMinChildWidth, childWidth); maxMinChildWidth = math.max(maxMinChildWidth, childWidth);
child = childParentData.nextSibling; child = nonSeparatorChildAfter(child);
} }
return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
} }
@override @override
double computeMaxIntrinsicWidth(double height) { double computeMaxIntrinsicWidth(double height) {
final int childCount = this.childCount ~/ 2 + 1;
RenderBox? child = firstChild; RenderBox? child = firstChild;
double maxMaxChildWidth = 0; double maxMaxChildWidth = 0;
while (child != null) { while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData =
child.parentData! as _SegmentedControlContainerBoxParentData;
final double childWidth = child.getMaxIntrinsicWidth(height); final double childWidth = child.getMaxIntrinsicWidth(height);
maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth); maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth);
child = childParentData.nextSibling; child = nonSeparatorChildAfter(child);
} }
return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
} }
...@@ -788,13 +881,11 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -788,13 +881,11 @@ class _RenderSegmentedControl<T> extends RenderBox
@override @override
double computeMinIntrinsicHeight(double width) { double computeMinIntrinsicHeight(double width) {
RenderBox? child = firstChild; RenderBox? child = firstChild;
double maxMinChildHeight = 0; double maxMinChildHeight = _kMinSegmentedControlHeight;
while (child != null) { while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData =
child.parentData! as _SegmentedControlContainerBoxParentData;
final double childHeight = child.getMinIntrinsicHeight(width); final double childHeight = child.getMinIntrinsicHeight(width);
maxMinChildHeight = math.max(maxMinChildHeight, childHeight); maxMinChildHeight = math.max(maxMinChildHeight, childHeight);
child = childParentData.nextSibling; child = nonSeparatorChildAfter(child);
} }
return maxMinChildHeight; return maxMinChildHeight;
} }
...@@ -802,13 +893,11 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -802,13 +893,11 @@ class _RenderSegmentedControl<T> extends RenderBox
@override @override
double computeMaxIntrinsicHeight(double width) { double computeMaxIntrinsicHeight(double width) {
RenderBox? child = firstChild; RenderBox? child = firstChild;
double maxMaxChildHeight = 0; double maxMaxChildHeight = _kMinSegmentedControlHeight;
while (child != null) { while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData =
child.parentData! as _SegmentedControlContainerBoxParentData;
final double childHeight = child.getMaxIntrinsicHeight(width); final double childHeight = child.getMaxIntrinsicHeight(width);
maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight); maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight);
child = childParentData.nextSibling; child = nonSeparatorChildAfter(child);
} }
return maxMaxChildHeight; return maxMaxChildHeight;
} }
...@@ -826,12 +915,13 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -826,12 +915,13 @@ class _RenderSegmentedControl<T> extends RenderBox
} }
Size _calculateChildSize(BoxConstraints constraints) { Size _calculateChildSize(BoxConstraints constraints) {
final int childCount = this.childCount ~/ 2 + 1;
double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount; double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount;
double maxHeight = _kMinSegmentedControlHeight; double maxHeight = _kMinSegmentedControlHeight;
RenderBox? child = firstChild; RenderBox? child = firstChild;
while (child != null) { while (child != null) {
childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding); childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding);
child = childAfter(child); child = nonSeparatorChildAfter(child);
} }
childWidth = math.min( childWidth = math.min(
childWidth, childWidth,
...@@ -841,158 +931,141 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -841,158 +931,141 @@ class _RenderSegmentedControl<T> extends RenderBox
while (child != null) { while (child != null) {
final double boxHeight = child.getMaxIntrinsicHeight(childWidth); final double boxHeight = child.getMaxIntrinsicHeight(childWidth);
maxHeight = math.max(maxHeight, boxHeight); maxHeight = math.max(maxHeight, boxHeight);
child = childAfter(child); child = nonSeparatorChildAfter(child);
} }
return Size(childWidth, maxHeight); return Size(childWidth, maxHeight);
} }
Size _computeOverallSizeFromChildSize(Size childSize) { Size _computeOverallSizeFromChildSize(Size childSize, BoxConstraints constraints) {
final int childCount = this.childCount ~/ 2 + 1;
return constraints.constrain(Size(childSize.width * childCount + totalSeparatorWidth, childSize.height)); return constraints.constrain(Size(childSize.width * childCount + totalSeparatorWidth, childSize.height));
} }
@override @override
Size computeDryLayout(BoxConstraints constraints) { Size computeDryLayout(BoxConstraints constraints) {
final Size childSize = _calculateChildSize(constraints); final Size childSize = _calculateChildSize(constraints);
return _computeOverallSizeFromChildSize(childSize); return _computeOverallSizeFromChildSize(childSize, constraints);
} }
@override @override
void performLayout() { void performLayout() {
final BoxConstraints constraints = this.constraints; final BoxConstraints constraints = this.constraints;
final Size childSize = _calculateChildSize(constraints); final Size childSize = _calculateChildSize(constraints);
final BoxConstraints childConstraints = BoxConstraints.tight(childSize);
final BoxConstraints separatorConstraints = childConstraints.heightConstraints();
final BoxConstraints childConstraints = BoxConstraints.tightFor(
width: childSize.width,
height: childSize.height,
);
// Layout children.
RenderBox? child = firstChild; RenderBox? child = firstChild;
while (child != null) { int index = 0;
child.layout(childConstraints, parentUsesSize: true);
child = childAfter(child);
}
double start = 0; double start = 0;
child = firstChild;
while (child != null) { while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.layout(index.isEven ? childConstraints : separatorConstraints, parentUsesSize: true);
child.parentData! as _SegmentedControlContainerBoxParentData; final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData;
final Offset childOffset = Offset(start, 0); final Offset childOffset = Offset(start, 0);
childParentData.offset = childOffset; childParentData.offset = childOffset;
start += child.size.width + _kSeparatorWidth + _kSeparatorInset.horizontal; start += child.size.width;
assert(
index.isEven || child.size.width == _kSeparatorWidth + _kSeparatorInset.horizontal,
'${child.size.width} != ${_kSeparatorWidth + _kSeparatorInset.horizontal}',
);
child = childAfter(child); child = childAfter(child);
index += 1;
} }
size = _computeOverallSizeFromChildSize(childSize); 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 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
// calcuates 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 @override
void paint(PaintingContext context, Offset offset) { void paint(PaintingContext context, Offset offset) {
final List<RenderBox> children = getChildrenAsList(); final List<RenderBox> children = getChildrenAsList();
// Paint thumb if highlightedIndex is not null. for (int index = 1; index < childCount; index += 2) {
if (highlightedIndex != null) { _paintSeparator(context, offset, children[index]);
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 = final int? highlightedChildIndex = highlightedIndex;
selectedChild.parentData! as _SegmentedControlContainerBoxParentData; // Paint thumb if there's a highlighted segment.
final Rect unscaledThumbTargetRect = _kThumbInsets.inflateRect(childParentData.offset & selectedChild.size); if (highlightedChildIndex != null) {
final RenderBox selectedChild = children[highlightedChildIndex * 2];
// Update related Tweens before animation update phase.
if (_needsThumbAnimationUpdate) { final _SegmentedControlContainerBoxParentData childParentData = selectedChild.parentData! as _SegmentedControlContainerBoxParentData;
// Needs to ensure _currentThumbRect is valid. final Rect newThumbRect = _kThumbInsets.inflateRect(childParentData.offset & selectedChild.size);
_currentThumbTween = RectTween(begin: currentThumbRect ?? unscaledThumbTargetRect, end: unscaledThumbTargetRect);
// Update thumb animation's tween, in case the end rect changed (e.g., a
for (int i = 0; i < childCount - 1; i += 1) { // new segment is added during the animation).
// The separator associated with the last child will not be painted (unless if (state.thumbController.isAnimating) {
// a new segment is appended to the child list), and its opacity will always be 1. final Animatable<Rect?>? thumbTween = state.thumbAnimatable;
final bool shouldFadeOut = i == highlightedIndex || i == highlightedIndex! - 1; if (thumbTween == null) {
final RenderBox child = children[i]; // This is the first frame of the animation.
final _ChildAnimationManifest manifest = _childAnimations![child]!; final Rect startingRect = moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect;
assert(manifest != null); state.thumbAnimatable = RectTween(begin: startingRect, end: newThumbRect);
manifest.separatorTween = Tween<double>( } else if (newThumbRect != thumbTween.transform(1)) {
begin: manifest.separatorOpacity, // The thumbTween of the running sliding animation needs updating,
end: shouldFadeOut ? 0 : 1, // 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 {
_needsThumbAnimationUpdate = false; state.thumbAnimatable = null;
} 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) final Rect unscaledThumbRect = state.thumbAnimatable?.evaluate(state.thumbController) ?? newThumbRect;
?? unscaledThumbTargetRect; currentThumbRect = unscaledThumbRect;
currentThumbScale = _thumbScaleTween.evaluate(state.thumbScaleController);
final Rect thumbRect = Rect.fromCenter( final Rect thumbRect = Rect.fromCenter(
center: currentThumbRect!.center, center: unscaledThumbRect.center,
width: currentThumbRect!.width * currentThumbScale, width: unscaledThumbRect.width * thumbScale,
height: currentThumbRect!.height * currentThumbScale, height: unscaledThumbRect.height * thumbScale,
); );
_paintThumb(context, offset, thumbRect); _paintThumb(context, offset, thumbRect);
} else { } else {
// Reset all animations when there's no thumb.
currentThumbRect = null; 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++) { for (int index = 0; index < children.length; index += 2) {
_paintChild(context, offset, children[index], index); _paintChild(context, offset, children[index]);
} }
} }
// Paint the separator to the right of the given child. // Paint the separator to the right of the given child.
final Paint separatorPaint = Paint();
void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) { void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) {
assert(child != null); assert(child != null);
final _SegmentedControlContainerBoxParentData childParentData = final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData;
child.parentData! as _SegmentedControlContainerBoxParentData; context.paintChild(child, offset + childParentData.offset);
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) { void _paintChild(PaintingContext context, Offset offset, RenderBox child) {
assert(child != null); assert(child != null);
final _SegmentedControlContainerBoxParentData childParentData = final _SegmentedControlContainerBoxParentData childParentData = child.parentData! as _SegmentedControlContainerBoxParentData;
child.parentData! as _SegmentedControlContainerBoxParentData;
context.paintChild(child, childParentData.offset + offset); context.paintChild(child, childParentData.offset + offset);
} }
...@@ -1024,7 +1097,7 @@ class _RenderSegmentedControl<T> extends RenderBox ...@@ -1024,7 +1097,7 @@ class _RenderSegmentedControl<T> extends RenderBox
context.canvas.drawRRect( context.canvas.drawRRect(
thumbRRect, thumbRRect,
Paint()..color = thumbColor!, Paint()..color = thumbColor,
); );
} }
......
...@@ -7,6 +7,7 @@ import 'dart:collection'; ...@@ -7,6 +7,7 @@ import 'dart:collection';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
...@@ -29,7 +30,7 @@ Rect currentUnscaledThumbRect(WidgetTester tester, { bool useGlobalCoordinate = ...@@ -29,7 +30,7 @@ Rect currentUnscaledThumbRect(WidgetTester tester, { bool useGlobalCoordinate =
return local.shift(segmentedControl.localToGlobal(Offset.zero)); return local.shift(segmentedControl.localToGlobal(Offset.zero));
} }
double currentThumbScale(WidgetTester tester) => getRenderSegmentedControl(tester).currentThumbScale as double; double currentThumbScale(WidgetTester tester) => getRenderSegmentedControl(tester).thumbScale as double;
Widget setupSimpleSegmentedControl() { Widget setupSimpleSegmentedControl() {
const Map<int, Widget> children = <int, Widget>{ const Map<int, Widget> children = <int, Widget>{
...@@ -133,20 +134,20 @@ void main() { ...@@ -133,20 +134,20 @@ void main() {
final Rect segmentedControlRect = tester.getRect(find.byKey(key)); final Rect segmentedControlRect = tester.getRect(find.byKey(key));
expect( expect(
tester.getTopLeft(find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(Opacity))), tester.getTopLeft(find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData))),
segmentedControlRect.topLeft + effectivePadding.topLeft, segmentedControlRect.topLeft + effectivePadding.topLeft,
); );
expect( expect(
tester.getBottomLeft(find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(Opacity))), tester.getBottomLeft(find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData))),
segmentedControlRect.bottomLeft + effectivePadding.bottomLeft, segmentedControlRect.bottomLeft + effectivePadding.bottomLeft,
); );
expect( expect(
tester.getTopRight(find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(Opacity))), tester.getTopRight(find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData))),
segmentedControlRect.topRight + effectivePadding.topRight, segmentedControlRect.topRight + effectivePadding.topRight,
); );
expect( expect(
tester.getBottomRight(find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(Opacity))), tester.getBottomRight(find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData))),
segmentedControlRect.bottomRight + effectivePadding.bottomRight, segmentedControlRect.bottomRight + effectivePadding.bottomRight,
); );
} }
...@@ -400,9 +401,9 @@ void main() { ...@@ -400,9 +401,9 @@ void main() {
); );
double getChildOpacityByName(String childName) { double getChildOpacityByName(String childName) {
return tester.widget<Opacity>( return tester.renderObject<RenderAnimatedOpacity>(
find.ancestor(matching: find.byType(Opacity), of: find.text(childName)), find.ancestor(matching: find.byType(AnimatedOpacity), of: find.text(childName)),
).opacity; ).opacity.value;
} }
// Opacity 1 with no interaction. // Opacity 1 with no interaction.
...@@ -441,9 +442,9 @@ void main() { ...@@ -441,9 +442,9 @@ void main() {
testWidgets('Long press does not change the opacity of currently-selected child', (WidgetTester tester) async { testWidgets('Long press does not change the opacity of currently-selected child', (WidgetTester tester) async {
double getChildOpacityByName(String childName) { double getChildOpacityByName(String childName) {
return tester.widget<Opacity>( return tester.renderObject<RenderAnimatedOpacity>(
find.ancestor(matching: find.byType(Opacity), of: find.text(childName)), find.ancestor(matching: find.byType(AnimatedOpacity), of: find.text(childName)),
).opacity; ).opacity.value;
} }
await tester.pumpWidget(setupSimpleSegmentedControl()); await tester.pumpWidget(setupSimpleSegmentedControl());
...@@ -782,6 +783,7 @@ void main() { ...@@ -782,6 +783,7 @@ void main() {
// Tap up and the sliding animation should play. // Tap up and the sliding animation should play.
await gesture.up(); await gesture.up();
await tester.pump(); await tester.pump();
// 10 ms isn't long enough for this gesture to be recognized as a longpress.
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(currentThumbScale(tester), 1); expect(currentThumbScale(tester), 1);
...@@ -795,8 +797,8 @@ void main() { ...@@ -795,8 +797,8 @@ void main() {
expect(currentThumbScale(tester), 1); expect(currentThumbScale(tester), 1);
expect( expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
// We're using a critically damped spring so the value of the animation // We're using a critically damped spring so expect the value of the
// controller will never reach 1. // animation controller to not be 1.
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01), offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01),
); );
...@@ -847,6 +849,62 @@ void main() { ...@@ -847,6 +849,62 @@ void main() {
expect(currentThumbScale(tester), moreOrLessEquals(1, epsilon: 0.01)); expect(currentThumbScale(tester), moreOrLessEquals(1, epsilon: 0.01));
}); });
testWidgets(
'Thumb does not go out of bounds in animation',
(WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1', maxLines: 1),
1: Text('wiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiide Child 2', maxLines: 1),
2: SizedBox(height: 400),
};
await tester.pumpWidget(boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
children: children,
groupValue: groupValue,
onValueChanged: defaultCallback,
);
},
));
final Rect initialThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true);
// Starts animating towards 1.
setState!(() { groupValue = 1; });
await tester.pump(const Duration(milliseconds: 10));
const Map<int, Widget> newChildren = <int, Widget>{
0: Text('C1', maxLines: 1),
1: Text('C2', maxLines: 1),
};
// Now let the segments shrink.
await tester.pumpWidget(boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
children: newChildren,
groupValue: 1,
onValueChanged: defaultCallback,
);
},
));
final RenderBox renderSegmentedControl = getRenderSegmentedControl(tester) as RenderBox;
final Offset segmentedControlOrigin = renderSegmentedControl.localToGlobal(Offset.zero);
// Expect the segmented control to be much narrower.
expect(segmentedControlOrigin.dx, greaterThan(initialThumbRect.left));
final Rect thumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true);
expect(initialThumbRect.size.height, 400);
expect(thumbRect.size.height, lessThan(100));
// The new thumbRect should fit in the segmentedControl. The -1 and the +1
// are to account for the thumb's vertical EdgeInsets.
expect(segmentedControlOrigin.dx - 1, lessThanOrEqualTo(thumbRect.left));
expect(segmentedControlOrigin.dx + renderSegmentedControl.size.width + 1, greaterThanOrEqualTo(thumbRect.right));
});
testWidgets('Transition is triggered while a transition is already occurring', (WidgetTester tester) async { testWidgets('Transition is triggered while a transition is already occurring', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{ const Map<int, Widget> children = <int, Widget>{
0: Text('A'), 0: Text('A'),
...@@ -943,6 +1001,227 @@ void main() { ...@@ -943,6 +1001,227 @@ void main() {
); );
}); });
testWidgets('change selection programmatically when dragging', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('B'),
2: Text('C'),
};
bool callbackCalled = false;
void onValueChanged(int? newValue) {
callbackCalled = true;
}
await tester.pumpWidget(
boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
groupValue: groupValue,
onValueChanged: onValueChanged,
);
},
),
);
// Start dragging.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Change selection programmatically.
setState!(() { groupValue = 1; });
await tester.pump();
await tester.pumpAndSettle();
// The ongoing drag gesture should veto the programmatic change.
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('A')), epsilon: 0.01),
);
// Move the pointer to 'B'. The onValueChanged callback will be called but
// since the parent widget thinks we're already at 'B', it will not trigger
// a rebuild for us.
await gesture.moveTo(tester.getCenter(find.text('B')));
await gesture.up();
await tester.pump();
await tester.pumpAndSettle();
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('B')), epsilon: 0.01),
);
expect(callbackCalled, isFalse);
});
testWidgets('Disallow new gesture when dragging', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('B'),
2: Text('C'),
};
bool callbackCalled = false;
void onValueChanged(int? newValue) {
callbackCalled = true;
}
await tester.pumpWidget(
boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
groupValue: groupValue,
onValueChanged: onValueChanged,
);
},
),
);
// Start dragging.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Tap a different segment.
await tester.tap(find.text('C'));
await tester.pump();
await tester.pumpAndSettle();
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('A')), epsilon: 0.01),
);
// A different drag.
await tester.drag(find.text('A'), const Offset(300, 0));
await tester.pump();
await tester.pumpAndSettle();
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('A')), epsilon: 0.01),
);
await gesture.up();
expect(callbackCalled, isFalse);
});
testWidgets('gesture outlives the widget', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/63338.
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('B'),
2: Text('C'),
};
await tester.pumpWidget(
boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
groupValue: groupValue,
onValueChanged: defaultCallback,
);
},
),
);
// Start dragging.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpWidget(const Placeholder());
await gesture.moveBy(const Offset(200, 0));
await tester.pump();
await tester.pump();
await gesture.up();
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('computeDryLayout is pure', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/73362.
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('B'),
2: Text('C'),
};
const Key key = ValueKey<int>(1);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: 10,
child: CupertinoSlidingSegmentedControl<int>(
key: key,
children: children,
groupValue: groupValue,
onValueChanged: defaultCallback,
),
),
),
),
);
final RenderBox renderBox = getRenderSegmentedControl(tester) as RenderBox;
final Size size = renderBox.getDryLayout(const BoxConstraints());
expect(size.width, greaterThan(10));
expect(tester.takeException(), isNull);
});
testWidgets('Has consistent size, independent of groupValue', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/62063.
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('BB'),
2: Text('CCCC'),
};
groupValue = null;
await tester.pumpWidget(
boilerplate(
builder: (BuildContext context) {
return CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
groupValue: groupValue,
onValueChanged: defaultCallback,
);
},
),
);
final RenderBox renderBox = getRenderSegmentedControl(tester) as RenderBox;
final Size size = renderBox.size;
for (final int value in children.keys) {
setState!(() { groupValue = value; });
await tester.pump();
await tester.pumpAndSettle();
expect(renderBox.size, size);
}
});
testWidgets('ScrollView + SlidingSegmentedControl interaction', (WidgetTester tester) async { testWidgets('ScrollView + SlidingSegmentedControl interaction', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{ const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'), 0: Text('Child 1'),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment