From 3b4f5333287977251975fdb135567a743a418b01 Mon Sep 17 00:00:00 2001 From: Luke Memet Date: Thu, 23 Apr 2026 09:49:57 -0400 Subject: [PATCH] fix: lock drawer intent during interactive drags Locks the drawer side inferred at the start of a continuous drag so reversing across neutral no longer activates the opposite action pane mid-gesture. Adds a resisted rubber-band movement while the user pulls toward the non-inferred side, then resets the intent only after the drag settles back to neutral. Covers the behavior with LTR, RTL, neutral-reset, and already-open-pane regression tests. This is the same UX class as upstream issue letsar/flutter_slidable#311. --- lib/src/controller.dart | 179 +++++++++++-- lib/src/gesture_detector.dart | 10 +- lib/src/slidable.dart | 30 +-- test/slidable_test.dart | 471 ++++++++++++++++++++++++---------- 4 files changed, 512 insertions(+), 178 deletions(-) diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 319cdaab..f8a08dda 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -1,7 +1,12 @@ +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/widgets.dart'; const _defaultMovementDuration = Duration(milliseconds: 200); const _defaultCurve = Curves.ease; +const _lockedOppositeDragResistance = 0.2; +const _lockedOppositeDragLimit = 0.08; + +/// The default ratio of a slidable occupied by an action pane. const kDefaultExtentRatio = 0.5; /// The different kinds of action panes. @@ -102,16 +107,26 @@ class SlidableController { /// Creates a [SlidableController]. SlidableController(TickerProvider vsync) : _animationController = AnimationController(vsync: vsync), + _movementAnimationController = AnimationController.unbounded( + vsync: vsync, + ), endGesture = ValueNotifier(null), _dismissGesture = _ValueNotifier(null), resizeRequest = ValueNotifier(null), actionPaneType = ValueNotifier(ActionPaneType.none), + _movementRatio = ValueNotifier(0), direction = ValueNotifier(0) { + _animationController.addListener(_handleAnimationChanged); + _movementAnimationController.addListener(_handleMovementAnimationChanged); direction.addListener(_onDirectionChanged); } final AnimationController _animationController; + final AnimationController _movementAnimationController; final _ValueNotifier _dismissGesture; + final ValueNotifier _movementRatio; + int? _lockedDragDirection; + bool _hasMovementRatioOverride = false; /// Whether the start action pane is enabled. bool enableStartActionPane = true; @@ -167,6 +182,15 @@ class SlidableController { /// The value of the ratio over time. Animation get animation => _animationController.view; + /// The visual movement ratio for the slidable child. + /// + /// This normally matches [ratio]. During a locked drag, it can briefly show + /// a resisted offset past neutral without activating the opposite pane. + ValueListenable get movement => _movementRatio; + + /// The current visual movement ratio for the slidable child. + double get movementRatio => _movementRatio.value; + /// Track the end gestures. final ValueNotifier endGesture; @@ -211,9 +235,15 @@ class SlidableController { double get ratio => _animationController.value * direction.value; set ratio(double value) { final newRatio = actionPaneConfigurator?.normalizeRatio(value) ?? value; - if (_acceptRatio(newRatio) && newRatio != ratio) { - direction.value = newRatio.sign.toInt(); - _animationController.value = newRatio.abs(); + if (_acceptRatio(newRatio)) { + _movementAnimationController.stop(); + _hasMovementRatioOverride = false; + if (newRatio != ratio) { + direction.value = newRatio.sign.toInt(); + _animationController.value = newRatio.abs(); + } else { + _syncMovementRatio(); + } } } @@ -221,6 +251,104 @@ class SlidableController { final mulitiplier = isLeftToRight ? 1 : -1; final index = (direction.value * mulitiplier) + 1; actionPaneType.value = ActionPaneType.values[index]; + _syncMovementRatio(); + } + + void _handleAnimationChanged() { + _syncMovementRatio(); + } + + void _handleMovementAnimationChanged() { + _setMovementRatio(_movementAnimationController.value); + } + + Future _animateMovementRatioTo( + double target, { + required Duration duration, + required Curve curve, + }) async { + _movementAnimationController + ..stop() + ..value = _movementRatio.value; + + await _movementAnimationController.animateTo( + target, + duration: duration, + curve: curve, + ); + _setMovementRatio(target); + } + + void _syncMovementRatio() { + if (!_hasMovementRatioOverride) { + _setMovementRatio(ratio); + } + } + + void _setMovementRatio(double value) { + if (_movementRatio.value != value) { + _movementRatio.value = value; + } + } + + /// Starts an interactive drag sequence. + void beginDrag() { + _movementAnimationController.stop(); + _lockedDragDirection = direction.value == 0 ? null : direction.value; + _hasMovementRatioOverride = false; + _syncMovementRatio(); + } + + /// Updates the interactive drag ratio while keeping the first drawer intent. + void dragTo(double value) { + var dragDirection = _lockedDragDirection; + if (dragDirection == null) { + dragDirection = value.sign.toInt(); + if (dragDirection == 0 || !_acceptRatio(dragDirection.toDouble())) { + return; + } + _lockedDragDirection = dragDirection; + direction.value = dragDirection; + } + + final distanceInLockedDirection = value * dragDirection; + if (distanceInLockedDirection >= 0) { + ratio = dragDirection * distanceInLockedDirection; + return; + } + + _animationController.value = 0; + _hasMovementRatioOverride = true; + final resistedDistance = + (-distanceInLockedDirection * _lockedOppositeDragResistance).clamp( + 0.0, + _lockedOppositeDragLimit, + ); + _setMovementRatio(-dragDirection * resistedDistance); + } + + /// Ends an interactive drag sequence. + void endDrag(double? velocity, GestureDirection direction) { + final hadMovementRatioOverride = _hasMovementRatioOverride; + dispatchEndGesture(velocity, direction); + _lockedDragDirection = null; + if (hadMovementRatioOverride && !_closing) { + _restoreMovementRatio(); + } + } + + void _restoreMovementRatio({ + Duration duration = _defaultMovementDuration, + Curve curve = _defaultCurve, + }) { + _animateMovementRatioTo( + ratio, + duration: duration, + curve: curve, + ).whenComplete(() { + _hasMovementRatioOverride = false; + _syncMovementRatio(); + }); } /// Dispatches a new [EndGesture] determined by the given [velocity] and @@ -261,13 +389,28 @@ class SlidableController { Duration duration = _defaultMovementDuration, Curve curve = _defaultCurve, }) async { + final shouldAnimateMovementRatio = _hasMovementRatioOverride; _closing = true; - await _animationController.animateBack( + if (!shouldAnimateMovementRatio) { + _movementAnimationController.stop(); + } + final animation = _animationController.animateBack( 0, duration: duration, curve: curve, ); + if (shouldAnimateMovementRatio) { + await Future.wait([ + animation, + _animateMovementRatioTo(0, duration: duration, curve: curve), + ]); + } else { + await animation; + } direction.value = 0; + _lockedDragDirection = null; + _hasMovementRatioOverride = false; + _syncMovementRatio(); _closing = false; } @@ -284,11 +427,7 @@ class SlidableController { final extentRatio = actionPaneConfigurator?.extentRatio ?? defaultExtentRatio; - return openTo( - extentRatio, - duration: duration, - curve: curve, - ); + return openTo(extentRatio, duration: duration, curve: curve); } /// Opens the [Slidable.startActionPane]. @@ -301,11 +440,7 @@ class SlidableController { ratio = 0; } - return openTo( - startActionPaneExtentRatio, - duration: duration, - curve: curve, - ); + return openTo(startActionPaneExtentRatio, duration: duration, curve: curve); } /// Opens the [Slidable.endActionPane]. @@ -318,11 +453,7 @@ class SlidableController { ratio = 0; } - return openTo( - -endActionPaneExtentRatio, - duration: duration, - curve: curve, - ); + return openTo(-endActionPaneExtentRatio, duration: duration, curve: curve); } /// Opens the [Slidable] to the given [ratio]. @@ -341,6 +472,8 @@ class SlidableController { if (_closing) { return; } + _movementAnimationController.stop(); + _hasMovementRatioOverride = false; // Edge case: to be able to correctly set the sign when the value is zero, // we have to manually set the ratio to a tiny amount. @@ -360,6 +493,8 @@ class SlidableController { Duration duration = _defaultMovementDuration, Curve curve = _defaultCurve, }) async { + _movementAnimationController.stop(); + _hasMovementRatioOverride = false; await _animationController.animateTo( 1, duration: _defaultMovementDuration, @@ -371,7 +506,13 @@ class SlidableController { /// Disposes the controller. void dispose() { _animationController.stop(); + _animationController.removeListener(_handleAnimationChanged); _animationController.dispose(); + _movementAnimationController.removeListener( + _handleMovementAnimationChanged, + ); + _movementAnimationController.dispose(); + _movementRatio.dispose(); direction.removeListener(_onDirectionChanged); direction.dispose(); } diff --git a/lib/src/gesture_detector.dart b/lib/src/gesture_detector.dart index 8a827129..40bc7ae1 100644 --- a/lib/src/gesture_detector.dart +++ b/lib/src/gesture_detector.dart @@ -77,17 +77,15 @@ class _SlidableGestureDetectorState extends State { void handleDragStart(DragStartDetails details) { startPosition = details.localPosition; lastPosition = startPosition; - dragExtent = dragExtent.sign * - overallDragAxisExtent * - widget.controller.ratio * - widget.controller.direction.value; + widget.controller.beginDrag(); + dragExtent = overallDragAxisExtent * widget.controller.movementRatio; } void handleDragUpdate(DragUpdateDetails details) { final delta = details.primaryDelta!; dragExtent += delta; lastPosition = details.localPosition; - widget.controller.ratio = dragExtent / overallDragAxisExtent; + widget.controller.dragTo(dragExtent / overallDragAxisExtent); } void handleDragEnd(DragEndDetails details) { @@ -96,7 +94,7 @@ class _SlidableGestureDetectorState extends State { final gestureDirection = primaryDelta >= 0 ? GestureDirection.opening : GestureDirection.closing; - widget.controller.dispatchEndGesture( + widget.controller.endDrag( details.primaryVelocity, gestureDirection, ); diff --git a/lib/src/slidable.dart b/lib/src/slidable.dart index a0a38045..f0059d87 100644 --- a/lib/src/slidable.dart +++ b/lib/src/slidable.dart @@ -129,7 +129,6 @@ class Slidable extends StatefulWidget { class _SlidableState extends State with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { late final SlidableController controller; - late Animation moveAnimation; late bool keepPanesOrder; @override @@ -147,7 +146,6 @@ class _SlidableState extends State super.didChangeDependencies(); updateIsLeftToRight(); updateController(); - updateMoveAnimation(); } @override @@ -193,9 +191,7 @@ class _SlidableState extends State } void handleActionPanelTypeChanged() { - setState(() { - updateMoveAnimation(); - }); + setState(() {}); } void handleDismissing() { @@ -204,18 +200,6 @@ class _SlidableState extends State } } - void updateMoveAnimation() { - final double end = controller.direction.value.toDouble(); - moveAnimation = controller.animation.drive( - Tween( - begin: Offset.zero, - end: widget.direction == Axis.horizontal - ? Offset(end, 0) - : Offset(0, end), - ), - ); - } - Widget? get actionPane { switch (controller.actionPaneType.value) { case ActionPaneType.start: @@ -244,13 +228,21 @@ class _SlidableState extends State Widget build(BuildContext context) { super.build(context); // See AutomaticKeepAliveClientMixin. - Widget content = SlideTransition( - position: moveAnimation, + Widget content = ValueListenableBuilder( + valueListenable: controller.movement, child: SlidableAutoCloseBehaviorInteractor( groupTag: widget.groupTag, controller: controller, child: widget.child, ), + builder: (context, ratio, child) { + return FractionalTranslation( + translation: widget.direction == Axis.horizontal + ? Offset(ratio, 0) + : Offset(0, ratio), + child: child, + ); + }, ); content = Stack( diff --git a/test/slidable_test.dart b/test/slidable_test.dart index 73a03192..565c6c80 100644 --- a/test/slidable_test.dart +++ b/test/slidable_test.dart @@ -7,55 +7,59 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('Slidable', () { testWidgets( - 'child should be able to open the horitzontal start action pane', - (tester) async { - const gestureDetectorKey = ValueKey('gesture_detector'); - const startActionPaneKey = ValueKey('start'); - const endActionPaneKey = ValueKey('end'); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Slidable( - startActionPane: ActionPane( - key: startActionPaneKey, - motion: const BehindMotion(), - children: [ - SlidableAction(onPressed: (_) {}, icon: Icons.share), - SlidableAction(onPressed: (_) {}, icon: Icons.delete), - ], - ), - endActionPane: ActionPane( - key: endActionPaneKey, - motion: const ScrollMotion(), - children: [ - SlidableAction(onPressed: (_) {}, icon: Icons.share), - SlidableAction(onPressed: (_) {}, icon: Icons.delete), - ], - ), - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openStartActionPane(); + 'child should be able to open the horitzontal start action pane', + (tester) async { + const gestureDetectorKey = ValueKey('gesture_detector'); + const startActionPaneKey = ValueKey('start'); + const endActionPaneKey = ValueKey('end'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Slidable( + startActionPane: ActionPane( + key: startActionPaneKey, + motion: const BehindMotion(), + children: [ + SlidableAction(onPressed: (_) {}, icon: Icons.share), + SlidableAction(onPressed: (_) {}, icon: Icons.delete), + ], + ), + endActionPane: ActionPane( + key: endActionPaneKey, + motion: const ScrollMotion(), + children: [ + SlidableAction(onPressed: (_) {}, icon: Icons.share), + SlidableAction(onPressed: (_) {}, icon: Icons.delete), + ], + ), + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openStartActionPane(); + }, + ); }, - ); - }), + ), + ), ), - ), - ); + ); - expect(find.byKey(startActionPaneKey), findsNothing); - expect(find.byKey(endActionPaneKey), findsNothing); + expect(find.byKey(startActionPaneKey), findsNothing); + expect(find.byKey(endActionPaneKey), findsNothing); - await tester.tap(find.byKey(gestureDetectorKey)); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(gestureDetectorKey)); + await tester.pumpAndSettle(); - expect(find.byKey(startActionPaneKey), findsOneWidget); - expect(find.byKey(endActionPaneKey), findsNothing); - }); + expect(find.byKey(startActionPaneKey), findsOneWidget); + expect(find.byKey(endActionPaneKey), findsNothing); + }, + ); - testWidgets('child should be able to open the horizontal end action pane', - (tester) async { + testWidgets('child should be able to open the horizontal end action pane', ( + tester, + ) async { const gestureDetectorKey = ValueKey('gesture_detector'); const startActionPaneKey = ValueKey('start'); const endActionPaneKey = ValueKey('end'); @@ -79,14 +83,16 @@ void main() { SlidableAction(onPressed: (_) {}, icon: Icons.delete), ], ), - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openEndActionPane(); - }, - ); - }), + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openEndActionPane(); + }, + ); + }, + ), ), ), ); @@ -101,8 +107,9 @@ void main() { expect(find.byKey(endActionPaneKey), findsOneWidget); }); - testWidgets('child should be able to open the vertical start action pane', - (tester) async { + testWidgets('child should be able to open the vertical start action pane', ( + tester, + ) async { const gestureDetectorKey = ValueKey('gesture_detector'); const startActionPaneKey = ValueKey('start'); const endActionPaneKey = ValueKey('end'); @@ -127,14 +134,16 @@ void main() { SlidableAction(onPressed: (_) {}, icon: Icons.delete), ], ), - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openStartActionPane(); - }, - ); - }), + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openStartActionPane(); + }, + ); + }, + ), ), ), ); @@ -149,8 +158,9 @@ void main() { expect(find.byKey(endActionPaneKey), findsNothing); }); - testWidgets('child should be able to open the vertical end action pane', - (tester) async { + testWidgets('child should be able to open the vertical end action pane', ( + tester, + ) async { const gestureDetectorKey = ValueKey('gesture_detector'); const startActionPaneKey = ValueKey('start'); const endActionPaneKey = ValueKey('end'); @@ -175,14 +185,16 @@ void main() { SlidableAction(onPressed: (_) {}, icon: Icons.delete), ], ), - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openEndActionPane(); - }, - ); - }), + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openEndActionPane(); + }, + ); + }, + ), ), ), ); @@ -279,81 +291,272 @@ void main() { }); testWidgets( - 'should work if TextDirection.rtl and only startActionPane is set', - (tester) async { - const gestureDetectorKey = ValueKey('gesture_detector'); - const actionPaneKey = ValueKey('action_pane'); - final actionPane = ActionPane( - key: actionPaneKey, - motion: const BehindMotion(), - children: [ - SlidableAction(onPressed: (_) {}, icon: Icons.share), - SlidableAction(onPressed: (_) {}, icon: Icons.delete), - ], - ); + 'locks drawer direction and rubber bands during one horizontal drag', + (tester) async { + await _pumpDirectionalLockSlidable(tester); + final child = find.byKey(_childKey); + final initialLeft = tester.getTopLeft(child).dx; - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: Slidable( - startActionPane: actionPane, - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openStartActionPane(); - }, - ); - }), - ), - ), - ); + final gesture = await tester.startGesture(tester.getCenter(child)); + await gesture.moveBy(const Offset(-160, 0)); + await tester.pump(); + + expect(find.byKey(_endPaneKey), findsOneWidget); + expect(find.byKey(_startPaneKey), findsNothing); + + await gesture.moveBy(const Offset(220, 0)); + await tester.pump(); - expect(find.byKey(actionPaneKey), findsNothing); + final overdrag = tester.getTopLeft(child).dx - initialLeft; + expect(find.byKey(_endPaneKey), findsOneWidget); + expect(find.byKey(_startPaneKey), findsNothing); + expect(overdrag, greaterThan(0)); + expect(overdrag, lessThanOrEqualTo(_maxRubberBandExtent)); + + await gesture.up(); + expect(tester.getTopLeft(child).dx - initialLeft, closeTo(overdrag, 0.1)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final settlingOverdrag = tester.getTopLeft(child).dx - initialLeft; + expect(settlingOverdrag, greaterThan(0)); + expect(settlingOverdrag, lessThan(overdrag)); + + await tester.pumpAndSettle(); - await tester.tap(find.byKey(gestureDetectorKey)); + expect(tester.getTopLeft(child).dx, closeTo(initialLeft, 0.1)); + expect(find.byKey(_endPaneKey), findsNothing); + expect(find.byKey(_startPaneKey), findsNothing); + }, + ); + + testWidgets('can infer the opposite drawer after settling neutral', ( + tester, + ) async { + await _pumpDirectionalLockSlidable(tester); + final child = find.byKey(_childKey); + final initialLeft = tester.getTopLeft(child).dx; + + final firstGesture = await tester.startGesture(tester.getCenter(child)); + await firstGesture.moveBy(const Offset(-160, 0)); + await tester.pump(); + await firstGesture.moveBy(const Offset(220, 0)); + await tester.pump(); + await firstGesture.up(); await tester.pumpAndSettle(); - expect(find.byKey(actionPaneKey), findsOneWidget); + final secondGesture = await tester.startGesture(tester.getCenter(child)); + await secondGesture.moveBy(const Offset(120, 0)); + await tester.pump(); + + expect(find.byKey(_startPaneKey), findsOneWidget); + expect(find.byKey(_endPaneKey), findsNothing); + expect(tester.getTopLeft(child).dx, greaterThan(initialLeft)); + + await secondGesture.up(); + await tester.pumpAndSettle(); }); - testWidgets('should work if TextDirection.rtl and only endActionPane is set', - (tester) async { - const gestureDetectorKey = ValueKey('gesture_detector'); - const actionPaneKey = ValueKey('action_pane'); - final actionPane = ActionPane( - key: actionPaneKey, - motion: const BehindMotion(), - children: [ - SlidableAction(onPressed: (_) {}, icon: Icons.share), - SlidableAction(onPressed: (_) {}, icon: Icons.delete), - ], + testWidgets('keeps an already-open drawer locked while dragging closed', ( + tester, + ) async { + await _pumpDirectionalLockSlidable(tester, childOpensEndPane: true); + final child = find.byKey(_childKey); + final initialLeft = tester.getTopLeft(child).dx; + + await tester.tap(child); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(child).dx, lessThan(initialLeft)); + expect(find.byKey(_endPaneKey), findsOneWidget); + + final gesture = await tester.startGesture(tester.getCenter(child)); + await gesture.moveBy(const Offset(260, 0)); + await tester.pump(); + + final overdrag = tester.getTopLeft(child).dx - initialLeft; + expect(find.byKey(_endPaneKey), findsOneWidget); + expect(find.byKey(_startPaneKey), findsNothing); + expect(overdrag, greaterThan(0)); + expect(overdrag, lessThanOrEqualTo(_maxRubberBandExtent)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('locks drawer intent using text direction in RTL', ( + tester, + ) async { + await _pumpDirectionalLockSlidable( + tester, + textDirection: TextDirection.rtl, ); + final child = find.byKey(_childKey); + final initialLeft = tester.getTopLeft(child).dx; - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: Slidable( - endActionPane: actionPane, - child: Builder(builder: (context) { - return GestureDetector( - key: gestureDetectorKey, - onTap: () { - Slidable.of(context)!.openEndActionPane(); + final gesture = await tester.startGesture(tester.getCenter(child)); + await gesture.moveBy(const Offset(-160, 0)); + await tester.pump(); + + expect(find.byKey(_startPaneKey), findsOneWidget); + expect(find.byKey(_endPaneKey), findsNothing); + + await gesture.moveBy(const Offset(220, 0)); + await tester.pump(); + + final overdrag = tester.getTopLeft(child).dx - initialLeft; + expect(find.byKey(_startPaneKey), findsOneWidget); + expect(find.byKey(_endPaneKey), findsNothing); + expect(overdrag, greaterThan(0)); + expect(overdrag, lessThanOrEqualTo(_maxRubberBandExtent)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'should work if TextDirection.rtl and only startActionPane is set', + (tester) async { + const gestureDetectorKey = ValueKey('gesture_detector'); + const actionPaneKey = ValueKey('action_pane'); + final actionPane = ActionPane( + key: actionPaneKey, + motion: const BehindMotion(), + children: [ + SlidableAction(onPressed: (_) {}, icon: Icons.share), + SlidableAction(onPressed: (_) {}, icon: Icons.delete), + ], + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Slidable( + startActionPane: actionPane, + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openStartActionPane(); + }, + ); }, - ); - }), + ), + ), ), - ), - ); + ); - expect(find.byKey(actionPaneKey), findsNothing); + expect(find.byKey(actionPaneKey), findsNothing); - await tester.tap(find.byKey(gestureDetectorKey)); - await tester.pumpAndSettle(); - await tester.pumpAndSettle(); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(gestureDetectorKey)); + await tester.pumpAndSettle(); - expect(find.byKey(actionPaneKey), findsOneWidget); - }); + expect(find.byKey(actionPaneKey), findsOneWidget); + }, + ); + + testWidgets( + 'should work if TextDirection.rtl and only endActionPane is set', + (tester) async { + const gestureDetectorKey = ValueKey('gesture_detector'); + const actionPaneKey = ValueKey('action_pane'); + final actionPane = ActionPane( + key: actionPaneKey, + motion: const BehindMotion(), + children: [ + SlidableAction(onPressed: (_) {}, icon: Icons.share), + SlidableAction(onPressed: (_) {}, icon: Icons.delete), + ], + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Slidable( + endActionPane: actionPane, + child: Builder( + builder: (context) { + return GestureDetector( + key: gestureDetectorKey, + onTap: () { + Slidable.of(context)!.openEndActionPane(); + }, + ); + }, + ), + ), + ), + ); + + expect(find.byKey(actionPaneKey), findsNothing); + + await tester.tap(find.byKey(gestureDetectorKey)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect(find.byKey(actionPaneKey), findsOneWidget); + }, + ); +} + +const _childKey = ValueKey('direction_lock_child'); +const _startPaneKey = ValueKey('direction_lock_start_pane'); +const _endPaneKey = ValueKey('direction_lock_end_pane'); +const _slidableWidth = 400.0; +const _slidableHeight = 80.0; +const _maxRubberBandExtent = _slidableWidth * 0.08; + +Future _pumpDirectionalLockSlidable( + WidgetTester tester, { + TextDirection textDirection = TextDirection.ltr, + bool childOpensEndPane = false, +}) async { + await tester.pumpWidget( + Directionality( + textDirection: textDirection, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: _slidableWidth, + height: _slidableHeight, + child: Slidable( + startActionPane: ActionPane( + key: _startPaneKey, + motion: const BehindMotion(), + children: [ + SlidableAction(onPressed: (_) {}, icon: Icons.archive), + ], + ), + endActionPane: ActionPane( + key: _endPaneKey, + motion: const BehindMotion(), + children: [SlidableAction(onPressed: (_) {}, icon: Icons.delete)], + ), + child: Builder( + builder: (context) { + const content = ColoredBox( + color: Colors.white, + child: SizedBox.expand(child: Text('Slide me')), + ); + if (childOpensEndPane) { + return GestureDetector( + key: _childKey, + onTap: () { + Slidable.of( + context, + )! + .openEndActionPane(duration: Duration.zero); + }, + child: content, + ); + } + return const SizedBox.expand(key: _childKey, child: content); + }, + ), + ), + ), + ), + ), + ); }