Unverified Commit f4a0511e authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Re-land 'Default Keyboard ScrollActions with PrimaryScrollController' (#70893)

parent a76289bb
...@@ -50,7 +50,7 @@ Future<void> main() async { ...@@ -50,7 +50,7 @@ Future<void> main() async {
print('flutter_test allElements benchmark... (${WidgetsBinding.instance.renderViewElement})'); print('flutter_test allElements benchmark... (${WidgetsBinding.instance.renderViewElement})');
// Make sure we get enough elements to process for consistent benchmark runs // Make sure we get enough elements to process for consistent benchmark runs
int elementCount = collectAllElementsFrom(WidgetsBinding.instance.renderViewElement, skipOffstage: false).length; int elementCount = collectAllElementsFrom(WidgetsBinding.instance.renderViewElement, skipOffstage: false).length;
while (elementCount < 2482) { while (elementCount < 2458) {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
elementCount = collectAllElementsFrom(WidgetsBinding.instance.renderViewElement, skipOffstage: false).length; elementCount = collectAllElementsFrom(WidgetsBinding.instance.renderViewElement, skipOffstage: false).length;
} }
......
...@@ -12,6 +12,12 @@ import 'theme.dart'; ...@@ -12,6 +12,12 @@ import 'theme.dart';
/// The scaffold lays out the navigation bar on top and the content between or /// The scaffold lays out the navigation bar on top and the content between or
/// behind the navigation bar. /// behind the navigation bar.
/// ///
/// When tapping a status bar at the top of the CupertinoPageScaffold, an
/// animation will complete for the current primary [ScrollView], scrolling to
/// the beginning. This is done using the [PrimaryScrollController] that
/// encloses the [ScrollView]. The [ScrollView.primary] flag is used to connect
/// a [ScrollView] to the enclosing [PrimaryScrollController].
///
/// See also: /// See also:
/// ///
/// * [CupertinoTabScaffold], a similar widget for tabbed applications. /// * [CupertinoTabScaffold], a similar widget for tabbed applications.
...@@ -75,11 +81,11 @@ class CupertinoPageScaffold extends StatefulWidget { ...@@ -75,11 +81,11 @@ class CupertinoPageScaffold extends StatefulWidget {
} }
class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> { class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> {
final ScrollController _primaryScrollController = ScrollController();
void _handleStatusBarTap() { void _handleStatusBarTap() {
final ScrollController? _primaryScrollController = PrimaryScrollController.of(context);
// Only act on the scroll controller if it has any attached scroll positions. // Only act on the scroll controller if it has any attached scroll positions.
if (_primaryScrollController.hasClients) { if (_primaryScrollController != null && _primaryScrollController.hasClients) {
_primaryScrollController.animateTo( _primaryScrollController.animateTo(
0.0, 0.0,
// Eyeballed from iOS. // Eyeballed from iOS.
...@@ -163,10 +169,7 @@ class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> { ...@@ -163,10 +169,7 @@ class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> {
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
// The main content being at the bottom is added to the stack first. // The main content being at the bottom is added to the stack first.
PrimaryScrollController( paddedContent,
controller: _primaryScrollController,
child: paddedContent,
),
if (widget.navigationBar != null) if (widget.navigationBar != null)
Positioned( Positioned(
top: 0.0, top: 0.0,
......
...@@ -2670,13 +2670,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -2670,13 +2670,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
// iOS FEATURES - status bar tap, back gesture // iOS FEATURES - status bar tap, back gesture
// On iOS, tapping the status bar scrolls the app's primary scrollable to the // On iOS, tapping the status bar scrolls the app's primary scrollable to the
// top. We implement this by providing a primary scroll controller and // top. We implement this by looking up the primary scroll controller and
// scrolling it to the top when tapped. // scrolling it to the top when tapped.
final ScrollController _primaryScrollController = ScrollController();
void _handleStatusBarTap() { void _handleStatusBarTap() {
if (_primaryScrollController.hasClients) { final ScrollController? _primaryScrollController = PrimaryScrollController.of(context);
if (_primaryScrollController != null && _primaryScrollController.hasClients) {
_primaryScrollController.animateTo( _primaryScrollController.animateTo(
0.0, 0.0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
...@@ -3160,30 +3158,27 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -3160,30 +3158,27 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
return _ScaffoldScope( return _ScaffoldScope(
hasDrawer: hasDrawer, hasDrawer: hasDrawer,
geometryNotifier: _geometryNotifier, geometryNotifier: _geometryNotifier,
child: PrimaryScrollController( child: Material(
controller: _primaryScrollController, color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor,
child: Material( child: AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget? child) {
color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, return CustomMultiChildLayout(
child: AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget? child) { children: children,
return CustomMultiChildLayout( delegate: _ScaffoldLayout(
children: children, extendBody: _extendBody,
delegate: _ScaffoldLayout( extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
extendBody: _extendBody, minInsets: minInsets,
extendBodyBehindAppBar: widget.extendBodyBehindAppBar, minViewPadding: minViewPadding,
minInsets: minInsets, currentFloatingActionButtonLocation: _floatingActionButtonLocation!,
minViewPadding: minViewPadding, floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value,
currentFloatingActionButtonLocation: _floatingActionButtonLocation!, floatingActionButtonMotionAnimator: _floatingActionButtonAnimator,
floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value, geometryNotifier: _geometryNotifier,
floatingActionButtonMotionAnimator: _floatingActionButtonAnimator, previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation!,
geometryNotifier: _geometryNotifier, textDirection: textDirection,
previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation!, isSnackBarFloating: isSnackBarFloating,
textDirection: textDirection, snackBarWidth: snackBarWidth,
isSnackBarFloating: isSnackBarFloating, ),
snackBarWidth: snackBarWidth, );
), }),
);
}),
),
), ),
); );
} }
......
...@@ -16,6 +16,19 @@ import 'scroll_controller.dart'; ...@@ -16,6 +16,19 @@ import 'scroll_controller.dart';
/// This mechanism can be used to provide default behavior for scroll views in a /// This mechanism can be used to provide default behavior for scroll views in a
/// subtree. For example, the [Scaffold] uses this mechanism to implement the /// subtree. For example, the [Scaffold] uses this mechanism to implement the
/// scroll-to-top gesture on iOS. /// scroll-to-top gesture on iOS.
///
/// Another default behavior handled by the PrimaryScrollController is default
/// [ScrollAction]s. If a ScrollAction is not handled by an otherwise focused
/// part of the application, the ScrollAction will be evaluated using the scroll
/// view associated with a PrimaryScrollController, for example, when executing
/// [Shortcuts] key events like page up and down.
///
/// See also:
/// * [ScrollAction], an [Action] that scrolls the [Scrollable] that encloses
/// the current [primaryFocus] or is attached to the PrimaryScrollController.
/// * [Shortcuts], a widget that establishes a [ShortcutManager] to be used
/// by its descendants when invoking an [Action] via a keyboard key
/// combination that maps to an [Intent].
class PrimaryScrollController extends InheritedWidget { class PrimaryScrollController extends InheritedWidget {
/// Creates a widget that associates a [ScrollController] with a subtree. /// Creates a widget that associates a [ScrollController] with a subtree.
const PrimaryScrollController({ const PrimaryScrollController({
......
...@@ -18,7 +18,9 @@ import 'modal_barrier.dart'; ...@@ -18,7 +18,9 @@ import 'modal_barrier.dart';
import 'navigator.dart'; import 'navigator.dart';
import 'overlay.dart'; import 'overlay.dart';
import 'page_storage.dart'; import 'page_storage.dart';
import 'primary_scroll_controller.dart';
import 'restoration.dart'; import 'restoration.dart';
import 'scroll_controller.dart';
import 'transitions.dart'; import 'transitions.dart';
// Examples can assume: // Examples can assume:
...@@ -722,6 +724,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -722,6 +724,7 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
/// The node this scope will use for its root [FocusScope] widget. /// The node this scope will use for its root [FocusScope] widget.
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: '$_ModalScopeState Focus Scope'); final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: '$_ModalScopeState Focus Scope');
final ScrollController primaryScrollController = ScrollController();
@override @override
void initState() { void initState() {
...@@ -802,44 +805,47 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -802,44 +805,47 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
DismissIntent: _DismissModalAction(context), DismissIntent: _DismissModalAction(context),
}, },
child: FocusScope( child: PrimaryScrollController(
node: focusScopeNode, // immutable controller: primaryScrollController,
child: RepaintBoundary( child: FocusScope(
child: AnimatedBuilder( node: focusScopeNode, // immutable
animation: _listenable, // immutable child: RepaintBoundary(
builder: (BuildContext context, Widget? child) { child: AnimatedBuilder(
return widget.route.buildTransitions( animation: _listenable, // immutable
context, builder: (BuildContext context, Widget? child) {
widget.route.animation!, return widget.route.buildTransitions(
widget.route.secondaryAnimation!, context,
// This additional AnimatedBuilder is include because if the widget.route.animation!,
// value of the userGestureInProgressNotifier changes, it's widget.route.secondaryAnimation!,
// only necessary to rebuild the IgnorePointer widget and set // This additional AnimatedBuilder is include because if the
// the focus node's ability to focus. // value of the userGestureInProgressNotifier changes, it's
AnimatedBuilder( // only necessary to rebuild the IgnorePointer widget and set
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false), // the focus node's ability to focus.
builder: (BuildContext context, Widget? child) { AnimatedBuilder(
final bool ignoreEvents = _shouldIgnoreFocusRequest; animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
focusScopeNode.canRequestFocus = !ignoreEvents; builder: (BuildContext context, Widget? child) {
return IgnorePointer( final bool ignoreEvents = _shouldIgnoreFocusRequest;
ignoring: ignoreEvents, focusScopeNode.canRequestFocus = !ignoreEvents;
child: child, return IgnorePointer(
ignoring: ignoreEvents,
child: child,
);
},
child: child,
),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation!,
widget.route.secondaryAnimation!,
); );
}, },
child: child,
), ),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation!,
widget.route.secondaryAnimation!,
);
},
), ),
), ),
), ),
......
...@@ -149,6 +149,11 @@ abstract class ScrollView extends StatelessWidget { ...@@ -149,6 +149,11 @@ abstract class ScrollView extends StatelessWidget {
/// sufficient content to actually scroll. Otherwise, by default the user can /// sufficient content to actually scroll. Otherwise, by default the user can
/// only scroll the view if it has sufficient content. See [physics]. /// only scroll the view if it has sufficient content. See [physics].
/// ///
/// Also when true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the application,
/// the ScrollAction will be evaluated using this scroll view, for example,
/// when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this also identifies the scroll view that will scroll to top in /// On iOS, this also identifies the scroll view that will scroll to top in
/// response to a tap in the status bar. /// response to a tap in the status bar.
/// ///
......
...@@ -17,6 +17,7 @@ import 'focus_manager.dart'; ...@@ -17,6 +17,7 @@ import 'focus_manager.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'notification_listener.dart'; import 'notification_listener.dart';
import 'primary_scroll_controller.dart';
import 'restoration.dart'; import 'restoration.dart';
import 'restoration_properties.dart'; import 'restoration_properties.dart';
import 'scroll_configuration.dart'; import 'scroll_configuration.dart';
...@@ -959,6 +960,10 @@ class ScrollIntent extends Intent { ...@@ -959,6 +960,10 @@ class ScrollIntent extends Intent {
/// An [Action] that scrolls the [Scrollable] that encloses the current /// An [Action] that scrolls the [Scrollable] that encloses the current
/// [primaryFocus] by the amount configured in the [ScrollIntent] given to it. /// [primaryFocus] by the amount configured in the [ScrollIntent] given to it.
/// ///
/// If a Scrollable cannot be found above the current [primaryFocus], the
/// [PrimaryScrollController] will be considered for default handling of
/// [ScrollAction]s.
///
/// If [Scrollable.incrementCalculator] is null for the scrollable, the default /// If [Scrollable.incrementCalculator] is null for the scrollable, the default
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the /// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical /// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
...@@ -967,7 +972,21 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -967,7 +972,21 @@ class ScrollAction extends Action<ScrollIntent> {
@override @override
bool isEnabled(ScrollIntent intent) { bool isEnabled(ScrollIntent intent) {
final FocusNode? focus = primaryFocus; final FocusNode? focus = primaryFocus;
return focus != null && focus.context != null && Scrollable.of(focus.context!) != null; final bool contextIsValid = focus != null && focus.context != null;
if (contextIsValid) {
// Check for primary scrollable within the current context
if (Scrollable.of(focus!.context!) != null)
return true;
// Check for fallback scrollable with context from PrimaryScrollController
if (PrimaryScrollController.of(focus.context!) != null) {
final ScrollController? primaryScrollController = PrimaryScrollController.of(focus.context!);
return primaryScrollController != null
&& primaryScrollController.hasClients
&& primaryScrollController.position.context.notificationContext != null
&& Scrollable.of(primaryScrollController.position.context.notificationContext!) != null;
}
}
return false;
} }
// Returns the scroll increment for a single scroll request, for use when // Returns the scroll increment for a single scroll request, for use when
...@@ -1051,7 +1070,11 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -1051,7 +1070,11 @@ class ScrollAction extends Action<ScrollIntent> {
@override @override
void invoke(ScrollIntent intent) { void invoke(ScrollIntent intent) {
final ScrollableState? state = Scrollable.of(primaryFocus!.context!); ScrollableState? state = Scrollable.of(primaryFocus!.context!);
if (state == null) {
final ScrollController? primaryScrollController = PrimaryScrollController.of(primaryFocus!.context!);
state = Scrollable.of(primaryScrollController!.position.context.notificationContext!);
}
assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent'); assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
assert(state!.position.hasPixels, 'Scrollable must be laid out before it can be scrolled via a ScrollAction'); assert(state!.position.hasPixels, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');
assert(state!.position.viewportDimension != null); assert(state!.position.viewportDimension != null);
......
...@@ -382,7 +382,7 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable { ...@@ -382,7 +382,7 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
} }
} }
/// A widget that establishes an [ShortcutManager] to be used by its descendants /// A widget that establishes a [ShortcutManager] to be used by its descendants
/// when invoking an [Action] via a keyboard key combination that maps to an /// when invoking an [Action] via a keyboard key combination that maps to an
/// [Intent]. /// [Intent].
/// ///
......
...@@ -270,6 +270,11 @@ class SingleChildScrollView extends StatelessWidget { ...@@ -270,6 +270,11 @@ class SingleChildScrollView extends StatelessWidget {
/// Whether this is the primary scroll view associated with the parent /// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController]. /// [PrimaryScrollController].
/// ///
/// When true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the application,
/// the ScrollAction will be evaluated using this scroll view, for example,
/// when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this identifies the scroll view that will scroll to top in /// On iOS, this identifies the scroll view that will scroll to top in
/// response to a tap in the status bar. /// response to a tap in the status bar.
/// ///
......
...@@ -19,43 +19,32 @@ Widget buildSliverAppBarApp({ ...@@ -19,43 +19,32 @@ Widget buildSliverAppBarApp({
bool snap = false, bool snap = false,
double toolbarHeight = kToolbarHeight, double toolbarHeight = kToolbarHeight,
}) { }) {
return Localizations( return MaterialApp(
locale: const Locale('en', 'US'), home: Scaffold(
delegates: const <LocalizationsDelegate<dynamic>>[ body: DefaultTabController(
DefaultMaterialLocalizations.delegate, length: 3,
DefaultWidgetsLocalizations.delegate, child: CustomScrollView(
], primary: true,
child: Directionality( slivers: <Widget>[
textDirection: TextDirection.ltr, SliverAppBar(
child: MediaQuery( title: const Text('AppBar Title'),
data: const MediaQueryData(), floating: floating,
child: Scaffold( pinned: pinned,
body: DefaultTabController( collapsedHeight: collapsedHeight,
length: 3, expandedHeight: expandedHeight,
child: CustomScrollView( toolbarHeight: toolbarHeight,
primary: true, snap: snap,
slivers: <Widget>[ bottom: TabBar(
SliverAppBar( tabs: <String>['A','B','C'].map<Widget>((String t) => Tab(text: 'TAB $t')).toList(),
title: const Text('AppBar Title'), ),
floating: floating,
pinned: pinned,
collapsedHeight: collapsedHeight,
expandedHeight: expandedHeight,
toolbarHeight: toolbarHeight,
snap: snap,
bottom: TabBar(
tabs: <String>['A','B','C'].map<Widget>((String t) => Tab(text: 'TAB $t')).toList(),
),
),
SliverToBoxAdapter(
child: Container(
height: 1200.0,
color: Colors.orange[400],
),
),
],
), ),
), SliverToBoxAdapter(
child: Container(
height: 1200.0,
color: Colors.orange[400],
),
),
],
), ),
), ),
), ),
......
...@@ -135,6 +135,7 @@ void main() { ...@@ -135,6 +135,7 @@ void main() {
' _FocusMarker\n' ' _FocusMarker\n'
' Semantics\n' ' Semantics\n'
' FocusScope\n' ' FocusScope\n'
' PrimaryScrollController\n'
' _ActionsMarker\n' ' _ActionsMarker\n'
' Actions\n' ' Actions\n'
' Builder\n' ' Builder\n'
......
...@@ -2128,12 +2128,11 @@ void main() { ...@@ -2128,12 +2128,11 @@ void main() {
' AnimatedBuilder\n' ' AnimatedBuilder\n'
' DefaultTextStyle\n' ' DefaultTextStyle\n'
' AnimatedDefaultTextStyle\n' ' AnimatedDefaultTextStyle\n'
' _InkFeatures-[GlobalKey#342d0 ink renderer]\n' ' _InkFeatures-[GlobalKey#00000 ink renderer]\n'
' NotificationListener<LayoutChangedNotification>\n' ' NotificationListener<LayoutChangedNotification>\n'
' PhysicalModel\n' ' PhysicalModel\n'
' AnimatedPhysicalModel\n' ' AnimatedPhysicalModel\n'
' Material\n' ' Material\n'
' PrimaryScrollController\n'
' _ScaffoldScope\n' ' _ScaffoldScope\n'
' Scaffold\n' ' Scaffold\n'
' MediaQuery\n' ' MediaQuery\n'
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/gestures.dart' show DragStartBehavior;
...@@ -1259,4 +1260,48 @@ void main() { ...@@ -1259,4 +1260,48 @@ void main() {
semanticChildCount: 4, semanticChildCount: 4,
), throwsAssertionError); ), throwsAssertionError);
}); });
testWidgets('PrimaryScrollController provides fallback ScrollActions', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: CustomScrollView(
primary: true,
slivers: List<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
autofocus: index == 0,
child: SizedBox(key: ValueKey<String>('Box $index'), height: 50.0),
),
);
},
),
),
),
);
final ScrollController controller = PrimaryScrollController.of(
tester.element(find.byType(CustomScrollView))
)!;
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(
tester.getRect(find.byKey(const ValueKey<String>('Box 0'), skipOffstage: false)),
equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0)),
);
await tester.sendKeyEvent(LogicalKeyboardKey.pageDown);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(400.0));
expect(
tester.getRect(find.byKey(const ValueKey<String>('Box 0'), skipOffstage: false)),
equals(const Rect.fromLTRB(0.0, -400.0, 800.0, -350.0)),
);
await tester.sendKeyEvent(LogicalKeyboardKey.pageUp);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(
tester.getRect(find.byKey(const ValueKey<String>('Box 0'), skipOffstage: false)),
equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0)),
);
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment