Implicit scrolling for pageview (#45598)

diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart
index b08d0ec..213c63d 100644
--- a/packages/flutter/lib/src/widgets/page_view.dart
+++ b/packages/flutter/lib/src/widgets/page_view.dart
@@ -416,6 +416,25 @@
   }
 }
 
+class _ForceImplicitScrollPhysics extends ScrollPhysics {
+  const _ForceImplicitScrollPhysics({
+    @required this.allowImplicitScrolling,
+    ScrollPhysics parent,
+  }) : assert(allowImplicitScrolling != null),
+       super(parent: parent);
+
+  @override
+  _ForceImplicitScrollPhysics applyTo(ScrollPhysics ancestor) {
+    return _ForceImplicitScrollPhysics(
+      allowImplicitScrolling: allowImplicitScrolling,
+      parent: buildParent(ancestor),
+    );
+  }
+
+  @override
+  final bool allowImplicitScrolling;
+}
+
 /// Scroll physics used by a [PageView].
 ///
 /// These physics cause the page view to snap to page boundaries.
@@ -512,6 +531,13 @@
   /// children because constructing the [List] requires doing work for every
   /// child that could possibly be displayed in the page view, instead of just
   /// those children that are actually visible.
+  ///
+  /// {@template flutter.widgets.pageView.allowImplicitScrolling}
+  /// The [allowImplicitScrolling] parameter must not be null. If true, the
+  /// [PageView] will participate in accessibility scrolling more like a
+  /// [ListView], where implicit scroll actions will move to the next page
+  /// rather than into the contents of the [PageView].
+  /// {@endtemplate}
   PageView({
     Key key,
     this.scrollDirection = Axis.horizontal,
@@ -522,7 +548,9 @@
     this.onPageChanged,
     List<Widget> children = const <Widget>[],
     this.dragStartBehavior = DragStartBehavior.start,
-  }) : controller = controller ?? _defaultPageController,
+    this.allowImplicitScrolling = false,
+  }) : assert(allowImplicitScrolling != null),
+       controller = controller ?? _defaultPageController,
        childrenDelegate = SliverChildListDelegate(children),
        super(key: key);
 
@@ -542,6 +570,8 @@
   /// [PageView.builder] by default does not support child reordering. If
   /// you are planning to change child order at a later time, consider using
   /// [PageView] or [PageView.custom].
+  ///
+  /// {@macro flutter.widgets.pageView.allowImplicitScrolling}
   PageView.builder({
     Key key,
     this.scrollDirection = Axis.horizontal,
@@ -553,7 +583,9 @@
     @required IndexedWidgetBuilder itemBuilder,
     int itemCount,
     this.dragStartBehavior = DragStartBehavior.start,
-  }) : controller = controller ?? _defaultPageController,
+    this.allowImplicitScrolling = false,
+  }) : assert(allowImplicitScrolling != null),
+       controller = controller ?? _defaultPageController,
        childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
        super(key: key);
 
@@ -637,6 +669,8 @@
   /// }
   /// ```
   /// {@end-tool}
+  ///
+  /// {@macro flutter.widgets.pageView.allowImplicitScrolling}
   PageView.custom({
     Key key,
     this.scrollDirection = Axis.horizontal,
@@ -647,10 +681,25 @@
     this.onPageChanged,
     @required this.childrenDelegate,
     this.dragStartBehavior = DragStartBehavior.start,
+    this.allowImplicitScrolling = false,
   }) : assert(childrenDelegate != null),
+       assert(allowImplicitScrolling != null),
        controller = controller ?? _defaultPageController,
        super(key: key);
 
+  /// Controls whether the widget's pages will respond to
+  /// [RenderObject.showOnScreen], which will allow for implicit accessibility
+  /// scrolling.
+  ///
+  /// With this flag set to false, when accessibility focus reaches the end of
+  /// the current page and the user attempts to move it to the next element, the
+  /// focus will traverse to the next widget outside of the page view.
+  ///
+  /// With this flag set to true, when accessibility focus reaches the end of
+  /// the current page and user attempts to move it to the next element, focus
+  /// will traverse to the next page in the page view.
+  final bool allowImplicitScrolling;
+
   /// The axis along which the page view scrolls.
   ///
   /// Defaults to [Axis.horizontal].
@@ -731,9 +780,11 @@
   @override
   Widget build(BuildContext context) {
     final AxisDirection axisDirection = _getDirection(context);
-    final ScrollPhysics physics = widget.pageSnapping
+    final ScrollPhysics physics = _ForceImplicitScrollPhysics(
+      allowImplicitScrolling: widget.allowImplicitScrolling,
+    ).applyTo(widget.pageSnapping
         ? _kPagePhysics.applyTo(widget.physics)
-        : widget.physics;
+        : widget.physics);
 
     return NotificationListener<ScrollNotification>(
       onNotification: (ScrollNotification notification) {
@@ -754,7 +805,11 @@
         physics: physics,
         viewportBuilder: (BuildContext context, ViewportOffset position) {
           return Viewport(
-            cacheExtent: 0.0,
+            // TODO(dnfield): we should provide a way to set cacheExtent
+            // independent of implicit scrolling:
+            // https://github.com/flutter/flutter/issues/45632
+            cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0,
+            cacheExtentStyle: CacheExtentStyle.viewport,
             axisDirection: axisDirection,
             offset: position,
             slivers: <Widget>[
@@ -777,5 +832,6 @@
     description.add(DiagnosticsProperty<PageController>('controller', widget.controller, showName: false));
     description.add(DiagnosticsProperty<ScrollPhysics>('physics', widget.physics, showName: false));
     description.add(FlagProperty('pageSnapping', value: widget.pageSnapping, ifFalse: 'snapping disabled'));
+    description.add(FlagProperty('allowImplicitScrolling', value: widget.allowImplicitScrolling, ifTrue: 'allow implicit scrolling'));
   }
 }
diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart
index 3430dd9..b657ff4 100644
--- a/packages/flutter/lib/src/widgets/scroll_physics.dart
+++ b/packages/flutter/lib/src/widgets/scroll_physics.dart
@@ -259,7 +259,7 @@
   double get dragStartDistanceMotionThreshold => parent?.dragStartDistanceMotionThreshold;
 
   /// Whether a viewport is allowed to change its scroll position implicitly in
-  /// responds to a call to [RenderObject.showOnScreen].
+  /// response to a call to [RenderObject.showOnScreen].
   ///
   /// [RenderObject.showOnScreen] is for example used to bring a text field
   /// fully on screen after it has received focus. This property controls
diff --git a/packages/flutter/test/widgets/page_view_test.dart b/packages/flutter/test/widgets/page_view_test.dart
index 94823e5..efee8e6 100644
--- a/packages/flutter/test/widgets/page_view_test.dart
+++ b/packages/flutter/test/widgets/page_view_test.dart
@@ -784,4 +784,46 @@
     pageController.position.jumpTo(799.99999999999);
     expect(pageController.page, 1);
   });
+
+  testWidgets('PageView can participate in a11y scrolling', (WidgetTester tester) async {
+    final SemanticsTester semantics = SemanticsTester(tester);
+
+    final PageController controller = PageController();
+    await tester.pumpWidget(Directionality(
+      textDirection: TextDirection.ltr,
+      child: PageView(
+          controller: controller,
+          children: List<Widget>.generate(4, (int i) {
+            return Semantics(
+              child: Text('Page #$i'),
+              container: true,
+            );
+          }),
+          allowImplicitScrolling: true,
+        ),
+    ));
+    expect(controller.page, 0);
+
+    expect(semantics, includesNodeWith(flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling]));
+    expect(semantics, includesNodeWith(label: 'Page #0'));
+    expect(semantics, includesNodeWith(label: 'Page #1', flags: <SemanticsFlag>[SemanticsFlag.isHidden]));
+    expect(semantics, isNot(includesNodeWith(label: 'Page #2', flags: <SemanticsFlag>[SemanticsFlag.isHidden])));
+    expect(semantics, isNot(includesNodeWith(label: 'Page #3', flags: <SemanticsFlag>[SemanticsFlag.isHidden])));
+
+    controller.nextPage(duration: const Duration(milliseconds: 150), curve: Curves.ease);
+    await tester.pumpAndSettle();
+    expect(semantics, includesNodeWith(label: 'Page #0', flags: <SemanticsFlag>[SemanticsFlag.isHidden]));
+    expect(semantics, includesNodeWith(label: 'Page #1'));
+    expect(semantics, includesNodeWith(label: 'Page #2', flags: <SemanticsFlag>[SemanticsFlag.isHidden]));
+    expect(semantics, isNot(includesNodeWith(label: 'Page #3', flags: <SemanticsFlag>[SemanticsFlag.isHidden])));
+
+    controller.nextPage(duration: const Duration(milliseconds: 150), curve: Curves.ease);
+    await tester.pumpAndSettle();
+    expect(semantics, isNot(includesNodeWith(label: 'Page #0', flags: <SemanticsFlag>[SemanticsFlag.isHidden])));
+    expect(semantics, includesNodeWith(label: 'Page #1', flags: <SemanticsFlag>[SemanticsFlag.isHidden]));
+    expect(semantics, includesNodeWith(label: 'Page #2'));
+    expect(semantics, includesNodeWith(label: 'Page #3', flags: <SemanticsFlag>[SemanticsFlag.isHidden]));
+
+    semantics.dispose();
+  });
 }