From 30cc01fb2352534d2bbd3959a5eacfc622491bdb Mon Sep 17 00:00:00 2001 From: Bonsai11 Date: Fri, 15 Oct 2021 22:13:04 +0200 Subject: [PATCH] Add callback when dismiss threshold is reached (#88736) --- .../flutter/lib/src/widgets/dismissible.dart | 57 ++++++++++++++++++- .../test/widgets/dismissible_test.dart | 54 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/dismissible.dart b/packages/flutter/lib/src/widgets/dismissible.dart index f86a22020d6..0a58d93147d 100644 --- a/packages/flutter/lib/src/widgets/dismissible.dart +++ b/packages/flutter/lib/src/widgets/dismissible.dart @@ -30,6 +30,11 @@ typedef DismissDirectionCallback = void Function(DismissDirection direction); /// Used by [Dismissible.confirmDismiss]. typedef ConfirmDismissCallback = Future Function(DismissDirection direction); +/// Signature used by [Dismissible] to indicate that the dismissible has been dragged. +/// +/// Used by [Dismissible.onUpdate]. +typedef DismissUpdateCallback = void Function(DismissUpdateDetails details); + /// The direction in which a [Dismissible] can be dismissed. enum DismissDirection { /// The [Dismissible] can be dismissed by dragging either up or down. @@ -98,6 +103,7 @@ class Dismissible extends StatefulWidget { this.secondaryBackground, this.confirmDismiss, this.onResize, + this.onUpdate, this.onDismissed, this.direction = DismissDirection.horizontal, this.resizeDuration = const Duration(milliseconds: 300), @@ -205,10 +211,44 @@ class Dismissible extends StatefulWidget { /// This defaults to [HitTestBehavior.opaque]. final HitTestBehavior behavior; + /// Called when the dismissible widget has been dragged. + /// + /// If [onUpdate] is not null, then it will be invoked for every pointer event + /// to dispatch the latest state of the drag. For example, this callback + /// can be used to for example change the color of the background widget + /// depending on whether the dismiss threshold is currently reached. + final DismissUpdateCallback? onUpdate; + @override State createState() => _DismissibleState(); } +/// Details for [DismissUpdateCallback]. +/// +/// See also: +/// +/// * [Dismissible.onUpdate], which receives this information. +class DismissUpdateDetails { + /// Create a new instance of [DismissUpdateDetails]. + DismissUpdateDetails({ + this.direction = DismissDirection.horizontal, + this.reached = false, + this.previousReached = false + }); + + /// The direction that the dismissible is being dragged. + final DismissDirection direction; + + /// Whether the dismiss threshold is currently reached. + final bool reached; + + /// Whether the dismiss threshold was reached the last time this callback was invoked. + /// + /// This can be used in conjunction with [DismissUpdateDetails.reached] to catch the moment + /// that the [Dismissible] is dragged across the threshold. + final bool previousReached; +} + class _DismissibleClipper extends CustomClipper { _DismissibleClipper({ required this.axis, @@ -254,7 +294,8 @@ class _DismissibleState extends State with TickerProviderStateMixin void initState() { super.initState(); _moveController = AnimationController(duration: widget.movementDuration, vsync: this) - ..addStatusListener(_handleDismissStatusChanged); + ..addStatusListener(_handleDismissStatusChanged) + ..addListener(_handleDismissUpdateValueChanged); _updateMoveAnimation(); } @@ -268,6 +309,7 @@ class _DismissibleState extends State with TickerProviderStateMixin bool _confirming = false; bool _dragUnderway = false; Size? _sizePriorToCollapse; + bool _dismissThresholdReached = false; @override bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true; @@ -388,6 +430,19 @@ class _DismissibleState extends State with TickerProviderStateMixin } } + void _handleDismissUpdateValueChanged() { + if(widget.onUpdate != null) { + final bool oldDismissThresholdReached = _dismissThresholdReached; + _dismissThresholdReached = _moveController!.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold); + final DismissUpdateDetails details = DismissUpdateDetails( + direction: _dismissDirection, + reached: _dismissThresholdReached, + previousReached: oldDismissThresholdReached, + ); + widget.onUpdate!(details); + } + } + void _updateMoveAnimation() { final double end = _dragExtent.sign; _moveAnimation = _moveController!.drive( diff --git a/packages/flutter/test/widgets/dismissible_test.dart b/packages/flutter/test/widgets/dismissible_test.dart index eccd44cd78e..c8b3511ddd3 100644 --- a/packages/flutter/test/widgets/dismissible_test.dart +++ b/packages/flutter/test/widgets/dismissible_test.dart @@ -10,6 +10,9 @@ import 'package:flutter_test/flutter_test.dart'; const DismissDirection defaultDismissDirection = DismissDirection.horizontal; const double crossAxisEndOffset = 0.5; +bool reportedDismissUpdateReached = false; +bool reportedDismissUpdatePreviousReached = false; +late DismissDirection reportedDismissUpdateReachedDirection; DismissDirection reportedDismissDirection = DismissDirection.horizontal; List dismissedItems = []; @@ -46,6 +49,11 @@ Widget buildTest({ onResize: () { expect(dismissedItems.contains(item), isFalse); }, + onUpdate: (DismissUpdateDetails details) { + reportedDismissUpdateReachedDirection = details.direction; + reportedDismissUpdateReached = details.reached; + reportedDismissUpdatePreviousReached = details.previousReached; + }, background: background, dismissThresholds: startToEndThreshold == null ? {} @@ -1053,4 +1061,50 @@ void main() { expect(controller.offset, 100.0); controller.dispose(); }); + + testWidgets('onUpdate', (WidgetTester tester) async { + await tester.pumpWidget(buildTest( + scrollDirection: Axis.horizontal, + )); + expect(dismissedItems, isEmpty); + + // Successful dismiss therefore threshold has been reached + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + expect(reportedDismissUpdateReachedDirection, DismissDirection.endToStart); + expect(reportedDismissUpdateReached, true); + expect(reportedDismissUpdatePreviousReached, true); + + // Unsuccessful dismiss, threshold has not been reached + await checkFlingItemAfterMovement(tester, 1, gestureDirection: AxisDirection.right); + expect(find.text('1'), findsOneWidget); + expect(dismissedItems, equals([0])); + expect(reportedDismissUpdateReachedDirection, DismissDirection.startToEnd); + expect(reportedDismissUpdateReached, false); + expect(reportedDismissUpdatePreviousReached, false); + + // Another successful dismiss from another direction + await dismissItem(tester, 1, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('1'), findsNothing); + expect(dismissedItems, equals([0, 1])); + expect(reportedDismissUpdateReachedDirection, DismissDirection.startToEnd); + expect(reportedDismissUpdateReached, true); + expect(reportedDismissUpdatePreviousReached, true); + + await tester.pumpWidget(buildTest( + scrollDirection: Axis.horizontal, + confirmDismiss: (BuildContext context, DismissDirection dismissDirection) { + return Future.value(false); + }, + )); + + // Threshold has been reached but dismiss was not confirmed + await dismissItem(tester, 2, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('2'), findsOneWidget); + expect(dismissedItems, equals([0, 1])); + expect(reportedDismissUpdateReachedDirection, DismissDirection.startToEnd); + expect(reportedDismissUpdateReached, false); + expect(reportedDismissUpdatePreviousReached, false); + }); }