From 17db4e207cf4b12d9ee0dbb1f1a33ad294dbdc12 Mon Sep 17 00:00:00 2001 From: xubaolin Date: Sat, 26 Jun 2021 05:06:03 +0800 Subject: [PATCH] [new feature]introduce `ScrollMetricsNotification` (#85221) --- .../lib/src/widgets/scroll_position.dart | 126 +++++++++++++++++- .../widgets/scroll_notification_test.dart | 58 ++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 08432fd50d9..7b896b2ada6 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; @@ -10,6 +12,7 @@ import 'package:flutter/scheduler.dart'; import 'basic.dart'; import 'framework.dart'; +import 'notification_listener.dart'; import 'page_storage.dart'; import 'scroll_activity.dart'; import 'scroll_context.dart'; @@ -508,6 +511,17 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ScrollMetrics? _lastMetrics; Axis? _lastAxis; + bool _isMetricsChanged() { + assert(haveDimensions); + final ScrollMetrics currentMetrics = copyWith(); + + return _lastMetrics == null || + !(currentMetrics.extentBefore == _lastMetrics!.extentBefore + && currentMetrics.extentInside == _lastMetrics!.extentInside + && currentMetrics.extentAfter == _lastMetrics!.extentAfter + && currentMetrics.axisDirection == _lastMetrics!.axisDirection); + } + @override bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { assert(minScrollExtent != null); @@ -537,7 +551,14 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { _pendingDimensions = false; } assert(!_didChangeViewportDimensionOrReceiveCorrection, 'Use correctForNewDimensions() (and return true) to change the scroll offset during applyContentDimensions().'); - _lastMetrics = copyWith(); + + if (_isMetricsChanged()) { + // It isn't safe to trigger the ScrollMetricsNotification if we are in + // the middle of rendering the frame, the developer is likely to schedule + // a new frame(build scheduled during frame is illegal). + scheduleMicrotask(didUpdateScrollMetrics); + _lastMetrics = copyWith(); + } return true; } @@ -898,6 +919,13 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { UserScrollNotification(metrics: copyWith(), context: context.notificationContext!, direction: direction).dispatch(context.notificationContext); } + /// Dispatches a notification that the [ScrollMetrics] has changed. + void didUpdateScrollMetrics() { + assert(SchedulerBinding.instance!.schedulerPhase != SchedulerPhase.persistentCallbacks); + if (context.notificationContext != null) + ScrollMetricsNotification(metrics: copyWith(), context: context.notificationContext!).dispatch(context.notificationContext); + } + /// Provides a heuristic to determine if expensive frame-bound tasks should be /// deferred. /// @@ -942,3 +970,99 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { description.add('viewport: ${_viewportDimension?.toStringAsFixed(1)}'); } } + +/// A notification that a scrollable widget's [ScrollMetrics] have changed. +/// +/// For example, when the content of a scrollable is altered, making it larger +/// or smaller, this notification will be dispatched. Similarly, if the size +/// of the window or parent changes, the scrollable can notify of these +/// changes in dimensions. +/// +/// The above behaviors usually do not trigger [ScrollNotification] events, +/// so this is useful for listening to [ScrollMetrics] changes that are not +/// caused by the user scrolling. +/// +/// {@tool dartpad --template=freeform} +/// This sample shows how a [ScrollMetricsNotification] is dispatched when +/// the `windowSize` is changed. Press the floating action button to increase +/// the scrollable window's size. +/// +/// ```dart main +/// import 'package:flutter/material.dart'; +/// +/// void main() => runApp(const ScrollMetricsDemo()); +/// +/// class ScrollMetricsDemo extends StatefulWidget { +/// const ScrollMetricsDemo({Key? key}) : super(key: key); +/// +/// @override +/// State createState() => ScrollMetricsDemoState(); +/// } +/// +/// class ScrollMetricsDemoState extends State { +/// double windowSize = 200.0; +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: Scaffold( +/// appBar: AppBar( +/// title: const Text('ScrollMetrics Demo'), +/// ), +/// floatingActionButton: FloatingActionButton( +/// child: const Icon(Icons.add), +/// onPressed: () => setState(() { +/// windowSize += 10.0; +/// }), +/// ), +/// body: NotificationListener( +/// onNotification: (ScrollMetricsNotification notification) { +/// ScaffoldMessenger.of(notification.context).showSnackBar( +/// const SnackBar( +/// content: Text('Scroll metrics changed!'), +/// ), +/// ); +/// return false; +/// }, +/// child: Scrollbar( +/// isAlwaysShown: true, +/// child: SizedBox( +/// height: windowSize, +/// width: double.infinity, +/// child: const SingleChildScrollView( +/// child: FlutterLogo( +/// size: 300.0, +/// ), +/// ), +/// ), +/// ), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +class ScrollMetricsNotification extends LayoutChangedNotification with ViewportNotificationMixin { + /// Creates a notification that the scrollable widget's [ScrollMetrics] have + /// changed. + ScrollMetricsNotification({ + required this.metrics, + required this.context, + }); + + /// Description of a scrollable widget's [ScrollMetrics]. + final ScrollMetrics metrics; + + /// The build context of the widget that fired this notification. + /// + /// This can be used to find the scrollable widget's render objects to + /// determine the size of the viewport, for instance. + final BuildContext context; + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('$metrics'); + } +} diff --git a/packages/flutter/test/widgets/scroll_notification_test.dart b/packages/flutter/test/widgets/scroll_notification_test.dart index 726d8ea583d..6ea48649baa 100644 --- a/packages/flutter/test/widgets/scroll_notification_test.dart +++ b/packages/flutter/test/widgets/scroll_notification_test.dart @@ -7,6 +7,64 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('ScrollMetricsNotification test', (WidgetTester tester) async { + final List events = []; + Widget buildFrame(double height) { + return NotificationListener( + onNotification: (LayoutChangedNotification value) { + events.add(value); + return false; + }, + child: SingleChildScrollView( + child: SizedBox(height: height), + ), + ); + } + await tester.pumpWidget(buildFrame(1200.0)); + // Initial metrics notification. + expect(events.length, 1); + ScrollMetricsNotification event = events[0] as ScrollMetricsNotification; + expect(event.metrics.extentBefore, 0.0); + expect(event.metrics.extentInside, 600.0); + expect(event.metrics.extentAfter, 600.0); + + events.clear(); + await tester.pumpWidget(buildFrame(1000.0)); + // Change the content dimensions will trigger a new event. + expect(events.length, 1); + event = events[0] as ScrollMetricsNotification; + expect(event.metrics.extentBefore, 0.0); + expect(event.metrics.extentInside, 600.0); + expect(event.metrics.extentAfter, 400.0); + + events.clear(); + final TestGesture gesture = await tester.startGesture(const Offset(100.0, 100.0)); + expect(events.length, 1); + // user scroll do not trigger the ScrollContentMetricsNotification. + expect(events[0] is ScrollStartNotification, true); + + events.clear(); + await gesture.moveBy(const Offset(-10.0, -10.0)); + expect(events.length, 2); + // User scroll do not trigger the ScrollContentMetricsNotification. + expect(events[0] is UserScrollNotification, true); + expect(events[1] is ScrollUpdateNotification, true); + + events.clear(); + // Change the content dimensions again. + await tester.pumpWidget(buildFrame(500.0)); + expect(events.length, 1); + event = events[0] as ScrollMetricsNotification; + expect(event.metrics.extentBefore, 10.0); + expect(event.metrics.extentInside, 590.0); + expect(event.metrics.extentAfter, 0.0); + + events.clear(); + // The content dimensions does not change. + await tester.pumpWidget(buildFrame(500.0)); + expect(events.length, 0); + }); + testWidgets('Scroll notification basics', (WidgetTester tester) async { late ScrollNotification notification;