Skip to content

Commit 1b35e3b

Browse files
committed
swipe to reply moved to sdk
1 parent c86af06 commit 1b35e3b

5 files changed

Lines changed: 107 additions & 130 deletions

File tree

packages/stream_chat_flutter/example/lib/main.dart

Lines changed: 2 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// ignore_for_file: public_member_api_docs
22

33
import 'dart:async';
4-
import 'dart:math' as math;
5-
import 'dart:ui';
64

75
import 'package:flutter/material.dart';
86
import 'package:responsive_builder/responsive_builder.dart';
@@ -254,74 +252,8 @@ class _ChannelPageState extends State<ChannelPage> {
254252
Expanded(
255253
child: StreamMessageListView(
256254
threadBuilder: (_, parent) => ThreadPage(parent: parent!),
257-
messageBuilder: (context, message, defaultProps) {
258-
// The threshold after which the message is considered
259-
// swiped.
260-
const threshold = 0.2;
261-
262-
final currentUser = StreamChat.of(context).currentUser;
263-
final isMyMessage = message.user?.id == currentUser?.id;
264-
265-
// The direction in which the message can be swiped.
266-
final swipeDirection = isMyMessage ? SwipeDirection.endToStart : SwipeDirection.startToEnd;
267-
268-
return Swipeable(
269-
key: ValueKey(message.id),
270-
direction: swipeDirection,
271-
swipeThreshold: threshold,
272-
onSwiped: (details) => reply(message),
273-
backgroundBuilder: (context, details) {
274-
// The alignment of the swipe action.
275-
final alignment = isMyMessage ? Alignment.centerRight : Alignment.centerLeft;
276-
277-
// The progress of the swipe action.
278-
final progress = math.min(details.progress, threshold) / threshold;
279-
280-
// The offset for the reply icon.
281-
var offset = Offset.lerp(
282-
const Offset(-24, 0),
283-
const Offset(12, 0),
284-
progress,
285-
)!;
286-
287-
// If the message is mine, we need to flip the offset.
288-
if (isMyMessage) {
289-
offset = Offset(-offset.dx, -offset.dy);
290-
}
291-
292-
final _streamTheme = StreamChatTheme.of(context);
293-
294-
return Align(
295-
alignment: alignment,
296-
child: Transform.translate(
297-
offset: offset,
298-
child: Opacity(
299-
opacity: progress,
300-
child: SizedBox.square(
301-
dimension: 30,
302-
child: CustomPaint(
303-
painter: AnimatedCircleBorderPainter(
304-
progress: progress,
305-
color: _streamTheme.colorTheme.borders,
306-
),
307-
child: Center(
308-
child: Icon(
309-
context.streamIcons.arrowShareLeft,
310-
size: lerpDouble(0, 18, progress),
311-
color: _streamTheme.colorTheme.accentPrimary,
312-
),
313-
),
314-
),
315-
),
316-
),
317-
),
318-
);
319-
},
320-
child: DefaultStreamMessage(
321-
props: defaultProps.copyWith(onReplyTap: reply),
322-
),
323-
);
324-
},
255+
onReplyTap: reply,
256+
swipeToReply: true,
325257
),
326258
),
327259
StreamMessageInput(

packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class StreamMessageListView extends StatefulWidget {
111111
this.onThreadTap,
112112
this.onEditMessageTap,
113113
this.onReplyTap,
114+
this.swipeToReply = false,
114115
this.onUserAvatarTap,
115116
this.onReactionsTap,
116117
this.onQuotedMessageTap,
@@ -223,6 +224,14 @@ class StreamMessageListView extends StatefulWidget {
223224
/// Forwarded to each [StreamMessageWidget] in the list.
224225
final void Function(Message)? onReplyTap;
225226

227+
/// Whether swiping a message triggers a quoted-reply action.
228+
///
229+
/// Forwarded to each [StreamMessageWidget] in the list via
230+
/// [StreamMessageWidgetProps.swipeToReply].
231+
///
232+
/// Defaults to false.
233+
final bool swipeToReply;
234+
226235
/// Called when a user avatar is tapped.
227236
///
228237
/// Forwarded to each [StreamMessageWidget] in the list.
@@ -1056,6 +1065,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
10561065
) {
10571066
final parentMessageProps = StreamMessageWidgetProps(
10581067
message: message,
1068+
swipeToReply: widget.swipeToReply,
10591069
onThreadTap: _onThreadTap,
10601070
onMessageTap: widget.onMessageTap,
10611071
onMessageLongPress: widget.onMessageLongPress,
@@ -1200,6 +1210,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
12001210

12011211
final messageWidgetProps = StreamMessageWidgetProps(
12021212
message: message,
1213+
swipeToReply: widget.swipeToReply,
12031214
onThreadTap: _onThreadTap,
12041215
onMessageTap: widget.onMessageTap,
12051216
onMessageLongPress: widget.onMessageLongPress,

packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'dart:math' as math;
2+
import 'dart:ui' show lerpDouble;
3+
14
import 'package:collection/collection.dart';
25
import 'package:flutter/material.dart';
36
import 'package:flutter/services.dart';
@@ -75,6 +78,7 @@ class StreamMessageWidget extends StatelessWidget {
7578
double? spacing,
7679
Color? backgroundColor,
7780
double widthFactor = 0.8,
81+
bool swipeToReply = false,
7882
void Function(Message)? onMessageTap,
7983
void Function(Message)? onMessageLongPress,
8084
void Function(User)? onUserAvatarTap,
@@ -96,6 +100,7 @@ class StreamMessageWidget extends StatelessWidget {
96100
spacing: spacing,
97101
backgroundColor: backgroundColor,
98102
widthFactor: widthFactor,
103+
swipeToReply: swipeToReply,
99104
onMessageTap: onMessageTap,
100105
onMessageLongPress: onMessageLongPress,
101106
onUserAvatarTap: onUserAvatarTap,
@@ -149,6 +154,7 @@ class StreamMessageWidgetProps {
149154
this.spacing,
150155
this.backgroundColor,
151156
this.widthFactor = 0.8,
157+
this.swipeToReply = false,
152158
this.onMessageTap,
153159
this.onMessageLongPress,
154160
this.onUserAvatarTap,
@@ -198,6 +204,19 @@ class StreamMessageWidgetProps {
198204
/// Values should be between 0.0 and 1.0. Defaults to 0.8 when not specified.
199205
final double widthFactor;
200206

207+
/// Whether swiping the message triggers a quoted-reply action.
208+
///
209+
/// When true, the message can be swiped horizontally to initiate a reply.
210+
/// The swipe direction is determined automatically based on message
211+
/// alignment: end-to-start for the current user's messages and
212+
/// start-to-end for other users' messages. On completion, [onReplyTap] is
213+
/// invoked with the message.
214+
///
215+
/// Swipe is disabled for deleted messages and messages in a failed state.
216+
///
217+
/// Defaults to false.
218+
final bool swipeToReply;
219+
201220
/// Called when the message is tapped.
202221
///
203222
/// If null, no tap gesture is registered on mobile. On desktop and web,
@@ -309,6 +328,7 @@ class StreamMessageWidgetProps {
309328
double? spacing,
310329
Color? backgroundColor,
311330
double? widthFactor,
331+
bool? swipeToReply,
312332
void Function(Message)? onMessageTap,
313333
void Function(Message)? onMessageLongPress,
314334
void Function(User)? onUserAvatarTap,
@@ -331,6 +351,7 @@ class StreamMessageWidgetProps {
331351
spacing: spacing ?? this.spacing,
332352
backgroundColor: backgroundColor ?? this.backgroundColor,
333353
widthFactor: widthFactor ?? this.widthFactor,
354+
swipeToReply: swipeToReply ?? this.swipeToReply,
334355
onMessageTap: onMessageTap ?? this.onMessageTap,
335356
onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress,
336357
onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap,
@@ -436,7 +457,7 @@ class DefaultStreamMessage extends StatelessWidget {
436457
},
437458
);
438459

439-
return Material(
460+
Widget result = Material(
440461
animateColor: true,
441462
color: effectiveBackgroundColor,
442463
child: PlatformWidgetBuilder(
@@ -494,6 +515,16 @@ class DefaultStreamMessage extends StatelessWidget {
494515
),
495516
),
496517
);
518+
519+
if (props.swipeToReply && props.onReplyTap != null && !message.isDeleted && !message.state.isFailed) {
520+
result = _SwipeToReplyWrapper(
521+
message: message,
522+
onReplyTap: props.onReplyTap!,
523+
child: result,
524+
);
525+
}
526+
527+
return result;
497528
}
498529

499530
// Builds the action list for a bounced (moderation-error) message.
@@ -855,6 +886,66 @@ extension on Poll {
855886
}
856887
}
857888

889+
class _SwipeToReplyWrapper extends StatelessWidget {
890+
const _SwipeToReplyWrapper({
891+
required this.message,
892+
required this.onReplyTap,
893+
required this.child,
894+
});
895+
896+
final Message message;
897+
final void Function(Message) onReplyTap;
898+
final Widget child;
899+
900+
static const _swipeThreshold = 0.2;
901+
902+
@override
903+
Widget build(BuildContext context) {
904+
final alignment = StreamMessagePlacement.alignmentDirectionalOf(context);
905+
final isEnd = alignment == AlignmentDirectional.centerEnd;
906+
907+
return Swipeable(
908+
key: ValueKey('swipe-${message.id}'),
909+
direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd,
910+
swipeThreshold: _swipeThreshold,
911+
onSwiped: (_) => onReplyTap(message),
912+
backgroundBuilder: (context, details) {
913+
final progress = math.min(details.progress, _swipeThreshold) / _swipeThreshold;
914+
915+
var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!;
916+
if (isEnd) offset = Offset(-offset.dx, -offset.dy);
917+
918+
return Align(
919+
alignment: alignment,
920+
child: Transform.translate(
921+
offset: offset,
922+
child: Opacity(
923+
opacity: progress,
924+
child: SizedBox.square(
925+
dimension: 30,
926+
child: CustomPaint(
927+
painter: AnimatedCircleBorderPainter(
928+
progress: progress,
929+
color: context.streamColorScheme.borderDefault,
930+
),
931+
child: Center(
932+
child: Icon(
933+
context.streamIcons.arrowShareLeft,
934+
size: lerpDouble(0, 18, progress),
935+
color: context.streamColorScheme.accentPrimary,
936+
),
937+
),
938+
),
939+
),
940+
),
941+
),
942+
);
943+
},
944+
child: child,
945+
);
946+
}
947+
}
948+
858949
// Built-in fallback theme values for [DefaultStreamMessage].
859950
//
860951
// Used when neither the explicit props nor the ambient

sample_app/lib/pages/channel_page.dart

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values
22

3-
import 'dart:math' as math;
4-
import 'dart:ui';
5-
63
import 'package:collection/collection.dart';
74
import 'package:flutter/material.dart';
85
import 'package:go_router/go_router.dart';
@@ -106,8 +103,8 @@ class _ChannelPageState extends State<ChannelPage> {
106103
highlightInitialMessage: widget.highlightInitialMessage,
107104
onEditMessageTap: _editMessage,
108105
onReplyTap: _reply,
106+
swipeToReply: true,
109107
messageFilter: defaultFilter,
110-
messageBuilder: _messageBuilder,
111108
threadBuilder: (_, parentMessage) {
112109
return ThreadPage(parent: parentMessage!);
113110
},
@@ -201,61 +198,6 @@ class _ChannelPageState extends State<ChannelPage> {
201198
return channel.sendStaticLocation(location: result.coordinates);
202199
}
203200

204-
Widget _messageBuilder(
205-
BuildContext context,
206-
Message message,
207-
StreamMessageWidgetProps defaultProps,
208-
) {
209-
final defaultWidget = StreamMessageWidget.fromProps(props: defaultProps);
210-
211-
if (message.isDeleted || message.state.isFailed) return defaultWidget;
212-
213-
final alignment = StreamMessagePlacement.alignmentDirectionalOf(context);
214-
final isEnd = alignment == AlignmentDirectional.centerEnd;
215-
216-
const threshold = 0.2;
217-
218-
return Swipeable(
219-
key: ValueKey(message.id),
220-
direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd,
221-
swipeThreshold: threshold,
222-
onSwiped: (_) => _reply(message),
223-
backgroundBuilder: (context, details) {
224-
final progress = math.min(details.progress, threshold) / threshold;
225-
226-
var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!;
227-
if (isEnd) offset = Offset(-offset.dx, -offset.dy);
228-
229-
return Align(
230-
alignment: alignment,
231-
child: Transform.translate(
232-
offset: offset,
233-
child: Opacity(
234-
opacity: progress,
235-
child: SizedBox.square(
236-
dimension: 30,
237-
child: CustomPaint(
238-
painter: AnimatedCircleBorderPainter(
239-
progress: progress,
240-
color: context.streamColorScheme.borderDefault,
241-
),
242-
child: Center(
243-
child: Icon(
244-
context.streamIcons.arrowShareLeft,
245-
size: lerpDouble(0, 18, progress),
246-
color: context.streamColorScheme.accentPrimary,
247-
),
248-
),
249-
),
250-
),
251-
),
252-
),
253-
);
254-
},
255-
child: defaultWidget,
256-
);
257-
}
258-
259201
bool defaultFilter(Message m) {
260202
final currentUser = StreamChat.of(context).currentUser;
261203
final isMyMessage = m.user?.id == currentUser?.id;

sample_app/lib/pages/thread_page.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class _ThreadPageState extends State<ThreadPage> {
5454
initialScrollIndex: widget.initialScrollIndex,
5555
initialAlignment: widget.initialAlignment,
5656
onReplyTap: _reply,
57+
swipeToReply: true,
5758
messageFilter: defaultFilter,
5859
showScrollToBottom: false,
5960
highlightInitialMessage: true,

0 commit comments

Comments
 (0)