Let cupertino & material switches move to the right state after dragging (#51606)

diff --git a/packages/flutter/lib/src/cupertino/switch.dart b/packages/flutter/lib/src/cupertino/switch.dart
index 3d39e07..0988de1 100644
--- a/packages/flutter/lib/src/cupertino/switch.dart
+++ b/packages/flutter/lib/src/cupertino/switch.dart
@@ -140,8 +140,158 @@
 }
 
 class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderStateMixin {
+  TapGestureRecognizer _tap;
+  HorizontalDragGestureRecognizer _drag;
+
+  AnimationController _positionController;
+  CurvedAnimation position;
+
+  AnimationController _reactionController;
+  Animation<double> _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 @@
         ),
         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 @@
     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<bool> onChanged;
-  final TickerProvider vsync;
-  final DragStartBehavior dragStartBehavior;
+  final _CupertinoSwitchState state;
+  final TextDirection textDirection;
 
   @override
   _RenderCupertinoSwitch createRenderObject(BuildContext context) {
@@ -184,9 +344,8 @@
       activeColor: activeColor,
       trackColor: trackColor,
       onChanged: onChanged,
-      textDirection: Directionality.of(context),
-      vsync: vsync,
-      dragStartBehavior: dragStartBehavior,
+      textDirection: textDirection,
+      state: state,
     );
   }
 
@@ -197,9 +356,7 @@
       ..activeColor = activeColor
       ..trackColor = trackColor
       ..onChanged = onChanged
-      ..textDirection = Directionality.of(context)
-      ..vsync = vsync
-      ..dragStartBehavior = dragStartBehavior;
+      ..textDirection = textDirection;
   }
 }
 
@@ -224,53 +381,22 @@
     @required Color trackColor,
     ValueChanged<bool> 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<double> _reaction;
+  final _CupertinoSwitchState _state;
 
   bool get value => _value;
   bool _value;
@@ -280,24 +406,6 @@
       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 @@
     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 @@
   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 @@
     super.describeSemanticsConfiguration(config);
 
     if (isInteractive)
-      config.onTap = _handleTap;
+      config.onTap = _state._handleTap;
 
     config.isEnabled = isInteractive;
     config.isToggled = _value;
@@ -490,8 +480,8 @@
   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 ad229ec..094ed1e 100644
--- a/packages/flutter/lib/src/material/switch.dart
+++ b/packages/flutter/lib/src/material/switch.dart
@@ -267,6 +267,12 @@
 
   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 @@
             additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)),
             hasFocus: _focused,
             hovering: _hovering,
-            vsync: this,
+            state: this,
           );
         },
       ),
@@ -380,11 +386,11 @@
     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 @@
   final Color inactiveTrackColor;
   final ImageConfiguration configuration;
   final ValueChanged<bool> 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 @@
       additionalConstraints: additionalConstraints,
       hasFocus: hasFocus,
       hovering: hovering,
-      vsync: vsync,
+      state: state,
     );
   }
 
@@ -446,7 +452,7 @@
       ..dragStartBehavior = dragStartBehavior
       ..hasFocus = hasFocus
       ..hovering = hovering
-      ..vsync = vsync;
+      ..vsync = state;
   }
 }
 
@@ -468,7 +474,7 @@
     DragStartBehavior dragStartBehavior,
     bool hasFocus,
     bool hovering,
-    @required TickerProvider vsync,
+    @required this.state,
   }) : assert(textDirection != null),
        _activeThumbImage = activeThumbImage,
        _inactiveThumbImage = inactiveThumbImage,
@@ -487,7 +493,7 @@
          additionalConstraints: additionalConstraints,
          hasFocus: hasFocus,
          hovering: hovering,
-         vsync: vsync,
+         vsync: state,
        ) {
     _drag = HorizontalDragGestureRecognizer()
       ..onStart = _handleDragStart
@@ -562,6 +568,26 @@
     _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 @@
 
   HorizontalDragGestureRecognizer _drag;
 
+  bool _needsPositionAnimation = false;
+
   void _handleDragStart(DragStartDetails details) {
     if (isInteractive)
       reactionController.forward();
@@ -596,11 +624,12 @@
   }
 
   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 443696e..fb69be4 100644
--- a/packages/flutter/lib/src/material/toggleable.dart
+++ b/packages/flutter/lib/src/material/toggleable.dart
@@ -70,8 +70,7 @@
     _position = CurvedAnimation(
       parent: _positionController,
       curve: Curves.linear,
-    )..addListener(markNeedsPaint)
-     ..addStatusListener(_handlePositionStateChanged);
+    )..addListener(markNeedsPaint);
     _reactionController = AnimationController(
       duration: kRadialReactionDuration,
       vsync: vsync,
@@ -335,9 +334,7 @@
   /// 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 @@
     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 3857a26..3dc5495 100644
--- a/packages/flutter/test/cupertino/switch_test.dart
+++ b/packages/flutter/test/cupertino/switch_test.dart
@@ -342,27 +342,32 @@
     );
     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 @@
     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 4f5cb22..6358654 100644
--- a/packages/flutter/test/material/switch_test.dart
+++ b/packages/flutter/test/material/switch_test.dart
@@ -205,8 +205,9 @@
     // 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 @@
     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 @@
     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<RenderToggleable>(
+      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;