Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active October 24, 2024 16:33
Show Gist options
  • Save PlugFox/a977312dbe79c830f77306c2185064c1 to your computer and use it in GitHub Desktop.
Save PlugFox/a977312dbe79c830f77306c2185064c1 to your computer and use it in GitHub Desktop.
Horizontal PageView with Hero Animation
// 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