Launch Club

Flutter Scrolling Parallax Effect Tutorial

Flutter Package Animation

Published May 9, 2023

Marcus Ng

Marcus Ng

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:

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

  @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!

Flutter and Dart

made simple.

Everything you need to build production ready apps.

No spam. Just updates. Unsubscribe whenever.