Files
stream-chat-flutter/packages/stream_chat_flutter/lib/src/misc/swipeable.dart
T
2023-06-23 17:17:08 +05:30

465 lines
14 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Signature used by [swipeable] to indicate that it has been swiped in
/// the given `direction`.
///
/// Used by [Swipeable.onSwiped].
typedef SwipeDirectionCallback = void Function(SwipeDirection direction);
/// Signature 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,
);
/// The direction in which a [Swipeable] can be swiped.
enum SwipeDirection {
/// The [Swipeable] can be swiped by dragging either left or right.
horizontal,
/// The [Swipeable] can be swiped by dragging in the reverse of the
/// reading direction (e.g., from right to left in left-to-right languages).
endToStart,
/// The [Swipeable] can be swiped by dragging in the reading direction
/// (e.g., from left to right in left-to-right languages).
startToEnd,
/// The [Swipeable] cannot be swiped by dragging.
none
}
/// 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',
);
/// 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
void initState() {
super.initState();
_moveController = AnimationController(
duration: widget.movementDuration,
vsync: this,
)..addStatusListener((_) => updateKeepAlive());
_updateMoveAnimation();
}
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
void 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) {
_dragUnderway = true;
if (_moveController!.isAnimating) {
_dragExtent =
_moveController!.value * _overallDragAxisExtent * _dragExtent.sign;
_moveController!.stop();
} else {
_dragExtent = 0.0;
_moveController!.value = 0.0;
}
setState(_updateMoveAnimation);
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!_isActive || _moveController!.isAnimating) {
return;
}
final delta = details.primaryDelta!;
final oldDragExtent = _dragExtent;
switch (widget.direction) {
case SwipeDirection.horizontal:
_dragExtent += delta;
break;
case SwipeDirection.endToStart:
switch (Directionality.of(context)) {
case TextDirection.rtl:
if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
break;
case TextDirection.ltr:
if (_dragExtent + delta < 0) {
_dragExtent += delta;
}
break;
}
break;
case SwipeDirection.startToEnd:
switch (Directionality.of(context)) {
case TextDirection.rtl:
if (_dragExtent + delta < 0) {
_dragExtent += delta;
}
break;
case TextDirection.ltr:
if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
break;
}
break;
case SwipeDirection.none:
_dragExtent = 0;
break;
}
if (oldDragExtent.sign != _dragExtent.sign) {
setState(_updateMoveAnimation);
}
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) {
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: <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(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
behavior: widget.behavior,
dragStartBehavior: widget.dragStartBehavior,
child: content,
);
}
}