From 2636e986a06ca5b34027d27cd1c08b36f649ce41 Mon Sep 17 00:00:00 2001 From: Michael Fransen Date: Thu, 12 Nov 2026 12:56:43 -0800 Subject: [PATCH] fix(mobile): build channel section rows lazily (#9401) ## Summary Improve scrolling performance in the Messages channel list by building Solo and Team rows only as needed. Sections currently mount all their rows as one scroll item, which causes long UI stalls with large channel lists. In manual iPhone runs with 500 channels, maximum observed Flutter UI work fell from **65.5 ms to 4.2 ms** after adding lazy rows to a build with header isolation (#9400). The tester reported much smoother scrolling. This was an exploratory comparison with different gestures, not a controlled benchmark of this independent branch. Use stable channel keys and retain section controls, sorting, and collapse animations. Clip the sliver extent during collapse so the animation does not mount the entire section. The shared transition supports downward vertical lists. This is an independent production-only change. Test fixtures and diagnostic instrumentation are excluded; regression checks were run locally and are not included in this PR. ### Related issue Related: #5911 addresses scrolling behavior inside conversations. This change addresses eager row mounting in the Messages channel inventory. No matching open PR or issue was identified in the focused duplicate search. ### Testing On a physical iPhone with 500 channels against an isolated local relay, maximum Flutter UI work fell from 65.5 ms to 4.2 ms after adding lazy rows to a build that already isolated header rebuilds. The tester reported much smoother scrolling. Gestures differed between runs, so this is not a controlled benchmark or an isolated measurement of this branch on its own. The device run preceded the refresh onto current main. Local-only regression journeys passed 12 tests on this independent branch. They cover page laziness and updates, variable-height rows, large text, the last row, collapse reversal, reduced motion, and semantics. These checks are excluded from the commit. Smaller visible jumps remain under investigation; this PR does not claim to resolve them. Generated with Codex --------- Signed-off-by: Michael Fransen Signed-off-by: Tom Brow Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../lib/features/channels/channels_page.dart | 1 + .../features/channels/channels_page/body.dart | 16 +- .../channels/channels_page/channel_tile.dart | 1 + .../channels/channels_page/sections.dart | 111 +++++----- mobile/lib/features/channels/me/me_page.dart | 38 ++-- .../lib/features/channels/me/me_section.dart | 117 +++++----- .../widgets/collapsible_sliver_body.dart | 199 ++++++++++++++++++ .../widgets/collapsible_sliver_body_test.dart | 96 +++++++++ 8 files changed, 459 insertions(+), 120 deletions(-) create mode 100644 mobile/lib/shared/widgets/collapsible_sliver_body.dart create mode 100644 mobile/test/shared/widgets/collapsible_sliver_body_test.dart diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c07dc79ae2..acfe94be4b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:buzz/shared/widgets/adaptive_conversation_pane.dart'; import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; +import '../../shared/widgets/collapsible_sliver_body.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/hosted/hosted_relay_auth.dart'; import '../../shared/hosted/organization_environment.dart'; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 307317c213..3415e88290 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -366,14 +366,15 @@ class _SliverChannelsList extends HookConsumerWidget { return SliverPadding( padding: EdgeInsets.zero, - sliver: SliverList.list( - children: [ + sliver: SliverMainAxisGroup( + slivers: [ if (visibleChannels.isEmpty) - const _EmptyState() + const SliverToBoxAdapter(child: _EmptyState()) else ...[ // Starred channels (exclusive — pinned above all sections). if (starredStreamChannels.isNotEmpty) _ChannelSection( + key: const ValueKey('starred'), title: 'Starred', expanded: starredExpanded.value, onToggle: () => starredExpanded.value = !starredExpanded.value, @@ -391,8 +392,9 @@ class _SliverChannelsList extends HookConsumerWidget { for (final section in userSections) ...[ if (section != userSections.first || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.xxs), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), _CustomChannelSection( + key: ValueKey(section.id), section: section, channels: sortChannelsForList( streamChannels @@ -486,8 +488,9 @@ class _SliverChannelsList extends HookConsumerWidget { ), ], if (userSections.isNotEmpty || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.xxs), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), _ChannelSection( + key: const ValueKey('channels'), title: 'Channels', expanded: channelsExpanded.value, onToggle: () => channelsExpanded.value = !channelsExpanded.value, @@ -501,8 +504,9 @@ class _SliverChannelsList extends HookConsumerWidget { onSortModeChange: (mode) => setSortMode('channels', mode), onSelectChannel: onSelectChannel, ), - const SizedBox(height: Grid.xxs), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), _ChannelSection( + key: const ValueKey('dms'), title: 'DMs', expanded: dmsExpanded.value, onToggle: () => dmsExpanded.value = !dmsExpanded.value, diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index d3a12e9721..e86b02b1b9 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -19,6 +19,7 @@ class _ChannelTile extends ConsumerWidget { final String? sectionId; const _ChannelTile({ + super.key, required this.channel, required this.isUnread, required this.currentPubkey, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 9b02181862..62e2736d4a 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -23,6 +23,7 @@ class _CustomChannelSection extends StatelessWidget { final void Function(Channel channel) onMarkChannelRead; const _CustomChannelSection({ + super.key, required this.section, required this.channels, required this.unreadChannelIds, @@ -44,11 +45,10 @@ class _CustomChannelSection extends StatelessWidget { }); @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CustomSectionHeader( + Widget build(BuildContext context) => SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: _CustomSectionHeader( section: section, expanded: expanded, isFirst: isFirst, @@ -61,28 +61,33 @@ class _CustomChannelSection extends StatelessWidget { sortMode: sortMode, onSortModeChange: onSortModeChange, ), - CollapsibleSectionBody( - expanded: expanded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final channel in channels) - _ChannelTile( - channel: channel, - isUnread: unreadChannelIds.contains(channel.id), - hasAttention: attentionChannelIds.contains(channel.id), - isMuted: mutedChannelIds.contains(channel.id), - currentPubkey: currentPubkey, - onTap: () => onSelectChannel(channel), - onMarkRead: () => onMarkChannelRead(channel), - sectionId: section.id, - ), - ], - ), + ), + CollapsibleSliverBody( + expanded: expanded, + sliver: SliverList.builder( + itemCount: channels.length, + findChildIndexCallback: (key) { + final index = channels.indexWhere((c) => ValueKey(c.id) == key); + return index < 0 ? null : index; + }, + itemBuilder: (context, index) { + final channel = channels[index]; + return _ChannelTile( + key: ValueKey(channel.id), + channel: channel, + isUnread: unreadChannelIds.contains(channel.id), + hasAttention: attentionChannelIds.contains(channel.id), + isMuted: mutedChannelIds.contains(channel.id), + currentPubkey: currentPubkey, + onTap: () => onSelectChannel(channel), + onMarkRead: () => onMarkChannelRead(channel), + sectionId: section.id, + ); + }, ), - ], - ); - } + ), + ], + ); } class _CustomSectionHeader extends StatelessWidget { @@ -369,6 +374,7 @@ class _ChannelSection extends StatelessWidget { final Future Function(Channel channel) onSelectChannel; const _ChannelSection({ + super.key, required this.title, required this.expanded, required this.onToggle, @@ -384,24 +390,22 @@ class _ChannelSection extends StatelessWidget { }); @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionHeader( + Widget build(BuildContext context) => SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: _SectionHeader( label: title, expanded: expanded, onToggle: onToggle, sortMode: sortMode, onSortModeChange: onSortModeChange, ), - CollapsibleSectionBody( - expanded: expanded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (channels.isEmpty) - Padding( + ), + CollapsibleSliverBody( + expanded: expanded, + sliver: channels.isEmpty + ? SliverToBoxAdapter( + child: Padding( padding: const EdgeInsets.only( left: _kChannelLabelInset, right: _kChannelSectionInset, @@ -419,10 +423,20 @@ class _ChannelSection extends StatelessWidget { ), ), ), - ) - else - for (final channel in channels) - _ChannelTile( + ), + ) + : SliverList.builder( + itemCount: channels.length, + findChildIndexCallback: (key) { + final index = channels.indexWhere( + (c) => ValueKey(c.id) == key, + ); + return index < 0 ? null : index; + }, + itemBuilder: (context, index) { + final channel = channels[index]; + return _ChannelTile( + key: ValueKey(channel.id), channel: channel, isUnread: unreadChannelIds.contains(channel.id), hasAttention: attentionChannelIds.contains(channel.id), @@ -431,13 +445,12 @@ class _ChannelSection extends StatelessWidget { onTap: () => onSelectChannel(channel), onMarkRead: null, sectionId: null, - ), - ], - ), - ), - ], - ); - } + ); + }, + ), + ), + ], + ); } class _EmptyState extends StatelessWidget { diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index aee4b4fb91..040a381b4e 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -230,27 +230,32 @@ class MePage extends HookConsumerWidget { ? 0 : MediaQuery.viewPaddingOf(context).bottom + Grid.gutter, ), - sliver: SliverList.list( - children: [ + sliver: SliverMainAxisGroup( + slivers: [ if (offlineSaveFailed) - Semantics( - liveRegion: true, - child: Column( - children: [ - const Text( - 'Your chats are available, but couldn’t be saved for offline use.', - ), - TextButton( - onPressed: () => ref.invalidate(meGroupsProvider), - child: const Text('Retry offline save'), - ), - ], + SliverToBoxAdapter( + child: Semantics( + liveRegion: true, + child: Column( + children: [ + const Text( + 'Your chats are available, but couldn’t be saved for offline use.', + ), + TextButton( + onPressed: () => ref.invalidate(meGroupsProvider), + child: const Text('Retry offline save'), + ), + ], + ), ), ), for (final group in organization.groups) ...[ if (group != organization.groups.first) - const SizedBox(height: Grid.xxs), + const SliverToBoxAdapter( + child: SizedBox(height: Grid.xxs), + ), MeSection( + sliver: true, key: ValueKey(group['id']), label: group['name'] as String, children: [ @@ -268,9 +273,10 @@ class MePage extends HookConsumerWidget { ), ], if (organization.groups.isNotEmpty) - const SizedBox(height: Grid.xxs), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), if (visible.isNotEmpty || organization.groups.isNotEmpty) MeSection( + sliver: true, key: const ValueKey('me-ungrouped-chats'), label: 'Chats', children: [ diff --git a/mobile/lib/features/channels/me/me_section.dart b/mobile/lib/features/channels/me/me_section.dart index e9a43d753a..8fc735ef50 100644 --- a/mobile/lib/features/channels/me/me_section.dart +++ b/mobile/lib/features/channels/me/me_section.dart @@ -4,13 +4,22 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../../../shared/theme/theme.dart'; import '../../../shared/theme/buzz_icons.dart'; import '../../../shared/widgets/collapsible_section_body.dart'; +import '../../../shared/widgets/collapsible_sliver_body.dart'; /// A project or unassigned-chat section matching the Channels list headings. class MeSection extends HookWidget { - const MeSection({super.key, required this.label, required this.children}); + const MeSection({ + super.key, + required this.label, + required this.children, + this.sliver = false, + }); final String label; + /// Render rows individually in the containing scroll view. + final bool sliver; + final List children; @override @@ -18,58 +27,68 @@ class MeSection extends HookWidget { final expanded = useState(true); final color = navigationHeadingForeground(context); final reducedMotion = MediaQuery.disableAnimationsOf(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - label: label, - excludeSemantics: true, - onTap: () => expanded.value = !expanded.value, - button: true, - expanded: expanded.value, - child: InkWell( - borderRadius: BorderRadius.circular(Radii.md), - onTap: () => expanded.value = !expanded.value, - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.xxs, - Grid.twelve, - Grid.xxs, - Grid.half, - ), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 28), - child: Row( - children: [ - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: navigationSectionTextStyle.copyWith( - color: color, - ), - ), - ), - const SizedBox(width: Grid.quarter), - AnimatedRotation( - turns: expanded.value ? 0 : -0.25, - duration: reducedMotion - ? Duration.zero - : sectionExpandDuration, - curve: sectionExpandCurve, - child: Icon( - BuzzIcons.chevronDown, - size: 14, - color: navigationAccessoryForeground(context), - ), - ), - ], + final header = Semantics( + label: label, + excludeSemantics: true, + onTap: () => expanded.value = !expanded.value, + button: true, + expanded: expanded.value, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.md), + onTap: () => expanded.value = !expanded.value, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.xxs, + Grid.twelve, + Grid.xxs, + Grid.half, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 28), + child: Row( + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: navigationSectionTextStyle.copyWith(color: color), + ), ), - ), + const SizedBox(width: Grid.quarter), + AnimatedRotation( + turns: expanded.value ? 0 : -0.25, + duration: reducedMotion + ? Duration.zero + : sectionExpandDuration, + curve: sectionExpandCurve, + child: Icon( + BuzzIcons.chevronDown, + size: 14, + color: navigationAccessoryForeground(context), + ), + ), + ], ), ), ), + ), + ); + if (sliver) { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: header), + CollapsibleSliverBody( + expanded: expanded.value, + sliver: SliverList.list(children: children), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, CollapsibleSectionBody( expanded: expanded.value, child: Column(children: children), diff --git a/mobile/lib/shared/widgets/collapsible_sliver_body.dart b/mobile/lib/shared/widgets/collapsible_sliver_body.dart new file mode 100644 index 0000000000..1f9b640a97 --- /dev/null +++ b/mobile/lib/shared/widgets/collapsible_sliver_body.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'collapsible_section_body.dart'; + +/// The section-body transition for a lazy, downward-scrolling vertical sliver. +/// +/// Clips the section's extent instead of shrinking every row to zero, which +/// would force a lazy list to mount all its rows near the end of a collapse. +class CollapsibleSliverBody extends HookWidget { + const CollapsibleSliverBody({ + required this.expanded, + required this.sliver, + super.key, + }); + + final bool expanded; + final Widget sliver; + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final controller = useAnimationController(initialValue: expanded ? 1 : 0); + final animation = useMemoized( + () => CurvedAnimation( + parent: controller, + curve: sectionExpandCurve, + reverseCurve: sectionCollapseCurve, + ), + [controller], + ); + useEffect(() => animation.dispose, [animation]); + useEffect(() { + controller.duration = sectionExpandDuration; + controller.reverseDuration = sectionCollapseDuration; + if (reducedMotion) { + controller.value = expanded ? 1 : 0; + } else if (expanded) { + unawaited(controller.forward()); + } else { + unawaited(controller.reverse()); + } + return null; + }, [controller, expanded, reducedMotion]); + + return AnimatedBuilder( + animation: animation, + child: sliver, + builder: (context, child) { + final value = animation.value.clamp(0.0, 1.0); + if (value == 0) return const SliverToBoxAdapter(); + return SliverIgnorePointer( + ignoring: !expanded, + sliver: SliverFadeTransition( + opacity: animation, + sliver: _ClippedSectionSliver( + factor: value, + expanded: expanded, + sliver: child!, + ), + ), + ); + }, + ); + } +} + +class _ClippedSectionSliver extends SingleChildRenderObjectWidget { + const _ClippedSectionSliver({ + required this.factor, + required this.expanded, + required Widget sliver, + }) : super(child: sliver); + + final double factor; + final bool expanded; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderClippedSection(factor, expanded); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderClippedSection renderObject, + ) { + renderObject + ..factor = factor + ..expanded = expanded; + } +} + +class _RenderClippedSection extends RenderProxySliver { + _RenderClippedSection(this._factor, this._expanded); + + double _factor; + set factor(double value) { + if (_factor == value) return; + _factor = value; + markNeedsLayout(); + } + + bool _expanded; + set expanded(bool value) { + if (_expanded == value) return; + _expanded = value; + markNeedsSemanticsUpdate(); + } + + double get _scale => + sectionCollapsedScaleY + (1 - sectionCollapsedScaleY) * _factor; + + Matrix4 get _transform => Matrix4.identity() + ..translateByDouble(0, -constraints.scrollOffset * (1 - _scale), 0, 1) + ..scaleByDouble(1, _scale, 1, 1); + + @override + void performLayout() { + assert(constraints.axisDirection == AxisDirection.down); + child!.layout(constraints, parentUsesSize: true); + final full = child!.geometry!; + if (full.scrollOffsetCorrection != null || _factor == 1) { + geometry = full; + return; + } + final extent = full.scrollExtent * _factor; + final paint = math.min( + full.paintExtent, + calculatePaintOffset(constraints, from: 0, to: extent), + ); + geometry = SliverGeometry( + scrollExtent: extent, + paintExtent: paint, + layoutExtent: math.min(full.layoutExtent, paint), + maxPaintExtent: full.maxPaintExtent * _factor, + cacheExtent: math.min( + full.cacheExtent, + calculateCacheOffset(constraints, from: 0, to: extent), + ), + hitTestExtent: math.min(full.hitTestExtent, paint), + hasVisualOverflow: full.hasVisualOverflow || _factor < 1, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (!geometry!.visible) return; + if (_factor == 1) { + super.paint(context, offset); + return; + } + context.pushClipRect( + needsCompositing, + offset, + Rect.fromLTWH(0, 0, constraints.crossAxisExtent, geometry!.paintExtent), + (context, offset) => context.pushTransform( + needsCompositing, + offset, + _transform, + (context, offset) => context.paintChild(child!, offset), + ), + ); + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) => BoxHitTestResult.wrap(result).addWithOutOfBandPosition( + paintTransform: _transform, + hitTest: (result) => child!.hitTest( + SliverHitTestResult.wrap(result), + mainAxisPosition: + (mainAxisPosition + constraints.scrollOffset * (1 - _scale)) / _scale, + crossAxisPosition: crossAxisPosition, + ), + ); + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + transform.multiply(_transform); + } + + @override + Rect? describeApproximatePaintClip(covariant RenderObject child) => + _factor == 1 + ? null + : Rect.fromLTWH(0, 0, constraints.crossAxisExtent, geometry!.paintExtent); + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + if (_expanded) super.visitChildrenForSemantics(visitor); + } +} diff --git a/mobile/test/shared/widgets/collapsible_sliver_body_test.dart b/mobile/test/shared/widgets/collapsible_sliver_body_test.dart new file mode 100644 index 0000000000..5058b82854 --- /dev/null +++ b/mobile/test/shared/widgets/collapsible_sliver_body_test.dart @@ -0,0 +1,96 @@ +import 'package:buzz/shared/widgets/collapsible_sliver_body.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _rowCount = 10; + +/// Labels of row nodes a screen reader can reach (present and not hidden). +Set _reachableRows(WidgetTester tester) { + final reachable = {}; + void visit(SemanticsNode node) { + if (node.label.startsWith('Row ') && !node.flagsCollection.isHidden) { + reachable.add(node.label); + } + node.visitChildren((child) { + visit(child); + return true; + }); + } + + visit(tester.getSemantics(find.byKey(const Key('root')))); + return reachable; +} + +void main() { + testWidgets('screen readers reach only rows inside the opening clip', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var expanded = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics( + key: const Key('root'), + explicitChildNodes: true, + child: StatefulBuilder( + builder: (context, setState) => CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: TextButton( + onPressed: () => setState(() => expanded = !expanded), + child: const Text('Toggle'), + ), + ), + CollapsibleSliverBody( + expanded: expanded, + sliver: SliverList.builder( + itemCount: _rowCount, + itemBuilder: (_, index) => Semantics( + key: ValueKey('row-$index'), + button: true, + label: 'Row $index', + excludeSemantics: true, + child: const SizedBox(height: 48), + ), + ), + ), + const SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Text('Following section'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Toggle')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + final clipBottom = tester.getTopLeft(find.text('Following section')).dy; + final reachable = _reachableRows(tester); + for (var i = 0; i < _rowCount; i++) { + final rowTop = tester.getTopLeft(find.byKey(ValueKey('row-$i'))).dy; + expect( + reachable.contains('Row $i'), + rowTop < clipBottom, + reason: 'Row $i top=$rowTop, painted extent ends at $clipBottom', + ); + } + expect(reachable, {for (var i = 0; i < 4; i++) 'Row $i'}); + + await tester.pumpAndSettle(); + expect(_reachableRows(tester), { + for (var i = 0; i < _rowCount; i++) 'Row $i', + }); + + handle.dispose(); + }); +}