Parallax Via Flutter Scrollable
How to use the Transform widget and the Flow widget to make images parallax when the app user scrolls content
While, yes, we will get a parallax effect that works in all scrollables; the more important aspect is the Flow widget part. As Flow widget has a FlowDelegate which is just a step away from the way we compose complex widgets with layouts either via CustomSingleChildLayout or CustomMultiChildLayout.
What helps me output more articles is you sharing this article on your social media accounts And the tech dev groups have moved to threads and are posting under the Tech Threads tag along with myself(my username over there is fredgrott, just like my IG account). It's where you're raising my article SEO up by sharing it on your social media allows me to then raise your flutter app branding skills.
Let's first do a quick intro to using the Flow widget and Flow delegate.
Flow Widget And FlowDelegate
This example is straight from the API docs at:
The reason I show it as the intro is that it shows to expanding menu UX pattern that everyone re-uses in FABs:
import 'package:flutter/material.dart'; | |
/// Flutter code sample for [Flow]. | |
void main() => runApp(const FlowApp()); | |
class FlowApp extends StatelessWidget { | |
const FlowApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
home: Scaffold( | |
appBar: AppBar( | |
title: const Text('Flow Example'), | |
), | |
body: const FlowMenu(), | |
), | |
); | |
} | |
} | |
class FlowMenu extends StatefulWidget { | |
const FlowMenu({super.key}); | |
@override | |
State<FlowMenu> createState() => _FlowMenuState(); | |
} | |
class _FlowMenuState extends State<FlowMenu> | |
with SingleTickerProviderStateMixin { | |
late AnimationController menuAnimation; | |
IconData lastTapped = Icons.notifications; | |
final List<IconData> menuItems = <IconData>[ | |
Icons.home, | |
Icons.new_releases, | |
Icons.notifications, | |
Icons.settings, | |
Icons.menu, | |
]; | |
void _updateMenu(IconData icon) { | |
if (icon != Icons.menu) { | |
setState(() => lastTapped = icon); | |
} | |
} | |
@override | |
void initState() { | |
super.initState(); | |
menuAnimation = AnimationController( | |
duration: const Duration(milliseconds: 250), | |
vsync: this, | |
); | |
} | |
Widget flowMenuItem(IconData icon) { | |
final double buttonDiameter = | |
MediaQuery.of(context).size.width / menuItems.length; | |
return Padding( | |
padding: const EdgeInsets.symmetric(vertical: 8.0), | |
child: RawMaterialButton( | |
fillColor: lastTapped == icon ? Colors.amber[700] : Colors.blue, | |
splashColor: Colors.amber[100], | |
shape: const CircleBorder(), | |
constraints: BoxConstraints.tight(Size(buttonDiameter, buttonDiameter)), | |
onPressed: () { | |
_updateMenu(icon); | |
menuAnimation.status == AnimationStatus.completed | |
? menuAnimation.reverse() | |
: menuAnimation.forward(); | |
}, | |
child: Icon( | |
icon, | |
color: Colors.white, | |
size: 45.0, | |
), | |
), | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Flow( | |
delegate: FlowMenuDelegate(menuAnimation: menuAnimation), | |
children: | |
menuItems.map<Widget>((IconData icon) => flowMenuItem(icon)).toList(), | |
); | |
} | |
} | |
class FlowMenuDelegate extends FlowDelegate { | |
FlowMenuDelegate({required this.menuAnimation}) | |
: super(repaint: menuAnimation); | |
final Animation<double> menuAnimation; | |
@override | |
bool shouldRepaint(FlowMenuDelegate oldDelegate) { | |
return menuAnimation != oldDelegate.menuAnimation; | |
} | |
@override | |
void paintChildren(FlowPaintingContext context) { | |
double dx = 0.0; | |
for (int i = 0; i < context.childCount; ++i) { | |
dx = context.getChildSize(i)!.width * i; | |
context.paintChild( | |
i, | |
transform: Matrix4.translationValues( | |
dx * menuAnimation.value, | |
0, | |
0, | |
), | |
); | |
} | |
} | |
} |
The Flow widget takes a delegate and a child widget.The key reason why Flow widgets are used is that the child is positioned during the pain pass and that positioning is driven by transformation matrices. It gets its flow name from flowing during paint rather than during layout as in the Stack widget positioning set up.
We supply that animation to the FlowDelegate constructor to trigger the repaint and thus avoid the build and layout phases.
Now, we are ready for the parallax example.
Parallax Example
This is the Youtube short showing the parallax effect:
First, the Flutter SDK team never fully updates the docs with SDK changes. Since flutter 2.5 we set the scroll physics behavior locally to each scrollable to avoid interference with text fields:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
/// A customized scroll behavior that enables dragging using both touch and mouse input devices. | |
/// | |
/// This scroll behavior extends the default [MaterialScrollBehavior] to allow | |
/// dragging interactions for both touch and mouse input devices. By default, | |
/// the [MaterialScrollBehavior] only supports touch-based dragging. This custom | |
/// behavior makes it suitable for widgets that should respond to dragging with | |
/// both touch and mouse input, such as scrollable content. | |
class DragScrollBehavior extends MaterialScrollBehavior { | |
@override | |
Set<PointerDeviceKind> get dragDevices => { | |
// Enable dragging using both touch and mouse input devices. | |
PointerDeviceKind.touch, | |
PointerDeviceKind.mouse, | |
PointerDeviceKind.trackpad, | |
PointerDeviceKind.stylus, | |
PointerDeviceKind.invertedStylus, | |
}; | |
} |
And that piece of code get's applied using the ScrollableConfiguration widget, like this:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:image_parallax/drag_scroll_behavior_configuration.dart'; | |
import 'package:image_parallax/location.dart'; | |
import 'package:image_parallax/location_list.dart'; | |
class ExampleParallax extends StatelessWidget { | |
const ExampleParallax({ | |
super.key, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return ScrollConfiguration( | |
behavior: DragScrollBehavior(), | |
child: SingleChildScrollView( | |
child: Column( | |
children: [ | |
for (final location in locations) | |
LocationListItem( | |
imageUrl: location.imageUrl, | |
name: location.name, | |
country: location.place, | |
), | |
], | |
), | |
)); | |
} | |
} |
And the other preparation is a data model of the images:
Location
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
class Location { | |
const Location({ | |
required this.name, | |
required this.place, | |
required this.imageUrl, | |
}); | |
final String name; | |
final String place; | |
final String imageUrl; | |
} | |
const locations = [ | |
Location( | |
name: 'AMD', | |
place: 'U.S.A', | |
imageUrl: 'assets/amd.jpg', | |
), | |
Location( | |
name: 'Apple', | |
place: 'U.S.A.', | |
imageUrl: 'assets/apple.jpg', | |
), | |
Location( | |
name: 'Google', | |
place: 'U.S.A.', | |
imageUrl: 'assets/google.jpg', | |
), | |
Location( | |
name: 'Intel', | |
place: 'U.S.A.', | |
imageUrl: 'assets/intel.jpg', | |
), | |
Location( | |
name: 'NVIDIA', | |
place: 'U.S.A.', | |
imageUrl: 'assets/nvidia.jpg', | |
), | |
]; |
Location List
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:image_parallax/parallax_flow_delegate.dart'; | |
class LocationListItem extends StatelessWidget { | |
LocationListItem({ | |
super.key, | |
required this.imageUrl, | |
required this.name, | |
required this.country, | |
}); | |
final String imageUrl; | |
final String name; | |
final String country; | |
final GlobalKey _backgroundImageKey = GlobalKey(); | |
@override | |
Widget build(BuildContext context) { | |
return Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), | |
child: AspectRatio( | |
aspectRatio: 16 / 9, | |
child: ClipRRect( | |
borderRadius: BorderRadius.circular(16), | |
child: Stack( | |
children: [ | |
_buildParallaxBackground(context), | |
_buildGradient(), | |
_buildTitleAndSubtitle(), | |
], | |
), | |
), | |
), | |
); | |
} | |
Widget _buildParallaxBackground(BuildContext context) { | |
return Flow( | |
delegate: ParallaxFlowDelegate( | |
scrollable: Scrollable.of(context), | |
listItemContext: context, | |
backgroundImageKey: _backgroundImageKey, | |
), | |
children: [ | |
Image.asset( | |
imageUrl, | |
key: _backgroundImageKey, | |
fit: BoxFit.cover, | |
), | |
], | |
); | |
} | |
Widget _buildGradient() { | |
return Positioned.fill( | |
child: DecoratedBox( | |
decoration: BoxDecoration( | |
gradient: LinearGradient( | |
colors: [Colors.transparent, Colors.black.withOpacity(0.7)], | |
begin: Alignment.topCenter, | |
end: Alignment.bottomCenter, | |
stops: const [0.6, 0.95], | |
), | |
), | |
), | |
); | |
} | |
Widget _buildTitleAndSubtitle() { | |
return Positioned( | |
left: 20, | |
bottom: 20, | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
name, | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 20, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
Text( | |
country, | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 14, | |
), | |
), | |
], | |
), | |
); | |
} | |
} |
Now that we have those two pieces, let's start with the tricky part first.
The tricky part is upon the user scrolling we need to trigger a layout phase for the parallax background. FlowDelegate by itself only triggers repainting and not layout.
So we need to use a portion of the API behind the Single and Multi Child layouts to trigger that layout of the parallax background image:
Parallax
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:image_parallax/render_parallax.dart'; | |
class Parallax extends SingleChildRenderObjectWidget { | |
const Parallax({ | |
super.key, | |
required Widget background, | |
}) : super(child: background); | |
@override | |
RenderObject createRenderObject(BuildContext context) { | |
return RenderParallax(scrollable: Scrollable.of(context)); | |
} | |
@override | |
void updateRenderObject(BuildContext context, covariant RenderParallax renderObject) { | |
renderObject.scrollable = Scrollable.of(context); | |
} | |
} |
Render Parallax
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:image_parallax/parallax_parent_date.dart'; | |
class RenderParallax extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin { | |
RenderParallax({ | |
required ScrollableState scrollable, | |
}) : _scrollable = scrollable; | |
ScrollableState _scrollable; | |
ScrollableState get scrollable => _scrollable; | |
set scrollable(ScrollableState value) { | |
if (value != _scrollable) { | |
if (attached) { | |
_scrollable.position.removeListener(markNeedsLayout); | |
} | |
_scrollable = value; | |
if (attached) { | |
_scrollable.position.addListener(markNeedsLayout); | |
} | |
} | |
} | |
@override | |
void attach(covariant PipelineOwner owner) { | |
super.attach(owner); | |
_scrollable.position.addListener(markNeedsLayout); | |
} | |
@override | |
void detach() { | |
_scrollable.position.removeListener(markNeedsLayout); | |
super.detach(); | |
} | |
@override | |
void setupParentData(covariant RenderObject child) { | |
if (child.parentData is! ParallaxParentData) { | |
child.parentData = ParallaxParentData(); | |
} | |
} | |
@override | |
void performLayout() { | |
size = constraints.biggest; | |
// Force the background to take up all available width | |
// and then scale its height based on the image's aspect ratio. | |
final background = child!; | |
final backgroundImageConstraints = BoxConstraints.tightFor(width: size.width); | |
background.layout(backgroundImageConstraints, parentUsesSize: true); | |
// Set the background's local offset, which is zero. | |
(background.parentData as ParallaxParentData).offset = Offset.zero; | |
} | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
// Get the size of the scrollable area. | |
final viewportDimension = scrollable.position.viewportDimension; | |
// Calculate the global position of this list item. | |
final scrollableBox = scrollable.context.findRenderObject() as RenderBox; | |
final backgroundOffset = localToGlobal(size.centerLeft(Offset.zero), ancestor: scrollableBox); | |
// Determine the percent position of this list item within the | |
// scrollable area. | |
final scrollFraction = (backgroundOffset.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 background = child!; | |
final backgroundSize = background.size; | |
final listItemSize = size; | |
final childRect = verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize); | |
// Paint the background. | |
context.paintChild( | |
background, (background.parentData as ParallaxParentData).offset + offset + Offset(0.0, childRect.top)); | |
} | |
} |
Parallax Parent Data
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/rendering.dart'; | |
class ParallaxParentData extends ContainerBoxParentData<RenderBox> {} |
And then the ParallaxFlowDelegate is this:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
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; | |
} | |
} |
Obviously, if we needed the horizontal direction we would need to change the offset direction for the scrollfraction and where that plugs into the computationof horizontalAlignment. And those changes would then filter down to the childRect and the transform translate call in the context paintChild call.
Then the call to that in the Flow widget is in the location list widget set up:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:image_parallax/parallax_flow_delegate.dart'; | |
class LocationListItem extends StatelessWidget { | |
LocationListItem({ | |
super.key, | |
required this.imageUrl, | |
required this.name, | |
required this.country, | |
}); | |
final String imageUrl; | |
final String name; | |
final String country; | |
final GlobalKey _backgroundImageKey = GlobalKey(); | |
@override | |
Widget build(BuildContext context) { | |
return Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), | |
child: AspectRatio( | |
aspectRatio: 16 / 9, | |
child: ClipRRect( | |
borderRadius: BorderRadius.circular(16), | |
child: Stack( | |
children: [ | |
_buildParallaxBackground(context), | |
_buildGradient(), | |
_buildTitleAndSubtitle(), | |
], | |
), | |
), | |
), | |
); | |
} | |
Widget _buildParallaxBackground(BuildContext context) { | |
return Flow( | |
delegate: ParallaxFlowDelegate( | |
scrollable: Scrollable.of(context), | |
listItemContext: context, | |
backgroundImageKey: _backgroundImageKey, | |
), | |
children: [ | |
Image.asset( | |
imageUrl, | |
key: _backgroundImageKey, | |
fit: BoxFit.cover, | |
), | |
], | |
); | |
} | |
Widget _buildGradient() { | |
return Positioned.fill( | |
child: DecoratedBox( | |
decoration: BoxDecoration( | |
gradient: LinearGradient( | |
colors: [Colors.transparent, Colors.black.withOpacity(0.7)], | |
begin: Alignment.topCenter, | |
end: Alignment.bottomCenter, | |
stops: const [0.6, 0.95], | |
), | |
), | |
), | |
); | |
} | |
Widget _buildTitleAndSubtitle() { | |
return Positioned( | |
left: 20, | |
bottom: 20, | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
name, | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 20, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
Text( | |
country, | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 14, | |
), | |
), | |
], | |
), | |
); | |
} | |
} |
This is not the only scrollable it will work for, as one could do it for PageView and GridView as well.
Let me tell you what you as a subscriber are getting that the Flutter GDEs are not covering:
-Full Material Design Carousel
-Shape And Animation Branding for ListView and Gridview just like I am
doing for the custom page view carousel I came up with
-Flow Widget FAB expanding menus and other expanding menus
-Animated nav button branding
-ExpandingTile shape and animation branding
-ColorScheme ImageProvider content dynamic color tricks
-Applying shaders to navbars and other UI elements
-Expanding Carousel Apple-Book-like-UI
-Full Canonical Layout Article series covering Adaptive Shared Scaffold
and integrating canonical layouts with it
-fixing edge-to-edge natively android and ios
-firestore stuff including emulating it for testing
-the new storyboard library that Google is now supporting
-the new hive beta stuff that beats shared pref performance
-different ways to animate theme changes
-integrating rive animation into choregraphy of the app
-different ways to onboard coupled with hive storage
-More complex widgets using Boxy API to make it easier to
brand your app through complex custom widgets
-how to easily fill the null-ed component themes with
flex color scheme library
-how to mix both OOP and FP in model classes to make it
easy to use more FP in your models with no headaches or
hassles
-more shape exploration of techniques
-matrix4 transforms play
-more scroll physics fun