Last active
December 9, 2025 15:54
-
-
Save PlugFox/39c051d807efb04759498033ce8452dc to your computer and use it in GitHub Desktop.
Swipeable controller demo.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Swipeable controller demo. | |
| * https://gist.github.com/PlugFox/39c051d807efb04759498033ce8452dc | |
| * https://dartpad.dev?id=39c051d807efb04759498033ce8452dc | |
| * Mike Matiunin <[email protected]>, 09 December 2025 | |
| */ | |
| import 'dart:async'; | |
| import 'dart:math' as math; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| import 'package:flutter/services.dart'; | |
| void main() => runZonedGuarded<void>( | |
| () => runApp(const App()), | |
| (error, stackTrace) => print('Top level exception'), // ignore: avoid_print | |
| ); | |
| /// {@template app} | |
| /// App widget. | |
| /// {@endtemplate} | |
| class App extends StatelessWidget { | |
| /// {@macro app} | |
| const App({super.key}); | |
| @override | |
| Widget build(BuildContext context) => MaterialApp( | |
| title: 'Swipeable Demo', | |
| home: Scaffold( | |
| appBar: AppBar(title: const Text('Swipeable Demo')), | |
| body: const SafeArea( | |
| child: Center( | |
| child: SizedBox( | |
| width: 1024, | |
| child: Padding( | |
| padding: EdgeInsets.symmetric(horizontal: 16), | |
| child: Swipeable(), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| /// {@template swipeable} | |
| /// Swipeable widget. | |
| /// {@endtemplate} | |
| class Swipeable extends StatefulWidget { | |
| /// {@macro swipeable} | |
| const Swipeable({ | |
| super.key, // ignore: unused_element_parameter | |
| }); | |
| @override | |
| State<Swipeable> createState() => _SwipeableState(); | |
| } | |
| /// State for widget Swipeable. | |
| class _SwipeableState extends State<Swipeable> { | |
| late final SwipeableController _controller; | |
| /* #region Lifecycle */ | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = SwipeableController()..addListener(_onChange); | |
| } | |
| @override | |
| void dispose() { | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| /* #endregion */ | |
| void _onChange() { | |
| // TODO(plugfox): Add your callback logic here. | |
| // e.g. | |
| // if (_controller.progress == 1.0) | |
| // widget.onDismissed?.call(); | |
| // Mike Matiunin <[email protected]>, 09 December 2025 | |
| } | |
| @override | |
| Widget build(BuildContext context) => Center( | |
| child: SizedBox( | |
| height: 64, | |
| child: Material( | |
| type: MaterialType.canvas, | |
| borderRadius: BorderRadius.circular(32), | |
| color: Colors.grey.shade600, | |
| clipBehavior: Clip.hardEdge, | |
| child: LayoutBuilder( | |
| builder: (context, constraints) => Padding( | |
| padding: const EdgeInsets.all(8), | |
| child: Flow( | |
| delegate: _SwipeableDelegate(controller: _controller), | |
| clipBehavior: Clip.none, | |
| children: <Widget>[ | |
| // The draggable handle. | |
| GestureDetector( | |
| onHorizontalDragUpdate: (details) { | |
| // Update the progress based on drag delta. | |
| final delta = | |
| details.delta.dx / (constraints.maxWidth - 64); | |
| if (delta == 0) return; | |
| _controller.progress += delta; | |
| }, | |
| onHorizontalDragEnd: (details) { | |
| // User has released the drag, start the animation. | |
| _controller.startAnimation(); | |
| HapticFeedback.lightImpact().ignore(); | |
| }, | |
| onHorizontalDragStart: (details) { | |
| // User has started dragging, stop any ongoing animation. | |
| _controller.stopAnimation(); | |
| HapticFeedback.lightImpact().ignore(); | |
| }, | |
| child: Material( | |
| type: MaterialType.circle, | |
| color: Colors.blue, | |
| elevation: 4, | |
| clipBehavior: Clip.none, | |
| child: SizedBox.square( | |
| dimension: 48, | |
| // Show different icons based on the swipe progress. | |
| child: ListenableBuilder( | |
| listenable: _controller, | |
| builder: (context, _) => Icon(switch (_controller) { | |
| SwipeableController( | |
| isAnimating: true, | |
| progress: > .5, | |
| ) => | |
| Icons.arrow_forward, | |
| SwipeableController( | |
| isAnimating: true, | |
| ) => | |
| Icons.arrow_back, | |
| _ => Icons.drag_indicator, | |
| }, color: Colors.white), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| class _SwipeableDelegate extends FlowDelegate { | |
| _SwipeableDelegate({required this.controller}) : super(repaint: controller); | |
| final SwipeableController controller; | |
| @override | |
| void paintChildren(FlowPaintingContext context) { | |
| if (context.childCount == 0) return; | |
| final progress = controller.progress; | |
| final size = context.size; | |
| final childSize = context.getChildSize(0); | |
| if (childSize == null) return; | |
| final dx = (size.width - childSize.width) * progress; | |
| context.paintChild( | |
| 0, | |
| transform: Matrix4.translationValues( | |
| dx, | |
| (size.height - childSize.height) / 2, | |
| 0, | |
| ), | |
| ); | |
| } | |
| @override | |
| bool shouldRepaint(covariant _SwipeableDelegate oldDelegate) => | |
| !identical(controller, oldDelegate.controller); | |
| } | |
| /// {@template swipeable} | |
| /// Controller for swipeable widget. | |
| /// {@endtemplate} | |
| class SwipeableController with ChangeNotifier { | |
| /// {@macro swipeable} | |
| SwipeableController({double value = 0.0}) : _progress = value { | |
| _ticker = Ticker(_onTick); | |
| } | |
| /// The ticker for driving the animation. | |
| late final Ticker _ticker; | |
| double _progress; | |
| /// The current progress of the swipeable widget. | |
| double get progress => _progress; | |
| /// Starts the swipe animation. | |
| double _elapsed = 0; | |
| /// Sets the current progress of the swipeable widget. | |
| set progress(double value) { | |
| _progress = value.clamp(0.0, 1.0); | |
| notifyListeners(); | |
| } | |
| /// Whether the swipe animation is running. | |
| bool get isAnimating => _ticker.isActive; | |
| /// Starts the swipe animation. | |
| void startAnimation() { | |
| if (_progress == 0.0 || _progress == 1.0) return; | |
| if (_ticker.isActive) return; | |
| _elapsed = 0; | |
| _ticker.start(); | |
| } | |
| /// Stops the swipe animation. | |
| void stopAnimation() { | |
| if (!_ticker.isActive) return; | |
| _ticker.stop(); | |
| _elapsed = 0; | |
| } | |
| /// Reacts to a swipe gesture update. | |
| void _onTick(Duration elapsed) { | |
| final ms = elapsed.inMilliseconds.toDouble(); | |
| final delta = (ms - _elapsed) / 750; | |
| _elapsed = ms; | |
| _progress = switch (_progress) { | |
| > 0.5 => math.min(1, _progress + delta), | |
| _ => math.max(0, _progress - delta), | |
| }; | |
| if (_progress >= 1.0 || _progress <= 0.0) stopAnimation(); | |
| notifyListeners(); | |
| } | |
| @override | |
| void dispose() { | |
| _ticker.dispose(); | |
| super.dispose(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment