mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
perf(mobile): keep covered pages still while the keyboard moves (#8941)
## Summary A keyboard raised by a route above (the Add members sheet, search, a native picker) changes the app's `MediaQuery` on every animation frame. Covered pages that read `MediaQuery` directly or through a `Scaffold` rebuilt and relaid out all their content for keyboard motion they cannot show. On #4things, typing in Add members rebuilt every visible message of the channel page underneath for each keyboard frame. `CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding` at their last values from when its route was current, while the route is covered. Every other `MediaQueryData` field passes through. A covered page still follows the keyboard down, so when a covering route takes focus and the page's own keyboard closes, the page lowers behind the scrim. A size change (rotation) drops the held values so safe-area padding stays right. When the route becomes current again with the keyboard still above the held value, the page keeps the held values rather than jumping up and riding the keyboard back down. It switches to live values once the keyboard reaches the held value, or as soon as focus is inside the route (the page raised its own keyboard). Otherwise the live values return. It always returns the same widget shape, so covering a page never remounts it. Home, search, the channel page and channel details use it. ## Measurements iPhone 12 mini, Profile build, Builderlab #4things (1,361 members). Flow: open the channel, open its details, tap Add members, tap the search field, then type "chris" one character per second. The window is the 10 s of typing, keyboard included. Both builds also contain the fixes from [#8928](https://github.com/block/buzz/pull/8928), [#8929](https://github.com/block/buzz/pull/8929) and [#8931](https://github.com/block/buzz/pull/8931). | | Before | After | | --- | --- | --- | | UI frame time, total | 3,344 ms | 383 ms | | Frames over 16.7 ms | 101 of 232 | 2 of 297 | | Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms | | UI isolate CPU samples (1 ms) | 2,793 | 868 | | Samples in `MessageContent.build` | 621 | 0 | Single runs per side, not repeated. ## Testing - New widget tests: a covered page doesn't rebuild for keyboard frames and gets the live insets back when it's current again. A current page follows the keyboard. Covering a page keeps its state. A page uncovered with the keyboard still up does not jump, and follows the keyboard once it reaches the held value. A page whose own field takes focus after being uncovered follows the keyboard immediately. A covered page follows its own keyboard closing and takes new padding when rotated. The last test fails (3 mounts instead of 1) against a version that returned a different widget tree when covered. - `just mobile-check mobile-test` passes. - On the phone: Add members search, typing and results behave as before. The channel page and search still move above the keyboard when they're current. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
This commit is contained in:
@@ -48,6 +48,7 @@ import 'shared/relay/relay.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
import 'shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'shared/widgets/buzz_toast_host.dart';
|
||||
import 'shared/widgets/covered_route_keyboard_insets.dart';
|
||||
|
||||
const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0';
|
||||
|
||||
@@ -456,19 +457,21 @@ class App extends HookConsumerWidget {
|
||||
data: (state) => switch (state.status) {
|
||||
AuthStatus.authenticated => HostedAppGate(
|
||||
authenticated: true,
|
||||
child: HomePage(
|
||||
onAgents: showAgents,
|
||||
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
||||
onChooseAgent: () => showAgents(context, createOnOpen: true),
|
||||
child: CoveredRouteKeyboardInsets(
|
||||
child: HomePage(
|
||||
onAgents: showAgents,
|
||||
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
||||
onChooseAgent: () => showAgents(context, createOnOpen: true),
|
||||
),
|
||||
mePageBuilder: (_, chat, workspace) => MePage(
|
||||
sliver: true,
|
||||
newChatAction: chat,
|
||||
newWorkspaceAction: workspace,
|
||||
),
|
||||
settingsPageBuilder: _buildSettingsPage,
|
||||
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
|
||||
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
|
||||
),
|
||||
mePageBuilder: (_, chat, workspace) => MePage(
|
||||
sliver: true,
|
||||
newChatAction: chat,
|
||||
newWorkspaceAction: workspace,
|
||||
),
|
||||
settingsPageBuilder: _buildSettingsPage,
|
||||
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
|
||||
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
|
||||
),
|
||||
),
|
||||
_ => const HostedAppGate(
|
||||
|
||||
@@ -36,6 +36,7 @@ import 'members_sheet.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import '../../shared/read_state/read_state_time.dart';
|
||||
import '../../shared/widgets/covered_route_keyboard_insets.dart';
|
||||
|
||||
part 'channel_details_page.dart';
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/bouncing_dots_indicator.dart';
|
||||
import '../../shared/widgets/concentric_sheet_surface.dart';
|
||||
import '../../shared/widgets/covered_route_keyboard_insets.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/flapping_bee.dart';
|
||||
|
||||
@@ -46,21 +46,23 @@ class ChannelDetailPage extends ConsumerWidget {
|
||||
body: const Center(child: BuzzLoadingIndicator()),
|
||||
);
|
||||
}
|
||||
return _ResolvedChannelDetailPage(
|
||||
key: ValueKey((channel.id, placed)),
|
||||
channel: current,
|
||||
personalChat: placed,
|
||||
welcomeAgent: current.isDm
|
||||
? ref.watch(bestieWelcomeProvider(channel.id))
|
||||
: null,
|
||||
placementKnown:
|
||||
placed ||
|
||||
groups != null ||
|
||||
placement != ChannelRoutePlacement.unresolved,
|
||||
initialAssistant: initialAssistant,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialThreadRouteBehavior: initialThreadRouteBehavior,
|
||||
return CoveredRouteKeyboardInsets(
|
||||
child: _ResolvedChannelDetailPage(
|
||||
key: ValueKey((channel.id, placed)),
|
||||
channel: current,
|
||||
personalChat: placed,
|
||||
welcomeAgent: current.isDm
|
||||
? ref.watch(bestieWelcomeProvider(channel.id))
|
||||
: null,
|
||||
placementKnown:
|
||||
placed ||
|
||||
groups != null ||
|
||||
placement != ChannelRoutePlacement.unresolved,
|
||||
initialAssistant: initialAssistant,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialThreadRouteBehavior: initialThreadRouteBehavior,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ Future<bool?> showChannelDetailsPage({
|
||||
String? sectionId,
|
||||
}) => Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => ChannelDetailsPage(
|
||||
channel: channel,
|
||||
currentPubkey: currentPubkey,
|
||||
onMemberTap: onMemberTap,
|
||||
sectionId: sectionId,
|
||||
builder: (_) => CoveredRouteKeyboardInsets(
|
||||
child: ChannelDetailsPage(
|
||||
channel: channel,
|
||||
currentPubkey: currentPubkey,
|
||||
onMemberTap: onMemberTap,
|
||||
sectionId: sectionId,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../channels/channels_page.dart';
|
||||
import '../search/search_page.dart';
|
||||
import 'messages_filters.dart';
|
||||
import 'messages_navigation_bar.dart';
|
||||
import '../../shared/widgets/covered_route_keyboard_insets.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({
|
||||
@@ -67,7 +68,7 @@ class HomePage extends HookConsumerWidget {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
void openSearch() => Navigator.of(context).push(
|
||||
BottomUpPageRoute<void>(
|
||||
builder: (_) => const SearchPage(),
|
||||
builder: (_) => const CoveredRouteKeyboardInsets(child: SearchPage()),
|
||||
reducedMotion: reducedMotion,
|
||||
),
|
||||
);
|
||||
|
||||
92
mobile/lib/shared/widgets/covered_route_keyboard_insets.dart
Normal file
92
mobile/lib/shared/widgets/covered_route_keyboard_insets.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Holds [child]'s keyboard insets steady while its route is covered.
|
||||
///
|
||||
/// A keyboard raised by a route above (a sheet with a search field, or a
|
||||
/// native picker presented over the app) still changes the app's
|
||||
/// [MediaQuery] on every animation frame. Without this, a covered page that
|
||||
/// reads `MediaQuery.of`, `paddingOf` or `viewInsetsOf` (directly or through
|
||||
/// a [Scaffold]) rebuilds and relays out its whole content for keyboard
|
||||
/// motion it cannot show. While the route is not current, [child] keeps the
|
||||
/// view insets and padding it last had as the current route, except that it
|
||||
/// still follows the keyboard down: when a covering route takes focus and the
|
||||
/// page's own keyboard closes, the page lowers behind the scrim instead of
|
||||
/// jumping when it is uncovered. A size change (rotation) drops the held
|
||||
/// values, so safe-area padding stays right for the new orientation. Every
|
||||
/// other [MediaQueryData] field passes through.
|
||||
///
|
||||
/// When the route becomes current again with the keyboard still above the
|
||||
/// held value (the covering route's keyboard has not finished closing), the
|
||||
/// page keeps the held values instead of jumping up to the live keyboard and
|
||||
/// riding it back down. It releases to the live values once the keyboard
|
||||
/// comes down to the held value, or as soon as focus is inside this route
|
||||
/// (the page raised its own keyboard and has to follow it).
|
||||
class CoveredRouteKeyboardInsets extends HookWidget {
|
||||
const CoveredRouteKeyboardInsets({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = MediaQuery.of(context);
|
||||
final current = ModalRoute.isCurrentOf(context) ?? true;
|
||||
// Focus moving into the route must release the held values even when the
|
||||
// keyboard height is not changing at that moment.
|
||||
useListenable(FocusManager.instance);
|
||||
final focusInside = _hasFocusInside(FocusScope.of(context));
|
||||
// The insets and padding from the last build as the current route, or
|
||||
// the smallest keyboard seen since it was covered at the current size.
|
||||
final held = useRef<(EdgeInsets, EdgeInsets, Size)?>(null);
|
||||
// Whether the last build was as a covered route.
|
||||
final covered = useRef(false);
|
||||
// Whether the route is current again but still shows the held values.
|
||||
final settling = useRef(false);
|
||||
final heldValue = held.value;
|
||||
final sizeChanged = heldValue != null && data.size != heldValue.$3;
|
||||
if (!current) {
|
||||
covered.value = true;
|
||||
settling.value = false;
|
||||
if (heldValue == null ||
|
||||
sizeChanged ||
|
||||
data.viewInsets.bottom <= heldValue.$1.bottom) {
|
||||
held.value = (data.viewInsets, data.padding, data.size);
|
||||
}
|
||||
} else {
|
||||
if (covered.value &&
|
||||
heldValue != null &&
|
||||
!sizeChanged &&
|
||||
!focusInside &&
|
||||
data.viewInsets.bottom > heldValue.$1.bottom) {
|
||||
settling.value = true;
|
||||
}
|
||||
covered.value = false;
|
||||
if (settling.value &&
|
||||
(sizeChanged ||
|
||||
focusInside ||
|
||||
data.viewInsets.bottom <= heldValue!.$1.bottom)) {
|
||||
settling.value = false;
|
||||
}
|
||||
if (!settling.value) {
|
||||
held.value = (data.viewInsets, data.padding, data.size);
|
||||
}
|
||||
}
|
||||
final (viewInsets, padding, _) = held.value!;
|
||||
// Always the same widget shape, so covering never remounts the page.
|
||||
return MediaQuery(
|
||||
data: current && !settling.value
|
||||
? data
|
||||
: data.copyWith(viewInsets: viewInsets, padding: padding),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether primary focus is on a descendant of [scope], not [scope] itself
|
||||
/// (which holds focus when nothing inside the route has it).
|
||||
static bool _hasFocusInside(FocusScopeNode scope) {
|
||||
final primary = FocusManager.instance.primaryFocus;
|
||||
return primary != null &&
|
||||
primary != scope &&
|
||||
primary.ancestors.contains(scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import 'package:buzz/shared/widgets/covered_route_keyboard_insets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Counts builds and records the insets the page saw last.
|
||||
class _Probe extends StatelessWidget {
|
||||
const _Probe(this.builds, this.seen);
|
||||
|
||||
final List<int> builds;
|
||||
final List<EdgeInsets> seen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
builds[0]++;
|
||||
seen.add(MediaQuery.viewInsetsOf(context));
|
||||
MediaQuery.paddingOf(context);
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a covered page ignores keyboard motion and gets the live '
|
||||
'insets back when it is current again', (tester) async {
|
||||
final builds = [0];
|
||||
final seen = <EdgeInsets>[];
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
var insets = EdgeInsets.zero;
|
||||
late StateSetter setOuter;
|
||||
// Built once, so only the inherited MediaQuery changes below.
|
||||
final app = MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setOuter = setState;
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(400, 800),
|
||||
viewInsets: insets,
|
||||
padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34),
|
||||
),
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
|
||||
// A transparent route above, as the native member picker uses.
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final coveredBuilds = builds[0];
|
||||
|
||||
// Keyboard frames for the covering route's field.
|
||||
for (final bottom in [80.0, 160.0, 240.0, 300.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(builds[0], coveredBuilds);
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
|
||||
// Uncovered while the covering route's keyboard is still up: the page
|
||||
// stays where it was instead of jumping up, then takes live values once
|
||||
// the keyboard is gone.
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
for (final bottom in [200.0, 100.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
}
|
||||
setOuter(() => insets = EdgeInsets.zero);
|
||||
await tester.pump();
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
setOuter(() => insets = const EdgeInsets.only(bottom: 120));
|
||||
await tester.pump();
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 120));
|
||||
});
|
||||
|
||||
testWidgets('an uncovered page never rises above the live keyboard and '
|
||||
'follows it once it reaches the held value', (tester) async {
|
||||
final builds = [0];
|
||||
final seen = <EdgeInsets>[];
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
var insets = const EdgeInsets.only(bottom: 300);
|
||||
late StateSetter setOuter;
|
||||
final app = MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setOuter = setState;
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(400, 800),
|
||||
viewInsets: insets,
|
||||
),
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// The page's keyboard closes to 100 behind the covering route, whose own
|
||||
// field then raises the keyboard back to 300.
|
||||
for (final bottom in [200.0, 100.0, 200.0, 300.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 100));
|
||||
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 100));
|
||||
for (final bottom in [250.0, 150.0, 100.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 100));
|
||||
}
|
||||
setOuter(() => insets = const EdgeInsets.only(bottom: 40));
|
||||
await tester.pump();
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 40));
|
||||
});
|
||||
|
||||
testWidgets('an uncovered page follows the keyboard its own field raises', (
|
||||
tester,
|
||||
) async {
|
||||
final builds = [0];
|
||||
final seen = <EdgeInsets>[];
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
var insets = EdgeInsets.zero;
|
||||
late StateSetter setOuter;
|
||||
final app = MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: _Probe(builds, seen)),
|
||||
TextField(focusNode: focusNode),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setOuter = setState;
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(400, 800),
|
||||
viewInsets: insets,
|
||||
),
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
setOuter(() => insets = const EdgeInsets.only(bottom: 300));
|
||||
await tester.pump();
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
|
||||
// The page's own field takes focus while the keyboard is still up, so the
|
||||
// page has to follow the keyboard rather than hold below it.
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 300));
|
||||
setOuter(() => insets = const EdgeInsets.only(bottom: 280));
|
||||
await tester.pump();
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 280));
|
||||
});
|
||||
|
||||
testWidgets('a covered page follows its own keyboard closing', (
|
||||
tester,
|
||||
) async {
|
||||
final builds = [0];
|
||||
final seen = <EdgeInsets>[];
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
var insets = const EdgeInsets.only(bottom: 300);
|
||||
late StateSetter setOuter;
|
||||
final app = MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setOuter = setState;
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(400, 800),
|
||||
viewInsets: insets,
|
||||
padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34),
|
||||
),
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 300));
|
||||
|
||||
// A sheet takes focus, so the page's keyboard closes behind it.
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
for (final bottom in [200.0, 100.0, 0.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
expect(seen.last, EdgeInsets.only(bottom: bottom));
|
||||
}
|
||||
|
||||
// A keyboard the covering route raises is still ignored.
|
||||
final coveredBuilds = builds[0];
|
||||
for (final bottom in [80.0, 160.0, 300.0]) {
|
||||
setOuter(() => insets = EdgeInsets.only(bottom: bottom));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(builds[0], coveredBuilds);
|
||||
expect(seen.last, EdgeInsets.zero);
|
||||
});
|
||||
|
||||
testWidgets('a covered page takes the new padding when rotated', (
|
||||
tester,
|
||||
) async {
|
||||
EdgeInsets? padding;
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
var landscape = false;
|
||||
var keyboard = 0.0;
|
||||
late StateSetter setOuter;
|
||||
final app = MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
padding = MediaQuery.paddingOf(context);
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setOuter = setState;
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: landscape ? const Size(800, 400) : const Size(400, 800),
|
||||
padding: landscape
|
||||
? const EdgeInsets.symmetric(horizontal: 44)
|
||||
: const EdgeInsets.only(top: 44, bottom: 34),
|
||||
viewInsets: EdgeInsets.only(bottom: keyboard),
|
||||
),
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(padding, const EdgeInsets.only(top: 44, bottom: 34));
|
||||
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The covering route raises its own keyboard, so only the size change can
|
||||
// release the held portrait padding.
|
||||
setOuter(() => keyboard = 300);
|
||||
await tester.pump();
|
||||
expect(padding, const EdgeInsets.only(top: 44, bottom: 34));
|
||||
|
||||
setOuter(() => landscape = true);
|
||||
await tester.pump();
|
||||
expect(padding, const EdgeInsets.symmetric(horizontal: 44));
|
||||
});
|
||||
|
||||
testWidgets('a current page follows the keyboard', (tester) async {
|
||||
final builds = [0];
|
||||
final seen = <EdgeInsets>[];
|
||||
await tester.pumpWidget(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 800)),
|
||||
child: MaterialApp(
|
||||
home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: Size(400, 800),
|
||||
viewInsets: EdgeInsets.only(bottom: 300),
|
||||
),
|
||||
child: MaterialApp(
|
||||
home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(seen.last, const EdgeInsets.only(bottom: 300));
|
||||
});
|
||||
|
||||
testWidgets('covering the page keeps its state', (tester) async {
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
final mounts = <int>[0];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: CoveredRouteKeyboardInsets(child: _MountCounter(mounts)),
|
||||
),
|
||||
);
|
||||
expect(mounts.single, 1);
|
||||
|
||||
navigator.currentState!.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mounts.single, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _MountCounter extends HookWidget {
|
||||
const _MountCounter(this.mounts);
|
||||
|
||||
final List<int> mounts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
useEffect(() {
|
||||
mounts[0]++;
|
||||
return null;
|
||||
}, const []);
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user