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