mirror of
https://github.com/hanskokx/scroll_forwarder.git
synced 2026-08-12 06:40:52 +02:00
125 lines
7.1 KiB
Markdown
125 lines
7.1 KiB
Markdown
# 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.
|