🚀
[Flutter]pageviewのスクロール状態を検知(スクロール開始、page切り替え、スクロール完了)
Flutterプロジェクト開発では、上下にスクロールするためにPageViewを使用しました。通常のPageViewでは、onPageChangedコールバックはページが中央にスライドすると、次のページのpageIndex値が渡されます。しかし、実際の要件では、ページがスクロールが終了した時点で、ページが完全に表示された後に処理を実行する必要があります。そのため、PageViewをカスタマイズする必要があります
pageview、onPageChanged が途中でコールバックを返す理由
カスタム PageView の完全なコードは次のとおりです。
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
// Having this global (mutable) page controller is a bit of a hack. We need it
// to plumb in the factory for _PagePosition, but it will end up accumulating
// a large list of scroll positions. As long as you don't try to actually
// control the scroll positions, everything should be fine.
final PageController _defaultPageController = PageController();
const PageScrollPhysics _kPagePhysics = PageScrollPhysics();
/// A scrollable list that works page by page.
///
/// Each child of a page view is forced to be the same size as the viewport.
///
/// You can use a [PageController] to control which page is visible in the view.
/// In addition to being able to control the pixel offset of the content inside
/// the [CustomPageView], a [PageController] also lets you control the offset in terms
/// of pages, which are increments of the viewport size.
///
/// The [PageController] can also be used to control the
/// [PageController.initialPage], which determines which page is shown when the
/// [CustomPageView] is first constructed, and the [PageController.viewportFraction],
/// which determines the size of the pages as a fraction of the viewport size.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=J1gE9xvph-A}
///
/// {@tool dartpad}
/// Here is an example of [CustomPageView]. It creates a centered [Text] in each of the three pages
/// which scroll horizontally.
///
/// ** See code in examples/api/lib/widgets/page_view/page_view.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [PageController], which controls which page is visible in the view.
/// * [SingleChildScrollView], when you need to make a single child scrollable.
/// * [ListView], for a scrollable list of boxes.
/// * [GridView], for a scrollable grid of boxes.
/// * [ScrollNotification] and [NotificationListener], which can be used to watch
/// the scroll position without using a [ScrollController].
class CustomPageView extends StatefulWidget {
/// Creates a scrollable list that works page by page from an explicit [List]
/// of widgets.
///
/// This constructor is appropriate for page views with a small number of
/// 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.
///
/// Like other widgets in the framework, this widget expects that
/// the [children] list will not be mutated after it has been passed in here.
/// See the documentation at [SliverChildListDelegate.children] for more details.
///
/// {@template flutter.widgets.PageView.allowImplicitScrolling}
/// The [allowImplicitScrolling] parameter must not be null. If true, the
/// [CustomPageView] 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 [CustomPageView].
/// {@endtemplate}
CustomPageView({
super.key,
this.scrollDirection = Axis.horizontal,
this.reverse = false,
PageController? controller,
this.physics,
this.pageSnapping = true,
this.onPageChanged,
this.onPageStartChanged,
this.onPageEndChanged,
List<Widget> children = const <Widget>[],
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
this.scrollBehavior,
this.padEnds = true,
}) : controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildListDelegate(children);
/// Creates a scrollable list that works page by page using widgets that are
/// created on demand.
///
/// This constructor is appropriate for page views with a large (or infinite)
/// number of children because the builder is called only for those children
/// that are actually visible.
///
/// Providing a non-null [itemCount] lets the [CustomPageView] compute the maximum
/// scroll extent.
///
/// [itemBuilder] will be called only with indices greater than or equal to
/// zero and less than [itemCount].
///
/// {@macro flutter.widgets.ListView.builder.itemBuilder}
///
/// {@template flutter.widgets.PageView.findChildIndexCallback}
/// The [findChildIndexCallback] corresponds to the
/// [SliverChildBuilderDelegate.findChildIndexCallback] property. If null,
/// a child widget may not map to its existing [RenderObject] when the order
/// of children returned from the children builder changes.
/// This may result in state-loss. This callback needs to be implemented if
/// the order of the children may change at a later time.
/// {@endtemplate}
///
/// {@macro flutter.widgets.PageView.allowImplicitScrolling}
CustomPageView.builder({
super.key,
this.scrollDirection = Axis.horizontal,
this.reverse = false,
PageController? controller,
this.physics,
this.pageSnapping = true,
this.onPageChanged,
this.onPageStartChanged,
this.onPageEndChanged,
required NullableIndexedWidgetBuilder itemBuilder,
ChildIndexGetter? findChildIndexCallback,
int? itemCount,
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
this.scrollBehavior,
this.padEnds = true,
}) : controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildBuilderDelegate(
itemBuilder,
findChildIndexCallback: findChildIndexCallback,
childCount: itemCount,
);
/// Creates a scrollable list that works page by page with a custom child
/// model.
///
/// {@tool snippet}
///
/// This [CustomPageView] uses a custom [SliverChildBuilderDelegate] to support child
/// reordering.
///
/// ```dart
/// class MyPageView extends StatefulWidget {
/// const MyPageView({super.key});
///
/// @override
/// State<MyPageView> createState() => _MyPageViewState();
/// }
///
/// class _MyPageViewState extends State<MyPageView> {
/// List<String> items = <String>['1', '2', '3', '4', '5'];
///
/// void _reverse() {
/// setState(() {
/// items = items.reversed.toList();
/// });
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// body: SafeArea(
/// child: PageView.custom(
/// childrenDelegate: SliverChildBuilderDelegate(
/// (BuildContext context, int index) {
/// return KeepAlive(
/// data: items[index],
/// key: ValueKey<String>(items[index]),
/// );
/// },
/// childCount: items.length,
/// findChildIndexCallback: (Key key) {
/// final ValueKey<String> valueKey = key as ValueKey<String>;
/// final String data = valueKey.value;
/// return items.indexOf(data);
/// }
/// ),
/// ),
/// ),
/// bottomNavigationBar: BottomAppBar(
/// child: Row(
/// mainAxisAlignment: MainAxisAlignment.center,
/// children: <Widget>[
/// TextButton(
/// onPressed: () => _reverse(),
/// child: const Text('Reverse items'),
/// ),
/// ],
/// ),
/// ),
/// );
/// }
/// }
///
/// class KeepAlive extends StatefulWidget {
/// const KeepAlive({super.key, required this.data});
///
/// final String data;
///
/// @override
/// State<KeepAlive> createState() => _KeepAliveState();
/// }
///
/// class _KeepAliveState extends State<KeepAlive> with AutomaticKeepAliveClientMixin{
/// @override
/// bool get wantKeepAlive => true;
///
/// @override
/// Widget build(BuildContext context) {
/// super.build(context);
/// return Text(widget.data);
/// }
/// }
/// ```
/// {@end-tool}
///
/// {@macro flutter.widgets.PageView.allowImplicitScrolling}
CustomPageView.custom({
super.key,
this.scrollDirection = Axis.horizontal,
this.reverse = false,
PageController? controller,
this.physics,
this.pageSnapping = true,
this.onPageChanged,
this.onPageStartChanged,
this.onPageEndChanged,
required this.childrenDelegate,
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
this.scrollBehavior,
this.padEnds = true,
}) : controller = controller ?? _defaultPageController;
/// 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;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
/// The [Axis] along which the scroll view's offset increases with each page.
///
/// For the direction in which active scrolling may be occurring, see
/// [ScrollDirection].
///
/// Defaults to [Axis.horizontal].
final Axis scrollDirection;
/// Whether the page view scrolls in the reading direction.
///
/// For example, if the reading direction is left-to-right and
/// [scrollDirection] is [Axis.horizontal], then the page view scrolls from
/// left to right when [reverse] is false and from right to left when
/// [reverse] is true.
///
/// Similarly, if [scrollDirection] is [Axis.vertical], then the page view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
final bool reverse;
/// An object that can be used to control the position to which this page
/// view is scrolled.
final PageController controller;
/// How the page view should respond to user input.
///
/// For example, determines how the page view continues to animate after the
/// user stops dragging the page view.
///
/// The physics are modified to snap to page boundaries using
/// [PageScrollPhysics] prior to being used.
///
/// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
/// [ScrollPhysics] provided by that behavior will take precedence after
/// [physics].
///
/// Defaults to matching platform conventions.
final ScrollPhysics? physics;
/// Set to false to disable page snapping, useful for custom scroll behavior.
///
/// If the [padEnds] is false and [PageController.viewportFraction] < 1.0,
/// the page will snap to the beginning of the viewport; otherwise, the page
/// will snap to the center of the viewport.
final bool pageSnapping;
/// Called whenever the page in the center of the viewport changes.
final ValueChanged<int>? onPageChanged;
final ValueChanged<int>? onPageStartChanged;
final ValueChanged<int>? onPageEndChanged;
/// A delegate that provides the children for the [CustomPageView].
///
/// The [PageView.custom] constructor lets you specify this delegate
/// explicitly. The [CustomPageView] and [PageView.builder] constructors create a
/// [childrenDelegate] that wraps the given [List] and [IndexedWidgetBuilder],
/// respectively.
final SliverChildDelegate childrenDelegate;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
///
/// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be
/// modified by default to not apply a [Scrollbar].
final ScrollBehavior? scrollBehavior;
/// Whether to add padding to both ends of the list.
///
/// If this is set to true and [PageController.viewportFraction] < 1.0, padding will be added
/// such that the first and last child slivers will be in the center of
/// the viewport when scrolled all the way to the start or end, respectively.
///
/// If [PageController.viewportFraction] >= 1.0, this property has no effect.
///
/// This property defaults to true and must not be null.
final bool padEnds;
State<CustomPageView> createState() => _CustomPageViewState();
}
class _CustomPageViewState extends State<CustomPageView> {
int _lastReportedPage = 0;
int _currentReportedPage = 0;
void initState() {
super.initState();
_lastReportedPage = widget.controller.initialPage;
_currentReportedPage = widget.controller.initialPage;
}
AxisDirection _getDirection(BuildContext context) {
switch (widget.scrollDirection) {
case Axis.horizontal:
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection);
return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection;
case Axis.vertical:
return widget.reverse ? AxisDirection.up : AxisDirection.down;
}
}
Widget build(BuildContext context) {
final AxisDirection axisDirection = _getDirection(context);
final ScrollPhysics physics = _ForceImplicitScrollPhysics(
allowImplicitScrolling: widget.allowImplicitScrolling,
).applyTo(
widget.pageSnapping
? _kPagePhysics
.applyTo(widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context))
: widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context),
);
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
/// スクロール開始
if (notification.depth == 0 &&
widget.onPageStartChanged != null &&
notification is ScrollStartNotification) {
widget.onPageStartChanged!(_currentReportedPage);
}
/// スクロール中、切り替え
if (notification.depth == 0 &&
widget.onPageChanged != null &&
notification is ScrollUpdateNotification) {
final PageMetrics metrics = notification.metrics as PageMetrics;
final int currentPage = metrics.page!.round();
if (currentPage != _lastReportedPage) {
_currentReportedPage = currentPage;
_lastReportedPage = currentPage;
widget.onPageChanged!(currentPage);
}
}
/// スクロール終了
if (notification.depth == 0 &&
widget.onPageEndChanged != null &&
notification is ScrollEndNotification) {
widget.onPageEndChanged!(_currentReportedPage);
}
return false;
},
child: Scrollable(
dragStartBehavior: widget.dragStartBehavior,
axisDirection: axisDirection,
controller: widget.controller,
physics: physics,
restorationId: widget.restorationId,
scrollBehavior:
widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false),
viewportBuilder: (BuildContext context, ViewportOffset position) {
return Viewport(
// 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,
clipBehavior: widget.clipBehavior,
slivers: <Widget>[
SliverFillViewport(
viewportFraction: widget.controller.viewportFraction,
delegate: widget.childrenDelegate,
padEnds: widget.padEnds,
),
],
);
},
),
);
}
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(EnumProperty<Axis>('scrollDirection', widget.scrollDirection));
description.add(FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed'));
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'));
}
}
class _ForceImplicitScrollPhysics extends ScrollPhysics {
const _ForceImplicitScrollPhysics({
required this.allowImplicitScrolling,
super.parent,
});
_ForceImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _ForceImplicitScrollPhysics(
allowImplicitScrolling: allowImplicitScrolling,
parent: buildParent(ancestor),
);
}
final bool allowImplicitScrolling;
}
Discussion