mirror of
https://github.com/hanskokx/scroll_forwarder.git
synced 2026-08-12 06:40:52 +02:00
first commit
This commit is contained in:
+31
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
export "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<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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user