From 1b35e3b1f92cb4de656a005de98a79bc5f5f1c74 Mon Sep 17 00:00:00 2001 From: Brazol Date: Tue, 24 Mar 2026 16:26:42 +0100 Subject: [PATCH 1/2] swipe to reply moved to sdk --- .../stream_chat_flutter/example/lib/main.dart | 72 +------------- .../message_list_view/message_list_view.dart | 11 +++ .../src/message_widget/message_widget.dart | 93 ++++++++++++++++++- sample_app/lib/pages/channel_page.dart | 60 +----------- sample_app/lib/pages/thread_page.dart | 1 + 5 files changed, 107 insertions(+), 130 deletions(-) diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 5d814dfa7..a9719055b 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -1,8 +1,6 @@ // 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'; @@ -254,74 +252,8 @@ class _ChannelPageState extends State { Expanded( child: StreamMessageListView( threadBuilder: (_, parent) => ThreadPage(parent: parent!), - messageBuilder: (context, message, defaultProps) { - // The threshold after which the message is considered - // swiped. - const threshold = 0.2; - - final currentUser = StreamChat.of(context).currentUser; - final isMyMessage = message.user?.id == currentUser?.id; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage ? SwipeDirection.endToStart : SwipeDirection.startToEnd; - - return Swipeable( - key: ValueKey(message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (details) => reply(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: Icon( - context.streamIcons.arrowShareLeft, - size: lerpDouble(0, 18, progress), - color: _streamTheme.colorTheme.accentPrimary, - ), - ), - ), - ), - ), - ), - ); - }, - child: DefaultStreamMessage( - props: defaultProps.copyWith(onReplyTap: reply), - ), - ); - }, + onReplyTap: reply, + swipeToReply: true, ), ), StreamMessageInput( 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 c5ff97c05..67912b028 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 @@ -111,6 +111,7 @@ class StreamMessageListView extends StatefulWidget { this.onThreadTap, this.onEditMessageTap, this.onReplyTap, + this.swipeToReply = false, this.onUserAvatarTap, this.onReactionsTap, this.onQuotedMessageTap, @@ -223,6 +224,14 @@ class StreamMessageListView extends StatefulWidget { /// Forwarded to each [StreamMessageWidget] in the list. final void Function(Message)? onReplyTap; + /// Whether swiping a message triggers a quoted-reply action. + /// + /// Forwarded to each [StreamMessageWidget] in the list via + /// [StreamMessageWidgetProps.swipeToReply]. + /// + /// Defaults to false. + final bool swipeToReply; + /// Called when a user avatar is tapped. /// /// Forwarded to each [StreamMessageWidget] in the list. @@ -1056,6 +1065,7 @@ class _StreamMessageListViewState extends State { ) { final parentMessageProps = StreamMessageWidgetProps( message: message, + swipeToReply: widget.swipeToReply, onThreadTap: _onThreadTap, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, @@ -1200,6 +1210,7 @@ class _StreamMessageListViewState extends State { final messageWidgetProps = StreamMessageWidgetProps( message: message, + swipeToReply: widget.swipeToReply, onThreadTap: _onThreadTap, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 42f3f253a..28a647f37 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -1,3 +1,6 @@ +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -75,6 +78,7 @@ class StreamMessageWidget extends StatelessWidget { double? spacing, Color? backgroundColor, double widthFactor = 0.8, + bool swipeToReply = false, void Function(Message)? onMessageTap, void Function(Message)? onMessageLongPress, void Function(User)? onUserAvatarTap, @@ -96,6 +100,7 @@ class StreamMessageWidget extends StatelessWidget { spacing: spacing, backgroundColor: backgroundColor, widthFactor: widthFactor, + swipeToReply: swipeToReply, onMessageTap: onMessageTap, onMessageLongPress: onMessageLongPress, onUserAvatarTap: onUserAvatarTap, @@ -149,6 +154,7 @@ class StreamMessageWidgetProps { this.spacing, this.backgroundColor, this.widthFactor = 0.8, + this.swipeToReply = false, this.onMessageTap, this.onMessageLongPress, this.onUserAvatarTap, @@ -198,6 +204,19 @@ class StreamMessageWidgetProps { /// Values should be between 0.0 and 1.0. Defaults to 0.8 when not specified. final double widthFactor; + /// Whether swiping the message triggers a quoted-reply action. + /// + /// When true, the message can be swiped horizontally to initiate a reply. + /// The swipe direction is determined automatically based on message + /// alignment: end-to-start for the current user's messages and + /// start-to-end for other users' messages. On completion, [onReplyTap] is + /// invoked with the message. + /// + /// Swipe is disabled for deleted messages and messages in a failed state. + /// + /// Defaults to false. + final bool swipeToReply; + /// Called when the message is tapped. /// /// If null, no tap gesture is registered on mobile. On desktop and web, @@ -309,6 +328,7 @@ class StreamMessageWidgetProps { double? spacing, Color? backgroundColor, double? widthFactor, + bool? swipeToReply, void Function(Message)? onMessageTap, void Function(Message)? onMessageLongPress, void Function(User)? onUserAvatarTap, @@ -331,6 +351,7 @@ class StreamMessageWidgetProps { spacing: spacing ?? this.spacing, backgroundColor: backgroundColor ?? this.backgroundColor, widthFactor: widthFactor ?? this.widthFactor, + swipeToReply: swipeToReply ?? this.swipeToReply, onMessageTap: onMessageTap ?? this.onMessageTap, onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress, onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, @@ -436,7 +457,7 @@ class DefaultStreamMessage extends StatelessWidget { }, ); - return Material( + Widget result = Material( animateColor: true, color: effectiveBackgroundColor, child: PlatformWidgetBuilder( @@ -494,6 +515,16 @@ class DefaultStreamMessage extends StatelessWidget { ), ), ); + + if (props.swipeToReply && props.onReplyTap != null && !message.isDeleted && !message.state.isFailed) { + result = _SwipeToReplyWrapper( + message: message, + onReplyTap: props.onReplyTap!, + child: result, + ); + } + + return result; } // Builds the action list for a bounced (moderation-error) message. @@ -855,6 +886,66 @@ extension on Poll { } } +class _SwipeToReplyWrapper extends StatelessWidget { + const _SwipeToReplyWrapper({ + required this.message, + required this.onReplyTap, + required this.child, + }); + + final Message message; + final void Function(Message) onReplyTap; + final Widget child; + + static const _swipeThreshold = 0.2; + + @override + Widget build(BuildContext context) { + final alignment = StreamMessagePlacement.alignmentDirectionalOf(context); + final isEnd = alignment == AlignmentDirectional.centerEnd; + + return Swipeable( + key: ValueKey('swipe-${message.id}'), + direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd, + swipeThreshold: _swipeThreshold, + onSwiped: (_) => onReplyTap(message), + backgroundBuilder: (context, details) { + final progress = math.min(details.progress, _swipeThreshold) / _swipeThreshold; + + var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!; + if (isEnd) 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: context.streamColorScheme.borderDefault, + ), + child: Center( + child: Icon( + context.streamIcons.arrowShareLeft, + size: lerpDouble(0, 18, progress), + color: context.streamColorScheme.accentPrimary, + ), + ), + ), + ), + ), + ), + ); + }, + child: child, + ); + } +} + // Built-in fallback theme values for [DefaultStreamMessage]. // // Used when neither the explicit props nor the ambient diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index b6a7d7f9a..afa9dc234 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -1,8 +1,5 @@ // ignore_for_file: deprecated_member_use, avoid_redundant_argument_values -import 'dart:math' as math; -import 'dart:ui'; - import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -106,8 +103,8 @@ class _ChannelPageState extends State { highlightInitialMessage: widget.highlightInitialMessage, onEditMessageTap: _editMessage, onReplyTap: _reply, + swipeToReply: true, messageFilter: defaultFilter, - messageBuilder: _messageBuilder, threadBuilder: (_, parentMessage) { return ThreadPage(parent: parentMessage!); }, @@ -201,61 +198,6 @@ class _ChannelPageState extends State { return channel.sendStaticLocation(location: result.coordinates); } - Widget _messageBuilder( - BuildContext context, - Message message, - StreamMessageWidgetProps defaultProps, - ) { - final defaultWidget = StreamMessageWidget.fromProps(props: defaultProps); - - if (message.isDeleted || message.state.isFailed) return defaultWidget; - - final alignment = StreamMessagePlacement.alignmentDirectionalOf(context); - final isEnd = alignment == AlignmentDirectional.centerEnd; - - const threshold = 0.2; - - return Swipeable( - key: ValueKey(message.id), - direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd, - swipeThreshold: threshold, - onSwiped: (_) => _reply(message), - backgroundBuilder: (context, details) { - final progress = math.min(details.progress, threshold) / threshold; - - var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!; - if (isEnd) 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: context.streamColorScheme.borderDefault, - ), - child: Center( - child: Icon( - context.streamIcons.arrowShareLeft, - size: lerpDouble(0, 18, progress), - color: context.streamColorScheme.accentPrimary, - ), - ), - ), - ), - ), - ), - ); - }, - child: defaultWidget, - ); - } - bool defaultFilter(Message m) { final currentUser = StreamChat.of(context).currentUser; final isMyMessage = m.user?.id == currentUser?.id; diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart index 197fb7215..234ec4366 100644 --- a/sample_app/lib/pages/thread_page.dart +++ b/sample_app/lib/pages/thread_page.dart @@ -54,6 +54,7 @@ class _ThreadPageState extends State { initialScrollIndex: widget.initialScrollIndex, initialAlignment: widget.initialAlignment, onReplyTap: _reply, + swipeToReply: true, messageFilter: defaultFilter, showScrollToBottom: false, highlightInitialMessage: true, From 348d71d0e740a225b3b01ac6f7e001c6dc137068 Mon Sep 17 00:00:00 2001 From: Brazol Date: Thu, 26 Mar 2026 13:11:42 +0100 Subject: [PATCH 2/2] reply left-to-right always --- .../src/message_widget/message_widget.dart | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 28a647f37..94b29aa82 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -206,11 +206,11 @@ class StreamMessageWidgetProps { /// Whether swiping the message triggers a quoted-reply action. /// - /// When true, the message can be swiped horizontally to initiate a reply. - /// The swipe direction is determined automatically based on message - /// alignment: end-to-start for the current user's messages and - /// start-to-end for other users' messages. On completion, [onReplyTap] is - /// invoked with the message. + /// When true, the message can be swiped from left to right to initiate a + /// reply. The swipe direction and reply icon position are always + /// start-to-end (left to right in LTR layouts), regardless of whether the + /// message belongs to the current user or another participant. + /// On completion, [onReplyTap] is invoked with the message. /// /// Swipe is disabled for deleted messages and messages in a failed state. /// @@ -901,28 +901,23 @@ class _SwipeToReplyWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - final alignment = StreamMessagePlacement.alignmentDirectionalOf(context); - final isEnd = alignment == AlignmentDirectional.centerEnd; - return Swipeable( key: ValueKey('swipe-${message.id}'), - direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd, + direction: SwipeDirection.startToEnd, swipeThreshold: _swipeThreshold, onSwiped: (_) => onReplyTap(message), backgroundBuilder: (context, details) { final progress = math.min(details.progress, _swipeThreshold) / _swipeThreshold; - - var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!; - if (isEnd) offset = Offset(-offset.dx, -offset.dy); + final offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!; return Align( - alignment: alignment, + alignment: AlignmentDirectional.centerStart, child: Transform.translate( offset: offset, child: Opacity( opacity: progress, child: SizedBox.square( - dimension: 30, + dimension: 32, child: CustomPaint( painter: AnimatedCircleBorderPainter( progress: progress, @@ -931,8 +926,7 @@ class _SwipeToReplyWrapper extends StatelessWidget { child: Center( child: Icon( context.streamIcons.arrowShareLeft, - size: lerpDouble(0, 18, progress), - color: context.streamColorScheme.accentPrimary, + size: lerpDouble(0, 20, progress), ), ), ),