Un Widget à l'intérieur d'une Scrollable est-il visible ?

Compatibilité
Date révision
30 mars 2023
Publié le
26 mai 2019
Flutter
v 3.13.x
Dart
v 3.1.x

Question

Dernièrement, j'ai reçu une question très intéressante :

Comment savoir si un Widget, faisant partie d'une ListView (ou GridView), est réellement visible à l'écran et comment détecter s'il devient visible lorsque l'utilisateur 'scrolle' ?

Réponse

Voici le code d'une solution possible. Je vous donnerai les explications après...



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(),
    );
  }
}

///
/// Classe d'aide qui fait la relation entre
/// un index des articles et son 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 pour contenir le BuildContext associé à un item
  //
  late Set<ItemContext> _itemsContexts;

  //
  // Flux pour contrôler les événements de défilement et empêcher
  // faire les calculs à chaque défilement
  //
  late BehaviorSubject<ScrollNotification> _streamController;

  
  void initState() {
    super.initState();

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

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

    //
    // Lorsqu'une notification de défilement est émise, il suffit de mettre un peu de bufferisation
    // afin de ne pas trop calculer
    //
    _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) {
    // Itération de chaque item à vérifier
    // si celui-ci se trouve dans le viewport

    for (var item in _itemsContexts) {
      // On s'assure que le contexte est toujours monté
      if (item.context.mounted == false) {
        continue;
      }

      // Récupérer le RenderObject, lié à un élément spécifique
      final RenderObject? object = item.context.findRenderObject();

      // S'il n'y en avait pas, ou s'il n'était pas attaché, quitter maintenant
      // S'agissant de Slivers, les objets ne faisant plus partie de la vue
      // seront détachés
      if (object == null || !object.attached) {
        continue;
      }

      / Récupérer la fenêtre de visualisation liée à la zone de défilement
      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);

      // Récupérer les dimensions de l'objet
      final Size size = object.semanticBounds.size;

      // Vérifiez si l'élément se trouve dans le 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,
            //
              // Nous sommes à l'écoute des notifications, émises par
              // le Scrolllable
              //
            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);
                },
              ),
            ),
          ),
        ),
      ),
    );
  }

  //
  // Petite astuce :  Nous utilisons un LayoutBuilder pour obtenir le contexte d'un certain article
  // afin que nous puissions le sauvegarder pour une réutilisation ultérieure (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)),
        ),
      ),
    );
  }
}



Explication

Cette solution est basée sur :

  • NotificationListener

    Ce NotificationListener intercepte tout événement de type ScrollNotification, émis par un Scrolllable. Dans ce cas, un ListView.builder().

  • BehaviorSubject (= StreamController)

    Ce BehaviorSubject est uniquement destiné à être utilisé pour mettre en mémoire tampon les événements ScrollNotification et ne les prendre en compte qu'après un certain délai.
    La raison est d'éviter d'avoir à faire tous les calculs à chaque défilement, ce qui consommerait trop de ressources.

  • Set collection

    Cette collection est destinée à enregistrer le BuildContext de chaque Widget présent dans le Scrolllable (ici, dans le ListView.builder()).

  • LayoutBuilder

    Ce Widget fournit le Contexte de construction (et les BoxConstraints) d'un objet en cours de construction. Cela est très pratique dans cet exemple pour obtenir (et enregistrer) les informations.

Enregistrement du BuildContext de l'élément

Il y a certainement d'autres solutions, mais c'est celle qui m'est venue directement... Lors de la construction d'un objet, faisant partie de la ListView, j'enregistre d'abord son BuildContext grâce à l'utilisation d'un LayoutBuilder.

Cela me permet d'itérer la collection de ces éléments pour identifier ceux qui sont visibles à l'écran.

Déterminer si un élément est visible

La méthode "_onScroll" est celle qui calcule la visibilité de chaque Widget.

Pour chacun d'entre eux, nous devons déterminer s'il est rendu. Ceci est connu grâce à la propriété attached de son RenderObject.

Ensuite, nous obtenons la référence et les dimensions du conteneur Scroll dans le ViewPort.

Il devient alors facile de savoir si un Widget particulier est visible ou non.


Conclusions

J'ai pensé que cette question/réponse pourrait être intéressante car elle implique de nombreux concepts et nécessite de faire quelques calculs basés sur le viewport et le BuildContext.

0 Commentaires
Soyez le premier à faire un commentaire...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Quand l'excellence rencontre l'innovation
"votre satisfaction est notre priorité"

© 2024 - Flutteris
email: info@flutteris.com