From 747ed571417be60962dfc7fb940902bef6a59973 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 31 Jul 2026 17:09:04 +0200 Subject: [PATCH] first commit --- .gitignore | 31 ++++ LICENSE | 28 ++++ README.md | 124 ++++++++++++++ analysis_options.yaml | 4 + lib/scroll_forwarder.dart | 3 + lib/src/scroll_forwarder.dart | 117 ++++++++++++++ pubspec.yaml | 20 +++ test/scroll_forwarder_test.dart | 279 ++++++++++++++++++++++++++++++++ 8 files changed, 606 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 lib/scroll_forwarder.dart create mode 100644 lib/src/scroll_forwarder.dart create mode 100644 pubspec.yaml create mode 100644 test/scroll_forwarder_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f89de39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +build/ +coverage/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..684a19a --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Hans Kokx + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e690aed --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# scroll_forwarder + +A Flutter widget that lets the empty space around a scrollable drive that scrollable. Mouse wheel, trackpad, and touch drags performed outside a scrollable's own hit area are forwarded to a target scroll position. + +This is useful for layouts where a list or feed is centered on screen and the surrounding margins would otherwise swallow scroll input. The widget is a drop-in wrapper: put it around the whole layout, tell it which position to drive, and it handles the rest. + +## Features + +- **Scroll signals** — mouse wheel and two-finger trackpad scrolling anywhere within the wrapper (including dead margins) drives the target position. +- **Drags** — touch/stylus/trackpad drags that start on the wrapper but not on a nested scrollable drive the target position, including fling momentum. +- **Claim-first semantics** — any scrollable nested inside the wrapper keeps full ownership of its own input. The target is only driven when the interaction is *not* claimed by another scrollable. +- **Device aware** — the set of pointer devices that can drag comes from `ScrollConfiguration.of(context).dragDevices`, matching how a real `Scrollable` behaves in the same context. +- **Always current** — the target position is resolved via a callback for every signal and gesture, so the position can change over time (for example when the feed is rebuilt) without the widget being reconfigured. + +## How it works + +Flutter dispatches two kinds of pointer-driven scroll input, and each has its own "who handles it first" mechanism: + +### Scroll signals (wheel / trackpad) + +`PointerSignalEvent`s are routed through the [`PointerSignalResolver`](https://api.flutter.dev/flutter/gestures/PointerSignalResolver-class.html), which delivers the event to the *first* registered handler. A `Scrollable` that sits under the pointer registers itself first, so it wins. `ScrollForwarder` registers its handler too; when the pointer is over empty space the nested scrollable is not in the hit test path, so the resolver falls through to the forwarder, which converts the signal into a call to `position.pointerScroll(delta)`. + +### Drags (touch / trackpad drag) + +Drags are resolved by the gesture arena. A nested `Scrollable` under the pointer claims the gesture and wins. When a drag begins outside any nested scrollable, `ScrollForwarder`'s own `VerticalDragGestureRecognizer` wins and starts a drag on the target position via `position.drag(...)`, the exact same machinery a `Scrollable` uses. This means drags get natural behavior for free: direction is clamped to the vertical axis, the drag follows the finger, and a fling at the end produces ballistic (inertial) scrolling. + +Because the forwarder targets a `ScrollPosition`, the drag is clamped by the target's own `ScrollPhysics` (e.g. `ClampingScrollPhysics` on Android), so overscroll at the ends of the list behaves exactly as if the drag had started on the list itself. + +## Installation + +Add the package to your dependencies: + +```yaml +# pubspec.yaml + +dependencies: + scroll_forwarder: ^1.0.0 # or latest +``` + +## Usage + +Wrap the layout you want to make scrollable and provide a callback that resolves the target position: + +```dart +import "package:flutter/material.dart"; +import "package:scroll_forwarder/scroll_forwarder.dart"; + +class MyWidget extends StatefulWidget { + const MyWidget({super.key}); + + @override + State createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScrollForwarder( + positionProvider: () => _scrollController.hasClients + ? _scrollController.position + : null, + child: Center( + child: SizedBox( + width: 400, + child: ListView( + controller: _scrollController, + children: const [/* ... */], + ), + ), + ), + ); + } +} +``` + +Notes on the example: + +- `positionProvider` is invoked for every signal and drag. Return `null` to ignore the interaction — for example when the scrollable has been disposed or is not visible. The callback is also what makes the widget robust to rebuilds: you can resolve the *current* position from a static holder, a `Bloc`, or `Scrollable.of(context)`, whichever fits your architecture. +- The forwarder does not need the target position to be inside its subtree. The position just has to exist at the time the interaction arrives. +- Nest the forwarder *outside* the scrollable so the scrollable's own hit area still claims input that starts on it. + +## API + +### `ScrollForwarder` + +A `StatefulWidget` with two parameters: + +| Parameter | Type | Description | +| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------- | +| `positionProvider` | `ScrollPosition? Function()` | Resolves the position to receive unclaimed scroll input. Return `null` to ignore the interaction. | +| `child` | `Widget` | The layout to wrap. | + +The widget builds a `Listener` (for pointer signals) wrapped around a `RawGestureDetector` (for drags), both with `HitTestBehavior.translucent`, so only the target's own hit area needs to be interactive. + +## Behavior details + +### Nested scrollables always win + +The forwarder only acts on input that would otherwise be *unclaimed*: + +- **Signals:** a nested `Scrollable` registers its handler first in the hit test path, so it always receives the signal. The forwarder fires only when no nested scrollable is under the pointer. +- **Drags:** a nested `Scrollable`'s recognizer wins the arena for drags that start on it. The forwarder fires only for drags that start outside it. + +The single "scrolls once" behavior you may observe in tests (a signal over the list scrolls exactly once) is the consequence: the nested scrollable handles it, and the resolver ensures the forwarder's handler is not called a second time. + +### Drag devices + +The `VerticalDragGestureRecognizer` is configured with `ScrollConfiguration.of(context).dragDevices`, the same set a `Scrollable` uses. It is re-read on dependency changes, so if the surrounding `ScrollConfiguration` changes (for example across platforms), the recognizer follows. + +### Deltas, slop, and boundaries + +Deltas from drags are applied in full to the target position, the same way a lone scrollable applies them — there is no extra slop buffering on top of the framework's own gesture threshold. The target's `ScrollPhysics` clamps the result, so dragging at the top or bottom of the list behaves exactly like dragging the list itself (including overscroll effects and fling momentum). + +### Signals need content dimensions + +The widget ignores scroll signals while the target position has no content dimensions yet (i.e. its scroll extent has not been established by a layout). This avoids scrolling before the target knows what it can scroll. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..951a110 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:arcane_analysis/analysis_options.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/scroll_forwarder.dart b/lib/scroll_forwarder.dart new file mode 100644 index 0000000..2d9e949 --- /dev/null +++ b/lib/scroll_forwarder.dart @@ -0,0 +1,3 @@ +library; + +export "src/scroll_forwarder.dart"; diff --git a/lib/src/scroll_forwarder.dart b/lib/src/scroll_forwarder.dart new file mode 100644 index 0000000..a3e4ad7 --- /dev/null +++ b/lib/src/scroll_forwarder.dart @@ -0,0 +1,117 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; + +/// Forwards unclaimed pointer scroll interactions within [child]'s bounds to +/// the position resolved by [positionProvider]. +/// +/// A scrollable only reacts to mouse wheel input while the pointer hovers +/// directly over it, and only reacts to drags that begin on it. Wrapping a +/// layout with this widget lets surrounding areas (for example the empty +/// margins beside a centered list) also drive the target scrollable, via +/// wheel/trackpad scroll signals or touch drags. A nested scrollable always +/// claims the interaction first — scroll signals through +/// [PointerSignalResolver] and drags through the gesture arena — so the target +/// only scrolls when the pointer is not interacting with another scrollable. +class ScrollForwarder extends StatefulWidget { + const ScrollForwarder({ + required this.positionProvider, + required this.child, + super.key, + }); + + /// Resolves the scroll position to receive unclaimed scroll interactions. + /// + /// Invoked for every signal or gesture so the current position is always + /// used. Return `null` to ignore the interaction. + final ScrollPosition? Function() positionProvider; + + final Widget child; + + @override + State createState() => _ScrollForwarderState(); +} + +class _ScrollForwarderState extends State { + Drag? _drag; + Set _dragDevices = const {}; + Map _gestureRecognizers = + const {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final Set devices = ScrollConfiguration.of( + context, + ).dragDevices; + if (setEquals(devices, _dragDevices)) return; + + _dragDevices = devices; + _gestureRecognizers = { + VerticalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(supportedDevices: devices), + (VerticalDragGestureRecognizer instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, + ), + }; + } + + @override + void dispose() { + _drag?.cancel(); + super.dispose(); + } + + void _onPointerSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent || event.scrollDelta.dy == 0.0) return; + + final ScrollPosition? position = widget.positionProvider(); + if (position == null || !position.hasContentDimensions) return; + + GestureBinding.instance.pointerSignalResolver.register(event, (_) { + position.pointerScroll(event.scrollDelta.dy); + event.respond(allowPlatformDefault: false); + }); + } + + void _onDragStart(DragStartDetails details) { + final ScrollPosition? position = widget.positionProvider(); + if (position == null) return; + _drag = position.drag(details, _disposeDrag); + } + + void _disposeDrag() { + _drag = null; + } + + void _onDragUpdate(DragUpdateDetails details) { + _drag?.update(details); + } + + void _onDragEnd(DragEndDetails details) { + _drag?.end(details); + } + + void _onDragCancel() { + _drag?.cancel(); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerSignal: _onPointerSignal, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: _gestureRecognizers, + child: widget.child, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..de18704 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: scroll_forwarder +description: Forwards unclaimed pointer scroll input to a target scroll position. +version: 1.0.0 +repository: https://github.com/hanskokx/scroll_forwarder +issue_tracker: https://github.com/hanskokx/scroll_forwarder/issues + +environment: + sdk: ^3.12.1 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + arcane_analysis: ^1.0.5 + flutter_test: + sdk: flutter + +flutter: null diff --git a/test/scroll_forwarder_test.dart b/test/scroll_forwarder_test.dart new file mode 100644 index 0000000..9f73f92 --- /dev/null +++ b/test/scroll_forwarder_test.dart @@ -0,0 +1,279 @@ +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:scroll_forwarder/scroll_forwarder.dart"; + +void main() { + const double listWidth = 200; + const double listHeight = 400; + const Offset margin = Offset(10, listHeight / 2); + const Offset overList = Offset(400, listHeight / 2); + + Future pumpForwarder( + WidgetTester tester, { + ScrollPosition? Function()? positionProvider, + }) async { + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ScrollForwarder( + positionProvider: + positionProvider ?? + () => controller.hasClients ? controller.position : null, + child: Center( + child: SizedBox( + width: listWidth, + height: listHeight, + child: ListView( + controller: controller, + children: const [ + SizedBox(height: 600), + ], + ), + ), + ), + ), + ), + ), + ); + return controller; + } + + Future scrollAt( + WidgetTester tester, + Offset position, + Offset delta, + ) async { + final TestPointer pointer = TestPointer(1, PointerDeviceKind.mouse); + pointer.hover(position); + await tester.sendEventToBinding( + PointerScrollEvent(position: position, scrollDelta: delta), + ); + } + + group("scroll signals", () { + testWidgets("scrolls the target when the pointer is outside the list", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + await scrollAt(tester, margin, const Offset(0, 100)); + + expect(controller.offset, 100.0); + }); + + testWidgets("scrolls once when the pointer is over the list", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + await scrollAt(tester, overList, const Offset(0, 100)); + + expect(controller.offset, 100.0); + + await scrollAt(tester, overList, const Offset(0, -40)); + + expect(controller.offset, 60.0); + }); + + testWidgets("ignores signals when no position is available", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder( + tester, + positionProvider: () => null, + ); + + await scrollAt(tester, margin, const Offset(0, 100)); + + expect(controller.offset, 0.0); + }); + + testWidgets("ignores horizontal-only scroll signals", (tester) async { + final ScrollController controller = await pumpForwarder(tester); + + await scrollAt(tester, margin, const Offset(50, 0)); + + expect(controller.offset, 0.0); + }); + + testWidgets("ignores non-scroll pointer signals", (tester) async { + final ScrollController controller = await pumpForwarder(tester); + + await tester.sendEventToBinding( + const PointerScaleEvent(position: margin), + ); + + expect(controller.offset, 0.0); + }); + + testWidgets("ignores signals when the position has no content dimensions", ( + tester, + ) async { + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ScrollForwarder( + positionProvider: () => + controller.hasClients ? controller.position : null, + child: Scrollable( + controller: controller, + viewportBuilder: (context, position) => const SizedBox(), + ), + ), + ), + ), + ); + + await scrollAt(tester, margin, const Offset(0, 100)); + + expect(controller.offset, 0.0); + }); + }); + + group("drags", () { + testWidgets("dragging up on the margin scrolls the target down", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + final TestGesture gesture = await tester.startGesture(margin); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, -60)); + expect(controller.offset, closeTo(60, 10)); + + await gesture.up(); + await tester.pump(); + expect(controller.offset, greaterThanOrEqualTo(55)); + }); + + testWidgets("dragging down on the margin scrolls the target up", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + controller.jumpTo(120); + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(margin); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 100)); + expect(controller.offset, closeTo(0, 10)); + + await gesture.up(); + await tester.pump(); + expect(controller.offset, inInclusiveRange(0, 20)); + }); + + testWidgets("dragging over the list still scrolls the list once", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + final TestGesture gesture = await tester.startGesture(overList); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, -100)); + expect(controller.offset, closeTo(100, 10)); + + await gesture.up(); + await tester.pump(); + expect(controller.offset, greaterThanOrEqualTo(95)); + }); + + testWidgets("ignores drags when no position is available", (tester) async { + final ScrollController controller = await pumpForwarder( + tester, + positionProvider: () => null, + ); + + final TestGesture gesture = await tester.startGesture(margin); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, -100)); + await gesture.up(); + await tester.pump(); + + expect(controller.offset, 0.0); + }); + + testWidgets("cancels the pending drag when the pointer is released early", ( + tester, + ) async { + final ScrollController controller = await pumpForwarder(tester); + + final TestGesture gesture = await tester.startGesture(margin); + await gesture.up(); + await tester.pump(); + + expect(controller.offset, 0.0); + }); + }); + + group("lifecycle", () { + testWidgets("keeps recognizers when the drag devices are unchanged", ( + tester, + ) async { + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + + Widget build(ScrollBehavior behavior) { + return MaterialApp( + home: Scaffold( + body: ScrollConfiguration( + behavior: behavior, + child: ScrollForwarder( + positionProvider: () => + controller.hasClients ? controller.position : null, + child: Center( + child: SizedBox( + width: listWidth, + height: listHeight, + child: ListView( + controller: controller, + children: const [ + SizedBox(height: 600), + ], + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + build(const _FirstDevicesScrollBehavior()), + ); + + await tester.pumpWidget( + build(const _SecondDevicesScrollBehavior()), + ); + + final TestGesture gesture = await tester.startGesture(margin); + await gesture.moveBy(const Offset(0, -60)); + expect(controller.offset, closeTo(60, 10)); + await gesture.up(); + await tester.pump(); + }); + }); +} + +class _FirstDevicesScrollBehavior extends ScrollBehavior { + const _FirstDevicesScrollBehavior(); + + @override + Set get dragDevices => const {PointerDeviceKind.touch}; +} + +class _SecondDevicesScrollBehavior extends ScrollBehavior { + const _SecondDevicesScrollBehavior(); + + @override + Set get dragDevices => const {PointerDeviceKind.touch}; +}