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 :
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()).
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.