One of my favorite animations is the scrolling parallax effect. I see it in a lot apps and websites, and I think it’s about time I implement it myself using Flutter. But instead of just applying the parallax effect to images, I decided to apply it to videos. I think it looks pretty cool, and it’s a great way to add some visual interest to your app.
This parallax effect also works with images!
PageView.builder
First, we need a scrolling widget that supports snapping to each item, so a PageView
is perfect for this. We’ll use a builder
to build as many videos as we want and return a Container
for now.
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Parallax Videos',
debugShowCheckedModeBanner: false,
home: VideosScreen(),
);
}
}
class VideosScreen extends StatefulWidget {
const VideosScreen({super.key});
@override
State<VideosScreen> createState() => _VideosScreenState();
}
class _VideosScreenState extends State<VideosScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView.builder(
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.symmetric(
vertical: 32,
horizontal: 16,
),
color: Colors.red,
child: Center(
child: Tet('Page $index'),
),
);
},
),
);
}
}
These items are taking up way too much space on our screen. So let’s restrict the size of our PageView
using a SizedBox
and make it 70% of the current screen height. To center our PageView
, we wrap the SizedBox
in a Column
widget and set the MainAxisAlignment
.
// main.dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: PageView.builder(
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.symmetric(
vertical: 32,
horizontal: 16,
),
color: Colors.red,
child: Center(
child: Text('Page $index'),
),
);
},
),
),
],
),
);
}
And finally, we want the sides of the next and previous card to peek out, so create a PageController
with a viewportFraction
of 0.8
.
// main.dart
class _VideosScreenState extends State<VideosScreen> {
late PageController _pageController;
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.8);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: PageView.builder(
controller: _pageController,
// ...
),
),
],
),
);
}
}
Video Cards
Now for our video cards.
Sure, we could customize the premade Card
widget, but I think it’s much more enjoyable to cook up our own from scratch. Add a BoxDecoration
where the container color
is white, borderRadius
is 16
and make the boxShadow
nice and subtle.
// main.dart
class VideoCard extends StatelessWidget {
const VideoCard({
super.key,
required this.assetPath,
});
final String assetPath;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(0, 6),
blurRadius: 8,
),
],
),
);
}
}
Playing Videos
For playing videos in our app we need two things:
- The video_player package
dependencies:
flutter:
sdk: flutter
video_player: ^2.5.1 # or latest version
flutter:
uses-material-design: true
assets:
- assets/videos/
- Videos to play
I downloaded a bunch of videos already and chucked them into the assets/videos
folder. We’ll make a list of asset paths to reference the videos.
// main.dart
import 'package:video_player/video_player.dart';
final videos = [
'assets/videos/coral-reef.mp4',
'assets/videos/flowers.mp4',
'assets/videos/city.mp4',
'assets/videos/beach.mp4',
'assets/videos/mountains.mp4',
];
Each VideoCard
needs to be a StatefulWidget
because we need to instantiate a VideoPlayerController
that plays continuously.
Don’t forget to dispose, so the allocated memory for it is released which avoids memory leaks when the widget is destroyed.
We can display our videos with the VideoPlayer
widget and wrap it in a ClipRRect
for borderRadius
and AspectRatio
to control the aspect ratio of the video.
// main.dart
class _VideosScreenState extends State<VideosScreen> {
// ...
@override
Widget build(BuildContext context) {
// ...
PageView.builder(
controller: _pageController,
itemCount: videos.length,
itemBuilder: (context, index) {
return VideoCard(
assetPath: videos[index],
);
},
),
}
}
class VideoCard extends StatefulWidget {
const VideoCard({
super.key,
required this.assetPath,
});
final String assetPath;
@override
State<VideoCard> createState() => _VideoCardState();
}
class _VideoCardState extends State<VideoCard> {
late VideoPlayerController _controller;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.asset(widget.assetPath);
_controller
..addListener(() => setState(() {}))
..setLooping(true)
..setVolume(0)
..initialize().then((_) => setState(() {}))
..play();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(0, 6),
blurRadius: 8,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
),
),
);
}
}
Parallax Effect
Now, I’m not sure how to implement parallax scrolling off the top of my head, so let’s look it up…
There’s an official cookbook for it. This looks great, but it’s for vertical scrolling. That’s okay. Let’s just yoink the ParallaxFlowDelegate
and make some modifications.
// main.dart
/// Original `ParallaxFlowDelegate` from cookbook.
/// Works for vertical scrolling.
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
width: constraints.maxWidth,
);
}
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
// Paint the background.
context.paintChild(
0,
transform:
Transform.translate(offset: Offset(0.0, childRect.top)).transform,
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
}
Instead of manipulating vertical scrolling, we need to manipulate horizontal scrolling.
This means BoxConstraints.tightFor
should set the maxHeight
, not maxWidth
, as we want to show the sides of the video when scrolling.
Use the listItemBox
’s topCenter
, calculate the scrollFraction
by dividing the x
component of the listItemOffSet
, and move the alignment calculation to the x
value.
The final thing we have to do is translate based off the x axis and not the y axis when painting.
// main.dart
/// Updated `ParallaxFlowDelegate`.
/// Works for horizontal scrolling.
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
height: constraints.maxHeight,
);
}
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.topCenter(Offset.zero),
ancestor: scrollableBox,
);
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dx / viewportDimension).clamp(0.0, 1.0);
// Calculate the horizontal alignment of the background
// based on the scroll percent.
final horizontalAlignment = Alignment(scrollFraction * 2 - 1, 0);
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect = horizontalAlignment.inscribe(
backgroundSize,
Offset.zero & listItemSize,
);
// Paint the background.
context.paintChild(
0,
transform:
Transform.translate(offset: Offset(childRect.left, 0)).transform,
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
}
To attach our ParallaxFlowDelegate
to each video, we need to create a GlobalKey
to reference our VideoPlayer
.
// main.dart
class _VideoCardState extends State<VideoCard> {
final GlobalKey _videoKey = GlobalKey();
// ...
@override
Widget build(BuildContext context) {
// ...
VideoPlayer(
_controller,
key: _videoKey,
),
// ...
}
}
Then wrap the AspectRatio
in a Flow
widget, passing in the new ParallaxFlowDelegate
with scrollable
, listItemContext
, and backgroundItemKey
.
// main.dart
class _VideoCardState extends State<VideoCard> {
// ...
@override
Widget build(BuildContext context) {
// ...
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _videoKey,
),
children: [
AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(
_controller,
key: _videoKey,
),
),
],
),
),
// ...
}
}
Animated Container
Alright, everything is looking great! We’ll make the currently selected VideoCard
stand out more by animating the size of the card when scrolling.
To figure out the selected index, add a _selectedIndex
state variable that defaults to 0 and updates when the page is changed.
Now we’re able to tell if a VideoCard
isSelected
or not.
VideoCard
takes in the boolean isSelected
which makes modifying the margin simple.
The size is changing, but it feels very abrupt. We just need to switch the Container
out for an AnimatedContainer
, and set the duration to 250
.
// main.dart
class _VideosScreenState extends State<VideosScreen> {
int _selectedIndex = 0;
// ...
@override
Widget build(BuildContext context) {
// ...
PageView.builder(
controller: _pageController,
itemCount: videos.length,
itemBuilder: (context, index) {
return VideoCard(
assetPath: videos[index],
isSelected: _selectedIndex == index,
);
},
onPageChanged: (i) => setState(
() => _selectedIndex = i,
),
),
// ...
}
}
class VideoCard extends StatefulWidget {
const VideoCard({
super.key,
required this.assetPath,
required this.isSelected,
});
final String assetPath;
final bool isSelected;
@override
State<VideoCard> createState() => _VideoCardState();
}
class _VideoCardState extends State<VideoCard> {
final GlobalKey _videoKey = GlobalKey();
late VideoPlayerController _controller;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.asset(widget.assetPath);
_controller
..addListener(() => setState(() {}))
..setLooping(true)
..setVolume(0)
..initialize().then((_) => setState(() {}))
..play();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: widget.isSelected
? const EdgeInsets.symmetric(vertical: 16, horizontal: 4)
: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(0, 6),
blurRadius: 8,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _videoKey,
),
children: [
AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(
_controller,
key: _videoKey,
),
),
],
),
),
);
}
}
Wrap Up
And now we’re all done adding our parallax effect to videos. I hope to see it in more apps!