mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ class _ChannelTile extends ConsumerWidget {
|
||||
final String? sectionId;
|
||||
|
||||
const _ChannelTile({
|
||||
super.key,
|
||||
required this.channel,
|
||||
required this.isUnread,
|
||||
required this.currentPubkey,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
199
mobile/lib/shared/widgets/collapsible_sliver_body.dart
Normal file
199
mobile/lib/shared/widgets/collapsible_sliver_body.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
96
mobile/test/shared/widgets/collapsible_sliver_body_test.dart
Normal file
96
mobile/test/shared/widgets/collapsible_sliver_body_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user