diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6be7b..40c1d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ `Scaffold(resizeToAvoidBottomInset: false)`). No-op when insets are already zero, and does not affect `BottomBarScope.barHeight` or `BottomBarBodyPadding`. +- `BottomBarController.reportScroll({required double delta})`: drives the + bar's threshold/reverse/`hideOnScroll` visibility rules from a scroll + source that never emits `ScrollNotification`s, such as an embedded WebView. + Visibility-only — it does not affect `scrollToStart()`/`scrollToEnd()`, + which still require a real `ScrollPosition`. ### Changed @@ -28,6 +33,11 @@ `borderRadius` to 28 so passing `layout:` no longer squares the bar. Pass `BorderRadius.zero` to opt out. +### Fixed + +- Hidden bar descendants are excluded from keyboard focus, so a `TextField` + in the floating child cannot be tab-focused while the bar is hidden. + ## 2.1.0 ### Added diff --git a/README.md b/README.md index c63b376..524c708 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,21 @@ BottomBar( ) ``` +### Non-notification scroll sources (e.g. WebView) + +`BottomBar` normally drives visibility from `ScrollNotification`s bubbled up +from `body`. Some content — most notably an embedded WebView — scrolls +internally without ever emitting one. For those cases, call +`BottomBarController.reportScroll` from the WebView's own scroll callback: + +```dart +controller.reportScroll(delta: newPixels - oldPixels); +``` + +This drives the same threshold/reverse/`hideOnScroll` visibility rules as +body scrolling. It only affects visibility — `scrollToStart()`/`scrollToEnd()` +still require a real `ScrollPosition`, which a WebView cannot provide. + ### Custom transitions Custom transition builders must preserve the child's layout footprint. Use @@ -339,6 +354,7 @@ animating. | `show()` / `hide()` / `toggle()` | Imperative visibility controls. | | `scrollToStart()` | Always scrolls the last active scrollable to its minimum extent. | | `scrollToEnd()` | Always scrolls the last active scrollable to its maximum extent. | +| `reportScroll(delta: ...)` | Drives visibility from a source that doesn't emit `ScrollNotification`s (e.g. WebView). Visibility only; doesn't affect `scrollToStart`/`scrollToEnd`. | A controller can own only one live bar at a time. Double-attach fails in both debug and release, and visibility updates are accepted only from the owning bar diff --git a/example/pubspec.lock b/example/pubspec.lock index 5ec69a4..7903ea6 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.19.1" cupertino_ui: - dependency: transitive + dependency: "direct main" description: name: cupertino_ui sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 @@ -99,10 +99,10 @@ packages: dependency: transitive description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -139,10 +139,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -163,10 +163,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" motor: dependency: transitive description: @@ -232,18 +232,18 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" vector_math: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 2a7cc87..c75a980 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -13,7 +13,10 @@ dependencies: flutter_floating_bottom_bar: path: ../ - material_ui: ^1.0.0 + # Upper bounds mirror the parent package: material_ui 1.3.0 and + # cupertino_ui 1.1.0 need a newer Dart than Flutter 3.44.4 ships. + material_ui: ">=1.0.0 <1.3.0" + cupertino_ui: ">=1.0.0 <1.1.0" dev_dependencies: flutter_test: diff --git a/lib/src/bottom_bar.dart b/lib/src/bottom_bar.dart index 437de0d..5d8e210 100644 --- a/lib/src/bottom_bar.dart +++ b/lib/src/bottom_bar.dart @@ -351,6 +351,12 @@ class _BottomBarState extends State _setBarVisible(true, notifyCallbacks: true, fromController: true); } + @override + void reportScroll({required double delta}) { + if (!mounted) return; + _dispatcher.handleDelta(delta); + } + /// Returns the [NestedScrollViewState] enclosing [context], or `null` if the /// active scrollable is not inside a [NestedScrollView] (or the context is no /// longer mounted). diff --git a/lib/src/bottom_bar_controller.dart b/lib/src/bottom_bar_controller.dart index a52694e..0ce34a9 100644 --- a/lib/src/bottom_bar_controller.dart +++ b/lib/src/bottom_bar_controller.dart @@ -38,8 +38,8 @@ class BottomBarController extends ChangeNotifier { /// Whether this controller is currently attached to a live [BottomBar]. /// - /// [show], [hide], [toggle], [scrollToStart], and [scrollToEnd] are no-ops - /// when [isAttached] is `false`. + /// [show], [hide], [toggle], [reportScroll], [scrollToStart], and + /// [scrollToEnd] are no-ops when [isAttached] is `false`. bool get isAttached => _binding != null; /// Shows the bar, animating it into view if it is currently hidden. @@ -61,6 +61,24 @@ class BottomBarController extends ChangeNotifier { } } + /// Reports a scroll delta from a source that does not emit + /// [ScrollNotification]s, such as an embedded WebView. + /// + /// [delta] is signed: positive moves toward the end (the usual "hide" + /// direction), negative moves toward the start (the usual "show" + /// direction) — for example `newPixels - oldPixels` from the WebView's own + /// scroll callback. Drives the same threshold/reverse/ + /// [BottomBarScrollBehavior.hideOnScroll] visibility rules as body + /// [ScrollNotification]s. + /// + /// This only affects visibility. It never affects [scrollToStart] or + /// [scrollToEnd], which require a real [ScrollPosition] to have been + /// observed via a [ScrollNotification] first. + /// + /// No-op if the controller is not attached. + void reportScroll({required double delta}) => + _binding?.reportScroll(delta: delta); + /// Animates the most-recently-active scrollable inside [BottomBar.body] to /// its [ScrollPosition.minScrollExtent] (i.e. the start/top boundary). /// @@ -130,4 +148,5 @@ abstract class BottomBarBindingForController { bool get isVisible; void requestVisible(bool visible); Future scrollToBoundary({required bool toEnd}); + void reportScroll({required double delta}); } diff --git a/lib/src/internal/scroll_notification_dispatcher.dart b/lib/src/internal/scroll_notification_dispatcher.dart index 7cbb798..b19fff7 100644 --- a/lib/src/internal/scroll_notification_dispatcher.dart +++ b/lib/src/internal/scroll_notification_dispatcher.dart @@ -31,6 +31,8 @@ class ScrollNotificationDispatcher { final LinkedHashMap _trackedScrollables = LinkedHashMap(); + _TrackedScrollState? _externalState; + ScrollPosition? _lastActivePosition; ScrollPosition? get lastActivePosition => _lastActivePosition; @@ -104,6 +106,29 @@ class ScrollNotificationDispatcher { return; } + _handlePixels(state, pixels); + } + + /// Reports a scroll delta from a source that never emits + /// [ScrollNotification]s (e.g. an embedded WebView). + /// + /// [delta] is signed: positive moves toward the end (the usual hide + /// direction), negative moves toward the start (the usual show direction). + /// Applies the same [deltaThreshold]/[reverse] accumulation as [handle], + /// but tracks its own independent accumulator: it never runs [predicate], + /// never touches [lastActivePosition]/[lastActiveContext], and does not + /// share state with notification-tracked scrollables. + void handleDelta(double delta) { + if (delta == 0) return; + + final state = _externalState ??= _TrackedScrollState( + anchorPixels: 0, + lastPixels: 0, + ); + _handlePixels(state, state.lastPixels + delta); + } + + void _handlePixels(_TrackedScrollState state, double pixels) { final delta = pixels - state.lastPixels; state.lastPixels = pixels; if (delta == 0) return; diff --git a/lib/src/internal/visibility_animator.dart b/lib/src/internal/visibility_animator.dart index 67f4ab2..578534b 100644 --- a/lib/src/internal/visibility_animator.dart +++ b/lib/src/internal/visibility_animator.dart @@ -69,9 +69,12 @@ class VisibilityAnimator extends StatelessWidget { } Widget _wrapInteraction(Widget child) { - return IgnorePointer( - ignoring: !isVisible, - child: ExcludeSemantics(excluding: !isVisible, child: child), + return ExcludeFocus( + excluding: !isVisible, + child: IgnorePointer( + ignoring: !isVisible, + child: ExcludeSemantics(excluding: !isVisible, child: child), + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index db3f111..52da6d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,11 @@ environment: dependencies: flutter: sdk: flutter - material_ui: ^1.0.0 + # Upper bounds are pinned because material_ui 1.3.0 and cupertino_ui 1.1.0 + # use @awaitNotRequired, which the Dart shipped with Flutter 3.44.4 + # cannot compile. Revert to open ranges after bumping past Flutter 3.44. + material_ui: ">=1.0.0 <1.3.0" + cupertino_ui: ">=1.0.0 <1.1.0" motor: ^1.1.0 dev_dependencies: diff --git a/test/bottom_bar_controller_test.dart b/test/bottom_bar_controller_test.dart index 788a1cb..dd38ac0 100644 --- a/test/bottom_bar_controller_test.dart +++ b/test/bottom_bar_controller_test.dart @@ -38,6 +38,10 @@ void main() { await controller.scrollToEnd(); expect(first.lastScrollToEnd, isTrue); expect(second.lastScrollToEnd, isNull); + + controller.reportScroll(delta: 42); + expect(first.lastReportedDelta, 42); + expect(second.lastReportedDelta, isNull); }, ); @@ -103,6 +107,7 @@ class _FakeBinding implements BottomBarBindingForController { bool? requestedVisibility; bool? lastScrollToEnd; + double? lastReportedDelta; @override void requestVisible(bool visible) { @@ -113,4 +118,9 @@ class _FakeBinding implements BottomBarBindingForController { Future scrollToBoundary({required bool toEnd}) async { lastScrollToEnd = toEnd; } + + @override + void reportScroll({required double delta}) { + lastReportedDelta = delta; + } } diff --git a/test/floating_bar_test.dart b/test/floating_bar_test.dart index ad87611..e5fe9f3 100644 --- a/test/floating_bar_test.dart +++ b/test/floating_bar_test.dart @@ -152,6 +152,58 @@ void main() { expect(controller.isVisible, isFalse); }); + testWidgets('reportScroll hides the bar like a body scroll would', ( + tester, + ) async { + final controller = BottomBarController(); + await tester.pumpWidget(buildHarness(controller: controller)); + expect(controller.isVisible, isTrue); + + controller.reportScroll(delta: 80); + await tester.pumpAndSettle(); + + expect(controller.isVisible, isFalse); + }); + + testWidgets('reportScroll shows the bar again on a negative delta', ( + tester, + ) async { + final controller = BottomBarController(); + await tester.pumpWidget(buildHarness(controller: controller)); + + controller.reportScroll(delta: 80); + await tester.pumpAndSettle(); + expect(controller.isVisible, isFalse); + + controller.reportScroll(delta: -80); + await tester.pumpAndSettle(); + + expect(controller.isVisible, isTrue); + }); + + testWidgets('reportScroll respects hideOnScroll: false', (tester) async { + final controller = BottomBarController(); + await tester.pumpWidget( + buildHarness( + controller: controller, + scrollBehavior: const BottomBarScrollBehavior(hideOnScroll: false), + ), + ); + + controller.reportScroll(delta: 80); + await tester.pumpAndSettle(); + + expect(controller.isVisible, isTrue); + }); + + testWidgets('reportScroll on an unattached controller does not throw', ( + tester, + ) async { + final controller = BottomBarController(); + + expect(() => controller.reportScroll(delta: 80), returnsNormally); + }); + testWidgets('showAtStart forces the bar visible at the top boundary', ( tester, ) async { diff --git a/test/hidden_focus_test.dart b/test/hidden_focus_test.dart new file mode 100644 index 0000000..bff6459 --- /dev/null +++ b/test/hidden_focus_test.dart @@ -0,0 +1,47 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_floating_bottom_bar/flutter_floating_bottom_bar.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('hiding the bar removes focus from its descendants', ( + tester, + ) async { + final controller = BottomBarController(); + final focusNode = FocusNode(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BottomBar( + controller: controller, + body: const SizedBox.shrink(), + child: TextField(focusNode: focusNode), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + controller.hide(); + await tester.pumpAndSettle(); + + expect(focusNode.hasFocus, isFalse); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + + controller.show(); + await tester.pumpAndSettle(); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + }); +} diff --git a/test/scroll_notification_dispatcher_test.dart b/test/scroll_notification_dispatcher_test.dart index b52b8f4..a1decba 100644 --- a/test/scroll_notification_dispatcher_test.dart +++ b/test/scroll_notification_dispatcher_test.dart @@ -284,6 +284,89 @@ void main() { expect(events, isEmpty); }); + test('handleDelta absorbs deltas below threshold', () { + final events = []; + final dispatcher = ScrollNotificationDispatcher( + deltaThreshold: 50, + onShouldHide: events.add, + ); + + dispatcher.handleDelta(30); + + expect(events, isEmpty); + }); + + test( + 'handleDelta accumulates positive deltas until crossing the threshold', + () { + final events = []; + final dispatcher = ScrollNotificationDispatcher( + deltaThreshold: 8, + onShouldHide: events.add, + ); + + dispatcher.handleDelta(3); + dispatcher.handleDelta(3); + expect(events, isEmpty); + + dispatcher.handleDelta(3); + expect(events, [isTrue]); + }, + ); + + test('handleDelta emits show after a direction reversal crosses the ' + 'threshold', () { + final events = []; + final dispatcher = ScrollNotificationDispatcher( + deltaThreshold: 8, + onShouldHide: events.add, + ); + + dispatcher.handleDelta(20); + expect(events, [isTrue]); + + dispatcher.handleDelta(-20); + expect(events, [isTrue, isFalse]); + }); + + test('handleDelta reverse=true inverts direction', () { + final events = []; + final dispatcher = ScrollNotificationDispatcher( + deltaThreshold: 8, + reverse: true, + onShouldHide: events.add, + ); + + dispatcher.handleDelta(20); + + expect(events, [isFalse]); + }); + + test('handle and handleDelta track independent accumulators', () { + final events = []; + final dispatcher = ScrollNotificationDispatcher( + deltaThreshold: 8, + onShouldHide: events.add, + ); + final context = _DummyBuildContext(); + + dispatcher.handle( + _FakeUpdate(depth: 0, axis: Axis.vertical, pixels: 0, context: context), + ); + dispatcher.handle( + _FakeUpdate(depth: 0, axis: Axis.vertical, pixels: 6, context: context), + ); + expect(events, isEmpty); + + dispatcher.handleDelta(10); + expect(events, [isTrue]); + + dispatcher.handle( + _FakeUpdate(depth: 0, axis: Axis.vertical, pixels: 8, context: context), + ); + expect(events, [isTrue, isTrue]); + }); + test( 'settling decisions reuse the predicate exactly once per notification', () {