Created
February 11, 2026 20:01
-
-
Save slightfoot/4583432bd2dc3427695bb0aa8ff25c6a to your computer and use it in GitHub Desktop.
Overscroll Pulldown - by Simon Lightfoot :: #HumpdayQandA on 11th February 2025 :: https://www.youtube.com/watch?v=B6uDx6P5oz8
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
| // MIT License | |
| // | |
| // Copyright (c) 2026 Simon Lightfoot | |
| // | |
| // Permission is hereby granted, free of charge, to any person obtaining a copy | |
| // of this software and associated documentation files (the "Software"), to deal | |
| // in the Software without restriction, including without limitation the rights | |
| // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| // copies of the Software, and to permit persons to whom the Software is | |
| // furnished to do so, subject to the following conditions: | |
| // | |
| // The above copyright notice and this permission notice shall be included in all | |
| // copies or substantial portions of the Software. | |
| // | |
| // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| // SOFTWARE. | |
| // | |
| import 'package:flutter/material.dart'; | |
| /// A transition that slides the page down when over-scrolled. | |
| /// | |
| /// Example: | |
| /// return OverscrollPullDown.wrap( | |
| /// child: ListView.builder( | |
| /// itemCount: 10, | |
| /// itemBuilder: (BuildContext context, int index) { | |
| /// return ListTile(title: Text('Item $index')); | |
| /// } | |
| /// ), | |
| /// ); | |
| /// | |
| /// A transition that slides the page up when over-scrolled. | |
| class OverscrollPullDownTransition extends StatefulWidget { | |
| /// Create a new [OverscrollPullDownTransition]. | |
| const OverscrollPullDownTransition({ | |
| super.key, | |
| this.overscrollDistance = 600.0, | |
| required this.child, | |
| }); | |
| /// Convenience method for creating a route that uses this transition. | |
| static Route<T> routeBuilder<T>({ | |
| RouteSettings? settings, | |
| required Duration transitionDuration, | |
| required Widget child, | |
| }) { | |
| return PageRouteBuilder( | |
| opaque: false, | |
| settings: settings, | |
| transitionDuration: transitionDuration, | |
| pageBuilder: | |
| ( | |
| BuildContext context, | |
| Animation<double> animation, | |
| Animation<double> secondaryAnimation, | |
| ) { | |
| return OverscrollPullDownTransition(child: child); | |
| }, | |
| ); | |
| } | |
| /// The distance at which the page will be fully pulled down. | |
| final double overscrollDistance; | |
| /// The child widget to be transitioned. | |
| final Widget child; | |
| @override | |
| State<OverscrollPullDownTransition> createState() => _OverscrollPullDownTransitionState(); | |
| } | |
| class _OverscrollPullDownTransitionState extends State<OverscrollPullDownTransition> | |
| with SingleTickerProviderStateMixin { | |
| late final AnimationController _animationController; | |
| late final Animation<Offset> _animation; | |
| bool _popping = false; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _animationController = AnimationController( | |
| vsync: this, | |
| duration: const Duration(milliseconds: 500), | |
| ); | |
| _animation = | |
| Tween<Offset>( | |
| begin: Offset.zero, | |
| end: const Offset(0.0, 1.0), | |
| ).animate( | |
| CurvedAnimation( | |
| parent: _animationController, | |
| curve: Curves.easeIn, | |
| ), | |
| ); | |
| } | |
| /// Set the overscroll delta. | |
| void setOverscroll(double overscrollDelta) { | |
| if (_popping == false) { | |
| // Calculate the animation delta change based on the overscroll delta | |
| final value = -overscrollDelta / widget.overscrollDistance; | |
| _animationController.value = (_animationController.value + value).clamp(0.0, 1.0); | |
| } | |
| } | |
| /// End the overscroll. | |
| void endOverscroll() { | |
| if (_popping == false) { | |
| // Halfway through the animation, pop the page | |
| if (_animationController.value > 0.5) { | |
| _popping = true; | |
| _animationController.animateTo(1.0); | |
| Navigator.of(context).pop(); | |
| } else { | |
| // Otherwise, reset the animation | |
| _animationController.animateTo(0.0); | |
| } | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _animationController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return SlideTransition( | |
| position: _animation, | |
| child: widget.child, | |
| ); | |
| } | |
| } | |
| /// A widget that listens for overscroll notifications and updates the | |
| /// transition state. | |
| /// | |
| class OverscrollPullDown extends StatefulWidget { | |
| /// Create a new [OverscrollPullDown]. | |
| const OverscrollPullDown({ | |
| super.key, | |
| required this.axisDirection, | |
| required this.child, | |
| }); | |
| /// Convenience method for wrapping a widget with this behavior. | |
| static Widget wrap({required Widget child}) { | |
| return ScrollConfiguration( | |
| behavior: _OverscrollPullDownBehavior(), | |
| child: child, | |
| ); | |
| } | |
| /// The direction of the scroll axis. | |
| final AxisDirection axisDirection; | |
| /// The child widget to be transitioned. | |
| final Widget child; | |
| @override | |
| State<OverscrollPullDown> createState() => _OverscrollPullDownState(); | |
| } | |
| class _OverscrollPullDownState extends State<OverscrollPullDown> { | |
| late _OverscrollPullDownTransitionState _transitionState; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _transitionState = context.findAncestorStateOfType<_OverscrollPullDownTransitionState>()!; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return NotificationListener<ScrollNotification>( | |
| onNotification: (ScrollNotification notification) { | |
| if (notification case OverscrollNotification notification) { | |
| _transitionState.setOverscroll(notification.overscroll); | |
| } else if (notification is! ScrollEndNotification) { | |
| _transitionState.endOverscroll(); | |
| } | |
| return false; | |
| }, | |
| child: GlowingOverscrollIndicator( | |
| axisDirection: widget.axisDirection, | |
| color: Theme.of(context).colorScheme.secondary, | |
| // Hide the default overscroll indicator | |
| showLeading: false, | |
| child: widget.child, | |
| ), | |
| ); | |
| } | |
| } | |
| /// A scroll behavior that uses [OverscrollPullDown] as the overscroll indicator. | |
| class _OverscrollPullDownBehavior extends ScrollBehavior { | |
| @override | |
| Widget buildOverscrollIndicator( | |
| BuildContext context, | |
| Widget child, | |
| ScrollableDetails details, | |
| ) { | |
| return OverscrollPullDown( | |
| axisDirection: details.direction, | |
| child: child, | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment