Is a Widget inside a Scrollable visible?

Compatibility
Last Reviewed
Mar 30, 2023
Published on
May 26, 2019
Flutter
v 3.13.x
Dart
v 3.1.x

Question

Lately I received a very interesting question:

How can we know if a Widget, part of a ListView (or GridView), is actually visible on the screen and how to detect whether it becomes visible when the user scrolls?

Answer

Here is the code of a possible solution. I will give the explanations after...



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

void main() {
  runApp(const Application());
}

class Application extends StatelessWidget {
  const Application({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Page(),
    );
  }
}

///
/// Helper class that makes the relationship between
/// an item index and its BuildContext
///
class ItemContext {
  ItemContext({
    required this.context,
    required this.id,
  });
  final BuildContext context;
  final int id;

  
  bool operator ==(Object other) =>
      identical(this, other) || other is ItemContext && other.id == id;

  
  int get hashCode => Object.hashAll([
        id,
      ]);
}

class Page extends StatefulWidget {
  const Page({super.key});

  
  State<Page> createState() => _PageState();
}

class _PageState extends State<Page> {
  //
  // Collection to hold the BuildContext associated with an Item
  //
  late Set<ItemContext> _itemsContexts;

  //
  // Stream to control the scroll events and prevents
  // doing the computations at each scroll
  //
  late BehaviorSubject<ScrollNotification> _streamController;

  
  void initState() {
    super.initState();

    // Initialize the collection (of unique items)
    _itemsContexts = <ItemContext>{};

    // Initialize a stream controller
    _streamController = BehaviorSubject<ScrollNotification>();

    //
    // When a scroll notification is emitted, simply bufferize a bit
    // so that we do not compute too much
    //
    _streamController
        .bufferTime(const Duration(
          milliseconds: 100,
        ))
        .where((batch) => batch.isNotEmpty)
        .listen(_onScroll);
  }

  
  void dispose() {
    _itemsContexts.clear();
    _streamController.close();
    super.dispose();
  }

  void _onScroll(List<ScrollNotification> notifications) {
    // Iterate through each item to check
    // whether it is in the viewport

    for (var item in _itemsContexts) {
      // Make sure the context is still mounted
      if (item.context.mounted == false) {
        continue;
      }

      // Retrieve the RenderObject, linked to a specific item
      final RenderObject? object = item.context.findRenderObject();

      // If none was to be found, or if not attached, leave by now
      // As we are dealing with Slivers, items no longer part of the
      // viewport will be detached
      if (object == null || !object.attached) {
        continue;
      }

      // Retrieve the viewport related to the scroll area
      final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
      final double vpHeight = viewport.paintBounds.height;
      final ScrollableState scrollableState = Scrollable.of(item.context);
      final ScrollPosition scrollPosition = scrollableState.position;
      final RevealedOffset vpOffset = viewport.getOffsetToReveal(object, 0.0);

      // Retrieve the dimensions of the item
      final Size size = object.semanticBounds.size;

      // Check if the item is in the viewport
      final double deltaTop = vpOffset.offset - scrollPosition.pixels;
      final double deltaBottom = deltaTop + size.height;

      bool isInViewport = false;

      isInViewport = (deltaTop >= 0.0 && deltaTop < vpHeight);
      if (!isInViewport) {
        isInViewport = (deltaBottom > 0.0 && deltaBottom < vpHeight);
      }

      debugPrint(
          ${item.id} --> offset: ${vpOffset.offset} -- VP?: $isInViewport');
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Test in Viewport'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(30.0),
        child: SizedBox(
          width: 200.0,
          height: 300.0,
          child: Container(
            color: Colors.yellow,
            //
            // We are listening to notifications, emitted by
            // the Scrollable
            //
            child: NotificationListener<ScrollNotification>(
              onNotification: (ScrollNotification scroll) {
                // Make sure the page is not in an unstable state
                if (!_streamController.isClosed) {
                  _streamController.add(scroll);
                }

                return true;
              },
              child: ListView.builder(
                itemCount: 10,
                itemBuilder: (BuildContext context, int index) {
                  return _buildItem(index);
                },
              ),
            ),
          ),
        ),
      ),
    );
  }

  //
  // Little trick:  We use a LayoutBuilder to get the context of a certain item
  // so that we can save it for later re-use (at ScrollNotification)
  //
  Widget _buildItem(int index) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        //
        // Record the couple: BuildContext, item index
        //
        _itemsContexts.add(ItemContext(
          context: context,
          id: index,
        ));

        return ListViewItem(itemIndex: index);
      },
    );
  }
}

class ListViewItem extends StatelessWidget {
  const ListViewItem({
    super.key,
    required this.itemIndex,
  });

  final int itemIndex;

  
  Widget build(BuildContext context) {
    return Card(
      child: Container(
        width: 100.0,
        height: 100.0,
        color: Colors.blue,
        child: Center(
          child:
              Text('$itemIndex', style: const TextStyle(color: Colors.white)),
        ),
      ),
    );
  }
}



Explanation

This solution is based on:

  • a NotificationListener

    This NotificationListener intercepts any ScrollNotification event emitted by a Scrollable. In this case a ListView.builder().

  • a BehaviorSubject (= StreamController)

    This BehaviorSubject is only meant to be used to bufferize the ScrollNotification events and only consider them after a certain delay.
    The rationale is to prevent having to do all the computations at each scroll, which would be too much resource consuming.

  • a Set collection

    This collection is aimed at recording the BuildContext of each Widget that is present in the Scrollable (here, in the ListView.builder()).

  • a LayoutBuilder

    This Widget provides the BuildContext (and BoxConstraints) of an item being built. This is very convenient in this example to obtain (and record) the information.

Recording of the items' BuildContext

There are most certainly other solutions, but this is the one that directly came to me... At time of building an item, part of the ListView, I first record its BuildContext through the use of a LayoutBuilder.

This will allow me to iterate the collection of those items to identify the ones which are visible on screen.

Determination whether an item is visible

The method "_onScroll" is the one that computes the visibility of each Widgets.

For each of them, we need to determine whether it is rendered. This is known thanks to the attached property of its RenderObject.

Then, we obtain the reference and dimensions of the Scroll container within the ViewPort.

It becomes then easy to know whether a particular Widget is visible or is not.


Conclusions

I thought this question/answer could be interesting as it involves many concepts and requires doing some computations based on the viewport and BuildContext.

0 Comments
Be the first to give a comment...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Where excellence meets innovation
"your satisfaction is our priority"

© 2024 - Flutteris
email: info@flutteris.com