first commit

This commit is contained in:
2026-07-31 17:09:04 +02:00
commit 747ed57141
8 changed files with 606 additions and 0 deletions
+31
View File
@@ -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/
+28
View File
@@ -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.
+124
View File
@@ -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<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
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.
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
library;
export "src/scroll_forwarder.dart";
+117
View File
@@ -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<ScrollForwarder> createState() => _ScrollForwarderState();
}
class _ScrollForwarderState extends State<ScrollForwarder> {
Drag? _drag;
Set<PointerDeviceKind> _dragDevices = const <PointerDeviceKind>{};
Map<Type, GestureRecognizerFactory> _gestureRecognizers =
const <Type, GestureRecognizerFactory>{};
@override
void didChangeDependencies() {
super.didChangeDependencies();
final Set<PointerDeviceKind> devices = ScrollConfiguration.of(
context,
).dragDevices;
if (setEquals(devices, _dragDevices)) return;
_dragDevices = devices;
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => 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,
),
);
}
}
+20
View File
@@ -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
+279
View File
@@ -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<ScrollController> 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<void> 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<PointerDeviceKind> get dragDevices => const {PointerDeviceKind.touch};
}
class _SecondDevicesScrollBehavior extends ScrollBehavior {
const _SecondDevicesScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => const {PointerDeviceKind.touch};
}