Merge pull request #1635 from GetStream/feat/swipeable-v2
This commit is contained in:
@@ -6,6 +6,9 @@
|
|||||||
after overriding the `onConfirmDeleteTap` callback.
|
after overriding the `onConfirmDeleteTap` callback.
|
||||||
- [[#1621]](https://github.com/GetStream/stream-chat-flutter/issues/1621) Fixed `createdAtStyle` null check error
|
- [[#1621]](https://github.com/GetStream/stream-chat-flutter/issues/1621) Fixed `createdAtStyle` null check error
|
||||||
in `SendingIndicatorBuilder`.
|
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
|
✅ Added
|
||||||
|
|
||||||
@@ -26,6 +29,54 @@
|
|||||||
🔄 Changed
|
🔄 Changed
|
||||||
|
|
||||||
- Deprecated `StreamMessageInput.attachmentThumbnailBuilders` in favor of `StreamMessageInput.mediaAttachmentBuilder`.
|
- 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
|
## 6.4.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// ignore_for_file: public_member_api_docs
|
// ignore_for_file: public_member_api_docs
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:responsive_builder/responsive_builder.dart';
|
import 'package:responsive_builder/responsive_builder.dart';
|
||||||
@@ -277,19 +279,84 @@ class _ChannelPageState extends State<ChannelPage> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamMessageListView(
|
child: StreamMessageListView(
|
||||||
onMessageSwiped:
|
|
||||||
(CurrentPlatform.isAndroid || CurrentPlatform.isIos)
|
|
||||||
? reply
|
|
||||||
: null,
|
|
||||||
threadBuilder: (context, parent) {
|
threadBuilder: (context, parent) {
|
||||||
return ThreadPage(
|
return ThreadPage(
|
||||||
parent: parent!,
|
parent: parent!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
messageBuilder:
|
messageBuilder: (
|
||||||
(context, details, messages, defaultWidget) {
|
context,
|
||||||
return defaultWidget.copyWith(
|
messageDetails,
|
||||||
onReplyTap: reply,
|
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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// ignore_for_file: lines_longer_than_80_chars
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -10,7 +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/mlv_utils.dart';
|
||||||
import 'package:stream_chat_flutter/src/message_list_view/thread_separator.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/message_list_view/unread_messages_separator.dart';
|
||||||
import 'package:stream_chat_flutter/src/misc/swipeable.dart';
|
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
/// Spacing Types (These are properties of a message to help inform the decision
|
/// Spacing Types (These are properties of a message to help inform the decision
|
||||||
@@ -96,6 +97,10 @@ class StreamMessageListView extends StatefulWidget {
|
|||||||
this.initialAlignment,
|
this.initialAlignment,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
this.itemPositionListener,
|
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.onMessageSwiped,
|
||||||
this.highlightInitialMessage = false,
|
this.highlightInitialMessage = false,
|
||||||
this.messageHighlightColor,
|
this.messageHighlightColor,
|
||||||
@@ -1226,26 +1231,6 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var child = messageWidget;
|
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 &&
|
if (!initialMessageHighlightComplete &&
|
||||||
widget.highlightInitialMessage &&
|
widget.highlightInitialMessage &&
|
||||||
isInitialMessage(message.id, streamChannel)) {
|
isInitialMessage(message.id, streamChannel)) {
|
||||||
@@ -1269,6 +1254,77 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,173 +1,464 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
|
||||||
|
|
||||||
/// {@template swipeable}
|
/// Signature used by [swipeable] to indicate that it has been swiped in
|
||||||
/// A swipeable tile in a list. Swiping on the tile will reveal actions that
|
/// the given `direction`.
|
||||||
/// can be taken.
|
///
|
||||||
/// {@endtemplate}
|
/// Used by [Swipeable.onSwiped].
|
||||||
class Swipeable extends StatefulWidget {
|
typedef SwipeDirectionCallback = void Function(SwipeDirection direction);
|
||||||
/// {@macro swipeable}
|
|
||||||
const Swipeable({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
required this.backgroundIcon,
|
|
||||||
this.onSwipeStart,
|
|
||||||
this.onSwipeCancel,
|
|
||||||
this.onSwipeEnd,
|
|
||||||
this.threshold = 82.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Child to make swipeable
|
/// Signature for a function that builds a widget given the progress of the
|
||||||
final Widget child;
|
/// dismissing action.
|
||||||
|
///
|
||||||
|
/// Used by [Swipeable.backgroundBuilder].
|
||||||
|
typedef BackgroundWidgetBuilder = Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
SwipeUpdateDetails details,
|
||||||
|
);
|
||||||
|
|
||||||
/// Background icon after swipe
|
/// The direction in which a [Swipeable] can be swiped.
|
||||||
final Widget backgroundIcon;
|
enum SwipeDirection {
|
||||||
|
/// The [Swipeable] can be swiped by dragging either left or right.
|
||||||
|
horizontal,
|
||||||
|
|
||||||
/// The action to perform when the swipe starts
|
/// The [Swipeable] can be swiped by dragging in the reverse of the
|
||||||
final VoidCallback? onSwipeStart;
|
/// reading direction (e.g., from right to left in left-to-right languages).
|
||||||
|
endToStart,
|
||||||
|
|
||||||
/// The action to perform when the swipe is cancelled
|
/// The [Swipeable] can be swiped by dragging in the reading direction
|
||||||
final VoidCallback? onSwipeCancel;
|
/// (e.g., from left to right in left-to-right languages).
|
||||||
|
startToEnd,
|
||||||
|
|
||||||
/// The action to perform when the swipe ends
|
/// The [Swipeable] cannot be swiped by dragging.
|
||||||
final VoidCallback? onSwipeEnd;
|
none
|
||||||
|
|
||||||
/// Threshold for swipe
|
|
||||||
final double threshold;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<StatefulWidget> createState() => _SwipeableState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeableState extends State<Swipeable> with TickerProviderStateMixin {
|
/// A widget that can be swiped in a specified direction.
|
||||||
double _dragExtent = 0;
|
///
|
||||||
late AnimationController _moveController;
|
/// The `Swipeable` widget allows its child to be swiped by the user in a
|
||||||
late AnimationController _iconMoveController;
|
/// specified direction.
|
||||||
late Animation<Offset> _moveAnimation;
|
///
|
||||||
late Animation<Offset> _iconTransitionAnimation;
|
/// It provides options for customizing the swipe behavior, including the
|
||||||
late Animation<double> _iconFadeAnimation;
|
/// ability to specify a background widget that appears during the swipe,
|
||||||
bool _pastThreshold = false;
|
/// 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<Rect> {
|
||||||
|
_SwipeableClipper({
|
||||||
|
required this.moveAnimation,
|
||||||
|
}) : 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, 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<Swipeable>
|
||||||
|
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_moveController =
|
_moveController = AnimationController(
|
||||||
AnimationController(duration: _animationDuration, vsync: this);
|
duration: widget.movementDuration,
|
||||||
_iconMoveController =
|
vsync: this,
|
||||||
AnimationController(duration: _animationDuration, vsync: this);
|
)..addStatusListener((_) => updateKeepAlive());
|
||||||
_moveAnimation = Tween<Offset>(begin: Offset.zero, end: const Offset(1, 0))
|
_updateMoveAnimation();
|
||||||
.animate(_moveController);
|
|
||||||
_iconTransitionAnimation =
|
|
||||||
Tween<Offset>(begin: const Offset(-0.1, 0), end: const Offset(0.4, 0))
|
|
||||||
.animate(_moveController);
|
|
||||||
_iconFadeAnimation =
|
|
||||||
Tween<double>(begin: 0.7, end: 1).animate(_iconMoveController);
|
|
||||||
|
|
||||||
const controllerValue = 0.0;
|
|
||||||
_moveController.animateTo(controllerValue);
|
|
||||||
_iconMoveController.animateTo(controllerValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimationController? _moveController;
|
||||||
|
late Animation<Offset> _moveAnimation;
|
||||||
|
|
||||||
|
double _dragExtent = 0;
|
||||||
|
bool _dragUnderway = false;
|
||||||
|
bool _swipeThresholdReached = false;
|
||||||
|
|
||||||
|
final GlobalKey _contentKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => _moveController?.isAnimating ?? false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_moveController.dispose();
|
_moveController?.dispose();
|
||||||
_iconMoveController.dispose();
|
|
||||||
super.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) {
|
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) {
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
|
if (!_isActive || _moveController!.isAnimating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final delta = details.primaryDelta!;
|
final delta = details.primaryDelta!;
|
||||||
_dragExtent += delta;
|
final oldDragExtent = _dragExtent;
|
||||||
|
switch (widget.direction) {
|
||||||
if (_dragExtent.isNegative) return;
|
case SwipeDirection.horizontal:
|
||||||
|
_dragExtent += delta;
|
||||||
final movePastThresholdPixels = widget.threshold;
|
break;
|
||||||
var newPos = _dragExtent.abs() / context.size!.width;
|
case SwipeDirection.endToStart:
|
||||||
|
switch (Directionality.of(context)) {
|
||||||
if (_dragExtent.abs() > movePastThresholdPixels) {
|
case TextDirection.rtl:
|
||||||
// how many "thresholds" past the threshold we are. 1 = the threshold 2
|
if (_dragExtent + delta > 0) {
|
||||||
// = two thresholds.
|
_dragExtent += delta;
|
||||||
final n = _dragExtent.abs() / movePastThresholdPixels;
|
}
|
||||||
|
break;
|
||||||
// Take the number of thresholds past the threshold, and reduce this
|
case TextDirection.ltr:
|
||||||
// number
|
if (_dragExtent + delta < 0) {
|
||||||
final reducedThreshold = math.pow(n, 0.3);
|
_dragExtent += delta;
|
||||||
|
}
|
||||||
final adjustedPixelPos = movePastThresholdPixels * reducedThreshold;
|
break;
|
||||||
newPos = adjustedPixelPos / context.size!.width;
|
}
|
||||||
|
break;
|
||||||
if (_dragExtent > 0 && !_pastThreshold) {
|
case SwipeDirection.startToEnd:
|
||||||
_iconMoveController.value = 1;
|
switch (Directionality.of(context)) {
|
||||||
_pastThreshold = true;
|
case TextDirection.rtl:
|
||||||
}
|
if (_dragExtent + delta < 0) {
|
||||||
} else {
|
_dragExtent += delta;
|
||||||
// Send a cancel event if the user has swiped back underneath the
|
}
|
||||||
// threshold
|
break;
|
||||||
if (_pastThreshold && widget.onSwipeCancel != null) {
|
case TextDirection.ltr:
|
||||||
widget.onSwipeCancel!();
|
if (_dragExtent + delta > 0) {
|
||||||
}
|
_dragExtent += delta;
|
||||||
_pastThreshold = false;
|
}
|
||||||
|
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<Offset>(
|
||||||
|
begin: Offset.zero,
|
||||||
|
end: Offset(end, 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragEnd(DragEndDetails details) {
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
_moveController.animateTo(0, duration: _animationDuration);
|
if (!_isActive || _moveController!.isAnimating) return;
|
||||||
_iconMoveController.animateTo(0, duration: _animationDuration);
|
_dragUnderway = false;
|
||||||
_dragExtent = 0.0;
|
|
||||||
if (_pastThreshold && widget.onSwipeEnd != null) {
|
// Once dragging ends, animate back to the initial offset.
|
||||||
widget.onSwipeEnd!();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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: <Widget>[
|
||||||
|
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(
|
return GestureDetector(
|
||||||
onHorizontalDragStart: _handleDragStart,
|
onHorizontalDragStart: _handleDragStart,
|
||||||
onHorizontalDragUpdate: _handleDragUpdate,
|
onHorizontalDragUpdate: _handleDragUpdate,
|
||||||
onHorizontalDragEnd: _handleDragEnd,
|
onHorizontalDragEnd: _handleDragEnd,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: widget.behavior,
|
||||||
child: Stack(
|
dragStartBehavior: widget.dragStartBehavior,
|
||||||
alignment: Alignment.center,
|
child: content,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export 'src/message_widget/message_text.dart';
|
|||||||
export 'src/message_widget/message_widget.dart';
|
export 'src/message_widget/message_widget.dart';
|
||||||
export 'src/message_widget/reactions/reaction_picker.dart';
|
export 'src/message_widget/reactions/reaction_picker.dart';
|
||||||
export 'src/message_widget/text_bubble.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/back_button.dart';
|
||||||
export 'src/misc/connection_status_builder.dart';
|
export 'src/misc/connection_status_builder.dart';
|
||||||
export 'src/misc/date_divider.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/reaction_icon.dart';
|
||||||
export 'src/misc/stream_neumorphic_button.dart';
|
export 'src/misc/stream_neumorphic_button.dart';
|
||||||
export 'src/misc/stream_svg_icon.dart';
|
export 'src/misc/stream_svg_icon.dart';
|
||||||
|
export 'src/misc/swipeable.dart';
|
||||||
export 'src/misc/system_message.dart';
|
export 'src/misc/system_message.dart';
|
||||||
export 'src/misc/thread_header.dart';
|
export 'src/misc/thread_header.dart';
|
||||||
export 'src/misc/visible_footnote.dart';
|
export 'src/misc/visible_footnote.dart';
|
||||||
|
|||||||
Reference in New Issue
Block a user