Browse Source

feat: Swipe to Reply

yiffed
Inex Code 9 months ago
parent
commit
b6c3506126
  1. 459
      lib/components/swipeable.dart
  2. 58
      lib/views/chat.dart

459
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<bool> 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 <SwipeDirection, double>{},
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<bool> completes true, then this widget will be
/// dismissed, otherwise it will be moved back to its original location.
///
/// If the returned Future<bool> 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<SwipeDirection, double> 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<Rect> {
_SwipeableClipper({
@required this.moveAnimation,
}) : assert(moveAnimation != null),
super(reclip: moveAnimation);
final Animation<Offset> 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<Swipeable>
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
@override
void initState() {
super.initState();
_moveController =
AnimationController(duration: widget.movementDuration, vsync: this)
..addStatusListener(_handleDismissStatusChanged);
_updateMoveAnimation();
}
AnimationController _moveController;
Animation<Offset> _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<Offset>(
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<void> _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<void> _handleDismissStatusChanged(AnimationStatus status) async {
if (status == AnimationStatus.completed && !_dragUnderway) {
if (await _confirmStartResizeAnimation() == true) {
_startResizeAnimation();
} else {
await _moveController.reverse();
}
}
updateKeepAlive();
}
Future<bool> _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: <Widget>[
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,
);
}
}

58
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),
),
);
});
},

Loading…
Cancel
Save