From 13d9ad396123aec28216251b643800d384787df9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 23 Jun 2023 17:17:08 +0530 Subject: [PATCH 1/5] feat(ui): Swipeable v2, migrate message widget. Signed-off-by: xsahil03x --- .../message_list_view/message_list_view.dart | 94 ++- .../misc/animated_circle_border_painter.dart | 72 +++ .../lib/src/misc/swipeable.dart | 547 ++++++++++++++---- 3 files changed, 565 insertions(+), 148 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/misc/animated_circle_border_painter.dart diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 8863c991..c1ad1b04 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,5 +1,7 @@ // ignore_for_file: lines_longer_than_80_chars import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -10,6 +12,7 @@ import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart'; import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; +import 'package:stream_chat_flutter/src/misc/animated_circle_border_painter.dart'; import 'package:stream_chat_flutter/src/misc/swipeable.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -1226,26 +1229,6 @@ class _StreamMessageListViewState extends State { } var child = messageWidget; - if (!message.isDeleted && - !message.isSystem && - !message.isEphemeral && - widget.onMessageSwiped != null) { - child = Container( - decoration: const BoxDecoration(), - clipBehavior: Clip.hardEdge, - child: Swipeable( - onSwipeEnd: () { - FocusScope.of(context).unfocus(); - widget.onMessageSwiped?.call(message); - }, - backgroundIcon: StreamSvgIcon.reply( - color: _streamTheme.colorTheme.accentPrimary, - ), - child: child, - ), - ); - } - if (!initialMessageHighlightComplete && widget.highlightInitialMessage && isInitialMessage(message.id, streamChannel)) { @@ -1269,6 +1252,77 @@ class _StreamMessageListViewState extends State { ), ); } + + // Add swipeable if the callback is provided and the message is not deleted, + // system or ephemeral. + final onMessageSwiped = widget.onMessageSwiped; + if (onMessageSwiped != null && + !message.isDeleted && + !message.isSystem && + !message.isEphemeral) { + // The threshold after which the message is considered swiped. + const threshold = 0.2; + + // The direction in which the message can be swiped. + final swipeDirection = isMyMessage + ? SwipeDirection.endToStart // + : SwipeDirection.startToEnd; + + child = Swipeable( + key: ValueKey(message.id), + direction: swipeDirection, + swipeThreshold: threshold, + onSwiped: (_) => onMessageSwiped(message), + backgroundBuilder: (context, details) { + // The alignment of the swipe action. + final alignment = isMyMessage + ? Alignment.centerRight // + : Alignment.centerLeft; + + // The progress of the swipe action. + final progress = math.min(details.progress, threshold) / threshold; + + // The offset for the reply icon. + var offset = Offset.lerp( + const Offset(-24, 0), + const Offset(12, 0), + progress, + )!; + + // If the message is mine, we need to flip the offset. + if (isMyMessage) { + offset = Offset(-offset.dx, -offset.dy); + } + + return Align( + alignment: alignment, + child: Transform.translate( + offset: offset, + child: Opacity( + opacity: progress, + child: SizedBox.square( + dimension: 30, + child: CustomPaint( + painter: AnimatedCircleBorderPainter( + progress: progress, + color: _streamTheme.colorTheme.borders, + ), + child: Center( + child: StreamSvgIcon.reply( + size: lerpDouble(0, 18, progress), + color: _streamTheme.colorTheme.accentPrimary, + ), + ), + ), + ), + ), + ), + ); + }, + child: child, + ); + } + return child; } diff --git a/packages/stream_chat_flutter/lib/src/misc/animated_circle_border_painter.dart b/packages/stream_chat_flutter/lib/src/misc/animated_circle_border_painter.dart new file mode 100644 index 00000000..bd02678f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/animated_circle_border_painter.dart @@ -0,0 +1,72 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// A custom painter that animates a circle border or fills it based on a +/// progress value. +/// +/// This painter draws a circle with a border that can be animated to fill the +/// circle or stroke its outline based on a given progress value. The progress +/// value is a double between 0.0 and 1.0, representing the completion of the +/// animation. +/// +/// When the progress is 0.0, the circle is completely empty, and when the +/// progress is 1.0, the circle is fully filled or stroked. +/// +/// The color of the arc/circle can be customized by providing a [color]. +/// +/// Example usage: +/// ```dart +/// AnimatedCircleBorderPainter painter = AnimatedCircleBorderPainter( +/// progress: 0.5, +/// color: Colors.blue, +/// ); +/// +/// CustomPaint( +/// painter: painter, +/// size: Size(200, 200), +/// // ... other properties +/// ) +/// ``` +class AnimatedCircleBorderPainter extends CustomPainter { + /// Creates an [AnimatedCircleBorderPainter] with the specified [progress] + /// and [color]. + const AnimatedCircleBorderPainter({ + required this.progress, + required this.color, + }); + + /// The progress of the animation, as a value between 0.0 and 1.0. + final double progress; + + /// The color of the arc/circle. + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final style = progress == 1.0 ? PaintingStyle.fill : PaintingStyle.stroke; + + final arcPaint = Paint() + ..style = style + ..color = color + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round; + + final radius = size.width / 2; + final center = Offset(size.width / 2, size.height / 2); + final sweepAngle = math.pi * 2 * progress; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(AnimatedCircleBorderPainter oldPainter) { + return oldPainter.progress != progress || oldPainter.color != color; + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/swipeable.dart b/packages/stream_chat_flutter/lib/src/misc/swipeable.dart index ded8520b..45b9b2dc 100644 --- a/packages/stream_chat_flutter/lib/src/misc/swipeable.dart +++ b/packages/stream_chat_flutter/lib/src/misc/swipeable.dart @@ -1,173 +1,464 @@ import 'dart:math' as math; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template swipeable} -/// A swipeable tile in a list. Swiping on the tile will reveal actions that -/// can be taken. -/// {@endtemplate} -class Swipeable extends StatefulWidget { - /// {@macro swipeable} - const Swipeable({ - super.key, - required this.child, - required this.backgroundIcon, - this.onSwipeStart, - this.onSwipeCancel, - this.onSwipeEnd, - this.threshold = 82.0, - }); +/// 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); - /// Child to make swipeable - final Widget child; +/// Signature for a function that builds a widget given the progress of the +/// dismissing action. +/// +/// Used by [Swipeable.backgroundBuilder]. +typedef BackgroundWidgetBuilder = Widget Function( + BuildContext context, + SwipeUpdateDetails details, +); - /// Background icon after swipe - final Widget backgroundIcon; +/// The direction in which a [Swipeable] can be swiped. +enum SwipeDirection { + /// The [Swipeable] can be swiped by dragging either left or right. + horizontal, - /// The action to perform when the swipe starts - final VoidCallback? onSwipeStart; + /// 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 action to perform when the swipe is cancelled - final VoidCallback? onSwipeCancel; + /// The [Swipeable] can be swiped by dragging in the reading direction + /// (e.g., from left to right in left-to-right languages). + startToEnd, - /// The action to perform when the swipe ends - final VoidCallback? onSwipeEnd; - - /// Threshold for swipe - final double threshold; - - @override - State createState() => _SwipeableState(); + /// The [Swipeable] cannot be swiped by dragging. + none } -class _SwipeableState extends State with TickerProviderStateMixin { - double _dragExtent = 0; - late AnimationController _moveController; - late AnimationController _iconMoveController; - late Animation _moveAnimation; - late Animation _iconTransitionAnimation; - late Animation _iconFadeAnimation; - bool _pastThreshold = false; +/// A widget that can be swiped in a specified direction. +/// +/// The `Swipeable` widget allows its child to be swiped by the user in a +/// specified direction. +/// +/// It provides options for customizing the swipe behavior, including the +/// ability to specify a background widget that appears during the swipe, +/// callbacks for handling swipe completion, and more. +/// +/// Example usage: +/// ```dart +/// Swipeable( +/// child: Container( +/// height: 100, +/// width: 200, +/// color: Colors.blue, +/// child: Center( +/// child: Text('Swipe me'), +/// ), +/// ), +/// backgroundBuilder: (context, details) { +/// final direction = details.direction; +/// return Container( +/// color: Colors.red, +/// child: Center( +/// child: Text( +/// direction == SwipeDirection.left ? 'Swipe left' : 'Swipe right', +/// style: TextStyle( +/// color: Colors.white, +/// fontWeight: FontWeight.bold, +/// ), +/// ), +/// ), +/// ); +/// }, +/// onSwiped: (direction) { +/// if (direction == SwipeDirection.left) { +/// // Handle left swipe +/// } else if (direction == SwipeDirection.right) { +/// // Handle right swipe +/// } +/// }, +/// direction: SwipeDirection.horizontal, +/// swipeThreshold: 0.4, +/// movementDuration: Duration(milliseconds: 200), +/// ) +/// ``` +class Swipeable extends StatefulWidget { + /// Creates a widget that can be swiped . + const Swipeable({ + required super.key, + required this.child, + this.backgroundBuilder, + this.onSwiped, + this.direction = SwipeDirection.horizontal, + this.swipeThreshold = 0.4, + this.movementDuration = const Duration(milliseconds: 200), + this.dragStartBehavior = DragStartBehavior.start, + this.behavior = HitTestBehavior.opaque, + }) : assert( + swipeThreshold >= 0.0 && swipeThreshold <= 1.0, + 'swipeThreshold must be between 0.0 and 1.0', + ); - final _animationDuration = const Duration(milliseconds: 200); + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.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 + /// down or to the right. + final BackgroundWidgetBuilder? backgroundBuilder; + + /// Called when the widget has been successfully swiped based on the + /// [direction] and [swipeThreshold]. + final SwipeDirectionCallback? onSwiped; + + /// The direction in which the widget can be swiped. + final SwipeDirection direction; + + /// The offset threshold the item has to be dragged in order to be considered + /// swiped. + /// + /// 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 + /// swiped. + /// + /// See also: + /// + /// * [direction], which controls the directions in which the items can + /// be swiped. + final double swipeThreshold; + + /// Defines the duration for card to come back to original position. + final Duration movementDuration; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag gesture used to dismiss a + /// swipeable will begin at the position where the drag gesture won the + /// arena. + /// + /// If set to [DragStartBehavior.down] it will begin at the position where + /// 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; + + /// How to behave during hit tests. + /// + /// This defaults to [HitTestBehavior.opaque]. + final HitTestBehavior behavior; + + @override + _SwipeableState createState() => _SwipeableState(); +} + +/// Details for [DismissUpdateCallback]. +/// +/// See also: +/// +/// * [swipeable.onUpdate], which receives this information. +class SwipeUpdateDetails { + /// Create a new instance of [SwipeUpdateDetails]. + SwipeUpdateDetails({ + this.direction = SwipeDirection.horizontal, + this.reached = false, + this.previousReached = false, + this.progress = 0.0, + }); + + /// The direction that the swipeable is being dragged. + final SwipeDirection direction; + + /// Whether the swipe threshold is currently reached. + final bool reached; + + /// Whether the swipe threshold was reached the last time this callback was + /// invoked. + /// + /// This can be used in conjunction with [SwipeUpdateDetails.reached] to catch + /// the moment that the [Swipeable] is dragged across the threshold. + final bool previousReached; + + /// The offset ratio of the swipeable in its parent container. + /// + /// A value of 0.0 represents the normal position and 1.0 means the child is + /// completely outside its parent. + /// + /// This can be used to synchronize other elements to what the swipeable is + /// doing on screen, e.g. using this value to set the opacity thereby fading + /// swipeable as it's dragged offscreen. + final double progress; +} + +class _SwipeableClipper extends CustomClipper { + _SwipeableClipper({ + required this.moveAnimation, + }) : 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, size.width, size.height); + } + return Rect.fromLTRB(0, 0, offset, size.height); + } + + @override + Rect getApproximateClipRect(Size size) => getClip(size); + + @override + bool shouldReclip(_SwipeableClipper oldClipper) { + return oldClipper.moveAnimation.value != moveAnimation.value; + } +} + +class _SwipeableState extends State + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); - _moveController = - AnimationController(duration: _animationDuration, vsync: this); - _iconMoveController = - AnimationController(duration: _animationDuration, vsync: this); - _moveAnimation = Tween(begin: Offset.zero, end: const Offset(1, 0)) - .animate(_moveController); - _iconTransitionAnimation = - Tween(begin: const Offset(-0.1, 0), end: const Offset(0.4, 0)) - .animate(_moveController); - _iconFadeAnimation = - Tween(begin: 0.7, end: 1).animate(_iconMoveController); - - const controllerValue = 0.0; - _moveController.animateTo(controllerValue); - _iconMoveController.animateTo(controllerValue); + _moveController = AnimationController( + duration: widget.movementDuration, + vsync: this, + )..addStatusListener((_) => updateKeepAlive()); + _updateMoveAnimation(); } + AnimationController? _moveController; + late Animation _moveAnimation; + + double _dragExtent = 0; + bool _dragUnderway = false; + bool _swipeThresholdReached = false; + + final GlobalKey _contentKey = GlobalKey(); + + @override + bool get wantKeepAlive => _moveController?.isAnimating ?? false; + @override void dispose() { - _moveController.dispose(); - _iconMoveController.dispose(); + _moveController?.dispose(); super.dispose(); } + SwipeDirection _extentToDirection(double extent) { + if (extent == 0.0) { + return SwipeDirection.none; + } + switch (Directionality.of(context)) { + case TextDirection.rtl: + return extent < 0 + ? SwipeDirection.startToEnd + : SwipeDirection.endToStart; + case TextDirection.ltr: + return extent > 0 + ? SwipeDirection.startToEnd + : SwipeDirection.endToStart; + } + } + + SwipeDirection get _swipeDirection => _extentToDirection(_dragExtent); + + bool get _isActive => _dragUnderway || _moveController!.isAnimating; + + double get _overallDragAxisExtent => context.size!.width; + void _handleDragStart(DragStartDetails details) { - widget.onSwipeStart?.call(); + _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!; - _dragExtent += delta; - - if (_dragExtent.isNegative) return; - - final movePastThresholdPixels = widget.threshold; - var newPos = _dragExtent.abs() / context.size!.width; - - if (_dragExtent.abs() > movePastThresholdPixels) { - // how many "thresholds" past the threshold we are. 1 = the threshold 2 - // = two thresholds. - final n = _dragExtent.abs() / movePastThresholdPixels; - - // Take the number of thresholds past the threshold, and reduce this - // number - final reducedThreshold = math.pow(n, 0.3); - - final adjustedPixelPos = movePastThresholdPixels * reducedThreshold; - newPos = adjustedPixelPos / context.size!.width; - - if (_dragExtent > 0 && !_pastThreshold) { - _iconMoveController.value = 1; - _pastThreshold = true; - } - } else { - // Send a cancel event if the user has swiped back underneath the - // threshold - if (_pastThreshold && widget.onSwipeCancel != null) { - widget.onSwipeCancel!(); - } - _pastThreshold = false; + 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; + case SwipeDirection.none: + _dragExtent = 0; + break; } - if (!_pastThreshold || newPos < _moveController.value) { - _iconMoveController.value = newPos; + + if (oldDragExtent.sign != _dragExtent.sign) { + setState(_updateMoveAnimation); } - _moveController.value = newPos; + + if (!_moveController!.isAnimating) { + final currentDragExtent = _dragExtent.abs(); + final overallDragExtent = _overallDragAxisExtent; + final movePastThresholdExtent = widget.swipeThreshold * overallDragExtent; + + final double newPos; + if (currentDragExtent > movePastThresholdExtent) { + // How many "thresholds" past the threshold we are. + final n = currentDragExtent / movePastThresholdExtent; + + // Take the number of thresholds past the threshold, and reduce it by + // the threshold amount, then normalize it to the drag extents. + final reducedThreshold = math.pow(n, 0.3); + final adjustedDragExtent = movePastThresholdExtent * reducedThreshold; + + newPos = adjustedDragExtent / overallDragExtent; + } else { + newPos = currentDragExtent / overallDragExtent; + } + + _moveController!.value = newPos; + } + } + + SwipeUpdateDetails _currentSwipeUpdateDetails() { + final oldSwipeThresholdReached = _swipeThresholdReached; + _swipeThresholdReached = _moveController!.value > widget.swipeThreshold; + + return SwipeUpdateDetails( + direction: _swipeDirection, + reached: _swipeThresholdReached, + previousReached: oldSwipeThresholdReached, + progress: _moveController!.value, + ); + } + + void _updateMoveAnimation() { + final end = _dragExtent.sign; + _moveAnimation = _moveController!.drive( + Tween( + begin: Offset.zero, + end: Offset(end, 0), + ), + ); } void _handleDragEnd(DragEndDetails details) { - _moveController.animateTo(0, duration: _animationDuration); - _iconMoveController.animateTo(0, duration: _animationDuration); - _dragExtent = 0.0; - if (_pastThreshold && widget.onSwipeEnd != null) { - widget.onSwipeEnd!(); + if (!_isActive || _moveController!.isAnimating) return; + _dragUnderway = false; + + // Once dragging ends, animate back to the initial offset. + _moveController!.reverse(); + + // If the threshold was reached, report it. + if (_moveController!.value > widget.swipeThreshold) { + if (widget.onSwiped != null) { + final direction = _swipeDirection; + widget.onSwiped!(direction); + } } } @override Widget build(BuildContext context) { + super.build(context); // See AutomaticKeepAliveClientMixin. + + assert( + debugCheckHasDirectionality(context), + 'Swipeable must be inside of a Directionality widget.', + ); + + Widget? background; + final backgroundBuilder = widget.backgroundBuilder; + if (backgroundBuilder != null) { + background = AnimatedBuilder( + animation: _moveAnimation, + builder: (context, _) { + final updateDetails = _currentSwipeUpdateDetails(); + return backgroundBuilder.call(context, updateDetails); + }, + ); + } + + Widget content = SlideTransition( + position: _moveAnimation, + child: KeyedSubtree(key: _contentKey, child: widget.child), + ); + + if (background != null) { + content = Stack( + fit: StackFit.passthrough, + alignment: Alignment.center, + children: [ + if (!_moveAnimation.isDismissed) + Positioned.fill( + child: ClipRect( + clipper: _SwipeableClipper( + moveAnimation: _moveAnimation, + ), + child: background, + ), + ), + content, + ], + ); + } + + // If the SwipeDirection is none, we do not add drag gestures because the + // content cannot be dragged. + if (widget.direction == SwipeDirection.none) { + return content; + } + + // We are not resizing but we may be being dragging in widget.direction. return GestureDetector( onHorizontalDragStart: _handleDragStart, onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragEnd: _handleDragEnd, - behavior: HitTestBehavior.opaque, - child: Stack( - alignment: Alignment.center, - fit: StackFit.passthrough, - children: [ - SlideTransition( - position: _iconTransitionAnimation, - child: Row( - children: [ - FadeTransition( - opacity: _iconFadeAnimation, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ), - child: widget.backgroundIcon, - ), - ), - ], - ), - ), - SlideTransition( - position: _moveAnimation, - child: widget.child, - ), - ], - ), + behavior: widget.behavior, + dragStartBehavior: widget.dragStartBehavior, + child: content, ); } } From 1890d316f930bae3c3aab60af8713983afdd6e21 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 23 Jun 2023 17:19:39 +0530 Subject: [PATCH 2/5] feat(ui): export `Swipeable`, `AnimatedCircleBorderPainter`. Signed-off-by: xsahil03x --- packages/stream_chat_flutter/lib/stream_chat_flutter.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index e5ca5a1e..50977784 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -58,6 +58,7 @@ export 'src/message_widget/message_text.dart'; export 'src/message_widget/message_widget.dart'; export 'src/message_widget/reactions/reaction_picker.dart'; export 'src/message_widget/text_bubble.dart'; +export 'src/misc/animated_circle_border_painter.dart'; export 'src/misc/back_button.dart'; export 'src/misc/connection_status_builder.dart'; export 'src/misc/date_divider.dart'; @@ -66,6 +67,7 @@ export 'src/misc/option_list_tile.dart'; export 'src/misc/reaction_icon.dart'; export 'src/misc/stream_neumorphic_button.dart'; export 'src/misc/stream_svg_icon.dart'; +export 'src/misc/swipeable.dart'; export 'src/misc/system_message.dart'; export 'src/misc/thread_header.dart'; export 'src/misc/visible_footnote.dart'; From 92307a270e773d976fd7dac9fcc67660fe63451b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 23 Jun 2023 17:30:01 +0530 Subject: [PATCH 3/5] chore(ui): deprecate `MessageListView.onMessageSwiped`. Signed-off-by: xsahil03x --- .../lib/src/message_list_view/message_list_view.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c1ad1b04..68c19036 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -99,6 +99,10 @@ class StreamMessageListView extends StatefulWidget { this.initialAlignment, this.scrollController, this.itemPositionListener, + @Deprecated( + 'Try wrapping the `MessageWidget` with a `Swipeable`, `Dismissible` or a ' + 'custom widget to achieve the swipe to reply behaviour.', + ) this.onMessageSwiped, this.highlightInitialMessage = false, this.messageHighlightColor, From 59c4307205e84bf9f411e8a73e658368b316b1dc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 23 Jun 2023 17:52:15 +0530 Subject: [PATCH 4/5] chore: update CHANGELOG.md Signed-off-by: xsahil03x --- packages/stream_chat_flutter/CHANGELOG.md | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 49b324d6..11ca3d09 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,9 @@ after overriding the `onConfirmDeleteTap` callback. - [[#1621]](https://github.com/GetStream/stream-chat-flutter/issues/1621) Fixed `createdAtStyle` null check error in `SendingIndicatorBuilder`. +- [[#1069]](https://github.com/GetStream/stream-chat-flutter/issues/1069) Fixed message swipe to reply using same + direction for both current user and other users. It now uses `SwipeDirection.startToEnd` for current user + and `SwipeDirection.endToStart` for other users. ✅ Added @@ -26,6 +29,54 @@ 🔄 Changed - Deprecated `StreamMessageInput.attachmentThumbnailBuilders` in favor of `StreamMessageInput.mediaAttachmentBuilder`. +- Deprecated `StreamMessageListView.onMessageSwiped`. Try wrapping the `MessageWidget` with a `Swipeable`, `Dismissible` + or a custom widget to achieve the swipe to reply behaviour. + + ```dart + // Migration from onMessageSwiped to Swipeable. + StreamMessageListView( + ..., + messageBuilder: (context, messageDetails, messages, defaultWidget) { + // The threshold after which the message should be considered as swiped. + const threshold = 0.2; + + // The direction in which the message should be swiped to reply. + final swipeDirection = messageDetails.isMyMessage + ? SwipeDirection.endToStart // + : SwipeDirection.startToEnd; + + return Swipeable( + key: ValueKey(messageDetails.message.id), + direction: swipeDirection, + swipeThreshold: threshold, + onSwiped: (direction) { + // Handle the swipe action here. + }, + backgroundBuilder: (context, details) { + // The alignment of the swipe action. + final alignment = messageDetails.isMyMessage + ? Alignment.centerRight // + : Alignment.centerLeft; + + // The progress of the swipe action. + final progress = math.min(details.progress, threshold) / threshold; + + return Align( + alignment: alignment, + child: Opacity( + opacity: progress, + child: const Icon( + Icons.reply, + color: Colors.white, + ), + ), + ); + }, + child: defaultWidget, + ); + }, + ) + ``` ## 6.4.0 From a681ebfd683f5ca9810030b6ada78ccdc34ca0e8 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 23 Jun 2023 18:07:07 +0530 Subject: [PATCH 5/5] chore: fix analysis. Signed-off-by: xsahil03x --- .../stream_chat_flutter/example/lib/main.dart | 83 +++++++++++++++++-- .../message_list_view/message_list_view.dart | 2 - 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index f0acc0fa..332d813b 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -1,6 +1,8 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:responsive_builder/responsive_builder.dart'; @@ -277,19 +279,84 @@ class _ChannelPageState extends State { children: [ Expanded( child: StreamMessageListView( - onMessageSwiped: - (CurrentPlatform.isAndroid || CurrentPlatform.isIos) - ? reply - : null, threadBuilder: (context, parent) { return ThreadPage( parent: parent!, ); }, - messageBuilder: - (context, details, messages, defaultWidget) { - return defaultWidget.copyWith( - onReplyTap: reply, + messageBuilder: ( + context, + messageDetails, + messages, + defaultWidget, + ) { + // The threshold after which the message is considered + // swiped. + const threshold = 0.2; + + final isMyMessage = messageDetails.isMyMessage; + + // The direction in which the message can be swiped. + final swipeDirection = isMyMessage + ? SwipeDirection.endToStart // + : SwipeDirection.startToEnd; + + return Swipeable( + key: ValueKey(messageDetails.message.id), + direction: swipeDirection, + swipeThreshold: threshold, + onSwiped: (details) => reply(messageDetails.message), + backgroundBuilder: (context, details) { + // The alignment of the swipe action. + final alignment = isMyMessage + ? Alignment.centerRight // + : Alignment.centerLeft; + + // The progress of the swipe action. + final progress = + math.min(details.progress, threshold) / threshold; + + // The offset for the reply icon. + var offset = Offset.lerp( + const Offset(-24, 0), + const Offset(12, 0), + progress, + )!; + + // If the message is mine, we need to flip the offset. + if (isMyMessage) { + offset = Offset(-offset.dx, -offset.dy); + } + + final _streamTheme = StreamChatTheme.of(context); + + return Align( + alignment: alignment, + child: Transform.translate( + offset: offset, + child: Opacity( + opacity: progress, + child: SizedBox.square( + dimension: 30, + child: CustomPaint( + painter: AnimatedCircleBorderPainter( + progress: progress, + color: _streamTheme.colorTheme.borders, + ), + child: Center( + child: StreamSvgIcon.reply( + size: lerpDouble(0, 18, progress), + color: _streamTheme + .colorTheme.accentPrimary, + ), + ), + ), + ), + ), + ), + ); + }, + child: defaultWidget.copyWith(onReplyTap: reply), ); }, ), diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 68c19036..5a5075f8 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -12,8 +12,6 @@ import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart'; import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; -import 'package:stream_chat_flutter/src/misc/animated_circle_border_painter.dart'; -import 'package:stream_chat_flutter/src/misc/swipeable.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Spacing Types (These are properties of a message to help inform the decision