diff --git a/packages/flutter/lib/src/cupertino/switch.dart b/packages/flutter/lib/src/cupertino/switch.dart index 3d39e076b8..0988de167d 100644 --- a/packages/flutter/lib/src/cupertino/switch.dart +++ b/packages/flutter/lib/src/cupertino/switch.dart @@ -140,8 +140,158 @@ class CupertinoSwitch extends StatefulWidget { } class _CupertinoSwitchState extends State with TickerProviderStateMixin { + TapGestureRecognizer _tap; + HorizontalDragGestureRecognizer _drag; + + AnimationController _positionController; + CurvedAnimation position; + + AnimationController _reactionController; + Animation _reaction; + + bool get isInteractive => widget.onChanged != null; + + // A non-null boolean value that changes to true at the end of a drag if the + // switch must be animated to the position indicated by the widget's value. + bool needsPositionAnimation = false; + + @override + void initState() { + super.initState(); + + _tap = TapGestureRecognizer() + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTap = _handleTap + ..onTapCancel = _handleTapCancel; + _drag = HorizontalDragGestureRecognizer() + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..dragStartBehavior = widget.dragStartBehavior; + + _positionController = AnimationController( + duration: _kToggleDuration, + value: widget.value ? 1.0 : 0.0, + vsync: this, + ); + position = CurvedAnimation( + parent: _positionController, + curve: Curves.linear, + ); + _reactionController = AnimationController( + duration: _kReactionDuration, + vsync: this, + ); + _reaction = CurvedAnimation( + parent: _reactionController, + curve: Curves.ease, + ); + } + + @override + void didUpdateWidget(CupertinoSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + _drag.dragStartBehavior = widget.dragStartBehavior; + + if (needsPositionAnimation || oldWidget.value != widget.value) + _resumePositionAnimation(isLinear: needsPositionAnimation); + } + + // `isLinear` must be true if the position animation is trying to move the + // thumb to the closest end after the most recent drag animation, so the curve + // does not change when the controller's value is not 0 or 1. + // + // It can be set to false when it's an implicit animation triggered by + // widget.value changes. + void _resumePositionAnimation({ bool isLinear = true }) { + needsPositionAnimation = false; + position + ..curve = isLinear ? null : Curves.ease + ..reverseCurve = isLinear ? null : Curves.ease.flipped; + if (widget.value) + _positionController.forward(); + else + _positionController.reverse(); + } + + void _handleTapDown(TapDownDetails details) { + if (isInteractive) + needsPositionAnimation = false; + _reactionController.forward(); + } + + void _handleTap() { + if (isInteractive) { + widget.onChanged(!widget.value); + _emitVibration(); + } + } + + void _handleTapUp(TapUpDetails details) { + if (isInteractive) { + needsPositionAnimation = false; + _reactionController.reverse(); + } + } + + void _handleTapCancel() { + if (isInteractive) + _reactionController.reverse(); + } + + void _handleDragStart(DragStartDetails details) { + if (isInteractive) { + needsPositionAnimation = false; + _reactionController.forward(); + _emitVibration(); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (isInteractive) { + position + ..curve = null + ..reverseCurve = null; + final double delta = details.primaryDelta / _kTrackInnerLength; + switch (Directionality.of(context)) { + case TextDirection.rtl: + _positionController.value -= delta; + break; + case TextDirection.ltr: + _positionController.value += delta; + break; + } + } + } + + void _handleDragEnd(DragEndDetails details) { + // Deferring the animation to the next build phase. + setState(() { needsPositionAnimation = true; }); + // Call onChanged when the user's intent to change value is clear. + if (position.value >= 0.5 != widget.value) + widget.onChanged(!widget.value); + _reactionController.reverse(); + } + + void _emitVibration() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + HapticFeedback.lightImpact(); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + @override Widget build(BuildContext context) { + if (needsPositionAnimation) + _resumePositionAnimation(); return Opacity( opacity: widget.onChanged == null ? _kCupertinoSwitchDisabledOpacity : 1.0, child: _CupertinoSwitchRenderObjectWidget( @@ -152,11 +302,21 @@ class _CupertinoSwitchState extends State with TickerProviderSt ), trackColor: CupertinoDynamicColor.resolve(widget.trackColor ?? CupertinoColors.secondarySystemFill, context), onChanged: widget.onChanged, - vsync: this, - dragStartBehavior: widget.dragStartBehavior, + textDirection: Directionality.of(context), + state: this, ), ); } + + @override + void dispose() { + _tap.dispose(); + _drag.dispose(); + + _positionController.dispose(); + _reactionController.dispose(); + super.dispose(); + } } class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { @@ -166,16 +326,16 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { this.activeColor, this.trackColor, this.onChanged, - this.vsync, - this.dragStartBehavior = DragStartBehavior.start, + this.textDirection, + this.state, }) : super(key: key); final bool value; final Color activeColor; final Color trackColor; final ValueChanged onChanged; - final TickerProvider vsync; - final DragStartBehavior dragStartBehavior; + final _CupertinoSwitchState state; + final TextDirection textDirection; @override _RenderCupertinoSwitch createRenderObject(BuildContext context) { @@ -184,9 +344,8 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { activeColor: activeColor, trackColor: trackColor, onChanged: onChanged, - textDirection: Directionality.of(context), - vsync: vsync, - dragStartBehavior: dragStartBehavior, + textDirection: textDirection, + state: state, ); } @@ -197,9 +356,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { ..activeColor = activeColor ..trackColor = trackColor ..onChanged = onChanged - ..textDirection = Directionality.of(context) - ..vsync = vsync - ..dragStartBehavior = dragStartBehavior; + ..textDirection = textDirection; } } @@ -224,53 +381,22 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { @required Color trackColor, ValueChanged onChanged, @required TextDirection textDirection, - @required TickerProvider vsync, - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + @required _CupertinoSwitchState state, }) : assert(value != null), assert(activeColor != null), - assert(vsync != null), + assert(state != null), _value = value, _activeColor = activeColor, _trackColor = trackColor, _onChanged = onChanged, _textDirection = textDirection, - _vsync = vsync, + _state = state, super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) { - _tap = TapGestureRecognizer() - ..onTapDown = _handleTapDown - ..onTap = _handleTap - ..onTapUp = _handleTapUp - ..onTapCancel = _handleTapCancel; - _drag = HorizontalDragGestureRecognizer() - ..onStart = _handleDragStart - ..onUpdate = _handleDragUpdate - ..onEnd = _handleDragEnd - ..dragStartBehavior = dragStartBehavior; - _positionController = AnimationController( - duration: _kToggleDuration, - value: value ? 1.0 : 0.0, - vsync: vsync, - ); - _position = CurvedAnimation( - parent: _positionController, - curve: Curves.linear, - )..addListener(markNeedsPaint) - ..addStatusListener(_handlePositionStateChanged); - _reactionController = AnimationController( - duration: _kReactionDuration, - vsync: vsync, - ); - _reaction = CurvedAnimation( - parent: _reactionController, - curve: Curves.ease, - )..addListener(markNeedsPaint); + state.position.addListener(markNeedsPaint); + state._reaction.addListener(markNeedsPaint); } - AnimationController _positionController; - CurvedAnimation _position; - - AnimationController _reactionController; - Animation _reaction; + final _CupertinoSwitchState _state; bool get value => _value; bool _value; @@ -280,24 +406,6 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { return; _value = value; markNeedsSemanticsUpdate(); - _position - ..curve = Curves.ease - ..reverseCurve = Curves.ease.flipped; - if (value) - _positionController.forward(); - else - _positionController.reverse(); - } - - TickerProvider get vsync => _vsync; - TickerProvider _vsync; - set vsync(TickerProvider value) { - assert(value != null); - if (value == _vsync) - return; - _vsync = value; - _positionController.resync(vsync); - _reactionController.resync(vsync); } Color get activeColor => _activeColor; @@ -343,126 +451,8 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { markNeedsPaint(); } - DragStartBehavior get dragStartBehavior => _drag.dragStartBehavior; - set dragStartBehavior(DragStartBehavior value) { - assert(value != null); - if (_drag.dragStartBehavior == value) - return; - _drag.dragStartBehavior = value; - } - bool get isInteractive => onChanged != null; - TapGestureRecognizer _tap; - HorizontalDragGestureRecognizer _drag; - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - if (value) - _positionController.forward(); - else - _positionController.reverse(); - if (isInteractive) { - switch (_reactionController.status) { - case AnimationStatus.forward: - _reactionController.forward(); - break; - case AnimationStatus.reverse: - _reactionController.reverse(); - break; - case AnimationStatus.dismissed: - case AnimationStatus.completed: - // nothing to do - break; - } - } - } - - @override - void detach() { - _positionController.stop(); - _reactionController.stop(); - super.detach(); - } - - void _handlePositionStateChanged(AnimationStatus status) { - if (isInteractive) { - if (status == AnimationStatus.completed && !_value) - onChanged(true); - else if (status == AnimationStatus.dismissed && _value) - onChanged(false); - } - } - - void _handleTapDown(TapDownDetails details) { - if (isInteractive) - _reactionController.forward(); - } - - void _handleTap() { - if (isInteractive) { - onChanged(!_value); - _emitVibration(); - } - } - - void _handleTapUp(TapUpDetails details) { - if (isInteractive) - _reactionController.reverse(); - } - - void _handleTapCancel() { - if (isInteractive) - _reactionController.reverse(); - } - - void _handleDragStart(DragStartDetails details) { - if (isInteractive) { - _reactionController.forward(); - _emitVibration(); - } - } - - void _handleDragUpdate(DragUpdateDetails details) { - if (isInteractive) { - _position - ..curve = null - ..reverseCurve = null; - final double delta = details.primaryDelta / _kTrackInnerLength; - switch (textDirection) { - case TextDirection.rtl: - _positionController.value -= delta; - break; - case TextDirection.ltr: - _positionController.value += delta; - break; - } - } - } - - void _handleDragEnd(DragEndDetails details) { - if (_position.value >= 0.5) - _positionController.forward(); - else - _positionController.reverse(); - _reactionController.reverse(); - } - - void _emitVibration() { - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - HapticFeedback.lightImpact(); - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - break; - } - } - @override bool hitTestSelf(Offset position) => true; @@ -470,8 +460,8 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent && isInteractive) { - _drag.addPointer(event); - _tap.addPointer(event); + _state._drag.addPointer(event); + _state._tap.addPointer(event); } } @@ -480,7 +470,7 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { super.describeSemanticsConfiguration(config); if (isInteractive) - config.onTap = _handleTap; + config.onTap = _state._handleTap; config.isEnabled = isInteractive; config.isToggled = _value; @@ -490,8 +480,8 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { void paint(PaintingContext context, Offset offset) { final Canvas canvas = context.canvas; - final double currentValue = _position.value; - final double currentReactionValue = _reaction.value; + final double currentValue = _state.position.value; + final double currentReactionValue = _state._reaction.value; double visualPosition; switch (textDirection) { diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index ad229ec0e9..094ed1eff5 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -267,6 +267,12 @@ class _SwitchState extends State with TickerProviderStateMixin { bool get enabled => widget.onChanged != null; + void _didFinishDragging() { + // The user has finished dragging the thumb of this switch. Rebuild the switch + // to update the animation. + setState(() {}); + } + Widget buildMaterialSwitch(BuildContext context) { assert(debugCheckHasMaterial(context)); final ThemeData theme = Theme.of(context); @@ -313,7 +319,7 @@ class _SwitchState extends State with TickerProviderStateMixin { additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)), hasFocus: _focused, hovering: _hovering, - vsync: this, + state: this, ); }, ), @@ -380,11 +386,11 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { this.inactiveTrackColor, this.configuration, this.onChanged, - this.vsync, this.additionalConstraints, this.dragStartBehavior, this.hasFocus, this.hovering, + this.state, }) : super(key: key); final bool value; @@ -398,11 +404,11 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { final Color inactiveTrackColor; final ImageConfiguration configuration; final ValueChanged onChanged; - final TickerProvider vsync; final BoxConstraints additionalConstraints; final DragStartBehavior dragStartBehavior; final bool hasFocus; final bool hovering; + final _SwitchState state; @override _RenderSwitch createRenderObject(BuildContext context) { @@ -423,7 +429,7 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { additionalConstraints: additionalConstraints, hasFocus: hasFocus, hovering: hovering, - vsync: vsync, + state: state, ); } @@ -446,7 +452,7 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { ..dragStartBehavior = dragStartBehavior ..hasFocus = hasFocus ..hovering = hovering - ..vsync = vsync; + ..vsync = state; } } @@ -468,7 +474,7 @@ class _RenderSwitch extends RenderToggleable { DragStartBehavior dragStartBehavior, bool hasFocus, bool hovering, - @required TickerProvider vsync, + @required this.state, }) : assert(textDirection != null), _activeThumbImage = activeThumbImage, _inactiveThumbImage = inactiveThumbImage, @@ -487,7 +493,7 @@ class _RenderSwitch extends RenderToggleable { additionalConstraints: additionalConstraints, hasFocus: hasFocus, hovering: hovering, - vsync: vsync, + vsync: state, ) { _drag = HorizontalDragGestureRecognizer() ..onStart = _handleDragStart @@ -562,6 +568,26 @@ class _RenderSwitch extends RenderToggleable { _drag.dragStartBehavior = value; } + _SwitchState state; + + @override + set value(bool newValue) { + assert(value != null); + super.value = newValue; + // The widget is rebuilt and we have pending position animation to play. + if (_needsPositionAnimation) { + _needsPositionAnimation = false; + position + ..curve = null + ..reverseCurve = null; + if (newValue) + positionController.forward(); + else + positionController.reverse(); + } + } + + @override void detach() { _cachedThumbPainter?.dispose(); @@ -573,6 +599,8 @@ class _RenderSwitch extends RenderToggleable { HorizontalDragGestureRecognizer _drag; + bool _needsPositionAnimation = false; + void _handleDragStart(DragStartDetails details) { if (isInteractive) reactionController.forward(); @@ -596,11 +624,12 @@ class _RenderSwitch extends RenderToggleable { } void _handleDragEnd(DragEndDetails details) { - if (position.value >= 0.5) - positionController.forward(); - else - positionController.reverse(); + _needsPositionAnimation = true; + + if (position.value >= 0.5 != value) + onChanged(!value); reactionController.reverse(); + state._didFinishDragging(); } @override diff --git a/packages/flutter/lib/src/material/toggleable.dart b/packages/flutter/lib/src/material/toggleable.dart index 443696ea3e..fb69be46b7 100644 --- a/packages/flutter/lib/src/material/toggleable.dart +++ b/packages/flutter/lib/src/material/toggleable.dart @@ -70,8 +70,7 @@ abstract class RenderToggleable extends RenderConstrainedBox { _position = CurvedAnimation( parent: _positionController, curve: Curves.linear, - )..addListener(markNeedsPaint) - ..addStatusListener(_handlePositionStateChanged); + )..addListener(markNeedsPaint); _reactionController = AnimationController( duration: kRadialReactionDuration, vsync: vsync, @@ -335,9 +334,7 @@ abstract class RenderToggleable extends RenderConstrainedBox { /// Called when the control changes value. /// /// If the control is tapped, [onChanged] is called immediately with the new - /// value. If the control changes value due to an animation (see - /// [positionController]), the callback is called when the animation - /// completes. + /// value. /// /// The control is considered interactive (see [isInteractive]) if this /// callback is non-null. If the callback is null, then the control is @@ -397,19 +394,6 @@ abstract class RenderToggleable extends RenderConstrainedBox { super.detach(); } - // Handle the case where the _positionController's value changes because - // the user dragged the toggleable: we may reach 0.0 or 1.0 without - // seeing a tap. The Switch does this. - void _handlePositionStateChanged(AnimationStatus status) { - if (isInteractive && !tristate) { - if (status == AnimationStatus.completed && _value == false) { - onChanged(true); - } else if (status == AnimationStatus.dismissed && _value != false) { - onChanged(false); - } - } - } - void _handleTapDown(TapDownDetails details) { if (isInteractive) { _downPosition = globalToLocal(details.globalPosition); diff --git a/packages/flutter/test/cupertino/switch_test.dart b/packages/flutter/test/cupertino/switch_test.dart index 3857a26fca..3dc549548d 100644 --- a/packages/flutter/test/cupertino/switch_test.dart +++ b/packages/flutter/test/cupertino/switch_test.dart @@ -342,27 +342,32 @@ void main() { ); await tester.pumpAndSettle(); final Rect switchRect = tester.getRect(find.byType(CupertinoSwitch)); + expect(value, isFalse); TestGesture gesture = await tester.startGesture(switchRect.center); // We have to execute the drag in two frames because the first update will // just set the start position. await gesture.moveBy(const Offset(20.0, 0.0)); await gesture.moveBy(const Offset(20.0, 0.0)); - expect(value, isTrue); - await gesture.up(); - await tester.pump(); - - gesture = await tester.startGesture(switchRect.center); - await gesture.moveBy(const Offset(20.0, 0.0)); - await gesture.moveBy(const Offset(20.0, 0.0)); - expect(value, isTrue); - await gesture.up(); - await tester.pump(); - - gesture = await tester.startGesture(switchRect.center); - await gesture.moveBy(const Offset(-20.0, 0.0)); - await gesture.moveBy(const Offset(-20.0, 0.0)); expect(value, isFalse); + await gesture.up(); + expect(value, isTrue); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(20.0, 0.0)); + await gesture.moveBy(const Offset(20.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(-20.0, 0.0)); + await gesture.moveBy(const Offset(-20.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + expect(value, isFalse); + await tester.pump(); }); testWidgets('Switch can drag (RTL)', (WidgetTester tester) async { @@ -410,6 +415,77 @@ void main() { expect(value, isFalse); }); + testWidgets('can veto switch dragging result', (WidgetTester tester) async { + bool value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: CupertinoSwitch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = value || newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ); + + // Move a little to the right, not past the middle. + TestGesture gesture = await tester.startGesture(tester.getRect(find.byType(CupertinoSwitch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.moveBy(const Offset(-kTouchSlop + 5.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isFalse); + final CurvedAnimation position = (tester.state(find.byType(CupertinoSwitch)) as dynamic).position as CurvedAnimation; + expect(position.value, lessThan(0.5)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(position.value, 0); + + // Move past the middle. + gesture = await tester.startGesture(tester.getRect(find.byType(CupertinoSwitch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(position.value, greaterThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(position.value, 1.0); + + // Now move back to the left, the revert animation should play. + gesture = await tester.startGesture(tester.getRect(find.byType(CupertinoSwitch)).center); + await gesture.moveBy(const Offset(-kTouchSlop - 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(position.value, lessThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(position.value, 1.0); + }); + testWidgets('Switch is translucent when disabled', (WidgetTester tester) async { await tester.pumpWidget( const Directionality( diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index 4f5cb22826..6358654cee 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -205,8 +205,9 @@ void main() { // just set the start position. await gesture.moveBy(const Offset(20.0, 0.0)); await gesture.moveBy(const Offset(20.0, 0.0)); - expect(value, isTrue); + expect(value, isFalse); await gesture.up(); + expect(value, isTrue); await tester.pump(); gesture = await tester.startGesture(switchRect.center); @@ -214,11 +215,14 @@ void main() { await gesture.moveBy(const Offset(20.0, 0.0)); expect(value, isTrue); await gesture.up(); + expect(value, isTrue); await tester.pump(); gesture = await tester.startGesture(switchRect.center); await gesture.moveBy(const Offset(-20.0, 0.0)); await gesture.moveBy(const Offset(-20.0, 0.0)); + expect(value, isTrue); + await gesture.up(); expect(value, isFalse); }); @@ -489,6 +493,84 @@ void main() { expect(tester.hasRunningAnimations, false); }); + testWidgets('can veto switch dragging result', (WidgetTester tester) async { + bool value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = value || newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ); + + // Move a little to the right, not past the middle. + TestGesture gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.moveBy(const Offset(-kTouchSlop + 5.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isFalse); + final RenderToggleable renderObject = tester.renderObject( + find.descendant( + of: find.byType(Switch), + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_SwitchRenderObjectWidget', + ), + ), + ); + expect(renderObject.position.value, lessThan(0.5)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(renderObject.position.value, 0); + + // Move past the middle. + gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(renderObject.position.value, greaterThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(renderObject.position.value, 1.0); + + // Now move back to the left, the revert animation should play. + gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(-kTouchSlop - 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(renderObject.position.value, lessThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(renderObject.position.value, 1.0); + }); + testWidgets('switch has semantic events', (WidgetTester tester) async { dynamic semanticEvent; bool value = false;