Fix channel list scroll interruption (#5815)

## Summary
- keep transparent channel-list gaps in Flutter's gesture arena
- allow a new drag to interrupt active ballistic scrolling immediately
- add a behavioral fling-and-counter-drag regression test

## Scope audit
- audited mobile list and scroll constructors across `mobile/lib`
- channels is the only app scrollable overriding `hitTestBehavior`
- all other lists retain Flutter's default opaque hit testing and do not
share this defect

## Verification
- regression test fails before the production change: ballistic offset
continues from `271.17` to `345.56`
- focused interruption regression passes with the fix
- profile/community control test passes
- pre-commit: Dart formatting and Flutter analyzer pass
- pre-push: complete mobile suite passes, 1323 tests
- simulator: immediate counter-drag from the transparent gutter
interrupts deceleration

Simulator evidence:
`/Users/wesb/.buzz/.scratch/mobile-scroll-videos/interruption-verified.mp4`

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-13 19:13:06 -07:00
committed by GitHub
co-authored by Carl
parent eedcd886a0
commit 0f61f24ad6
2 changed files with 53 additions and 3 deletions
@@ -45,9 +45,10 @@ class _ChannelsBody extends StatelessWidget {
onRefresh: onRefresh,
child: CustomScrollView(
controller: scrollController,
// The transparent gap shows the top section and must not absorb
// taps meant for the community or profile controls beneath it.
hitTestBehavior: HitTestBehavior.deferToChild,
// Transparent list gaps must remain hit-testable so a new drag
// can interrupt ballistic scrolling. The app bar is painted
// later and retains its community and profile controls.
hitTestBehavior: HitTestBehavior.translucent,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
if (usesPinnedGradient)
@@ -232,6 +232,55 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('interrupts a ballistic scroll from a transparent list gap', (
tester,
) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final channels = List.generate(
40,
(index) => Channel(
id: 'channel-$index',
name: 'channel-$index',
channelType: 'stream',
visibility: 'open',
description: 'Channel $index',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
isMember: true,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
],
),
);
await tester.pumpAndSettle();
final scrollView = find.byType(CustomScrollView);
final scrollable = tester.state<ScrollableState>(
find.descendant(of: scrollView, matching: find.byType(Scrollable)).first,
);
await tester.fling(scrollView, const Offset(0, -300), 2400);
await tester.pump(const Duration(milliseconds: 32));
final ballisticOffset = scrollable.position.pixels;
await tester.pump(const Duration(milliseconds: 32));
expect(scrollable.position.pixels, greaterThan(ballisticOffset));
// x=1 is inside the scroll viewport but outside the padded section rows.
// A drag beginning here must still enter the scrollable's gesture arena.
final interruptingDrag = await tester.startGesture(const Offset(1, 300));
await interruptingDrag.moveBy(const Offset(0, 80));
await tester.pump();
expect(scrollable.position.pixels, lessThan(ballisticOffset));
await interruptingDrag.up();
});
testWidgets('keeps the last channel above the floating tab bar', (
tester,
) async {