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'), ];