-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathswipeable.dart
329 lines (284 loc) · 12.3 KB
/
swipeable.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter/physics.dart";
enum Side {
left,
right,
}
/// Adds the [Swipeable] trait to a widget.
/// Wraps its child in an [Transform] widget which controls the position.
///
/// Thus, to set the default position of a [Swipeable] widget there are two options :
/// - Use the property [defaultAlignment]
/// - Wrap it in another widget, and position the latter.
///
/// * Used : https://docs.flutter.dev/cookbook/animation/physics-simulation
class Swipeable extends StatefulWidget {
/// I think I was pretty successful at making these self-explanatory.
final Function? onLeftSwipeRelease;
final Function? onRightSwipeRelease;
final Function? onLeftSwipeKeyboard;
final Function? onRightSwipeKeyboard;
/// The « thresholds » are the (vertical, i.e. y = constant) boundaries of the imaginary box the confines the widget.
/// When crossed, if released in this area, the widget will be animated away.
/// Otherwise, it'll be animated back to its [defaultAlignment].
final Function? onLeftThresholdCrossed;
final Function? onRightThresholdCrossed;
final Function? onThresholdBackInside;
/// ! Warning : Not completely implemented.
final Alignment defaultAlignment;
final Widget child;
const Swipeable({
Key? key,
this.onLeftSwipeRelease,
this.onRightSwipeRelease,
this.onLeftSwipeKeyboard,
this.onRightSwipeKeyboard,
this.onLeftThresholdCrossed,
this.onRightThresholdCrossed,
this.onThresholdBackInside,
this.defaultAlignment = Alignment.center,
required this.child,
}) : super(key: key);
@override
State<Swipeable> createState() => SwipeableState();
}
class SwipeableState extends State<Swipeable> with SingleTickerProviderStateMixin {
/// Giving the [AnimationController] a run for its money, we'll use it for all and every animation.
late final AnimationController _controller;
/// Controls the position.
late Animation<Offset> _animation;
/// Is always [Offset.zero]. Could need to be reworked for [widget.defaultAlignment].
late final Offset _defaultOffset;
/// This is what will constantly change based on [_animation], allowing the widget to move 'round like there is no tomorrow.
late Offset _dragOffset;
/// Safety check to prevent a [Swipeable] from being discarded multiple times.
bool isDiscarded = false;
/// Threshold detection.
bool hasCrossedThreshold = false;
/// The damned keyboard focus. >:(
late final FocusNode _node;
@override
void initState() {
super.initState();
_node = FocusNode(debugLabel: "SwipeLeftRightNode");
/// Initial values, [_defaultOffset] will remain the same, but not [_dragOffset].
_defaultOffset = Offset.zero;
_dragOffset = Offset.zero;
_controller = AnimationController(vsync: this);
/// Keep the widget in sync with the animation at any given moment.
_controller.addListener(() {
setState(() {
_dragOffset = _animation.value;
});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/* -------------------------------------------------------------------------- */
/* UTILS */
/* -------------------------------------------------------------------------- */
/// Assume that [Offset] is a vector space homeomorphic to $\R^2$.
/// Let's verify that [_distance] defines a metric on our vector space.
/// 1. Non-negativity
/// For any $x = \left(x_1, x_2\right) \in \R^2$,
///
///
/// No jk, this just computes the distance between two points [p1] and [p2].
double _distance(Offset p1, Offset p2) {
return (p1 - p2).distance;
}
/// Snaps the widget back in the middle of the screen.
///
/// Computes and runs a [SpringSimulation].
/// Used when the user lets go of the widget, but it's not been dragged far enough to trigger [_runLeavingAnimation].
void _runSpringAnimation(Offset pixelsPerSecond, Size screenSize) {
// Define the path to take
_animation = _controller.drive(
Tween<Offset>(
begin: _dragOffset,
end: _defaultOffset,
),
);
// Calculate the velocity relative to the unit interval [0;1] used by the animation controller.
final double unitsPerSecondX = pixelsPerSecond.dx / screenSize.width;
final double unitsPerSecondY = pixelsPerSecond.dy / screenSize.height;
final Offset unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
final double unitVelocity = unitsPerSecond.distance;
const SpringDescription spring = SpringDescription(mass: 30.0, stiffness: 1.0, damping: 1.0);
final simulation = SpringSimulation(
spring,
.0,
1.0,
// I cannot remember why I made the velocity negative here
// It doesn't seem to make any difference, but y'know « if it ain't broke, don't fix it ».
-unitVelocity,
);
// Now that everything's ready, run the simulation
_controller.animateWith(simulation);
}
/// Animates the widget leaving the screen.
///
/// When done, the widget isn't destroyed, it stays where the animation ended.
/// This is by design. (also because I don't know if it's possible to destroy a widget)
void _runLeavingAnimation(Side side, Size screenSize) {
// Find the largest dimension. This will be our target, to be sure the widget remains off screen if the orientation changes
final double largest = screenSize.width > screenSize.height ? screenSize.width : screenSize.height;
// Destination of the widget
final Offset destination = (side == Side.left) ? Offset(-1.1 * largest, .0) : Offset(1.1 * largest, .0);
// Distance between the current position and the destination
final double distance = (_dragOffset.dx - destination.dx).abs();
// Velocity of the widget, in pixels per second (?)
const int velocity = 2300;
_animation = Tween<Offset>(
begin: _dragOffset,
end: destination,
).animate(_controller);
// Clear any ongoing animation
_controller.reset();
// Compute the animation duration in light of `velocity`
_controller.duration = Duration(milliseconds: ((distance / velocity).abs() * 1000).round());
// Now that everything's ready, run the simulations
_controller.forward();
}
/// Routine to be run when the child widget is swiped away.
/// Handles the animation, the event function call, and reflects the state change in [isDiscarded].
void discard(Size screenSize, Side side, bool keyboard) {
// Change this before running the animations since it is (also) used to discard pointer events while animating.
// (We don't want a mischievous user to interfere with our animation, do we ?)
isDiscarded = true;
switch (side) {
case Side.left:
_runLeavingAnimation(Side.left, screenSize);
(keyboard) ? widget.onLeftSwipeKeyboard?.call() : widget.onLeftSwipeRelease?.call();
case Side.right:
_runLeavingAnimation(Side.right, screenSize);
(keyboard) ? widget.onRightSwipeKeyboard?.call() : widget.onRightSwipeRelease?.call();
}
}
/* ------------------------------------ . ----------------------------------- */
@override
Widget build(BuildContext context) {
// Screen dimensions
final Size screenSize = MediaQuery.of(context).size;
final double width = screenSize.width;
final double height = screenSize.height;
// Screen orientation
final bool portrait = (height > width) ? true : false;
// To trigger the leaving animation
final double discardThreshold = (portrait) ? .3 * width : .15 * width;
// FIXME with default alignment.
// As of now, a default alignment in the threshold region could break things, I imagine.
// `Ctrl` + click for an explanation
final double dragSpeedCoefficient = (portrait) ? 2.0 : 1.5;
// Absorb taps if widget is animating, but don't if it is close to its resting position.
// If we don't take this precaution, we may absorb taps even if the widget looks still to the user.
final bool absorbTap = (_distance(_defaultOffset, _dragOffset) > 5.0) ? _controller.isAnimating : false;
// Detecting when the widget crosses the threshold
if (!isDiscarded) {
if ((_dragOffset.dx <= _defaultOffset.dx - discardThreshold)) {
if (!hasCrossedThreshold) {
hasCrossedThreshold = true;
widget.onLeftThresholdCrossed?.call();
}
} else if ((_dragOffset.dx >= _defaultOffset.dx + discardThreshold)) {
if (!hasCrossedThreshold) {
hasCrossedThreshold = true;
widget.onRightThresholdCrossed?.call();
}
} else if (hasCrossedThreshold) {
hasCrossedThreshold = false;
widget.onThresholdBackInside?.call();
}
}
/// Some time ago I wrote this :
///
/// > TODO Add detection for child focus
/// > If detected, do not request focus
/// > else, do.
///
/// And well, I never did it, but it would still be a nice thing to do if someone else is ever going to use this widget.
return Focus(
focusNode: _node,
onKeyEvent: (node, event) {
// If the key press is valid and not repeated, match it with the arrow keys, else ignore.
if (!isDiscarded && event is KeyDownEvent && event is! KeyRepeatEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
discard(screenSize, Side.left, true);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowRight:
discard(screenSize, Side.right, true);
return KeyEventResult.handled;
case _:
return KeyEventResult.ignored;
}
} else {
return KeyEventResult.ignored;
}
},
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.distance != .0) {
// These coefficients scale the movement so the user can achieve maximum widget displacement with minimum finger displacement
setState(() {
_dragOffset += Offset(details.delta.dx * dragSpeedCoefficient, details.delta.dy * .88);
});
}
},
// Stop animating back to center when the user swipes but don't stop if the child was, in fact, leaving.
onPanDown: (details) {
if (!isDiscarded) {
_controller.stop();
}
},
// Logic to detect where the user has released the widget
// If it is far enough on either side, the leaving animation will trigger, else the widget is animated back to the center of the screen
onPanEnd: (details) {
if (_dragOffset.dx >= _defaultOffset.dx + discardThreshold && !isDiscarded) {
discard(screenSize, Side.right, false);
} else if (_dragOffset.dx <= _defaultOffset.dx - discardThreshold && !isDiscarded) {
discard(screenSize, Side.left, false);
} else if (!isDiscarded) {
_runSpringAnimation(details.velocity.pixelsPerSecond, screenSize);
}
},
// If an animation is running, do not allow interaction (it may stop the the animation for some reason).
// If animating back to center, absorb to let user pick the swiping back up even when the animation is running.
// Also, if the leaving animation is running, ignore to let user interact with whatever other widget there may be.
child: (isDiscarded)
? IgnorePointer(
ignoring: absorbTap,
child: Stack(
children: <Widget>[
Align(
alignment: widget.defaultAlignment,
child: Transform.translate(
offset: _dragOffset,
child: widget.child,
),
),
],
),
)
: AbsorbPointer(
absorbing: absorbTap,
child: Stack(
children: <Widget>[
Align(
alignment: widget.defaultAlignment,
child: Transform.translate(
offset: _dragOffset,
child: widget.child,
),
),
],
),
),
),
);
}
}