Last active
April 2, 2025 17:07
-
-
Save slightfoot/ab0cd346baadc863c6b524d79773cc2b to your computer and use it in GitHub Desktop.
Animated Scrolling Custom Bottom Sheet - by Simon Lightfoot - Humpday Q&A :: 28th August 2024 #Flutter #Dart https://www.youtube.com/watch?v=AibrECd4gpg
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) 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<void> route(int index) { | |
return PageRouteBuilder( | |
opaque: false, | |
settings: RouteSettings(name: 'details-$index'), | |
transitionDuration: const Duration(milliseconds: 1000), | |
transitionsBuilder: (BuildContext context, Animation<double> animation, | |
Animation<double> secondaryAnimation, Widget child) { | |
return child; | |
}, | |
pageBuilder: (BuildContext context, Animation<double> animation, | |
Animation<double> secondaryAnimation) { | |
return ItemDetails._( | |
index: index, | |
); | |
}, | |
); | |
} | |
final int index; | |
@override | |
State<ItemDetails> createState() => _ItemDetailsState(); | |
} | |
class _ItemDetailsState extends State<ItemDetails> | |
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<ScrollNotification>( | |
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<Offset>( | |
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(), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment