# Flutter Parallax PageView
A super simple way to create PageView screens with a parallax background

If you don't know what Flutter is, I encourage you to check it out. It's a very nice framework for building cross platform apps, using the Dart programming language, which is pretty nice. It's like like a mix of TypeScript and Java (but better). I'm more into functional languages, but Dart is decent and does a good job for UIs.

In Flutter, there is a nice Widget called PageView that gives you that well-used swipe screens you often see as onboarding. There's a nice video on it provided by the Flutter team.


It'd be nice to have a single background image that spanned all pages, and moved along with the swiping action. Even better, if the page content and number of pages could be loaded dynamically, and the background movement adjusts, that would be great!

This makes it tricky to work out how much to move the image by. But not that tricky!

# Setup

Let's start with a simple app and PageView.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Parallax Demo',
        theme: ThemeData(primarySwatch: Colors.blue),
        home: Scaffold(body: Parallax()),
      );
}

class Parallax extends StatefulWidget {
  @override
  _ParallaxState createState() => _ParallaxState();
}

class _ParallaxState extends State<Parallax> {
  late PageController _pageController;
  late double _pageOffset;

  @override
  void initState() {
    super.initState();
    _pageOffset = 0; // Starting position
    _pageController = PageController(initialPage: 0);
    _pageController.addListener(
      () => setState(() => _pageOffset = _pageController.page ?? 0),
    );
  }

  @override
  Widget build(BuildContext context) {
    return PageView(
      controller: _pageController,
      children: [
        Center(child: Text('First page')),
        Center(child: Text('Second page')),
      ],
    );
  }

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


Ok, so we have a simple PageView up and running. Now we'll put a background on it, something neo-Tokyo like because it's cool. We better also make the text a bit more visible with a background box.

class Parallax extends StatefulWidget {
  @override
  _ParallaxState createState() => _ParallaxState();
}

class _ParallaxState extends State<Parallax> {
  late PageController _pageController;
  late double _pageOffset;

  @override
  void initState() {
    super.initState();
    _pageOffset = 0; // Starting position
    _pageController = PageController(initialPage: 0);
    _pageController.addListener(
      () => setState(() => _pageOffset = _pageController.page ?? 0),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        BackgroundImage(),
        PageView(
          controller: _pageController,
          children: [
            Center(
              child: Container(
                padding: EdgeInsets.all(10),
                color: Colors.white.withOpacity(0.7),
                child: Text('First page'),
              ),
            ),
            Center(
              child: Container(
                padding: EdgeInsets.all(10),
                color: Colors.white.withOpacity(0.7),
                child: Text('Second page'),
              ),
            ),
          ],
        ),
      ],
    );
  }

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

class BackgroundImage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Container(
      height: size.height,
      width: size.width,
      child: Image(
        image: AssetImage('assets/tokyo-street-pano.jpg'),
        fit: BoxFit.fitHeight,
      ),
    );
  }
}

# Moving the background

That's nice, but the background is static which we don't want. We can update the background image alignment with the alignment property on our background Image.

But we need to work out how much to move the background by depending on the current page. The alignment property takes a float (double in Dart) between -1 (100% offscreen) to 1 (100% on screen). So the range our offset can go from and to is -1..1.

Now, we also know the number of screens (even if loaded dynamically). So we can work out the ratio between pages and the range of -1..1. This requires something called Linear Conversion. You could watch a Khan Academy video on it probably, but to keep it simple, we need to smush one range (screen number 0..n-1) into the alignment range, -1..1.

We do that with this relatively simple equation. The last line is the main thing.

int lastPageIdx = n - 1;
int firstPageIdx = 0;
int alignmentMax = 1;
int alignmentMin = -1;
int pageRange = (lastPageIdx - firstPageIdx) - 1;
int alignmentRange = (alignmentMax - alignmentMin);
double alignment = (((offset - firstPageIdx) * alignmentRange) / pageRange) + alignmentMin;

Great, so let's add it into the BackgroundImage widget to to update the alignment whenever it's input property offset is updated. Remember, offset is the new page screen position when swiping.

class BackgroundImage extends StatelessWidget {
  BackgroundImage({
    Key? key,
    required this.pageCount,
    required this.screenSize,
    required this.offset,
  }) : super(key: key);

  /// Size of page
  final Size screenSize;

  /// Number of pages
  final int pageCount;

  /// Currnet page position
  final double offset;

  @override
  Widget build(BuildContext context) {
    // Image aligment goes from -1 to 1.
    // We convert page number range, 0..6 into the image alignment range -1..1
    int lastPageIdx = pageCount - 1;
    int firstPageIdx = 0;
    int alignmentMax = 1;
    int alignmentMin = -1;
    int pageRange = (lastPageIdx - firstPageIdx) - 1;
    int alignmentRange = (alignmentMax - alignmentMin);
    double alignment = (((offset - firstPageIdx) * alignmentRange) / pageRange) + alignmentMin;

    return Container(
      height: screenSize.height,
      width: screenSize.width,
      child: Image(
        image: AssetImage('assets/tokyo-street-pano.jpg'),
        alignment: Alignment(alignment, 0),
        fit: BoxFit.fitHeight,
      ),
    );
  }
}



# Adding screens

This is exactly what we want. And if we update the number of pages, it automatically updates how much the background alignment changes.



Done and done. I hope this was helpful in some way. Here's the full code snippet.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Parallax Demo',
        theme: ThemeData(primarySwatch: Colors.blue),
        home: Scaffold(body: Parallax()),
      );
}

class Parallax extends StatefulWidget {
  @override
  _ParallaxState createState() => _ParallaxState();
}

class _ParallaxState extends State<Parallax> {
  late PageController _pageController;
  late double _pageOffset;

  @override
  void initState() {
    super.initState();
    _pageOffset = 0;
    _pageController = PageController(initialPage: 0);
    _pageController.addListener(
      () => setState(() => _pageOffset = _pageController.page ?? 0),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        BackgroundImage(
          pageCount: screens.length + 1,
          screenSize: MediaQuery.of(context).size,
          offset: _pageOffset,
        ),
        PageView(
          controller: _pageController,
          children: [
            ...screens
                .map((screen) => Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Container(
                          padding: EdgeInsets.all(10),
                          color: Colors.black.withOpacity(0.4),
                          child: Text(
                            screen.title,
                            style: TextStyle(
                              color: Colors.white,
                              fontSize: 80,
                            ),
                          ),
                        ),
                        SizedBox(height: 20),
                        Container(
                          padding: EdgeInsets.all(10),
                          color: Colors.black.withOpacity(0.4),
                          child: Text(
                            screen.body,
                            style: TextStyle(
                              color: Colors.white,
                              fontSize: 24,
                            ),
                          ),
                        ),
                      ],
                    ))
                .toList(),
          ],
        ),
      ],
    );
  }

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

class BackgroundImage extends StatelessWidget {
  BackgroundImage({
    Key? key,
    required this.pageCount,
    required this.screenSize,
    required this.offset,
  }) : super(key: key);

  /// Size of page
  final Size screenSize;

  /// Number of pages
  final int pageCount;

  /// Currnet page position
  final double offset;

  @override
  Widget build(BuildContext context) {
    // Image aligment goes from -1 to 1.
    // We convert page number range, 0..6 into the image alignment range -1..1
    int lastPageIdx = pageCount - 1;
    int firstPageIdx = 0;
    int alignmentMax = 1;
    int alignmentMin = -1;
    int pageRange = (lastPageIdx - firstPageIdx) - 1;
    int alignmentRange = (alignmentMax - alignmentMin);
    double alignment = (((offset - firstPageIdx) * alignmentRange) / pageRange) + alignmentMin;

    return Container(
      height: screenSize.height,
      width: screenSize.width,
      child: Image(
        image: AssetImage('assets/tokyo-street-pano.jpg'),
        alignment: Alignment(alignment, 0),
        fit: BoxFit.fitHeight,
      ),
    );
  }
}

class Screen {
  const Screen({required this.title, required this.body});
  final String title;
  final String body;
}

const List<Screen> screens = [
  Screen(title: '夜', body: 'Night'),
  Screen(title: '通り', body: 'Street'),
  Screen(title: 'ネオン', body: 'Neon sign'),
  Screen(title: '舗', body: 'Store'),
  Screen(title: '東京', body: 'Tokyo'),
  Screen(title: '夜', body: 'Night'),
  Screen(title: '通り', body: 'Street'),
  Screen(title: 'ネオン', body: 'Neon sign'),
  Screen(title: '舗', body: 'Store'),
  Screen(title: '東京', body: 'Tokyo'),
];


Back to articles
Back to home

# Links