Flutter Scrolling Parallax Effect Tutorial

May 9, 2023

#flutter #animation

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});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Parallax Videos',
      debugShowCheckedModeBanner: false,
      home: VideosScreen(),
    );
  }
}

class VideosScreen extends StatefulWidget {
  const VideosScreen({super.key});

  
  State<VideosScreen> createState() => _VideosScreenState();
}

class _VideosScreenState extends State<VideosScreen> {
  
  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


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;

  
  void initState() {
    super.initState();
    _pageController = PageController(viewportFraction: 0.8);
  }

  
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  
  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;

  
  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:

  1. The video_player package
dependencies:
  flutter:
    sdk: flutter
  video_player: ^2.5.1 # or latest version

flutter:
  uses-material-design: true

  assets:
    - assets/videos/
  1. 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> {
  // ...

  
  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;

  
  State<VideoCard> createState() => _VideoCardState();
}

class _VideoCardState extends State<VideoCard> {
  late VideoPlayerController _controller;

  
  void initState() {
    super.initState();
    _controller = VideoPlayerController.asset(widget.assetPath);

    _controller
      ..addListener(() => setState(() {}))
      ..setLooping(true)
      ..setVolume(0)
      ..initialize().then((_) => setState(() {}))
      ..play();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  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;

  
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      width: constraints.maxWidth,
    );
  }

  
  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,
    );
  }

  
  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;

  
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      height: constraints.maxHeight,
    );
  }

  
  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,
    );
  }

  
  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();

  // ...

  
  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> {
  // ...

  
  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;

  // ...

  
  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;

  
  State<VideoCard> createState() => _VideoCardState();
}

class _VideoCardState extends State<VideoCard> {
  final GlobalKey _videoKey = GlobalKey();

  late VideoPlayerController _controller;

  
  void initState() {
    super.initState();
    _controller = VideoPlayerController.asset(widget.assetPath);

    _controller
      ..addListener(() => setState(() {}))
      ..setLooping(true)
      ..setVolume(0)
      ..initialize().then((_) => setState(() {}))
      ..play();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  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!

💳 Pro Membership


Already a member?  View your courses

❤️ Wall of Love


Well explained! Great instructor, simple and straight-forward.

Katsaros P.

Marcus’s Flutter courses have always been fantastic.

Max

Perfect audio quality. Good pace.

Marlos L.

The excellent feature of this course comparing to many others is the instructor’s explanation during the coding which helps me understand and know how to apply what I’ve learned into practice.

Quang L.

Focused, no waffle, straight-talking, no back-and-forth try-this try-that. WOW. Quality not Quantity. Concise and precise.

Mark T.

As a senior developer, I've learned so much about improving my development process. Marcus is a great instructor, and his coding style is top notch.

L. B.

The presentation is excellent, everything is detailed properly. I recommend Marcus' courses to everyone.

Krisztián S.

Very well explained course, with clear examples. Keep up the great work. A+++!

Fernando B.

Marcus is clear with his instructions and explains what he is doing as he is doing it.

Niki D.