From b6c35061262045d362ece05f7f8b7c9e61364247 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Sun, 4 Oct 2020 23:22:41 +0000 Subject: [PATCH] feat: Swipe to Reply --- lib/components/swipeable.dart | 459 ++++++++++++++++++++++++++++++++++ lib/views/chat.dart | 58 +++-- 2 files changed, 502 insertions(+), 15 deletions(-) create mode 100644 lib/components/swipeable.dart diff --git a/lib/components/swipeable.dart b/lib/components/swipeable.dart new file mode 100644 index 0000000..71543dc --- /dev/null +++ b/lib/components/swipeable.dart @@ -0,0 +1,459 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +const double _kMinFlingVelocity = 700.0; +const double _kMinFlingVelocityDelta = 400.0; +const double _kFlingVelocityScale = 1.0 / 300.0; +const double _kDismissThreshold = 0.4; + +/// Signature used by [Swipeable] to indicate that it has been swiped in +/// the given `direction`. +/// +/// Used by [Swipeable.onSwiped]. +typedef SwipeDirectionCallback = void Function(SwipeDirection direction); + +/// Signature used by [Swipeable] to give the application an opportunity to +/// confirm or veto a swipe gesture. +/// +/// Used by [Swipeable.confirmSwipe]. +typedef ConfirmSwipeCallback = Future Function(SwipeDirection direction); + +/// The direction in which a [Swipeable] can be swiped. +enum SwipeDirection { + /// The [Swipeable] can be swiped by dragging either left or right. + horizontal, + + /// The [Swipeable] can be swiped by dragging in the reverse of the + /// reading direction (e.g., from right to left in left-to-right languages). + endToStart, + + /// The [Swipeable] can be swiped by dragging in the reading direction + /// (e.g., from left to right in left-to-right languages). + startToEnd, +} + +class Swipeable extends StatefulWidget { + /// Creates a widget that calls a function when swiped. + /// + /// The [key] argument must not be null because [Swipeable]s are commonly + /// used in lists and removed from the list when dismissed. Without keys, the + /// default behavior is to sync widgets based on their index in the list, + /// which means the item after the dismissed item would be synced with the + /// state of the dismissed item. Using keys causes the widgets to sync + /// according to their keys and avoids this pitfall. + const Swipeable({ + @required Key key, + @required this.child, + this.background, + this.secondaryBackground, + this.confirmSwipe, + this.onSwiped, + this.direction = SwipeDirection.horizontal, + this.dismissThresholds = const {}, + this.maxOffset = 0.4, + this.movementDuration = const Duration(milliseconds: 200), + this.crossAxisEndOffset = 0.0, + this.dragStartBehavior = DragStartBehavior.start, + }) : assert(key != null), + assert(secondaryBackground == null || background != null), + assert(dragStartBehavior != null), + super(key: key); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + /// A widget that is stacked behind the child. If secondaryBackground is also + /// specified then this widget only appears when the child has been dragged + /// to the right. + final Widget background; + + /// A widget that is stacked behind the child and is exposed when the child + /// has been dragged to the left. It may only be specified when background + /// has also been specified. + final Widget secondaryBackground; + + /// Gives the app an opportunity to confirm or veto a pending dismissal. + /// + /// If the returned Future completes true, then this widget will be + /// dismissed, otherwise it will be moved back to its original location. + /// + /// If the returned Future completes to false or null the [onSwiped] + /// callback will not run. + final ConfirmSwipeCallback confirmSwipe; + + /// Called when the widget has been dismissed, after finishing resizing. + final SwipeDirectionCallback onSwiped; + + /// The direction in which the widget can be dismissed. + final SwipeDirection direction; + + /// The offset threshold the item has to be dragged in order to be considered + /// dismissed. + /// + /// Represented as a fraction, e.g. if it is 0.4 (the default), then the item + /// has to be dragged at least 40% towards one direction to be considered + /// dismissed. Clients can define different thresholds for each dismiss + /// direction. + /// + /// Flinging is treated as being equivalent to dragging almost to 1.0, so + /// flinging can dismiss an item past any threshold less than 1.0. + /// + /// Setting a threshold of 1.0 (or greater) prevents a drag in the given + /// [SwipeDirection] even if it would be allowed by the [direction] + /// property. + /// + /// See also: + /// + /// * [direction], which controls the directions in which the items can + /// be dismissed. + final Map dismissThresholds; + + /// The maximum horizontal offset the item can move to/ + /// + /// Represented as a fraction, e.g. if it is 0.4 (the default), then the + /// item can be moved at maximum 40% of item's width. + final double maxOffset; + + /// Defines the duration for card to dismiss or to come back to original position if not dismissed. + final Duration movementDuration; + + /// Defines the end offset across the main axis after the card is dismissed. + /// + /// If non-zero value is given then widget moves in cross direction depending on whether + /// it is positive or negative. + final double crossAxisEndOffset; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag gesture used to dismiss a + /// dismissible will begin upon the detection of a drag gesture. If set to + /// [DragStartBehavior.down] it will begin when a down event is first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. + final DragStartBehavior dragStartBehavior; + + @override + _SwipeableState createState() => _SwipeableState(); +} + +class _SwipeableClipper extends CustomClipper { + _SwipeableClipper({ + @required this.moveAnimation, + }) : assert(moveAnimation != null), + super(reclip: moveAnimation); + + final Animation moveAnimation; + + @override + Rect getClip(Size size) { + final offset = moveAnimation.value.dx * size.width; + if (offset < 0) { + return Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height); + } + return Rect.fromLTRB(0.0, 0.0, offset, size.height); + } + + @override + Rect getApproximateClipRect(Size size) => getClip(size); + + @override + bool shouldReclip(_SwipeableClipper oldClipper) { + return oldClipper.moveAnimation.value != moveAnimation.value; + } +} + +enum _FlingGestureKind { none, forward, reverse } + +class _SwipeableState extends State + with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + @override + void initState() { + super.initState(); + _moveController = + AnimationController(duration: widget.movementDuration, vsync: this) + ..addStatusListener(_handleDismissStatusChanged); + _updateMoveAnimation(); + } + + AnimationController _moveController; + Animation _moveAnimation; + + double _dragExtent = 0.0; + bool _dragUnderway = false; + Size _sizePriorToCollapse; + + @override + bool get wantKeepAlive => _moveController?.isAnimating == true; + + @override + void dispose() { + _moveController.dispose(); + super.dispose(); + } + + SwipeDirection _extentToDirection(double extent) { + if (extent == 0.0) { + return null; + } + switch (Directionality.of(context)) { + case TextDirection.rtl: + return extent < 0 + ? SwipeDirection.startToEnd + : SwipeDirection.endToStart; + case TextDirection.ltr: + return extent > 0 + ? SwipeDirection.startToEnd + : SwipeDirection.endToStart; + } + assert(false); + return null; + } + + SwipeDirection get _SwipeDirection => _extentToDirection(_dragExtent); + + bool get _isActive { + return _dragUnderway || _moveController.isAnimating; + } + + double get _overallDragAxisExtent { + final size = context.size; + return size.width; + } + + void _handleDragStart(DragStartDetails details) { + _dragUnderway = true; + if (_moveController.isAnimating) { + _dragExtent = + _moveController.value * _overallDragAxisExtent * _dragExtent.sign; + _moveController.stop(); + } else { + _dragExtent = 0.0; + _moveController.value = 0.0; + } + setState(() { + _updateMoveAnimation(); + }); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_isActive || _moveController.isAnimating) { + return; + } + + final delta = details.primaryDelta; + final oldDragExtent = _dragExtent; + switch (widget.direction) { + case SwipeDirection.horizontal: + _dragExtent += delta; + break; + + case SwipeDirection.endToStart: + switch (Directionality.of(context)) { + case TextDirection.rtl: + if (_dragExtent + delta > 0) { + _dragExtent += delta; + } + break; + case TextDirection.ltr: + if (_dragExtent + delta < 0) { + _dragExtent += delta; + } + break; + } + break; + + case SwipeDirection.startToEnd: + switch (Directionality.of(context)) { + case TextDirection.rtl: + if (_dragExtent + delta < 0) { + _dragExtent += delta; + } + break; + case TextDirection.ltr: + if (_dragExtent + delta > 0) { + _dragExtent += delta; + } + break; + } + break; + } + if (oldDragExtent.sign != _dragExtent.sign) { + setState(() { + _updateMoveAnimation(); + }); + } + if (!_moveController.isAnimating) { + _moveController.value = _dragExtent.abs() / _overallDragAxisExtent; + } + } + + void _updateMoveAnimation() { + final end = _dragExtent.sign; + _moveAnimation = _moveController.drive( + Tween( + begin: Offset.zero, + end: Offset(widget.maxOffset * end, widget.crossAxisEndOffset), + ), + ); + } + + _FlingGestureKind _describeFlingGesture(Velocity velocity) { + assert(widget.direction != null); + if (_dragExtent == 0.0) { + // If it was a fling, then it was a fling that was let loose at the exact + // middle of the range (i.e. when there's no displacement). In that case, + // we assume that the user meant to fling it back to the center, as + // opposed to having wanted to drag it out one way, then fling it past the + // center and into and out the other side. + return _FlingGestureKind.none; + } + final vx = velocity.pixelsPerSecond.dx; + final vy = velocity.pixelsPerSecond.dy; + SwipeDirection flingDirection; + // Verify that the fling is in the generally right direction and fast enough. + if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || + vx.abs() < _kMinFlingVelocity) { + return _FlingGestureKind.none; + } + assert(vx != 0.0); + flingDirection = _extentToDirection(vx); + + assert(_SwipeDirection != null); + if (flingDirection == _SwipeDirection) { + return _FlingGestureKind.forward; + } + return _FlingGestureKind.reverse; + } + + Future _handleDragEnd(DragEndDetails details) async { + if (!_isActive || _moveController.isAnimating) { + return; + } + _dragUnderway = false; + if (_moveController.isCompleted && + await _confirmStartResizeAnimation() == true) { + _startResizeAnimation(); + return; + } + final flingVelocity = details.velocity.pixelsPerSecond.dx; + switch (_describeFlingGesture(details.velocity)) { + case _FlingGestureKind.forward: + assert(_dragExtent != 0.0); + assert(!_moveController.isDismissed); + if ((widget.dismissThresholds[_SwipeDirection] ?? _kDismissThreshold) >= + 1.0) { + await _moveController.reverse(); + break; + } + _dragExtent = flingVelocity.sign; + await _moveController.fling( + velocity: flingVelocity.abs() * _kFlingVelocityScale); + break; + case _FlingGestureKind.reverse: + assert(_dragExtent != 0.0); + assert(!_moveController.isDismissed); + _dragExtent = flingVelocity.sign; + await _moveController.fling( + velocity: -flingVelocity.abs() * _kFlingVelocityScale); + break; + case _FlingGestureKind.none: + if (!_moveController.isDismissed) { + // we already know it's not completed, we check that above + if (_moveController.value > + (widget.dismissThresholds[_SwipeDirection] ?? + _kDismissThreshold)) { + await _moveController.forward(); + } else { + await _moveController.reverse(); + } + } + break; + } + } + + Future _handleDismissStatusChanged(AnimationStatus status) async { + if (status == AnimationStatus.completed && !_dragUnderway) { + if (await _confirmStartResizeAnimation() == true) { + _startResizeAnimation(); + } else { + await _moveController.reverse(); + } + } + updateKeepAlive(); + } + + Future _confirmStartResizeAnimation() async { + if (widget.confirmSwipe != null) { + final direction = _SwipeDirection; + assert(direction != null); + return widget.confirmSwipe(direction); + } + return true; + } + + void _startResizeAnimation() { + assert(_moveController != null); + assert(_moveController.isCompleted); + assert(_sizePriorToCollapse == null); + _moveController.reverse(); + if (widget.onSwiped != null) { + final direction = _SwipeDirection; + assert(direction != null); + widget.onSwiped(direction); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); // See AutomaticKeepAliveClientMixin. + + assert(debugCheckHasDirectionality(context)); + + var background = widget.background; + if (widget.secondaryBackground != null) { + final direction = _SwipeDirection; + if (direction == SwipeDirection.endToStart) { + background = widget.secondaryBackground; + } + } + + Widget content = SlideTransition( + position: _moveAnimation, + child: widget.child, + ); + + if (background != null) { + content = Stack(children: [ + if (!_moveAnimation.isDismissed) + Positioned.fill( + child: ClipRect( + clipper: _SwipeableClipper( + moveAnimation: _moveAnimation, + ), + child: background, + ), + ), + content, + ]); + } + // We are not swiping but we may be being dragging in widget.direction. + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + behavior: HitTestBehavior.opaque, + child: content, + dragStartBehavior: widget.dragStartBehavior, + ); + } +} diff --git a/lib/views/chat.dart b/lib/views/chat.dart index 5bb4cbf..036ced2 100644 --- a/lib/views/chat.dart +++ b/lib/views/chat.dart @@ -351,6 +351,14 @@ class _ChatState extends State<_Chat> { inputFocus.requestFocus(); } + void replyBySwipeAction(Event replyTo) { + setState(() { + replyEvent = replyTo; + selectedEvents.clear(); + }); + inputFocus.requestFocus(); + } + void _scrollToEventId(String eventId, {BuildContext context}) async { var eventIndex = getFilteredEvents().indexWhere((e) => e.eventId == eventId); @@ -668,21 +676,40 @@ class _ChatState extends State<_Chat> { key: ValueKey(i - 1), index: i - 1, controller: _scrollController, - child: Message(filteredEvents[i - 1], - onAvatarTab: (Event event) { - sendController.text += - ' ${event.senderId}'; - }, - onSelect: (Event event) { - if (!event.redacted) { - if (selectedEvents - .contains(event)) { - setState( - () => selectedEvents - .remove(event), - ); - } else { - setState( + child: Swipeable( + key: ValueKey(i - 1), + background: Container( + color: Theme.of(context).primaryColor.withAlpha(100), + padding: EdgeInsets.symmetric(horizontal: 12.0), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Icon(Icons.reply), + SizedBox(width: 2.0), + Text(L10n.of(context).reply) + ], + ), + ), + direction: SwipeDirection.startToEnd, + onSwiped: (direction) { + replyBySwipeAction( + filteredEvents[i - 1]); + }, + child: Message(filteredEvents[i - 1], + onAvatarTab: (Event event) { + sendController.text += + ' ${event.senderId}'; + }, + onSelect: (Event event) { + if (!event.redacted) { + if (selectedEvents + .contains(event)) { + setState( + () => selectedEvents + .remove(event), + ); + } else { + setState( () => selectedEvents.add(event), ); @@ -705,6 +732,7 @@ class _ChatState extends State<_Chat> { nextEvent: i >= 2 ? filteredEvents[i - 2] : null), + ), ); }); },