// MIT License // // Copyright (c) 2024 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 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; void main() { runApp(const App()); } class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, theme: ThemeData.light( useMaterial3: false, ), home: const Home(), ); } } class Home extends StatelessWidget { const Home({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Example pull to dismiss'), ), body: GridView.builder( padding: const EdgeInsets.symmetric(vertical: 4.0), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, ), itemCount: 200, itemBuilder: (BuildContext context, int index) { return ItemBox( index: index, ); }, ), ); } } class ItemBox extends StatelessWidget { const ItemBox({ super.key, required this.index, }); final int index; @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1.0, child: Padding( padding: const EdgeInsets.all(8.0), child: InkWell( onTap: () { Navigator.of(context).push( ItemDetails.route(index), ); }, child: const Placeholder(), ), ), ); } } class ItemDetails extends StatefulWidget { const ItemDetails._({ required this.index, }); static Route route(int index) { return PageRouteBuilder( opaque: false, settings: RouteSettings(name: 'details-$index'), transitionDuration: const Duration(milliseconds: 1000), transitionsBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { return child; }, pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { return ItemDetails._( index: index, ); }, ); } final int index; @override State createState() => _ItemDetailsState(); } class _ItemDetailsState extends State with SingleTickerProviderStateMixin { late AnimationController _verticalPosition; VelocityTracker? _velocityTracker; late ScrollMetrics _startMetrics; bool _isScrolling = false; bool _isOverridden = false; @override void initState() { super.initState(); _verticalPosition = AnimationController( vsync: this, value: 0.6, ); } @override void dispose() { _verticalPosition.dispose(); super.dispose(); } bool _onScrollNotification(ScrollNotification notification) { print('notification: $notification'); switch (notification) { case ScrollStartNotification(:final metrics): _startMetrics = metrics; _isScrolling = true; case UserScrollNotification(:final direction): // are we scrolling up from the the top if (_isScrolling && _startMetrics.pixels == _startMetrics.minScrollExtent) { if ((direction == ScrollDirection.reverse && _verticalPosition.value < 0.9) || (direction == ScrollDirection.forward)) { final scrollable = Scrollable.of(notification.context!); final position = scrollable.position; if (position is ScrollPositionWithSingleContext) { scheduleMicrotask(() { position.goIdle(); position.jumpTo(0.0); setState(() => _isOverridden = true); }); return true; } } } case ScrollEndNotification(): _isScrolling = false; } return false; } void _onPointerMove(PointerMoveEvent event) { print('move $event'); final maxHeight = MediaQuery.sizeOf(context).height; _verticalPosition.value -= event.delta.dy / maxHeight; _velocityTracker ??= VelocityTracker.withKind(PointerDeviceKind.touch); _velocityTracker!.addPosition(event.timeStamp, event.localPosition); } void _onPointerUp(PointerUpEvent event) { print('up $event'); setState(() => _isOverridden = false); _velocityTracker!.addPosition(event.timeStamp, event.localPosition); final velocity = _velocityTracker!.getVelocity(); _velocityTracker = null; if (_verticalPosition.value < 0.5) { Navigator.of(context).pop(); } else if (velocity.pixelsPerSecond.dy < 0) { // TODO: use velocity to determine duration of animation or switch to simulation. _verticalPosition.animateTo(1.0, duration: const Duration(milliseconds: 200), curve: Curves.fastOutSlowIn); } } void _onPointerCancel(PointerCancelEvent event) { print('cancel $event'); setState(() => _isOverridden = false); } @override Widget build(BuildContext context) { final route = ModalRoute.of(context)!; return Stack( fit: StackFit.expand, children: [ FadeTransition( opacity: CurvedAnimation( parent: route.animation!, curve: const Interval( 0.0, 0.5, curve: Curves.easeInOut, ), ), child: const ColoredBox( color: Colors.black38, ), ), _RouteSlideTransition( child: Listener( behavior: HitTestBehavior.opaque, onPointerMove: _isOverridden ? _onPointerMove : null, onPointerUp: _isOverridden ? _onPointerUp : null, onPointerCancel: _isOverridden ? _onPointerCancel : null, child: IgnorePointer( ignoring: _isOverridden, child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Material( color: Theme.of(context).primaryColorLight, shape: const RoundedRectangleBorder( side: BorderSide(color: Colors.black, width: 2.0), borderRadius: BorderRadius.only( topLeft: Radius.circular(12.0), topRight: Radius.circular(12.0), ), ), clipBehavior: Clip.antiAlias, child: NotificationListener( onNotification: _onScrollNotification, child: ValueListenableBuilder( valueListenable: _verticalPosition, builder: (BuildContext context, double value, Widget? child) { return FractionallySizedBox( heightFactor: value, child: child, ); }, child: const SingleChildScrollView( child: FakeContent(), ), ), ), ), ), ), ), ), ), ], ); } } class _RouteSlideTransition extends StatelessWidget { const _RouteSlideTransition({ required this.child, }); final Widget child; @override Widget build(BuildContext context) { final route = ModalRoute.of(context)!; return SlideTransition( position: Tween( begin: const Offset(0.0, 1.0), end: Offset.zero, ).animate( CurvedAnimation( parent: route.animation!, curve: Curves.fastLinearToSlowEaseIn, ), ), child: child, ); } } class FakeContent extends StatelessWidget { const FakeContent({super.key}); @override Widget build(BuildContext context) { return SizedBox( height: MediaQuery.sizeOf(context).height * 1.5, child: const Placeholder(), ); } }