Last active
October 24, 2024 16:33
-
-
Save PlugFox/a977312dbe79c830f77306c2185064c1 to your computer and use it in GitHub Desktop.
Horizontal PageView with Hero Animation
This file contains 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
// ignore_for_file: avoid_positional_boolean_parameters | |
/* | |
* Horizontal PageView with Hero Animation | |
* https://gist.github.com/PlugFox/a977312dbe79c830f77306c2185064c1 | |
* https://dartpad.dev/?id=a977312dbe79c830f77306c2185064c1 | |
* Mike Matiunin <[email protected]>, 24 October 2024 | |
*/ | |
import 'dart:async'; | |
import 'dart:ui'; | |
import 'package:flutter/material.dart'; | |
void main() => runZonedGuarded<void>( | |
() => runApp( | |
MaterialApp( | |
title: 'PageView App', | |
theme: ThemeData.dark(), | |
home: const Screen(), | |
), | |
), | |
(error, stackTrace) => print('Top level exception: $error\n$stackTrace'), // ignore: avoid_print | |
); | |
class Screen extends StatefulWidget { | |
const Screen({super.key}); | |
@override | |
State<Screen> createState() => _ScreenState(); | |
} | |
class _ScreenState extends State<Screen> { | |
static const double width = 360, height = 480, aspectRatio = width / height; | |
static final List<String> images = <String>[ | |
for (var id = 1; id < 24; id++) 'https://via.assets.so/movie.png?id=$id&q=95&w=$width&h=$height&fit=fill', | |
]; | |
final PageController pageController = PageController( | |
viewportFraction: .5, | |
initialPage: images.length ~/ 2, | |
keepPage: false, | |
); | |
final ValueNotifier<int> selectedController = ValueNotifier<int>(0); | |
late ThemeData theme; | |
@override | |
void initState() { | |
super.initState(); | |
pageController.addListener(_onPageChanged); | |
selectedController.value = pageController.initialPage; | |
} | |
void _onPageChanged() { | |
selectedController.value = pageController.page?.round() ?? 0; | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
theme = Theme.of(context); | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
selectedController.dispose(); | |
pageController.dispose(); | |
} | |
void pushImagePage({ | |
required String tag, | |
required String url, | |
required String label, | |
}) => | |
Navigator.maybeOf(context, rootNavigator: true)?.push<void>( | |
MaterialPageRoute( | |
builder: (context) => Scaffold( | |
body: SafeArea( | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Hero( | |
tag: tag, | |
child: Center( | |
child: AspectRatio( | |
aspectRatio: aspectRatio, | |
child: Material( | |
child: UrlImageWidget( | |
url: url, | |
logo: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Text( | |
label, | |
style: theme.textTheme.labelMedium, | |
), | |
), | |
onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
body: SafeArea( | |
child: LayoutBuilder( | |
builder: (context, constraints) => CustomScrollView( | |
slivers: <Widget>[ | |
const SliverAppBar( | |
pinned: false, | |
snap: true, | |
floating: true, | |
title: Text('PageView Screen'), | |
), | |
SliverPadding( | |
padding: const EdgeInsets.symmetric(vertical: 16), | |
sliver: SliverToBoxAdapter( | |
child: SizedBox( | |
height: constraints.maxWidth / aspectRatio * pageController.viewportFraction, | |
child: PageView.builder( | |
controller: pageController, | |
pageSnapping: true, | |
padEnds: true, | |
scrollDirection: Axis.horizontal, | |
allowImplicitScrolling: true, | |
itemCount: images.length, | |
scrollBehavior: const ScrollBehavior().copyWith( | |
overscroll: true, | |
physics: const BouncingScrollPhysics(), | |
scrollbars: false, | |
dragDevices: { | |
PointerDeviceKind.touch, | |
PointerDeviceKind.mouse, | |
PointerDeviceKind.stylus, | |
PointerDeviceKind.trackpad, | |
}, | |
), | |
itemBuilder: (context, index) => Padding( | |
padding: const EdgeInsets.all(8), | |
child: Center( | |
child: AspectRatio( | |
aspectRatio: aspectRatio, | |
child: ValueListenableBuilder<int>( | |
valueListenable: selectedController, | |
builder: (context, selected, _) { | |
final isCurrent = selected == index; | |
return AnimatedScale( | |
duration: const Duration(milliseconds: 350), | |
scale: isCurrent ? 1 : .9, | |
child: Hero( | |
tag: 'img#$index', | |
child: Material( | |
elevation: isCurrent ? 8 : 4, | |
type: MaterialType.card, | |
clipBehavior: Clip.antiAlias, | |
color: theme.cardColor, | |
borderRadius: BorderRadius.circular(16), | |
child: UrlImageWidget( | |
url: images[index], | |
logo: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Text( | |
'Image ${index + 1}', | |
style: theme.textTheme.labelMedium, | |
), | |
), | |
onTap: () { | |
if (!isCurrent) { | |
pageController.animateToPage( | |
index, | |
duration: const Duration(milliseconds: 450), | |
curve: Curves.easeInOut, | |
); | |
} else { | |
pushImagePage( | |
tag: 'img#$index', | |
url: images[index], | |
label: 'Image ${index + 1}', | |
); | |
} | |
}, | |
), | |
), | |
), | |
); | |
}, | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: SliverToBoxAdapter( | |
child: SizedBox( | |
height: 16, | |
child: Align( | |
alignment: Alignment.centerRight, | |
child: Text( | |
'Hold Shift to scroll horizontally with mouse wheel or trackpad', | |
style: theme.textTheme.labelSmall, | |
), | |
), | |
), | |
), | |
), | |
SliverFillRemaining( | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Center( | |
child: FittedBox( | |
alignment: Alignment.center, | |
fit: BoxFit.scaleDown, | |
child: ValueListenableBuilder<int>( | |
valueListenable: selectedController, | |
builder: (context, value, _) => Text( | |
'Selected Page: ${selectedController.value + 1}', | |
style: theme.textTheme.displayMedium, | |
maxLines: 1, | |
overflow: TextOverflow.ellipsis, | |
), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
class UrlImageWidget extends StatelessWidget { | |
const UrlImageWidget({ | |
required this.url, | |
required this.logo, | |
required this.onTap, | |
super.key, // ignore: unused_element | |
}); | |
final String url; | |
final Widget logo; | |
final VoidCallback onTap; | |
@override | |
Widget build(BuildContext context) => Ink.image( | |
image: NetworkImage(url), | |
/* onImageError: (exception, stackTrace) => print('Image error: $exception\n$stackTrace'), */ | |
fit: BoxFit.cover, | |
child: InkWell( | |
canRequestFocus: false, | |
onTap: onTap, | |
child: Align( | |
alignment: Alignment.topLeft, | |
child: logo, | |
), | |
), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment