Flutter Parallax Scroll Animations for Background and List
Parallax scrolling is a compelling visual effect where background content moves slower than foreground content when scrolling, creating an illusion of depth and immersion. In Flutter, achieving sophisticated parallax animations for both backgrounds and list items is highly flexible and performant, enhancing the user experience significantly.
Understanding Parallax in Flutter
The core principle of parallax relies on varying scroll speeds for different elements. When a user scrolls a certain distance, foreground elements might move 1:1 with the scroll, while background elements move at a fraction of that speed (e.g., 0.5:1). Flutter's declarative UI and robust scrolling mechanisms make this effect relatively straightforward to implement by manipulating widget positions or transformations based on the scroll offset.
Implementing Parallax for Backgrounds
For a background parallax effect, you typically use a scrollable widget (like SingleChildScrollView or CustomScrollView) to house your main content, and within a Stack, position your background image or widget. The background's vertical position is then adjusted based on the scroll controller's offset.
Example: Background Parallax
Consider a scenario where you have a tall background image that should scroll slower than the main content.
import 'package:flutter/material.dart';
class ParallaxBackgroundScreen extends StatefulWidget {
@override
_ParallaxBackgroundScreenState createState() => _ParallaxBackgroundScreenState();
}
class _ParallaxBackgroundScreenState extends State {
final ScrollController _scrollController = ScrollController();
double _offset = 0.0;
@override
void initState() {
super.initState();
_scrollController.addListener(() {
setState(() {
_offset = _scrollController.offset;
});
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Parallax Background')),
body: Stack(
children: [
// Background Image with Parallax Effect
Positioned(
top: -0.5 * _offset, // Adjust multiplier for parallax speed
left: 0,
right: 0,
child: SizedBox(
height: 800, // Make sure background content is taller than screen
child: Image.network(
'https://picsum.photos/id/1018/800/1200', // Example tall image
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
),
// Foreground Content
SingleChildScrollView(
controller: _scrollController,
child: Column(
children: [
Container(
height: 300,
color: Colors.transparent, // Placeholder to push content down
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 20),
child: Text(
'Welcome to the Parallax World!',
style: TextStyle(color: Colors.white, fontSize: 24, shadows: [
Shadow(offset: Offset(1,1), blurRadius: 3, color: Colors.black)
]),
),
),
Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: List.generate(20, (index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Content item ${index + 1}. This content scrolls normally over the parallax background. '
'The background image moves at a slower rate, creating a sense of depth.',
style: TextStyle(fontSize: 16),
),
)),
),
),
),
],
),
),
],
),
);
}
}
In this example, the background image's top position is dynamically updated by -0.5 * _offset. A smaller multiplier (e.g., 0.3) would make the background move even slower, while 0 would make it stationary.
Implementing Parallax for List Items
Applying parallax to individual list items creates a dynamic and engaging scroll experience. Each item's visual properties (e.g., vertical position, scale, opacity) are manipulated based on its current position within the viewport relative to the scroll controller's offset.
Example: List Item Parallax
This implementation often involves using a ListView.builder and a NotificationListener or direct access to the ScrollController to determine each item's viewport position.
import 'package:flutter/material.dart';
class ParallaxListItemScreen extends StatefulWidget {
@override
_ParallaxListItemScreenState createState() => _ParallaxListItemScreenState();
}
class _ParallaxListItemScreenState extends State {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Parallax List Items')),
body: ListView.builder(
controller: _scrollController,
itemCount: 15,
itemExtent: 200, // Fixed height for simplicity in calculation
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _scrollController,
builder: (context, child) {
final itemKey = GlobalKey();
// Calculate item's position relative to the scroll view
final RenderBox? renderBox = itemKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) {
return ParallaxListItem(
itemKey: itemKey,
index: index,
scrollOffset: 0,
itemHeight: 200, // Must match itemExtent
);
}
final itemOffset = renderBox.localToGlobal(Offset.zero).dy;
final viewportHeight = MediaQuery.of(context).size.height;
// Calculate the center of the item relative to the viewport center
// A simple parallax effect based on how far the item is from the screen's center
final screenCenter = viewportHeight / 2;
final itemCenter = itemOffset + (200 / 2); // 200 is itemHeight
final distanceToCenter = screenCenter - itemCenter;
// Apply a small translation
final parallaxTranslation = distanceToCenter * 0.1; // Adjust multiplier
return Transform.translate(
offset: Offset(0, parallaxTranslation),
child: ParallaxListItem(
itemKey: itemKey,
index: index,
scrollOffset: _scrollController.offset, // Not directly used in this specific item parallax, but useful for other effects
itemHeight: 200,
),
);
},
);
},
),
);
}
}
class ParallaxListItem extends StatelessWidget {
final Key itemKey;
final int index;
final double scrollOffset;
final double itemHeight;
const ParallaxListItem({
required this.itemKey,
required this.index,
required this.scrollOffset,
required this.itemHeight,
});
@override
Widget build(BuildContext context) {
return Container(
key: itemKey, // Attach key for position calculation
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.blueGrey[100 + (index % 5) * 100],
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
'Item ${index + 1}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white),
),
);
}
}
In the list item example, we wrap each item in an AnimatedBuilder that listens to the _scrollController. Inside the builder, we calculate the item's current screen position using its GlobalKey and RenderBox. The parallaxTranslation is then applied via Transform.translate, causing items near the screen's edges to move slightly differently than those in the center.
Alternative for List Item Parallax: LayoutBuilder with Viewport
For more robust list item parallax, especially for items with variable heights or more complex effects, you can use LayoutBuilder within each list item to get its local position relative to the scrollable viewport. This approach eliminates the need for GlobalKey and direct RenderBox lookup, which can be less performant for many items.
// This snippet shows the concept within a ListView.builder's item
// The ParallaxFlowDelegate example below is a more robust solution for complex parallax.
Widget _buildParallaxListItem(BuildContext context, int index) {
return Container(
height: 200, // Fixed height for simplicity
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
// Background image for the list item
Positioned.fill(
child: Image.network(
'https://picsum.photos/id/${10 + index}/400/300',
fit: BoxFit.cover,
),
),
// Parallax effect applied to the image based on its viewport position
// Using LayoutBuilder to get local constraints
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// This is a simplified calculation.
// In a real scenario, you'd need the item's scroll position
// relative to the viewport. This often involves a ScrollNotification
// or passing the scroll offset down.
// For demonstration, let's assume a simplified effect.
// To get the actual offset, one would need to use a GlobalKey or
// listen to ScrollNotifications higher up and pass the scroll offset.
// A more advanced approach would involve a NotificationListener.
// For now, let's just illustrate the idea of applying transform.
// This specific `LayoutBuilder` here will only give you the item's size,
// not its position within the overall scroll view directly.
// To make this work, you'd typically pass the current scroll offset
// from the ListView's controller down to this builder.
// Example:
// final double itemScrollOffset = (index * constraints.maxHeight) - _scrollController.offset;
// final double parallaxOffset = itemScrollOffset * 0.2; // Example effect
// For a simple illustrative transform without actual scroll data passed in:
return Transform.translate(
offset: Offset(0, 0), // Placeholder, would be based on scrollOffset
child: Image.network(
'https://picsum.photos/id/${10 + index}/400/300',
fit: BoxFit.cover,
),
);
},
),
Align(
alignment: Alignment.center,
child: Text(
'Item ${index + 1}',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [Shadow(blurRadius: 5, color: Colors.black)],
),
),
),
],
),
),
);
}
For a truly elegant and performant list item parallax, consider using a CustomScrollView with a SliverList and a custom SliverChildBuilderDelegate. This allows you to observe each child's scroll progress and apply transformations with greater control.
Advanced List Item Parallax with Flow Widget
The Flow widget provides a highly optimized way to implement custom layouts and painting effects, making it excellent for complex parallax. You define a FlowDelegate that receives the scroll offset and calculates the transformation for each child.
import 'package:flutter/material.dart';
class ParallaxFlowDelegate extends FlowDelegate {
final ScrollableState scrollable;
final Widget listItemContext; // The context of the list item
final double parallaxFactor;
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
this.parallaxFactor = 0.5,
}) : super(repaint: scrollable.position); // Repaint when scroll position changes
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(width: constraints.maxWidth);
}
@override
void paintChildren(FlowPaintingContext context) {
RenderBox? renderBox = listItemContext.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final scrollExtent = scrollable.position.extentInside;
final viewportDimension = scrollable.position.viewportDimension;
final scrollOffset = scrollable.position.pixels;
// Calculate the item's center within the viewport
final itemGlobalOffset = renderBox.localToGlobal(Offset.zero);
final itemCenter = itemGlobalOffset.dy + context.getChildSize(0)!.height / 2;
// Calculate the difference from the viewport center
final viewportCenter = viewportDimension / 2;
final distanceToCenter = viewportCenter - itemCenter;
// Apply parallax translation based on distance from center
final parallax = distanceToCenter * parallaxFactor;
context.paintChild(
0, // Assuming one child in the Flow
transform: Matrix4.translationValues(0.0, parallax, 0.0),
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
parallaxFactor != oldDelegate.parallaxFactor;
}
}
// How to use it in a ListView.builder:
class ParallaxFlowListItemScreen extends StatefulWidget {
@override
_ParallaxFlowListItemScreenState createState() => _ParallaxFlowListItemScreenState();
}
class _ParallaxFlowListItemScreenState extends State {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Parallax Flow List Items')),
body: ListView.builder(
controller: _scrollController,
itemCount: 20,
itemExtent: 200,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 200,
child: Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context, // Pass the current item's context
parallaxFactor: 0.2,
),
children: [
Image.network(
'https://picsum.photos/id/${100 + index}/600/400',
fit: BoxFit.cover,
alignment: Alignment.center,
),
],
),
),
),
);
},
),
);
}
}
Key Considerations and Best Practices
- Performance: Parallax effects involve frequent widget rebuilding or transformation. Use
AnimatedBuilderwith ananimationproperty (like aScrollController) to limit rebuilds to only the widgets that need to react to scroll changes. ConsiderRepaintBoundaryfor complex child widgets that don't need to be repainted when only their position changes. - Smoothness: Ensure the parallax calculations are simple and efficient. Avoid heavy computations in the scroll listener.
- Parallax Factor: Experiment with different parallax factors (multipliers) to find the right balance for your design. Too high a factor might make the effect jarring, while too low might make it unnoticeable.
- Content Alignment: For background parallax, ensure your background content is sufficiently large to fill the viewport even when shifted. For list items, be mindful of how transformations might clip content.
- Accessibility: While visually appealing, parallax effects can sometimes be distracting. Ensure the core information remains clear and accessible. Provide alternatives or options to disable complex animations if necessary.
- Responsiveness: Parallax effects should scale gracefully across different screen sizes and orientations. Ensure your calculations adapt to the viewport dimensions.
Conclusion
Flutter offers powerful primitives and widgets to create captivating parallax scroll animations for both backgrounds and individual list items. By leveraging ScrollController, AnimatedBuilder, Stack, Transform, and potentially advanced widgets like Flow, developers can craft highly immersive and visually rich user interfaces that stand out. Implementing these effects thoughtfully, with attention to performance and user experience, will undoubtedly elevate your Flutter applications.