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(); + }); }