Unverified Commit 9ea0ecc3 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

CupertinoSlidingSegmentedControl (#42775)

parent c5b3b3ac
......@@ -29,6 +29,7 @@ export 'src/cupertino/route.dart';
export 'src/cupertino/scrollbar.dart';
export 'src/cupertino/segmented_control.dart';
export 'src/cupertino/slider.dart';
export 'src/cupertino/sliding_segmented_control.dart';
export 'src/cupertino/switch.dart';
export 'src/cupertino/tab_scaffold.dart';
export 'src/cupertino/tab_view.dart';
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
// Extracted from https://developer.apple.com/design/resources/.
// Minimum padding from edges of the segmented control to edges of
// encompassing widget.
const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(vertical: 2, horizontal: 3);
// The corner radius of the thumb.
const Radius _kThumbRadius = Radius.circular(6.93);
// The amount of space by which to expand the thumb from the size of the currently
// selected child.
const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: 1);
// Minimum height of the segmented control.
const double _kMinSegmentedControlHeight = 28.0;
const Color _kSeparatorColor = Color(0x4D8E8E93);
const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFFFFFFF),
darkColor: Color(0xFF636366),
);
// The amount of space by which to inset each separator.
const EdgeInsets _kSeparatorInset = EdgeInsets.symmetric(vertical: 6);
const double _kSeparatorWidth = 1;
const Radius _kSeparatorRadius = Radius.circular(_kSeparatorWidth/2);
// The minimum scale factor of the thumb, when being pressed on for a sufficient
// amount of time.
const double _kMinThumbScale = 0.95;
// The minimum horizontal distance between the edges of the separator and the
// closest child.
const double _kSegmentMinPadding = 9.25;
// The threshold value used in hasDraggedTooFar, for checking against the square
// L2 distance from the location of the current drag pointer, to the closest
// vertice of the CupertinoSlidingSegmentedControl's Rect.
//
// Both the mechanism and the value are speculated.
const double _kTouchYDistanceThreshold = 50.0 * 50.0;
// The corner radius of the segmented control.
//
// Inspected from iOS 13.2 simulator.
const double _kCornerRadius = 8;
// The spring animation used when the thumb changes its rect.
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
0,
1,
0, // Everytime a new spring animation starts the previous animation stops.
);
const Duration _kSpringAnimationDuration = Duration(milliseconds: 412);
const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470);
const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200);
class _FontWeightTween extends Tween<FontWeight> {
_FontWeightTween({ FontWeight begin, FontWeight end }) : super(begin: begin, end: end);
@override
FontWeight lerp(double t) => FontWeight.lerp(begin, end, t);
}
/// An iOS 13 style segmented control.
///
/// Displays the widgets provided in the [Map] of [children] in a horizontal list.
/// Used to select between a number of mutually exclusive options. When one option
/// in the segmented control is selected, the other options in the segmented
/// control cease to be selected.
///
/// A segmented control can feature any [Widget] as one of the values in its
/// [Map] of [children]. The type T is the type of the [Map] keys used to identify
/// each widget and determine which widget is selected. As required by the [Map]
/// class, keys must be of consistent types and must be comparable. The [children]
/// argument must be an ordered [Map] such as a [LinkedHashMap], the ordering of
/// the keys will determine the order of the widgets in the segmented control.
///
/// When the state of the segmented control changes, the widget changes the
/// [controller]'s value to the map key associated with the newly selected widget,
/// causing all of its listeners to be notified.
///
/// {@tool dartpad --template=stateful_widget_material}
///
/// This sample shows two [CupertinoSlidingSegmentedControl]s that mirror each other.
///
/// ```dart
/// final Map<int, Widget> children = const <int, Widget>{
/// 0: Text('Child 1'),
/// 1: Text('Child 2'),
/// 2: Text('Child 3'),
/// };
///
/// // No segment is initially selected because the controller's value is null.
/// final ValueNotifier<int> controller = ValueNotifier<int>(null);
///
/// @override
/// void initState() {
/// super.initState();
/// // Prints a message whenever the currently selected widget changes.
/// controller.addListener(() { print('selected: ${controller.value}'); });
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Center(
/// child: Column(
/// children: <Widget>[
/// CupertinoSlidingSegmentedControl<int>(
/// children: children,
/// controller: controller,
/// ),
/// CupertinoSlidingSegmentedControl<int>(
/// children: children,
/// controller: controller,
/// ),
/// ],
/// ),
/// );
/// }
///
/// @override
/// void dispose() {
/// controller.dispose();
/// super.dispose();
/// }
/// ```
/// {@end-tool}
///
/// The [children] will be displayed in the order of the keys in the [Map].
/// The height of the segmented control is determined by the height of the
/// tallest widget provided as a value in the [Map] of [children].
/// The width of each child in the segmented control will be equal to the width
/// of widest child, unless the combined width of the children is wider than
/// the available horizontal space. In this case, the available horizontal space
/// is divided by the number of provided [children] to determine the width of
/// each widget. The selection area for each of the widgets in the [Map] of
/// [children] will then be expanded to fill the calculated space, so each
/// widget will appear to have the same dimensions.
///
/// A segmented control may optionally be created with custom colors. The
/// [thumbColor], [backgroundColor] arguments can be used to override the segmented
/// control's colors from its defaults.
///
/// See also:
///
/// * <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 [controller] 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 [Map] key
/// of type [T] that uniquely identifies this widget. This key will become the
/// [controller]'s new value, when the corresponding child widget from the
/// [children] map is selected.
///
/// The [controller]'s [ValueNotifier.value] is the currently selected value for
/// the segmented control. If it is null, no widget will appear as selected. The
/// [controller]'s value must be either null or one of the keys in the [children]
/// map.
CupertinoSlidingSegmentedControl({
Key key,
@required this.children,
@required this.controller,
this.thumbColor = _kThumbColor,
this.padding = _kHorizontalItemPadding,
this.backgroundColor = CupertinoColors.tertiarySystemFill,
}) : assert(children != null),
assert(children.length >= 2),
assert(padding != null),
assert(controller != null),
assert(
controller.value == null || children.keys.any((T child) => child == controller.value),
"The controller's value must be either null or one of the keys in the children map.",
),
super(key: key);
/// The identifying keys and corresponding widget values in the
/// segmented control.
///
/// The map must have more than one entry.
/// This attribute must be an ordered [Map] such as a [LinkedHashMap].
final Map<T, Widget> children;
/// A [ValueNotifier]<[T]> that controls the currently selected child.
///
/// Its value must be one of the keys in the [Map] of [children], or null, in
/// which case no widget will be selected.
///
/// The [controller]'s value changes when the user drags the thumb to a different
/// child widget, or taps on a different child widget. Its value can also be
/// changed programmatically, in which case all sliding animations will play as
/// if the new selected child widget was tapped on.
final ValueNotifier<T> controller;
/// The color used to paint the rounded rect behind the [children] and the separators.
///
/// The default value is [CupertinoColors.tertiarySystemFill]. The background
/// will not be painted if null is specified.
final Color backgroundColor;
/// The color used to paint the interior of the thumb that appears behind the
/// currently selected item.
///
/// The default value is a [CupertinoDynamicColor] that appears white in light
/// mode and becomes a gray color in dark mode.
final Color thumbColor;
/// The amount of space by which to inset the [children].
///
/// Must not be null. Defaults to EdgeInsets.symmetric(vertical: 2, horizontal: 3).
final EdgeInsetsGeometry padding;
@override
_SegmentedControlState<T> createState() => _SegmentedControlState<T>();
}
class _SegmentedControlState<T> extends State<CupertinoSlidingSegmentedControl<T>>
with TickerProviderStateMixin<CupertinoSlidingSegmentedControl<T>> {
final Map<T, AnimationController> _highlightControllers = <T, AnimationController>{};
final Tween<FontWeight> _highlightTween = _FontWeightTween(begin: FontWeight.normal, end: FontWeight.w600);
final Map<T, AnimationController> _pressControllers = <T, AnimationController>{};
final Tween<double> _pressTween = Tween<double>(begin: 1, end: 0.2);
List<T> keys;
AnimationController thumbController;
AnimationController separatorOpacityController;
AnimationController thumbScaleController;
final TapGestureRecognizer tap = TapGestureRecognizer();
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
final LongPressGestureRecognizer longPress = LongPressGestureRecognizer();
ValueNotifier<T> controller;
AnimationController _createHighlightAnimationController({ bool isCompleted = false }) {
return AnimationController(
duration: _kHighlightAnimationDuration,
value: isCompleted ? 1 : 0,
vsync: this,
);
}
AnimationController _createFadeoutAnimationController() {
return AnimationController(
duration: _kOpacityAnimationDuration,
vsync: this,
);
}
@override
void initState() {
super.initState();
final GestureArenaTeam team = GestureArenaTeam();
// If the long press or horizontal drag recognizer gets accepted, we know for
// sure the gesture is meant for the segmented control. Hand everything to
// the drag gesture recognizer.
longPress.team = team;
drag.team = team;
team.captain = drag;
controller = widget.controller;
controller.addListener(_didChangeControllerValue);
_highlighted = controller.value;
thumbController = AnimationController(
duration: _kSpringAnimationDuration,
value: 0,
vsync: this,
);
thumbScaleController = AnimationController(
duration: _kSpringAnimationDuration,
value: 1,
vsync: this,
);
separatorOpacityController = AnimationController(
duration: _kSpringAnimationDuration,
value: 0,
vsync: this,
);
for (T currentKey in widget.children.keys) {
_highlightControllers[currentKey] = _createHighlightAnimationController(
isCompleted: currentKey == controller.value, // Highlight the current selection.
);
_pressControllers[currentKey] = _createFadeoutAnimationController();
}
}
@override
void didUpdateWidget(CupertinoSlidingSegmentedControl<T> oldWidget) {
super.didUpdateWidget(oldWidget);
// Update animation controllers.
for (T oldKey in oldWidget.children.keys) {
if (!widget.children.containsKey(oldKey)) {
_highlightControllers[oldKey].dispose();
_pressControllers[oldKey].dispose();
_highlightControllers.remove(oldKey);
_pressControllers.remove(oldKey);
}
}
for (T newKey in widget.children.keys) {
if (!_highlightControllers.keys.contains(newKey)) {
_highlightControllers[newKey] = _createHighlightAnimationController();
_pressControllers[newKey] = _createFadeoutAnimationController();
}
}
if (controller != widget.controller) {
controller.removeListener(_didChangeControllerValue);
controller = widget.controller;
controller.addListener(_didChangeControllerValue);
}
if (controller.value != oldWidget.controller.value) {
highlighted = widget.controller.value;
}
}
@override
void dispose() {
for (AnimationController animationController in _highlightControllers.values) {
animationController.dispose();
}
for (AnimationController animationController in _pressControllers.values) {
animationController.dispose();
}
thumbScaleController.dispose();
thumbController.dispose();
separatorOpacityController.dispose();
drag.dispose();
tap.dispose();
longPress.dispose();
super.dispose();
}
void _didChangeControllerValue() {
assert(
controller.value == null || widget.children.keys.contains(controller.value),
"The controller's value ${controller.value} must be either null "
'or one of the keys in the children map: ${widget.children.keys}',
);
setState(() {
// Mark the state as dirty.
});
}
// Play highlight animation for the child located at _highlightControllers[at].
void _animateHighlightController({ T at, bool forward }) {
if (at == null)
return;
final AnimationController controller = _highlightControllers[at];
assert(!forward || controller != null);
controller?.animateTo(forward ? 1 : 0, duration: _kHighlightAnimationDuration, curve: Curves.ease);
}
T _highlighted;
set highlighted(T newValue) {
if (_highlighted == newValue)
return;
_animateHighlightController(at: newValue, forward: true);
_animateHighlightController(at: _highlighted, forward: false);
_highlighted = newValue;
}
T _pressed;
set pressed(T newValue) {
if (_pressed == newValue)
return;
if (_pressed != null) {
_pressControllers[_pressed]?.animateTo(0, duration: _kOpacityAnimationDuration, curve: Curves.ease);
}
if (newValue != _highlighted && newValue != null) {
_pressControllers[newValue].animateTo(1, duration: _kOpacityAnimationDuration, curve: Curves.ease);
}
_pressed = newValue;
}
void didChangeSelectedViaGesture() {
controller.value = _highlighted;
}
T indexToKey(int index) => index == null ? null : keys[index];
@override
Widget build(BuildContext context) {
debugCheckHasDirectionality(context);
switch (Directionality.of(context)) {
case TextDirection.ltr:
keys = widget.children.keys.toList(growable: false);
break;
case TextDirection.rtl:
keys = widget.children.keys.toList().reversed.toList(growable: false);
break;
}
return AnimatedBuilder(
animation: Listenable.merge(<Listenable>[
..._highlightControllers.values,
..._pressControllers.values,
]),
builder: (BuildContext context, Widget child) {
final List<Widget> children = <Widget>[];
for (T currentKey in keys) {
final TextStyle textStyle = DefaultTextStyle.of(context).style.copyWith(
fontWeight: _highlightTween.evaluate(_highlightControllers[currentKey]),
);
final Widget child = DefaultTextStyle(
style: textStyle,
child: Semantics(
button: true,
onTap: () { controller.value = currentKey; },
inMutuallyExclusiveGroup: true,
selected: controller.value == 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 = controller.value == null ? null : keys.indexOf(controller.value);
final Widget box = _SegmentedControlRenderWidget<T>(
children: children,
selectedIndex: selectedIndex,
thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context),
state: this,
);
return UnconstrainedBox(
constrainedAxis: Axis.horizontal,
child: Container(
padding: widget.padding.resolve(Directionality.of(context)),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(_kCornerRadius)),
color: CupertinoDynamicColor.resolve(widget.backgroundColor, context),
),
child: box,
),
);
},
);
}
}
class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget {
_SegmentedControlRenderWidget({
Key key,
List<Widget> children = const <Widget>[],
@required this.selectedIndex,
@required this.thumbColor,
@required this.state,
}) : super(key: key, children: children);
final int selectedIndex;
final Color thumbColor;
final _SegmentedControlState<T> state;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderSegmentedControl<T>(
selectedIndex: selectedIndex,
thumbColor: CupertinoDynamicColor.resolve(thumbColor, context),
state: state,
);
}
@override
void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) {
renderObject
..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context)
..guardedSetHighlightedIndex(selectedIndex);
}
}
class _ChildAnimationManifest {
_ChildAnimationManifest({
this.opacity = 1,
@required this.separatorOpacity,
}) : assert(separatorOpacity != null),
assert(opacity != null),
separatorTween = Tween<double>(begin: separatorOpacity, end: separatorOpacity),
opacityTween = Tween<double>(begin: opacity, end: opacity);
double opacity;
Tween<double> opacityTween;
double separatorOpacity;
Tween<double> separatorTween;
}
class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<RenderBox> { }
// The behavior of a UISegmentedControl as observed on iOS 13.1:
//
// 1. Tap up inside events will set the current selected index to the index of the
// segment at the tap up location instantaneously (there might be animation but
// the index change seems to happen before animation finishes), unless the tap
// down event from the same touch event didn't happen within the segmented
// control, in which case the touch event will be ignored entirely (will be
// referring to these touch events as invalid touch events below).
//
// 2. A valid tap up event will also trigger the sliding CASpringAnimation (even
// when it lands on the current segment), starting from the current `frame`
// of the thumb. The previous sliding animation, if still playing, will be
// removed and its velocity reset to 0. The sliding animation has a fixed
// duration, regardless of the distance or transform.
//
// 3. When the sliding animation plays two other animations take place. In one animation
// the content of the current segment gradually becomes "highlighted", turning the
// font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2).
// The other is the separator fadein/fadeout animation.
//
// 4. A tap down event on the segment pointed to by the current selected
// index will trigger a CABasicAnimation that shrinks the thumb to 95% of its
// original size, even if the sliding animation is still playing. The
/// corresponding tap up event inverts the process (eyeballed).
//
// 5. A tap down event on other segments will trigger a CABasicAnimation
// (timingFunction = default, duration = 0.47.) that fades out the content,
// eventually reducing the alpha of that segment to 20% unless interrupted by
// a tap up event or the pointer moves out of the region (either outside of the
// segmented control's vicinity or to a different segment). The reverse animation
// has the same duration and timing function.
class _RenderSegmentedControl<T> extends RenderBox
with ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>,
RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> {
_RenderSegmentedControl({
@required int selectedIndex,
@required Color thumbColor,
@required this.state,
}) : _highlightedIndex = selectedIndex,
_thumbColor = thumbColor,
assert(state != null) {
state.drag
..onDown = _onDown
..onUpdate = _onUpdate
..onEnd = _onEnd
..onCancel = _onCancel;
state.tap.onTapUp = _onTapUp;
// Empty callback to enable the long press recognizer.
state.longPress.onLongPress = () { };
}
final _SegmentedControlState<T> state;
Map<RenderBox, _ChildAnimationManifest> _childAnimations = <RenderBox, _ChildAnimationManifest>{};
// The current **Unscaled** Thumb Rect.
Rect currentThumbRect;
Tween<Rect> _currentThumbTween;
Tween<double> _thumbScaleTween = Tween<double>(begin: _kMinThumbScale, end: 1);
double currentThumbScale = 1;
// The current position of the active drag pointer.
Offset _localDragOffset;
// Whether the current drag gesture started on a selected segment.
bool _startedOnSelectedSegment;
@override
void insert(RenderBox child, { RenderBox after }) {
super.insert(child, after: after);
if (_childAnimations == null)
return;
assert(_childAnimations[child] == null);
_childAnimations[child] = _ChildAnimationManifest(separatorOpacity: 1);
}
@override
void remove(RenderBox child) {
super.remove(child);
_childAnimations?.remove(child);
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
state.thumbController.addListener(markNeedsPaint);
state.thumbScaleController.addListener(markNeedsPaint);
state.separatorOpacityController.addListener(markNeedsPaint);
}
@override
void detach() {
state.thumbController.removeListener(markNeedsPaint);
state.thumbScaleController.removeListener(markNeedsPaint);
state.separatorOpacityController.removeListener(markNeedsPaint);
super.detach();
}
// Indicates whether selectedIndex has changed and animations need to be updated.
// when true some animation tweens will be updated in paint phase.
bool _needsThumbAnimationUpdate = false;
int get highlightedIndex => _highlightedIndex;
int _highlightedIndex;
set highlightedIndex(int value) {
if (_highlightedIndex == value) {
return;
}
_needsThumbAnimationUpdate = true;
_highlightedIndex = value;
state.thumbController.animateWith(_kThumbSpringAnimationSimulation);
state.separatorOpacityController.reset();
state.separatorOpacityController.animateTo(
1,
duration: _kSpringAnimationDuration,
curve: Curves.ease,
);
state.highlighted = state.indexToKey(value);
markNeedsPaint();
markNeedsSemanticsUpdate();
}
void guardedSetHighlightedIndex(int value) {
// Ignore set highlightedIndex when the user is dragging the thumb around.
if (_startedOnSelectedSegment == true)
return;
highlightedIndex = value;
}
int get pressedIndex => _pressedIndex;
int _pressedIndex;
set pressedIndex(int value) {
if (_pressedIndex == value) {
return;
}
assert(value == null || (value >= 0 && value < childCount));
_pressedIndex = value;
state.pressed = state.indexToKey(value);
}
Color get thumbColor => _thumbColor;
Color _thumbColor;
set thumbColor(Color value) {
if (_thumbColor == value) {
return;
}
_thumbColor = value;
markNeedsPaint();
}
double get totalSeparatorWidth => (_kSeparatorInset.horizontal + _kSeparatorWidth) * (childCount - 1);
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
state.tap.addPointer(event);
state.longPress.addPointer(event);
state.drag.addPointer(event);
}
}
int indexFromLocation(Offset location) {
return childCount == 0
? null
// This assumes all children have the same width.
: (location.dx / (size.width / childCount))
.floor()
.clamp(0, childCount - 1);
}
void _onTapUp(TapUpDetails details) {
highlightedIndex = indexFromLocation(details.localPosition);
state.didChangeSelectedViaGesture();
}
void _onDown(DragDownDetails details) {
assert(size.contains(details.localPosition));
_localDragOffset = details.localPosition;
final int index = indexFromLocation(_localDragOffset);
_startedOnSelectedSegment = index == highlightedIndex;
pressedIndex = index;
if (_startedOnSelectedSegment) {
_playThumbScaleAnimation(isExpanding: false);
}
}
void _onUpdate(DragUpdateDetails details) {
_localDragOffset = details.localPosition;
final int newIndex = indexFromLocation(_localDragOffset);
if (_startedOnSelectedSegment) {
highlightedIndex = newIndex;
pressedIndex = newIndex;
} else {
pressedIndex = _hasDraggedTooFar(details) ? null : newIndex;
}
}
void _onEnd(DragEndDetails details) {
if (_startedOnSelectedSegment) {
_playThumbScaleAnimation(isExpanding: true);
state.didChangeSelectedViaGesture();
}
if (pressedIndex != null) {
highlightedIndex = pressedIndex;
state.didChangeSelectedViaGesture();
}
pressedIndex = null;
_localDragOffset = null;
_startedOnSelectedSegment = null;
}
void _onCancel() {
if (_startedOnSelectedSegment) {
_playThumbScaleAnimation(isExpanding: true);
}
_localDragOffset = null;
pressedIndex = null;
_startedOnSelectedSegment = null;
}
void _playThumbScaleAnimation({ @required bool isExpanding }) {
assert(isExpanding != null);
_thumbScaleTween = Tween<double>(begin: currentThumbScale, end: isExpanding ? 1 : _kMinThumbScale);
state.thumbScaleController.animateWith(_kThumbSpringAnimationSimulation);
}
bool _hasDraggedTooFar(DragUpdateDetails details) {
final Offset offCenter = details.localPosition - Offset(size.width/2, size.height/2);
return math.pow(math.max(0, offCenter.dx.abs() - size.width/2), 2) + math.pow(math.max(0, offCenter.dy.abs() - size.height/2), 2) > _kTouchYDistanceThreshold;
}
@override
double computeMinIntrinsicWidth(double height) {
RenderBox child = firstChild;
double maxMinChildWidth = 0;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final double childWidth = child.getMinIntrinsicWidth(height);
maxMinChildWidth = math.max(maxMinChildWidth, childWidth);
child = childParentData.nextSibling;
}
return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
RenderBox child = firstChild;
double maxMaxChildWidth = 0;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final double childWidth = child.getMaxIntrinsicWidth(height);
maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth);
child = childParentData.nextSibling;
}
return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
RenderBox child = firstChild;
double maxMinChildHeight = 0;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final double childHeight = child.getMinIntrinsicHeight(width);
maxMinChildHeight = math.max(maxMinChildHeight, childHeight);
child = childParentData.nextSibling;
}
return maxMinChildHeight;
}
@override
double computeMaxIntrinsicHeight(double width) {
RenderBox child = firstChild;
double maxMaxChildHeight = 0;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final double childHeight = child.getMaxIntrinsicHeight(width);
maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight);
child = childParentData.nextSibling;
}
return maxMaxChildHeight;
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _SegmentedControlContainerBoxParentData) {
child.parentData = _SegmentedControlContainerBoxParentData();
}
}
@override
void performLayout() {
double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount;
double maxHeight = _kMinSegmentedControlHeight;
for (RenderBox child in getChildrenAsList()) {
childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding);
}
childWidth = math.min(
childWidth,
(constraints.maxWidth - totalSeparatorWidth) / childCount,
);
RenderBox child = firstChild;
while (child != null) {
final double boxHeight = child.getMaxIntrinsicHeight(childWidth);
maxHeight = math.max(maxHeight, boxHeight);
child = childAfter(child);
}
constraints.constrainHeight(maxHeight);
final BoxConstraints childConstraints = BoxConstraints.tightFor(
width: childWidth,
height: maxHeight,
);
// Layout children.
child = firstChild;
while (child != null) {
child.layout(childConstraints, parentUsesSize: true);
child = childAfter(child);
}
double start = 0;
child = firstChild;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final Offset childOffset = Offset(start, 0);
childParentData.offset = childOffset;
start += child.size.width + _kSeparatorWidth + _kSeparatorInset.horizontal;
child = childAfter(child);
}
size = constraints.constrain(Size(childWidth * childCount + totalSeparatorWidth, maxHeight));
}
@override
void paint(PaintingContext context, Offset offset) {
final List<RenderBox> children = getChildrenAsList();
// Paint thumb if highlightedIndex is not null.
if (highlightedIndex != null) {
if (_childAnimations == null) {
_childAnimations = <RenderBox, _ChildAnimationManifest> { };
for (int i = 0; i < childCount - 1; i += 1) {
// The separator associated with the last child will not be painted (unless
// a new trailing segment is added), and its opacity will always be 1.
final bool shouldFadeOut = i == highlightedIndex || i == highlightedIndex - 1;
final RenderBox child = children[i];
_childAnimations[child] = _ChildAnimationManifest(separatorOpacity: shouldFadeOut ? 0 : 1);
}
}
final RenderBox selectedChild = children[highlightedIndex];
final _SegmentedControlContainerBoxParentData childParentData = selectedChild.parentData;
final Rect unscaledThumbTargetRect = _kThumbInsets.inflateRect(childParentData.offset & selectedChild.size);
// Update related Tweens before animation update phase.
if (_needsThumbAnimationUpdate) {
// Needs to ensure _currentThumbRect is valid.
_currentThumbTween = RectTween(begin: currentThumbRect ?? unscaledThumbTargetRect, end: unscaledThumbTargetRect);
for (int i = 0; i < childCount - 1; i += 1) {
// The separator associated with the last child will not be painted (unless
// a new segment is appended to the child list), and its opacity will always be 1.
final bool shouldFadeOut = i == highlightedIndex || i == highlightedIndex - 1;
final RenderBox child = children[i];
final _ChildAnimationManifest manifest = _childAnimations[child];
assert(manifest != null);
manifest.separatorTween = Tween<double>(
begin: manifest.separatorOpacity,
end: shouldFadeOut ? 0 : 1,
);
}
_needsThumbAnimationUpdate = false;
} else if (_currentThumbTween != null && unscaledThumbTargetRect != _currentThumbTween.begin) {
_currentThumbTween = RectTween(begin: _currentThumbTween.begin, end: unscaledThumbTargetRect);
}
for (int index = 0; index < childCount - 1; index += 1) {
_paintSeparator(context, offset, children[index]);
}
currentThumbRect = _currentThumbTween?.evaluate(state.thumbController)
?? unscaledThumbTargetRect;
currentThumbScale = _thumbScaleTween.evaluate(state.thumbScaleController);
final Rect thumbRect = Rect.fromCenter(
center: currentThumbRect.center,
width: currentThumbRect.width * currentThumbScale,
height: currentThumbRect.height * currentThumbScale,
);
_paintThumb(context, offset, thumbRect);
} else {
// Reset all animations when there's no thumb.
currentThumbRect = null;
_childAnimations = null;
for (int index = 0; index < childCount - 1; index += 1) {
_paintSeparator(context, offset, children[index]);
}
}
for (int index = 0; index < children.length; index++) {
_paintChild(context, offset, children[index], index);
}
}
// Paint the separator to the right of the given child.
void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) {
assert(child != null);
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final Paint paint = Paint();
final _ChildAnimationManifest manifest = _childAnimations == null ? null : _childAnimations[child];
final double opacity = manifest?.separatorTween?.evaluate(state.separatorOpacityController) ?? 1;
manifest?.separatorOpacity = opacity;
paint.color = _kSeparatorColor.withOpacity(_kSeparatorColor.opacity * opacity);
final Rect childRect = (childParentData.offset + offset) & child.size;
final Rect separatorRect = _kSeparatorInset.deflateRect(
childRect.topRight & Size(_kSeparatorInset.horizontal + _kSeparatorWidth, child.size.height),
);
context.canvas.drawRRect(
RRect.fromRectAndRadius(separatorRect, _kSeparatorRadius),
paint,
);
}
void _paintChild(PaintingContext context, Offset offset, RenderBox child, int childIndex) {
assert(child != null);
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
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 (BoxShadow shadow in thumbShadow) {
context.canvas.drawRRect(thumbRRect.shift(shadow.offset), shadow.toPaint());
}
context.canvas.drawRRect(
thumbRRect.inflate(0.5),
Paint()..color = const Color(0x0A000000),
);
context.canvas.drawRRect(
thumbRRect,
Paint()..color = thumbColor,
);
}
@override
bool hitTestChildren(BoxHitTestResult result, { @required Offset position }) {
assert(position != null);
RenderBox child = lastChild;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
if ((childParentData.offset & child.size).contains(position)) {
final Offset center = (Offset.zero & child.size).center;
return result.addWithRawTransform(
transform: MatrixUtils.forceToPoint(center),
position: center,
hitTest: (BoxHitTestResult result, Offset position) {
assert(position == center);
return child.hitTest(result, position: center);
},
);
}
child = childParentData.previousSibling;
}
return false;
}
}
// Copyright 2019 The Chromium 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:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart';
dynamic getRenderSegmentedControl(WidgetTester tester) {
return tester.allRenderObjects.firstWhere(
(RenderObject currentObject) {
return currentObject.toStringShort().contains('_RenderSegmentedControl');
},
);
}
Rect currentUnscaledThumbRect(WidgetTester tester, { bool useGlobalCoordinate = false }) {
final dynamic renderSegmentedControl = getRenderSegmentedControl(tester);
final Rect local = renderSegmentedControl.currentThumbRect;
if (!useGlobalCoordinate)
return local;
final RenderBox segmentedControl = renderSegmentedControl;
return local?.shift(segmentedControl.localToGlobal(Offset.zero));
}
double currentThumbScale(WidgetTester tester) => getRenderSegmentedControl(tester).currentThumbScale;
Widget setupSimpleSegmentedControl() {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
return boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
);
}
Widget boilerplate({ Widget child }) {
return Directionality(
textDirection: TextDirection.ltr,
child: Center(child: child),
);
}
void main() {
testWidgets('Children and controller and padding arguments can not be null', (WidgetTester tester) async {
try {
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: null,
controller: ValueNotifier<int>(null),
),
),
);
fail('Should not be possible to create segmented control with null children');
} on AssertionError catch (e) {
expect(e.toString(), contains('children'));
}
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
try {
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: null,
),
),
);
fail('Should not be possible to create segmented control without a controller');
} on AssertionError catch (e) {
expect(e.toString(), contains('controller'));
}
try {
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: ValueNotifier<int>(null),
padding: null,
),
),
);
fail('Should not be possible to create segmented control with null padding');
} on AssertionError catch (e) {
expect(e.toString(), contains('padding'));
}
});
testWidgets('Need at least 2 children', (WidgetTester tester) async {
final Map<int, Widget> children = <int, Widget>{};
try {
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: ValueNotifier<int>(null),
),
),
);
fail('Should not be possible to create a segmented control with no children');
} on AssertionError catch (e) {
expect(e.toString(), contains('children.length'));
}
try {
children[0] = const Text('Child 1');
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: ValueNotifier<int>(null),
),
),
);
fail('Should not be possible to create a segmented control with just one child');
} on AssertionError catch (e) {
expect(e.toString(), contains('children.length'));
}
try {
children[1] = const Text('Child 2');
children[2] = const Text('Child 3');
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: ValueNotifier<int>(-1),
),
),
);
fail('Should not be possible to create a segmented control with a controller pointing to a non-existent child');
} on AssertionError catch (e) {
expect(e.toString(), contains('value must be either null or one of the keys in the children map'));
}
});
testWidgets('Padding works', (WidgetTester tester) async {
const Key key = Key('Container');
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
Future<void> verifyPadding({ EdgeInsets padding }) async {
final EdgeInsets effectivePadding = padding ?? const EdgeInsets.symmetric(vertical: 2, horizontal: 3);
final Rect segmentedControlRect = tester.getRect(find.byKey(key));
expect(
tester.getTopLeft(find.ancestor(of: find.byWidget(children[0]), matching: find.byType(Opacity))),
segmentedControlRect.topLeft + effectivePadding.topLeft,
);
expect(
tester.getBottomLeft(find.ancestor(of: find.byWidget(children[0]), matching: find.byType(Opacity))),
segmentedControlRect.bottomLeft + effectivePadding.bottomLeft,
);
expect(
tester.getTopRight(find.ancestor(of: find.byWidget(children[1]), matching: find.byType(Opacity))),
segmentedControlRect.topRight + effectivePadding.topRight,
);
expect(
tester.getBottomRight(find.ancestor(of: find.byWidget(children[1]), matching: find.byType(Opacity))),
segmentedControlRect.bottomRight + effectivePadding.bottomRight,
);
}
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: key,
children: children,
controller: ValueNotifier<int>(null),
),
),
);
// Default padding works.
await verifyPadding();
// Switch to Child 2 padding should remain the same.
await tester.tap(find.text('Child 2'));
await tester.pumpAndSettle();
await verifyPadding();
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: key,
padding: const EdgeInsets.fromLTRB(1, 3, 5, 7),
children: children,
controller: ValueNotifier<int>(null),
),
),
);
// Custom padding works.
await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7));
// Switch back to Child 1 padding should remain the same.
await tester.tap(find.text('Child 1'));
await tester.pumpAndSettle();
await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7));
});
testWidgets('Tap changes toggle state', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
2: Text('Child 3'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
expect(controller.value, 0);
await tester.tap(find.text('Child 2'));
expect(controller.value, 1);
// Tapping the currently selected item should not change controller's value.
bool valueChanged = false;
controller.addListener(() { valueChanged = true; });
await tester.tap(find.text('Child 2'));
expect(valueChanged, isFalse);
expect(controller.value, 1);
});
testWidgets('Changing controller works', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
2: Text('Child 3'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
final ValueNotifier<int> newControlelr = ValueNotifier<int>(null);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 1'))),
);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: newControlelr,
),
),
);
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true),
isNull,
);
});
testWidgets('Can change controller value in build method', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
2: Text('Child 3'),
};
int currentIndex = 0;
StateSetter setState;
final ValueNotifier<int> controller = ValueNotifier<int>(currentIndex);
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
if (controller.value != currentIndex)
controller.value = currentIndex;
return boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
);
},
),
);
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 1'))),
);
setState(() {
currentIndex = 2;
});
await tester.pump();
await tester.pumpAndSettle();
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 3')), epsilon: 0.01),
);
});
testWidgets(
'Segmented controls respect theme',
(WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Icon(IconData(1)),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.dark),
home: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
);
},
),
),
);
DefaultTextStyle textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1').first);
expect(textStyle.style.fontWeight, FontWeight.w600);
await tester.tap(find.byIcon(const IconData(1)));
await tester.pump();
await tester.pumpAndSettle();
textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1').first);
expect(textStyle.style.fontWeight, FontWeight.normal);
},
);
testWidgets('SegmentedControl dark mode', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Icon(IconData(1)),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
Brightness brightness = Brightness.light;
StateSetter setState;
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return MediaQuery(
data: MediaQueryData(platformBrightness: brightness),
child: boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
thumbColor: CupertinoColors.systemGreen,
backgroundColor: CupertinoColors.systemRed,
),
),
);
},
),
);
final BoxDecoration decoration = tester.widget<Container>(find.descendant(
of: find.byType(UnconstrainedBox),
matching: find.byType(Container),
)).decoration;
expect(getRenderSegmentedControl(tester).thumbColor.value, CupertinoColors.systemGreen.color.value);
expect(decoration.color.value, CupertinoColors.systemRed.color.value);
setState(() { brightness = Brightness.dark; });
await tester.pump();
final BoxDecoration decorationDark = tester.widget<Container>(find.descendant(
of: find.byType(UnconstrainedBox),
matching: find.byType(Container),
)).decoration;
expect(getRenderSegmentedControl(tester).thumbColor.value, CupertinoColors.systemGreen.darkColor.value);
expect(decorationDark.color.value, CupertinoColors.systemRed.darkColor.value);
});
testWidgets(
'Children can be non-Text or Icon widgets (in this case, '
'a Container or Placeholder widget)',
(WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: SizedBox(width: 50, height: 50),
2: Placeholder(),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
),
);
},
);
testWidgets('Passed in value is child initially selected', (WidgetTester tester) async {
await tester.pumpWidget(setupSimpleSegmentedControl());
expect(getRenderSegmentedControl(tester).highlightedIndex, 0);
});
testWidgets('Null input for value results in no child initially selected', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(null);
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
);
},
),
);
expect(getRenderSegmentedControl(tester).highlightedIndex, null);
});
testWidgets('Long press not-selected child interactions', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
2: Text('Child 3'),
3: Text('Child 4'),
4: Text('Child 5'),
};
// Child 3 is intially selected.
final ValueNotifier<int> controller = ValueNotifier<int>(2);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
),
);
double getChildOpacityByName(String childName) {
return tester.widget<Opacity>(
find.ancestor(matching: find.byType(Opacity), of: find.text(childName)),
).opacity;
}
// Opacity 1 with no interaction.
expect(getChildOpacityByName('Child 1'), 1);
final Offset center = tester.getCenter(find.text('Child 1'));
final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle();
// Opacity drops to 0.2.
expect(getChildOpacityByName('Child 1'), 0.2);
// Move down slightly, slightly outside of the segmented control.
await gesture.moveBy(const Offset(0, 50));
await tester.pumpAndSettle();
expect(getChildOpacityByName('Child 1'), 0.2);
// Move further down and far away from the segmented control.
await gesture.moveBy(const Offset(0, 200));
await tester.pumpAndSettle();
expect(getChildOpacityByName('Child 1'), 1);
// Move to child 5.
await gesture.moveTo(tester.getCenter(find.text('Child 5')));
await tester.pumpAndSettle();
expect(getChildOpacityByName('Child 1'), 1);
expect(getChildOpacityByName('Child 5'), 0.2);
// Move to child 2.
await gesture.moveTo(tester.getCenter(find.text('Child 2')));
await tester.pumpAndSettle();
expect(getChildOpacityByName('Child 1'), 1);
expect(getChildOpacityByName('Child 5'), 1);
expect(getChildOpacityByName('Child 2'), 0.2);
});
testWidgets('Long press does not change the opacity of currently-selected child', (WidgetTester tester) async {
double getChildOpacityByName(String childName) {
return tester.widget<Opacity>(
find.ancestor(matching: find.byType(Opacity), of: find.text(childName)),
).opacity;
}
await tester.pumpWidget(setupSimpleSegmentedControl());
final Offset center = tester.getCenter(find.text('Child 1'));
await tester.startGesture(center);
await tester.pump();
await tester.pumpAndSettle();
expect(getChildOpacityByName('Child 1'), 1);
});
testWidgets('Height of segmented control is determined by tallest widget', (WidgetTester tester) async {
final Map<int, Widget> children = <int, Widget>{
0: Container(constraints: const BoxConstraints.tightFor(height: 100.0)),
1: Container(constraints: const BoxConstraints.tightFor(height: 400.0)),
2: Container(constraints: const BoxConstraints.tightFor(height: 200.0)),
};
final ValueNotifier<int> controller = ValueNotifier<int>(null);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
final RenderBox buttonBox = tester.renderObject(
find.byKey(const ValueKey<String>('Segmented Control')),
);
expect(
buttonBox.size.height,
400.0 + 2 * 2, // 2 px padding on both sides.
);
});
testWidgets('Width of each segmented control segment is determined by widest widget', (WidgetTester tester) async {
final Map<int, Widget> children = <int, Widget>{
0: Container(constraints: const BoxConstraints.tightFor(width: 50.0)),
1: Container(constraints: const BoxConstraints.tightFor(width: 100.0)),
2: Container(constraints: const BoxConstraints.tightFor(width: 200.0)),
};
final ValueNotifier<int> controller = ValueNotifier<int>(null);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
final RenderBox segmentedControl = tester.renderObject(
find.byKey(const ValueKey<String>('Segmented Control')),
);
// Subtract the 8.0px for horizontal padding separator. Remaining width should be allocated
// to each child equally.
final double childWidth = (segmentedControl.size.width - 8) / 3;
expect(childWidth, 200.0 + 9.25 * 2);
});
testWidgets('Width is finite in unbounded space', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: SizedBox(width: 50),
1: SizedBox(width: 70),
};
final ValueNotifier<int> controller = ValueNotifier<int>(null);
await tester.pumpWidget(
boilerplate(
child: Row(
children: <Widget>[
CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
],
),
),
);
final RenderBox segmentedControl = tester.renderObject(
find.byKey(const ValueKey<String>('Segmented Control')),
);
expect(
segmentedControl.size.width,
70 * 2 + 9.25 * 4 + 3 * 2 + 1, // 2 children + 4 child padding + 2 outer padding + 1 separator
);
});
testWidgets('Directionality test - RTL should reverse order of widgets', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(null);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: Center(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
),
),
);
expect(tester.getTopRight(find.text('Child 1')).dx >
tester.getTopRight(find.text('Child 2')).dx, isTrue);
});
testWidgets('Correct initial selection and toggling behavior - RTL', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: Center(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
),
),
);
// highlightedIndex is 1 instead of 0 because of RTL.
expect(getRenderSegmentedControl(tester).highlightedIndex, 1);
await tester.tap(find.text('Child 2'));
await tester.pump();
expect(getRenderSegmentedControl(tester).highlightedIndex, 0);
await tester.tap(find.text('Child 2'));
await tester.pump();
expect(getRenderSegmentedControl(tester).highlightedIndex, 0);
});
testWidgets('Segmented control semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
label: 'Child 1',
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.isSelected,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
TestSemantics.rootChild(
label: 'Child 2',
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isInMutuallyExclusiveGroup,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
],
),
ignoreId: true,
ignoreRect: true,
ignoreTransform: true,
),
);
await tester.tap(find.text('Child 2'));
await tester.pump();
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
label: 'Child 1',
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isInMutuallyExclusiveGroup,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
TestSemantics.rootChild(
label: 'Child 2',
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.isSelected,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
],
),
ignoreId: true,
ignoreRect: true,
ignoreTransform: true,
));
semantics.dispose();
});
testWidgets('Non-centered taps work on smaller widgets', (WidgetTester tester) async {
final Map<int, Widget> children = <int, Widget>{};
children[0] = const Text('Child 1');
children[1] = const SizedBox();
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
expect(controller.value, 0);
final Offset centerOfTwo = tester.getCenter(find.byWidget(children[1]));
// Tap just inside segment bounds
await tester.tapAt(centerOfTwo + const Offset(10, 0));
expect(controller.value, 1);
});
testWidgets('Thumb animation is correct when the selected segment changes', (WidgetTester tester) async {
await tester.pumpWidget(setupSimpleSegmentedControl());
final Rect initialRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true);
expect(currentThumbScale(tester), 1);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Child 2')));
await tester.pump();
// Does not move until tapUp.
expect(currentThumbScale(tester), 1);
expect(currentUnscaledThumbRect(tester, useGlobalCoordinate: true), initialRect);
// Tap up and the sliding animation should play.
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(currentThumbScale(tester), 1);
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx,
greaterThan(initialRect.center.dx),
);
await tester.pumpAndSettle();
expect(currentThumbScale(tester), 1);
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
// We're using a critically damped spring so the value of the animation
// controller will never reach 1.
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01),
);
// Press the currently selected widget.
await gesture.down(tester.getCenter(find.text('Child 2')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
// The thumb shrinks but does not moves towards left.
expect(currentThumbScale(tester), lessThan(1));
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01),
);
await tester.pumpAndSettle();
expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01));
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01),
);
// Drag to Child 1.
await gesture.moveTo(tester.getCenter(find.text('Child 1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
// Moved slightly to the left
expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01));
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx,
lessThan(tester.getCenter(find.text('Child 2')).dx),
);
await tester.pumpAndSettle();
expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01));
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('Child 1')), epsilon: 0.01),
);
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(currentThumbScale(tester), greaterThan(0.95));
await tester.pumpAndSettle();
expect(currentThumbScale(tester), moreOrLessEquals(1, epsilon: 0.01));
});
testWidgets('Transition is triggered while a transition is already occurring', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('A'),
1: Text('B'),
2: Text('C'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
await tester.tap(find.text('B'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
// Between A and B.
final Rect initialThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true);
expect(initialThumbRect.center.dx, greaterThan(tester.getCenter(find.text('A')).dx));
expect(initialThumbRect.center.dx, lessThan(tester.getCenter(find.text('B')).dx));
// While A to B transition is occurring, press on C.
await tester.tap(find.text('C'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
final Rect secondThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true);
// Between the initial Rect and B.
expect(secondThumbRect.center.dx, greaterThan(initialThumbRect.center.dx));
expect(secondThumbRect.center.dx, lessThan(tester.getCenter(find.text('B')).dx));
await tester.pump(const Duration(milliseconds: 500));
// Eventually moves to C.
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('C')), epsilon: 0.01),
);
});
testWidgets('Insert segment while animation is running', (WidgetTester tester) async {
final Map<int, Widget> children = SplayTreeMap<int, Widget>((int a, int b) => a - b);
children[0] = const Text('A');
children[2] = const Text('C');
children[3] = const Text('D');
final ValueNotifier<int> controller = ValueNotifier<int>(0);
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
await tester.tap(find.text('D'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
children[1] = const Text('B');
await tester.pumpWidget(
boilerplate(
child: CupertinoSlidingSegmentedControl<int>(
key: const ValueKey<String>('Segmented Control'),
children: children,
controller: controller,
),
),
);
await tester.pumpAndSettle();
// Eventually moves to D.
expect(
currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center,
offsetMoreOrLessEquals(tester.getCenter(find.text('D')), epsilon: 0.01),
);
});
testWidgets('ScrollView + SlidingSegmentedControl interaction', (WidgetTester tester) async {
const Map<int, Widget> children = <int, Widget>{
0: Text('Child 1'),
1: Text('Child 2'),
};
final ValueNotifier<int> controller = ValueNotifier<int>(0);
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ListView(
controller: scrollController,
children: <Widget>[
const SizedBox(height: 100),
CupertinoSlidingSegmentedControl<int>(
children: children,
controller: controller,
),
const SizedBox(height: 1000),
],
),
),
);
// Tapping still works.
await tester.tap(find.text('Child 2'));
await tester.pump();
expect(controller.value, 1);
// Vertical drag works for the scroll view.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Child 1')));
// The first moveBy doesn't actually move the scrollable. It's there to make
// sure VerticalDragGestureRecognizer wins the arena. This is due to
// startBehavior being set to DragStartBehavior.start.
await gesture.moveBy(const Offset(0, -100));
await gesture.moveBy(const Offset(0, -100));
await tester.pump();
expect(scrollController.offset, 100);
// Does not affect the segmented control.
expect(controller.value, 1);
await gesture.moveBy(const Offset(0, 100));
await gesture.up();
await tester.pump();
expect(scrollController.offset, 0);
expect(controller.value, 1);
// Long press vertical drag is recognized by the segmented control.
await gesture.down(tester.getCenter(find.text('Child 1')));
await tester.pump(const Duration(milliseconds: 600));
await gesture.moveBy(const Offset(0, -100));
await gesture.moveBy(const Offset(0, -100));
await tester.pump();
// Should not scroll.
expect(scrollController.offset, 0);
expect(controller.value, 1);
await gesture.moveBy(const Offset(0, 100));
await gesture.moveBy(const Offset(0, 100));
await gesture.up();
await tester.pump();
expect(scrollController.offset, 0);
expect(controller.value, 0);
// Horizontal drag is recognized by the segmentedControl.
await gesture.down(tester.getCenter(find.text('Child 1')));
await gesture.moveBy(const Offset(50, 0));
await gesture.moveTo(tester.getCenter(find.text('Child 2')));
await gesture.up();
await tester.pump();
expect(scrollController.offset, 0);
expect(controller.value, 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