Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active August 29, 2024 05:44
Show Gist options
  • Save slightfoot/ab0cd346baadc863c6b524d79773cc2b to your computer and use it in GitHub Desktop.
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
// 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