Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ packages:
source: hosted
version: "1.19.1"
cupertino_ui:
dependency: transitive
dependency: "direct main"
description:
name: cupertino_ui
sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions lib/src/bottom_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ class _BottomBarState extends State<BottomBar>
_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).
Expand Down
23 changes: 21 additions & 2 deletions lib/src/bottom_bar_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
///
Expand Down Expand Up @@ -130,4 +148,5 @@ abstract class BottomBarBindingForController {
bool get isVisible;
void requestVisible(bool visible);
Future<void> scrollToBoundary({required bool toEnd});
void reportScroll({required double delta});
}
25 changes: 25 additions & 0 deletions lib/src/internal/scroll_notification_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class ScrollNotificationDispatcher {
final LinkedHashMap<int, _TrackedScrollState> _trackedScrollables =
LinkedHashMap<int, _TrackedScrollState>();

_TrackedScrollState? _externalState;

ScrollPosition? _lastActivePosition;
ScrollPosition? get lastActivePosition => _lastActivePosition;

Expand Down Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions lib/src/internal/visibility_animator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
);
}
}
Expand Down
6 changes: 5 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions test/bottom_bar_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);

Expand Down Expand Up @@ -103,6 +107,7 @@ class _FakeBinding implements BottomBarBindingForController {

bool? requestedVisibility;
bool? lastScrollToEnd;
double? lastReportedDelta;

@override
void requestVisible(bool visible) {
Expand All @@ -113,4 +118,9 @@ class _FakeBinding implements BottomBarBindingForController {
Future<void> scrollToBoundary({required bool toEnd}) async {
lastScrollToEnd = toEnd;
}

@override
void reportScroll({required double delta}) {
lastReportedDelta = delta;
}
}
52 changes: 52 additions & 0 deletions test/floating_bar_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions test/hidden_focus_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading
Loading