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 <mfransen@squareup.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Michael Fransen
2026-11-12 12:56:43 -08:00
committed by GitHub
parent 745d2a86e0
commit 2636e986a0
8 changed files with 459 additions and 120 deletions

View File

@@ -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';

View File

@@ -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,

View File

@@ -19,6 +19,7 @@ class _ChannelTile extends ConsumerWidget {
final String? sectionId;
const _ChannelTile({
super.key,
required this.channel,
required this.isUnread,
required this.currentPubkey,

View File

@@ -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<void> 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 {

View File

@@ -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: [

View File

@@ -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<Widget> 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),

View File

@@ -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);
}
}

View File

@@ -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<String> _reachableRows(WidgetTester tester) {
final reachable = <String>{};
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();
});
}