scroll_forwarder 1.0.0+1
scroll_forwarder: ^1.0.0+1 copied to clipboard
Forwards unclaimed pointer scroll input to a target scroll position.
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 realScrollablebehaves 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) #
PointerSignalEvents are routed through the PointerSignalResolver, 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:
# 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:
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:
positionProvideris invoked for every signal and drag. Returnnullto 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, aBloc, orScrollable.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
Scrollableregisters 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.