image

08 Apr 2026

9K

35K

Creating Engaging Flutter UI: Parallax Image Scroll in ListView and GridView

User interfaces that captivate and delight are key to a compelling user experience. One powerful technique to achieve this is parallax scrolling, an effect where background content moves at a different speed than foreground content during scrolling. This creates an illusion of depth and immersion, making the UI feel more dynamic and professional. In Flutter, implementing parallax image scroll within `ListView` and `GridView` is entirely achievable, transforming static lists into visually rich experiences.

Understanding the Parallax Effect

At its core, the parallax effect relies on manipulating the position of an element (typically an image) based on the scroll position of its container. When a user scrolls, the foreground elements move at the standard rate, but the background element is translated at a slower or faster rate, creating the perceived depth. For images within a scrollable list, this means that as an item scrolls into or out of view, its contained image shifts vertically relative to its bounding box, making it appear as if the image is "sticking" or "floating" in the background while other content glides over it.

Flutter's Toolkit for Parallax Animation

Flutter provides all the necessary tools to implement this effect:
  • NotificationListener: This widget allows us to listen for scroll events originating from child scrollable widgets (like ListView or GridView) and rebuild parts of the UI in response.
  • GlobalKey: Essential for uniquely identifying widgets in the widget tree and accessing their corresponding RenderObject. We'll use this to determine the exact position and size of an image within the viewport.
  • RenderBox: The RenderObject responsible for laying out and painting widgets. We can query a RenderBox for its position relative to other widgets or the global coordinate system.
  • Transform.translate: Used to apply a translation (offset) to a widget, moving it without affecting its layout in the parent. This is how we'll achieve the vertical shift for our parallax images.
  • ClipRect: Ensures that any part of our image that moves outside its designated bounding box is clipped, preventing overflow and maintaining a clean visual.

Building the `ParallaxImage` Widget

Let's start by creating a reusable widget that takes an image and applies the parallax effect. This widget will need to know its own position relative to the scrollable viewport to calculate the correct translation.

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class ParallaxImage extends StatefulWidget {
  final String imageUrl;
  final double height;
  final double parallaxFactor; // How much the image should move relative to scroll

  const ParallaxImage({
    Key? key,
    required this.imageUrl,
    this.height = 200.0,
    this.parallaxFactor = 0.5,
  }) : super(key: key);

  @override
  _ParallaxImageState createState() => _ParallaxImageState();
}

class _ParallaxImageState extends State {
  final GlobalKey _imageKey = GlobalKey();
  double _offset = 0.0;

  @override
  void initState() {
    super.initState();
    // We don't want to add a listener here directly as we need
    // to listen to the parent scrollable's notifications.
    // The rebuild will be triggered by the NotificationListener
    // placed higher in the widget tree.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _updateOffset(); // Initial calculation
    });
  }

  void _updateOffset() {
    if (_imageKey.currentContext == null) return;

    final RenderObject? renderObject = _imageKey.currentContext!.findRenderObject();
    if (renderObject == null || renderObject is! RenderBox) return;

    final RenderBox imageBox = renderObject;
    final RenderAbstractViewport? viewport = RenderAbstractViewport.of(imageBox);

    if (viewport == null) return;

    final double viewportOffset = viewport.getOffsetToReveal(imageBox, 0.0).offset;
    final double center = (viewport.get : viewport.size.height / 2); // Get viewport center

    final double scrollFraction = (viewportOffset + imageBox.size.height / 2 - center) / (viewport.size.height / 2);
    
    // Calculate how much the image should move.
    // We want the image to shift vertically based on its position in the viewport.
    // If an item is in the center of the screen, offset is 0.
    // If it's at the top, it shifts one way; at the bottom, the other.
    final double parallaxOffset = (scrollFraction * (widget.height * widget.parallaxFactor));

    if (_offset != parallaxOffset) {
      setState(() {
        _offset = parallaxOffset;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return NotificationListener(
      onNotification: (ScrollNotification notification) {
        // Rebuild this widget whenever the parent scrollable scrolls
        _updateOffset();
        return false; // Don't stop the notification from propagating further
      },
      child: Container(
        height: widget.height,
        child: ClipRect(
          child: OverflowBox(
            maxWidth: double.infinity,
            maxHeight: double.infinity,
            alignment: Alignment.center,
            child: Transform.translate(
              offset: Offset(0.0, _offset),
              child: Image.network(
                widget.imageUrl,
                key: _imageKey,
                fit: BoxFit.cover,
                width: MediaQuery.of(context).size.width, // Ensure image fills width
                height: widget.height * (1 + widget.parallaxFactor * 2), // Make image taller than container to allow for movement
              ),
            ),
          ),
        ),
      ),
    );
  }
}
A brief explanation of the `_ParallaxImageState._updateOffset()` logic:
  1. It gets the `RenderBox` of the `Image.network` using its `GlobalKey`.
  2. It queries the `RenderAbstractViewport` to understand the scrollable context.
  3. It calculates `viewportOffset`, which is the scroll offset required to bring the start of the image into view.
  4. The `scrollFraction` determines how far the center of the image is from the center of the viewport, normalized.
  5. `parallaxOffset` is then derived from this fraction, scaled by `parallaxFactor` and the image height.
  6. `setState` is called to rebuild the widget with the new `_offset`, applying the `Transform.translate`.
  7. The `NotificationListener` wrapping the `Container` ensures that `_updateOffset` is called every time a scroll event happens in its parent scrollable.
**Important Note**: To make the parallax effect visible, the image itself must be larger (taller in this case) than its container. This allows the image to move vertically within the fixed `Container` height, revealing different parts of the image as the list scrolls.

Integrating with `ListView`

Now, let's use our `ParallaxImage` widget within a `ListView`.

import 'package:flutter/material.dart';
// Assuming ParallaxImage widget is in a file like 'parallax_image.dart'
import 'parallax_image.dart'; 

class ListViewParallaxPage extends StatefulWidget {
  const ListViewParallaxPage({Key? key}) : super(key: key);

  @override
  _ListViewParallaxPageState createState() => _ListViewParallaxPageState();
}

class _ListViewParallaxPageState extends State {
  final List _imageUrls = [
    'https://picsum.photos/id/1018/800/600',
    'https://picsum.photos/id/1015/800/600',
    'https://picsum.photos/id/1019/800/600',
    'https://picsum.photos/id/1020/800/600',
    'https://picsum.photos/id/1021/800/600',
    'https://picsum.photos/id/1023/800/600',
    'https://picsum.photos/id/1024/800/600',
    'https://picsum.photos/id/1025/800/600',
    'https://picsum.photos/id/1026/800/600',
    'https://picsum.photos/id/1027/800/600',
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Parallax ListView'),
      ),
      body: ListView.builder(
        itemCount: _imageUrls.length,
        itemBuilder: (BuildContext context, int index) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
            child: Card(
              elevation: 4,
              clipBehavior: Clip.antiAlias,
              child: Stack(
                children: [
                  ParallaxImage(
                    imageUrl: _imageUrls[index],
                    height: 250,
                    parallaxFactor: 0.3, // Adjust for desired effect
                  ),
                  Positioned.fill(
                    child: Align(
                      alignment: Alignment.bottomCenter,
                      child: Container(
                        padding: const EdgeInsets.all(16.0),
                        color: Colors.black.withOpacity(0.4),
                        child: Text(
                          'Image Title ${index + 1}',
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                          ),
                          textAlign: TextAlign.center,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}
In the `ListView.builder`, each item wraps the `ParallaxImage` in a `Card` for better visual separation. A `Stack` is used to overlay a title on top of the parallax image, demonstrating how to combine this effect with other UI elements.

Integrating with `GridView`

The `ParallaxImage` widget is designed to be independent of the scrollable type, so integrating it into a `GridView` is straightforward.

import 'package:flutter/material.dart';
// Assuming ParallaxImage widget is in a file like 'parallax_image.dart'
import 'parallax_image.dart'; 

class GridViewParallaxPage extends StatefulWidget {
  const GridViewParallaxPage({Key? key}) : super(key: key);

  @override
  _GridViewParallaxPageState createState() => _GridViewParallaxPageState();
}

class _GridViewParallaxPageState extends State {
  final List _imageUrls = [
    'https://picsum.photos/id/1018/400/300', // Smaller images for grid
    'https://picsum.photos/id/1015/400/300',
    'https://picsum.photos/id/1019/400/300',
    'https://picsum.photos/id/1020/400/300',
    'https://picsum.photos/id/1021/400/300',
    'https://picsum.photos/id/1023/400/300',
    'https://picsum.photos/id/1024/400/300',
    'https://picsum.photos/id/1025/400/300',
    'https://picsum.photos/id/1026/400/300',
    'https://picsum.photos/id/1027/400/300',
    'https://picsum.photos/id/1028/400/300',
    'https://picsum.photos/id/1029/400/300',
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Parallax GridView'),
      ),
      body: GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2, // Two columns
          crossAxisSpacing: 8.0,
          mainAxisSpacing: 8.0,
          childAspectRatio: 0.9, // Adjust ratio for image height
        ),
        padding: const EdgeInsets.all(8.0),
        itemCount: _imageUrls.length,
        itemBuilder: (BuildContext context, int index) {
          return Card(
            elevation: 4,
            clipBehavior: Clip.antiAlias,
            child: Stack(
              children: [
                ParallaxImage(
                  imageUrl: _imageUrls[index],
                  height: 200, // Adjusted height for grid items
                  parallaxFactor: 0.2, // Slightly less aggressive for grids
                ),
                Positioned.fill(
                  child: Align(
                    alignment: Alignment.bottomCenter,
                    child: Container(
                      padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
                      color: Colors.black.withOpacity(0.5),
                      child: Text(
                        'Item ${index + 1}',
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 14,
                          fontWeight: FontWeight.bold,
                        ),
                        textAlign: TextAlign.center,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}
Here, we use `GridView.builder` with `SliverGridDelegateWithFixedCrossAxisCount` to create a two-column grid. Each grid item similarly uses the `ParallaxImage` widget within a `Card` and `Stack`. Notice the `childAspectRatio` and `height` adjustments to better suit the grid layout.

Performance Considerations and Best Practices

Implementing parallax often involves rebuilding widgets frequently during scrolling, which can impact performance. Here are some tips:
  • Minimize Rebuild Scope: Our `ParallaxImage` widget limits the `setState` to only the parts necessary for the transform, which is good. Avoid rebuilding the entire `ListView` or `GridView` on every scroll.
  • `RepaintBoundary`: For complex children within your list items, consider wrapping them in a `RepaintBoundary` to prevent unnecessary repainting of other parts of the widget tree when only the `ParallaxImage` moves.
  • `AnimatedBuilder` with `ScrollController`: For highly optimized scenarios, instead of `NotificationListener` on each item, a `ScrollController` can be passed to an `AnimatedBuilder` that wraps the entire list/grid. The `AnimatedBuilder` listens to the `ScrollController` and rebuilds its children. Each child then gets the current `scrollOffset` and calculates its parallax effect without needing its own `NotificationListener`. This can be more efficient if many items need to react to scroll.
  • Image Caching: Ensure your images are cached (e.g., using `cached_network_image` package) to prevent repeated network requests and improve scroll fluidity.
  • Judicious `parallaxFactor`: An overly aggressive `parallaxFactor` can make the effect seem jarring or unnatural. Experiment to find a subtle yet engaging value.

Conclusion

Parallax image scrolling is a powerful way to add depth, visual interest, and a professional touch to your Flutter applications. By leveraging `NotificationListener`, `GlobalKey`, `RenderBox`, and `Transform.translate`, you can create custom parallax effects within both `ListView` and `GridView`, elevating the user experience from ordinary to extraordinary. With careful implementation and attention to performance, these animated elements can truly make your UI shine.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is