diff --git a/example/lib/main.dart b/example/lib/main.dart index 0745ec5..a0efd0b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,7 @@ import 'package:example/src/animation_example.dart'; import 'package:example/src/complex_example/complex_example.dart'; import 'package:example/src/custom_snap_insets_example.dart'; import 'package:example/src/gutter_example.dart'; +import 'package:example/src/hero_animation_example.dart'; import 'package:example/src/modal_dialog_example.dart'; import 'package:example/src/overshoot_effect_example.dart'; import 'package:example/src/simple_example.dart'; @@ -70,9 +71,13 @@ class Home extends StatelessWidget { onTap: () => pushRoute(const ViewportConfigurationExample()), ), ListTile( - title: const Text("Animation Example"), + title: const Text("Viewport Inset Animation Example"), onTap: () => pushRoute(const AnimationExample()), ), + ListTile( + title: const Text("Hero Animation Example"), + onTap: () => pushRoute(const HeroAnimationExample()), + ), ListTile( title: const Text("Complex Example"), onTap: () => pushRoute(const ComplexExample()), diff --git a/example/lib/src/common.dart b/example/lib/src/common.dart index ffb7bab..2abf877 100644 --- a/example/lib/src/common.dart +++ b/example/lib/src/common.dart @@ -20,7 +20,7 @@ class ExampleListView extends StatefulWidget { class _ExampleListViewState extends State { final Color color = Color.fromARGB( - 220, + 255, Random().nextInt(155) + 100, Random().nextInt(155) + 100, Random().nextInt(155) + 100, @@ -28,8 +28,10 @@ class _ExampleListViewState extends State { @override Widget build(BuildContext context) { - return Container( + return Card( color: color, + margin: EdgeInsets.zero, + shape: const RoundedRectangleBorder(), child: ListView.builder( padding: widget.padding ?? EdgeInsets.zero, controller: widget.controller, diff --git a/example/lib/src/complex_example/album_details.dart b/example/lib/src/complex_example/album_details.dart index 2669850..27e6261 100644 --- a/example/lib/src/complex_example/album_details.dart +++ b/example/lib/src/complex_example/album_details.dart @@ -7,12 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; void showAlbumDetailsDialog(BuildContext context, int index) { - showModalExprollable( - context, - useSafeArea: false, - useRootNavigator: false, - builder: (context) => AlbumDetailsDialog( - index: index, + Navigator.of(context).push( + ModalExprollableRouteBuilder( + pageBuilder: (_, __, ___) => AlbumDetailsDialog(index: index), ), ); } diff --git a/example/lib/src/hero_animation_example.dart b/example/lib/src/hero_animation_example.dart new file mode 100644 index 0000000..59810be --- /dev/null +++ b/example/lib/src/hero_animation_example.dart @@ -0,0 +1,141 @@ +import 'package:example/src/common.dart'; +import 'package:exprollable_page_view/exprollable_page_view.dart'; +import 'package:flutter/material.dart'; + +const colors = [ + Colors.red, + Colors.green, + Colors.blue, + Colors.amber, + Colors.black, + Colors.cyan, + Colors.blueGrey, + Colors.deepOrange, + Colors.purple, + Colors.indigo, + Colors.lime, + ...Colors.accents, +]; + +class HeroAnimationExample extends StatelessWidget { + const HeroAnimationExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: const ExampleBottomAppBar(), + body: GridView.builder( + itemCount: colors.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: (_, index) => Center( + child: HeroFlutterLogo( + color: colors[index], + tag: index, + size: 100, + onTap: () => showDetailsPage(context, index), + ), + ), + ), + ); + } +} + +void showDetailsPage(BuildContext context, int page) => + Navigator.of(context, rootNavigator: true).push( + // You can use `ModalExprollableRouteBuilder` like regular `PageRouteBuilder`. + // See [https://docs.flutter.dev/ui/animations/hero-animations#radial-hero-animations]. + ModalExprollableRouteBuilder( + // This is the only required paramter. + pageBuilder: (context, _, __) { + return PageConfiguration( + initialPage: page, + viewportConfiguration: ViewportConfiguration( + extendPage: true, + overshootEffect: true, + ), + child: ExprollablePageView( + itemCount: colors.length, + itemBuilder: (context, page) { + return PageGutter( + gutterWidth: 12, + child: DetailsPage(page: page), + ); + }, + ), + ); + }, + // Increase the transition durations and take a closer look at what's going on! + transitionDuration: const Duration(milliseconds: 500), + reverseTransitionDuration: const Duration(milliseconds: 300), + // The next two lines are not required, but are recommended for better performance. + backgroundColor: Colors.white, + opaque: true, + ), + ); + +class DetailsPage extends StatelessWidget { + const DetailsPage({ + super.key, + required this.page, + }); + + final int page; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + controller: PageContentScrollController.of(context), + child: Padding( + padding: EdgeInsets.all(16), + child: HeroFlutterLogo( + color: colors[page], + tag: page, + size: 400, + onTap: () => Navigator.of(context).pop(), + ), + ), + ), + ); + } +} + +class HeroFlutterLogo extends StatelessWidget { + const HeroFlutterLogo({ + super.key, + required this.color, + required this.tag, + required this.size, + required this.onTap, + }); + + final int tag; + final Color color; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Hero( + tag: tag, + child: Material( + color: color, + child: InkWell( + onTap: onTap, + child: FlutterLogo( + size: size, + ), + ), + ), + ); + } +} diff --git a/example/lib/src/modal_dialog_example.dart b/example/lib/src/modal_dialog_example.dart index f07e9b9..014901a 100644 --- a/example/lib/src/modal_dialog_example.dart +++ b/example/lib/src/modal_dialog_example.dart @@ -57,10 +57,10 @@ class _MyDialog extends StatefulWidget { required BuildContext context, required bool enableOvershootEffect, }) => - showModalExprollable( - context, - useSafeArea: false, - builder: (context) => _MyDialog(enableOvershootEffect), + Navigator.of(context).push( + ModalExprollableRouteBuilder( + pageBuilder: (context, _, __) => _MyDialog(enableOvershootEffect), + ), ); } diff --git a/package/CHANGELOG.md b/package/CHANGELOG.md index ea8d58d..171ed66 100644 --- a/package/CHANGELOG.md +++ b/package/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## 1.0.0-rc.1 17-05-2023 +## 1.0.0-rc.2 Jun 17, 2023 + +- `PageConfiguration` has been added for implicit definition of page controllers (issue #41) +- The limitations of the overshoot effect have been removed +- `ModalExprollableRouteBuilder` has been introduced to support hero animations (issue #36) + - Accordingly, `ModalExprollable` and `showModalExprollable` is now marked as deprecated +- Fixed issue #39 + +See the migration guide in the README for more information. + +## 1.0.0-rc.1 May 17, 2023 This version contains some breaking changes (see the migraiton guide in README). @@ -8,38 +18,38 @@ This version contains some breaking changes (see the migraiton guide in README). - Terminology was reorganized and some classes and properties were renamed accordingly - Some improvements in the documents -## 1.0.0-beta.8 05-05-2023 +## 1.0.0-beta.8 May 5, 2023 - Fix #25, #26 - Improve the documents -## 1.0.0-beta.7 30-04-2023 +## 1.0.0-beta.7 Apr 30, 2023 - Fix #20 - Add a convenience constructor `ExprollablePageController.withAdditionalSnapOffsets` (proposed in #21) - Improve the documents -## 1.0.0-beta.6 19-04-2023 +## 1.0.0-beta.6 Apr 19, 2023 - Fix #17 -## 1.0.0-beta.5 18-04-2023 +## 1.0.0-beta.5 Apr 18, 2023 - Add doc comments for public APIs -## 1.0.0-beta.4 16-04-2023 +## 1.0.0-beta.4 Apr 16, 2023 - Minor bug fixes -## 1.0.0-beta.3 14-04-2023 +## 1.0.0-beta.3 Apr 14, 2023 - Add utility callbacks (`onViewportChanged`, `onPageChanged`) - Add detailed documentation to README -## 1.0.0-beta.2 13-04-2023 +## 1.0.0-beta.2 Apr 13, 2023 - Fix issues (#1, #2, #3) -## 1.0.0-beta 11-04-2023 +## 1.0.0-beta Apr 11, 2023 The preview version. Not yet well documented. diff --git a/package/README.md b/package/README.md index 4dad0c5..568378b 100644 --- a/package/README.md +++ b/package/README.md @@ -2,32 +2,35 @@ [![Pub](https://img.shields.io/pub/v/exprollable_page_view.svg?logo=flutter&color=blue)](https://pub.dev/packages/exprollable_page_view) [![Pub Popularity](https://img.shields.io/pub/popularity/exprollable_page_view)](https://pub.dev/packages/exprollable_page_view) [![Docs](https://img.shields.io/badge/-API%20Reference-orange)](https://pub.dev/documentation/exprollable_page_view/latest/) [![Demo](https://img.shields.io/badge/Demo-try%20it%20on%20web-blueviolet)](#try-it) -# exprollable_page_view 🐦 +# exprollable_page_view :bird: Yet another PageView widget that expands the viewport of the current page while scrolling it. **Exprollable** is a coined word combining the words expandable and scrollable. This project is an attemt to clone a modal sheet UI used in [Apple Books](https://www.apple.com/jp/apple-books/) app on iOS. Here is an example of what you can do with this widget: - + ## Announcement -### XX-XX-2023 +### Jun. 17, 2023 -The first stable version has been released. See [the migration guide](#100-rcx-to-1x) for more details. +Version 1.0.0-rc.2 has been released. This update contains some changes that require migration from previous versions. See [the migration guild](#100-rc1-arrow_right-100-rc2) for more information. -### 17-05-2023 +Several new features have also been added. Please see the sections below: -Version 1.0.0-rc.1 has been released 🎉. This version includes several breaking changes, so if you are already using ^1.0.0-beta, you may need to migrate according to [the migration guide](#100-betax-to-100-rcx). +- [PageConfiguration](#pageconfiguration) +- [Hero animations](#hero-animations) +### May. 17, 2023 +Version 1.0.0-rc.1 has been released 🎉. This version includes several breaking changes, so if you are already using ^1.0.0-beta, you may need to migrate according to [the migration guide](#100-betax-arrow_right-100-rc1). ## Index -- [exprollable\_page\_view 🐦](#exprollable_page_view-) +- [exprollable\_page\_view :bird:](#exprollable_page_view-bird) - [Announcement](#announcement) - - [XX-XX-2023](#xx-xx-2023) - - [17-05-2023](#17-05-2023) + - [Jun. 17, 2023](#jun-17-2023) + - [May. 17, 2023](#may-17-2023) - [Index](#index) - [Background](#background) - [Try it](#try-it) @@ -39,8 +42,10 @@ Version 1.0.0-rc.1 has been released 🎉. This version includes several breakin - [Viewport fraction and inset](#viewport-fraction-and-inset) - [Overshoot effect](#overshoot-effect) - [ViewportConfiguration](#viewportconfiguration) - - [ModalExprollable](#modalexprollable) + - [PageConfiguration](#pageconfiguration) + - [ModalExprollableRouteBuilder](#modalexprollableroutebuilder) - [Slidable list items](#slidable-list-items) + - [Hero animations](#hero-animations) - [How to](#how-to) - [get the curret page?](#get-the-curret-page) - [make the page view like a BottomSheet?](#make-the-page-view-like-a-bottomsheet) @@ -49,11 +54,14 @@ Version 1.0.0-rc.1 has been released 🎉. This version includes several breakin - [2. Listen `ViewportUpdateNotification`](#2-listen-viewportupdatenotification) - [3. Use `onViewportChanged` callback](#3-use-onviewportchanged-callback) - [add space between pages?](#add-space-between-pages) - - [prevent my app bar going off the screen when overshoote ffect is true?](#prevent-my-app-bar-going-off-the-screen-when-overshoote-ffect-is-true) + - [prevent my app bar going off the screen when overshoot effect is enabled?](#prevent-my-app-bar-going-off-the-screen-when-overshoot-effect-is-enabled) - [animate the viewport state?](#animate-the-viewport-state) + - [remove the empty space at the bottom of the page?](#remove-the-empty-space-at-the-bottom-of-the-page) - [Migration guide](#migration-guide) - - [1.0.0-rc.x to 1.x](#100-rcx-to-1x) - - [1.0.0-beta.x to 1.0.0-rc.x](#100-betax-to-100-rcx) + - [1.0.0-rc.1 :arrow\_right: 1.0.0-rc.2](#100-rc1-arrow_right-100-rc2) + - [Eliminated the limitations of the overshoot effect](#eliminated-the-limitations-of-the-overshoot-effect) + - [Introduced ModalExprollableRouteBuilder](#introduced-modalexprollableroutebuilder) + - [1.0.0-beta.x :arrow\_right: 1.0.0-rc.1](#100-betax-arrow_right-100-rc1) - [PageViewportMetrics update](#pageviewportmetrics-update) - [ViewportController update](#viewportcontroller-update) - [ViewportOffset update](#viewportoffset-update) @@ -78,7 +86,7 @@ Unfortunately, `PageView` widget in flutter framework does not provide ways to d ## Try it -Run the example application and explore the all features of this package. It is also available on [web](https://fujidaiti.github.io/exprollable_page_view/#/) (⚠️ access from a **mobile browser** is recommended). +Run the example application and explore the all features of this package. It is also available on [web](https://fujidaiti.github.io/exprollable_page_view/#/) (⚠️ **mouse wheel scrolling is not currently supported**, see [#37](https://github.com/fujidaiti/exprollable_page_view/issues/37)). ```shell git clone git@github.com:fujidaiti/exprollable_page_view.git @@ -214,10 +222,10 @@ User defined insets can be created using `ViewportInset.fixed` and `ViewportInse `ViewportConfiguration` provides flexible ways to customize viewport behavior. For standard use cases, the unnamed constructor of `ViewportConfiguration` is sufficient. However, if you need more fine-grained control, you can use `ViewportConfiguration.raw` to specify the fraction range and the inset range of the viewport, as well as the position at which the page will shrink/expand. The following snippet is an example of a page view that snaps the current page to the 4 states: -- Collapsed: The page is almost hidden -- Shrunk: It's like a bottom sheet -- Expanded: It's still like a bottom sheet, but the page is expanded -- Fullscreen: The page completely covers the entire screen +- **Collapsed** : The page is almost hidden +- **Shrunk** : It's like a bottom sheet +- **Expanded** : It's still like a bottom sheet, but the page is expanded +- **Fullscreen** : The page completely covers the entire screen ```dart const fullscreenInset = ViewportInset.fixed(0); @@ -243,17 +251,56 @@ final controller = ExprollablePageController( -### ModalExprollable +### PageConfiguration -Use `ModalExprollable` to create modal dialog style page views. This widget adds a translucent background and *swipe down to dismiss* action to your `ExprollablePageView`. You can use `showModalExprollable` a convenience function that wraps your `ExprollablePageView` with `ModalExprollable` and display it as a dialog. If you want to customize reveal/dismiss behavior of the dialog, create your own `PageRoute` and use `ModalExprollable` in it. +This is a utility widget that would be useful if you want to use an `ExprollablePageView` with custom configurations in a `StatelessWidget` without explicitly creating a controller. +For example, the following code can be replaced: ```dart -showModalExprollable( - context, - builder: (context) { - return ExprollablePageView(...); - }, - ); +// In the initState method: +controller = ExprollablePageController( + initialPage: 0, + viewportConfiguration: ViewportConfiguration( + overshootEffect: true, + ), +); + +// In the build method: +return ExprollablePageView( + controller: controller, + ..., +); +``` + +with as follows: + +```dart +return PageConfiguration( + initialPage: 0, + viewportConfiguration: ViewportConfiguration( + overshootEffect: true, + ), + child: ExprollablePageView(...), +); +``` + +You can still get the controller from anywhere in the page view subtree using `ExprollablePageController.of` method. + +```dart +// e.g. In the build method of a page +final controller = ExprollablePageController.of(context); +``` + +### ModalExprollableRouteBuilder + +Use `ModalExprollableRouteBuilder` to create modal style page views. This route adds a translucent background (called barrier) and *drag down to dismiss* action to your page view. + +```dart +Navigator.of(context).push( + ModalExprollableRouteBuilder( + pageBuilder: (context, _, __) => ExprollablePageView(...), + ), +); ``` See [this example](https://github.com/fujidaiti/exprollable_page_view/blob/master/example/lib/src/modal_dialog_example.dart) for more detailed usage. @@ -269,6 +316,12 @@ One of the advantages of `ExprollablePageView` over the built-in `PageView` is t +### Hero animations + +Hero animations are also supported! Take a look at the example in `example/lib/src/hero_animation_example.dart`. + + + ## How to ### get the curret page? @@ -374,19 +427,13 @@ ExprollablePageView( ); ``` -### prevent my app bar going off the screen when overshoote ffect is true? +### prevent my app bar going off the screen when overshoot effect is enabled? Use `AdaptivePagePadding`. This widget adds appropriate padding to its child according to the current viewpor offset. An example code is found in [adaptive_padding_example.dart](https://github.com/fujidaiti/exprollable_page_view/blob/master/example/lib/src/adaptive_padding_example.dart). ```dart -Container( - color: Colors.lightBlue, - child: AdaptivePagePadding( - child: SizedBox( - height: height, - child: const Placeholder(), - ), - ), +AdaptivePagePadding( + child: YourAppBar(...), ); ``` @@ -407,16 +454,37 @@ A more concrete example can be seen in [animation_example.dart](https://github.c +### remove the empty space at the bottom of the page? + +This problem can occur if the bottom padding of the viewport is non-zero. In such a case, enable `ViewportConfiguration.extendPage`. When this is true, the pages will extend to the bottom of the viewport, ignoring the bottom padding. However, even if there is padding at the bottom, it may not be necessary to enable `extendPage` if there is a widget that obscures the empty space (e.g. `Scaffold` with `BottomNavigationBar`). + +```dart +controller = ExprollablePageController( + viewportConfiguration: ViewportConfiguration( + extendPage: true, + ... + ), +); +``` + +Here is an example of how `extendPage` works. It is disable in the left image below and enabled in the right image. + + + + + ## Migration guide -### 1.0.0-rc.x to 1.x +### 1.0.0-rc.1 :arrow_right: 1.0.0-rc.2 + +#### Eliminated the limitations of the overshoot effect -Prior to version 1.0.0, the overshoot effect only worked if the following conditions were satisfied: +Prior to version 1.0.0-rc.2, the overshoot effect only worked if the following conditions were satisfied: - `MediaQuery.padding.bottom` > 0 - The bottom part of the `ExprollablePageView` is behind a widget like `NavigationBar` or `BottomAppBar`. -Starting with version 1.0.0, the above restriction has been removed and the overshoot effect can be enabled with or without a bottom app bar. Also, `Scaffold.extendBody` is now optional. +Starting with version 1.0.0-rc.2, the above limitations have been eliminated and the overshoot effect can be enabled with or without a bottom app bar. Also, `Scaffold.extendBody` is now optional. ```dart controller = ExprollablePageController( @@ -427,7 +495,7 @@ Starting with version 1.0.0, the above restriction has been removed and the over Widget build(BuildContext context) { return Scaffold( - // These two lines are no longer required in version 1.0.0 or later + // The next two lines are no longer required in version 1.0.0-rc.2 or later: // extendBody: true, // bottomNavigationBar: BottomNavigationBar(...), body: ExprollablePageView( @@ -440,7 +508,30 @@ Starting with version 1.0.0, the above restriction has been removed and the over -### 1.0.0-beta.x to 1.0.0-rc.x +#### Introduced ModalExprollableRouteBuilder + +A new class `ModalExprollableRouteBuilder` have been introduced to support [hero animations](https://docs.flutter.dev/ui/animations/hero-animations), that replaces `ModalExprollable` class. Accordingly, `ModalExprollable` and `showModalExprollable` function are now deprecated. An example of using this new class and hero animations can be found in `example/lib/src/hero_animation_example.dart`. + +Before: + +```dart +showModalExprollable( + context, + builder: (context) => ExprollablePageView(...), +); +``` + +After: + +```dart +Navigator.of(context).push( + ModalExprollableRouteBuilder( + pageBuilder: (context, _, __) => ExprollablePageView(...), + ), +); +``` + +### 1.0.0-beta.x :arrow_right: 1.0.0-rc.1 With the release of version 1.0.0-rc.1, there are several breaking changes. @@ -448,10 +539,10 @@ With the release of version 1.0.0-rc.1, there are several breaking changes. `PageViewportMetrics` mixin was merged into `ViewportMetrics` mixin and now deleted, and some properties were renamed. Replace the symbols in your code according to the table below: -- `PageViewportMetrics` 👉 `ViewportMetrics` -- `PageViewportMetrics.isShrunk` 👉 `ViewportMetrics.isPageShrunk` -- `PageViewportMetrics.isExpanded` 👉 `ViewportMetrics.isPageExpanded` -- `PageViewportMetrics.xxxOffset` 👉 `ViewportMetrics.xxxInset` (the all properties with suffix `Offset` was renamed with the new suffix `Inset`) +- `PageViewportMetrics` ➡️ `ViewportMetrics` +- `PageViewportMetrics.isShrunk` ➡️ `ViewportMetrics.isPageShrunk` +- `PageViewportMetrics.isExpanded` ➡️ `ViewportMetrics.isPageExpanded` +- `PageViewportMetrics.xxxOffset` ➡️ `ViewportMetrics.xxxInset` (the all properties with suffix `Offset` was renamed with the new suffix `Inset`) - `PageViewportMetrics.overshootEffect` was deleted #### ViewportController update @@ -462,10 +553,10 @@ With the release of version 1.0.0-rc.1, there are several breaking changes. For `ViewportOffset` and its inherited classes, the suffix `Offset` was replaced with the new suffix `Inset`, and 2 new inherited classes were introduced (see [Viewport fraction and inset](#viewport-fraction-and-inset)). -- `ViewportOffset` 👉 `ViewportInset` +- `ViewportOffset` ➡️ `ViewportInset` -- `ExpandedViewportOffset` 👉 `DefaultExpandedViewportinset` -- `ShrunkViewportOffset` 👉 `DefaultShrunkViewportInset` +- `ExpandedViewportOffset` ➡️ `DefaultExpandedViewportinset` +- `ShrunkViewportOffset` ➡️ `DefaultShrunkViewportInset` #### ExprollablePageController update @@ -517,9 +608,9 @@ final controller = ExprollablePageController( #### Other renamed classes -- `StaticPageViewportMetrics` 👉 `StaticViewportMetrics` -- `PageViewportUpdateNotification` 👉 `ViewportUpdateNotification` -- `PageViewport` 👉 `Viewport` +- `StaticPageViewportMetrics` ➡️ `StaticViewportMetrics` +- `PageViewportUpdateNotification` ➡️ `ViewportUpdateNotification` +- `PageViewport` ➡️ `Viewport` diff --git a/package/lib/src/addon/adaptive_padding.dart b/package/lib/src/addon/adaptive_padding.dart index 2f74c7d..cb364de 100644 --- a/package/lib/src/addon/adaptive_padding.dart +++ b/package/lib/src/addon/adaptive_padding.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:exprollable_page_view/src/core/controller.dart'; -import 'package:exprollable_page_view/src/core/view.dart'; +import 'package:exprollable_page_view/src/core/view/view.dart'; import 'package:exprollable_page_view/src/internal/utils.dart'; import 'package:flutter/widgets.dart' hide Viewport; diff --git a/package/lib/src/addon/gutter.dart b/package/lib/src/addon/gutter.dart index a5760d8..97e8ae0 100644 --- a/package/lib/src/addon/gutter.dart +++ b/package/lib/src/addon/gutter.dart @@ -1,5 +1,5 @@ import 'package:exprollable_page_view/src/core/controller.dart'; -import 'package:exprollable_page_view/src/core/view.dart'; +import 'package:exprollable_page_view/src/core/view/view.dart'; import 'package:exprollable_page_view/src/internal/utils.dart'; import 'package:flutter/widgets.dart'; diff --git a/package/lib/src/addon/modal.dart b/package/lib/src/addon/modal.dart index e948075..f9a0041 100644 --- a/package/lib/src/addon/modal.dart +++ b/package/lib/src/addon/modal.dart @@ -1,10 +1,16 @@ import 'dart:math'; +import 'dart:ui'; import 'package:exprollable_page_view/src/core/controller.dart'; -import 'package:exprollable_page_view/src/core/view.dart'; +import 'package:exprollable_page_view/src/core/view/view.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide Viewport; /// Shows an [ExprollablePageView] as a modal dialog. +@Deprecated( + "It will be removed in v1.0.0. " + "Use 'ModalExprollableRouteBuilder' instead", +) Future showModalExprollable( BuildContext context, { required WidgetBuilder builder, @@ -42,11 +48,18 @@ Future showModalExprollable( void _defaultDismissBehavior(BuildContext context) => Navigator.of(context).pop(); +/// A threshold viewport inset used to trigger +/// the *drap down to dismiss* action of [ModalExprollableRouteBuilder]. class DismissThresholdInset extends ViewportInset { + /// Creates a threshold inset used for *drop down do dismiss* + /// action of [ModalExprollableRouteBuilder]. The threshold inset + /// is calculated by [Viewport.shrunkInset] plus [dragMargin] pixels. const DismissThresholdInset({ this.dragMargin = 86.0, }); + /// Specifies how many pixels at least the user must drag the + /// fully shrunk page down to trigger the *drop down to dismiss* action. final double dragMargin; @override @@ -57,12 +70,16 @@ class DismissThresholdInset extends ViewportInset { /// A widget that makes a modal dialog style [ExprollablePageView]. /// /// This widget adds a translucent background (barrier) and -/// *swipe down to dismiss* action to the decendant page view. +/// *swipe down to dismiss* action to the decendant page view. /// Use [showModalExprollable] as a convenience method /// to show the [ExprollablePageView] as a dialog, /// which wraps the page view with [ModalExprollable]. /// If you want to customize reveal/dismiss behavior of the dialog, /// create your own [PageRoute] and use [ModalExprollable] in it. +@Deprecated( + "It will be removed in v1.0.0. " + "Use 'ModalExprollableRouteBuilder' instead", +) class ModalExprollable extends StatefulWidget { /// Creates a modal dialog style [ExprollablePageView]. const ModalExprollable({ @@ -107,6 +124,7 @@ class ModalExprollable extends StatefulWidget { State createState() => _ModalExprollableState(); } +// ignore: deprecated_member_use_from_same_package class _ModalExprollableState extends State { final ValueNotifier barrierColorFraction = ValueNotifier(null); ViewportMetrics? lastViewportMetrics; @@ -198,11 +216,11 @@ class _ModalExprollableState extends State { } } -/// Scroll physics normally used for descendant scrollables of [ModalExprollable]. +/// Scroll physics normally used for scrollable contents of [ModalExprollableRouteBuilder]. /// /// This physics always lets the user overscroll making *drag down to dismiss* action -/// available on every platform. [ModalExprollable] provides this as the default physics -/// for its descendants via [ScrollConfiguration]. +/// available on every platform. [ModalExprollableRouteBuilder] provides this as the default physics +/// for its scrollable contents via [ScrollConfiguration]. /// If you explicitly specify a physics for a descendant scrollable, /// consider to wrap that physics with this. /// @@ -300,6 +318,8 @@ class ModalExprollableScrollPhysics extends ScrollPhysics { } } +/// Provides [BouncingScrollPhysics] or [ModalExprollableScrollPhysics] +/// as the default scroll physics for descendants. class _ModalExprollableScrollBehavior extends ScrollBehavior { const _ModalExprollableScrollBehavior(); @@ -311,3 +331,450 @@ class _ModalExprollableScrollBehavior extends ScrollBehavior { : ModalExprollableScrollPhysics(parent: defaultPhysics); } } + +/// A utility class for defining modal route style [ExprollablePageView]s. +/// +/// This route has a translucent background (barrier) and +/// adds the *drag down to dismiss* action to the decendant page view. +class ModalExprollableRouteBuilder extends PageRouteBuilder { + ///Creates a modal style route for a page view. + /// + /// If the routes behind this route do not need to be painted, + /// it is recommended to enable [opaque] and specify [backgroundColor] as well, + /// which can reduce the cost of building hidden widgets. + /// See [opaque] for more information. + /// + /// If you want to customize reveal/dismiss behavior of the route, + /// specify your own transitions in [ModalExprollableRouteBuilder.transitionsBuilder]. + ModalExprollableRouteBuilder({ + super.settings, + required super.pageBuilder, + super.transitionsBuilder = _defaultTransitionsBuilder, + super.transitionDuration = const Duration(milliseconds: 300), + super.reverseTransitionDuration = const Duration(milliseconds: 300), + super.opaque = false, + super.barrierDismissible = true, + super.barrierLabel, + super.maintainState = true, + super.fullscreenDialog, + super.allowSnapshotting = true, + super.barrierColor = Colors.black54, + this.backgroundColor, + this.dismissThresholdInset = const DismissThresholdInset(), + this.dragDownDismissible = true, + this.onDismiss, + }) : assert(backgroundColor == null || opaque, + "Only opaque routes can have a background color"); + + /// The color used for the background if the route is opaque. + final Color? backgroundColor; + + /// The threshold viewport inset used to trigger the *drap down to dismiss* action. + /// + /// When the [Viewport.inset] of the descendant page view + /// exceeds this threshold and [dragDownDismissible] is true, + /// [onDismiss] is called to pop the route. + final DismissThresholdInset dismissThresholdInset; + + /// Specifies if the route will be dismissed by the *drag down to dismiss* action. + final bool dragDownDismissible; + + /// Called when the route should be dismissed. + /// + /// If null, [Navigator.maybePop] is called. + final VoidCallback? onDismiss; + + ViewportMetrics? _lastReportedViewportMetrics; + + late final ValueNotifier _userDragDrivenBarrierOpacity; + + bool get _barrierIsNotTransparent => !_barrierIsTransparent; + + bool get _barrierIsTransparent => + barrierColor == null || barrierColor!.alpha == 0; + + @override + void install() { + super.install(); + if (_barrierIsNotTransparent) { + _userDragDrivenBarrierOpacity = ValueNotifier(null); + } + } + + @override + void dispose() { + super.dispose(); + if (_barrierIsNotTransparent) { + _userDragDrivenBarrierOpacity.dispose(); + } + } + + void _onViewportMetricsChanged(ViewportMetrics metrics) { + _lastReportedViewportMetrics = metrics; + if (_barrierIsNotTransparent) { + _updateUserDragDrivenBarrierOpacity(); + } + } + + void _updateUserDragDrivenBarrierOpacity() { + assert(barrierColor != null); + assert(_lastReportedViewportMetrics != null); + assert(_lastReportedViewportMetrics!.hasDimensions); + final metrics = _lastReportedViewportMetrics!; + final dismissThresholdInset = + this.dismissThresholdInset.toConcreteValue(metrics); + assert(dismissThresholdInset > metrics.shrunkInset); + final maxOverscroll = dismissThresholdInset - metrics.shrunkInset; + final overscroll = max(0.0, metrics.inset - metrics.maxInset); + final dimmedBarrierOpacity = barrierColor!.opacity; + final brightenedBarrierOpacity = barrierColor!.opacity * 0.5; + _userDragDrivenBarrierOpacity.value = lerpDouble( + brightenedBarrierOpacity, + dimmedBarrierOpacity, + 1.0 - (overscroll / maxOverscroll).clamp(0.0, 1.0), + ); + } + + @override + Widget buildModalBarrier() { + if (_barrierIsTransparent || offstage) { + return ModalBarrier( + onDismiss: onDismiss, + dismissible: barrierDismissible, + semanticsLabel: barrierLabel, + barrierSemanticsDismissible: semanticsDismissible, + ); + } + + assert(animation != null); + assert(barrierColor != null); + final targetOpacity = + _userDragDrivenBarrierOpacity.value ?? barrierColor!.opacity; + final barrier = _AnimatedModalExprollableRouteBarrier( + color: barrierColor!, + userDragDrivenOpacity: _userDragDrivenBarrierOpacity, + animationDrivenOpacity: animation!.drive( + Tween(begin: 0.0, end: targetOpacity) + .chain(CurveTween(curve: barrierCurve)), + ), + dismissible: barrierDismissible, + semanticsLabel: barrierLabel, + barrierSemanticsDismissible: semanticsDismissible, + onDismiss: onDismiss, + ); + + if (!opaque || backgroundColor == null || backgroundColor!.alpha == 0) { + return barrier; + } + + final background = FadeTransition( + opacity: animation!.drive(CurveTween(curve: barrierCurve)), + child: ColoredBox(color: backgroundColor!), + ); + return Stack(children: [ + Positioned.fill(child: background), + Positioned.fill(child: barrier), + ]); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + Widget page = pageBuilder(context, animation, secondaryAnimation); + if (_barrierIsNotTransparent) { + page = NotificationListener( + onNotification: (notification) { + _onViewportMetricsChanged(notification.metrics); + return false; + }, + child: page, + ); + } + if (dragDownDismissible) { + page = _ModalExprollableDismissible( + dismissThresholdInset: dismissThresholdInset, + onDismiss: onDismiss, + child: page, + ); + } + return page; + } + + static Widget _defaultTransitionsBuilder( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return DefaultModalExprollableRouteTransition( + parentAnimation: animation, + slideInCurve: Curves.easeOutCubic, + slideOutCurve: Curves.easeInCubic, + child: child, + ); + } +} + +/// The default transition for [ModalExprollableRouteBuilder]. +/// +/// It is a combination of a slide transition and a fade transition. +class DefaultModalExprollableRouteTransition extends StatefulWidget { + /// Creates a default transition for [ModalExprollableRouteBuilder]. + const DefaultModalExprollableRouteTransition({ + super.key, + required this.parentAnimation, + this.fadeInCurve = Curves.easeInOut, + this.fadeOutCurve, + this.slideInCurve = Curves.easeOutCubic, + this.slideOutCurve, + required this.child, + }); + + /// An animation that drives this transition. + final Animation parentAnimation; + + /// The curve used for the fade in transition. + final Curve fadeInCurve; + + /// The curve used for the fade out transition. + /// + /// If null, [fadeInCurve] is used. + final Curve? fadeOutCurve; + + /// The curve used for the slide in transition. + final Curve slideInCurve; + + /// The curve used for the slide out transition. + /// + /// If null, [slideInCurve] is used. + final Curve? slideOutCurve; + + /// The widget below this widget in the tree. + final Widget child; + + @override + State createState() => + _DefaultModalExprollableRouteTransitionState(); +} + +class _DefaultModalExprollableRouteTransitionState + extends State { + late Animation opacityAnimation; + late Animation positionAnimation; + + @override + void initState() { + super.initState(); + initAnimations(); + } + + @override + void didUpdateWidget(DefaultModalExprollableRouteTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.parentAnimation != oldWidget.parentAnimation || + widget.fadeInCurve != oldWidget.fadeInCurve || + widget.fadeOutCurve != oldWidget.fadeOutCurve || + widget.slideInCurve != oldWidget.slideInCurve || + widget.slideOutCurve != oldWidget.slideOutCurve) { + initAnimations(); + } + } + + void initAnimations() { + opacityAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate( + CurvedAnimation( + parent: widget.parentAnimation, + curve: widget.fadeInCurve, + reverseCurve: widget.fadeOutCurve ?? widget.fadeInCurve, + ), + ); + positionAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: widget.parentAnimation, + curve: widget.slideInCurve, + reverseCurve: widget.slideOutCurve ?? widget.slideInCurve, + ), + ); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: opacityAnimation, + child: SlideTransition( + position: positionAnimation, + child: widget.child, + ), + ); + } +} + +class _AnimatedModalExprollableRouteBarrier extends StatefulWidget { + _AnimatedModalExprollableRouteBarrier({ + required this.color, + required this.animationDrivenOpacity, + required this.userDragDrivenOpacity, + this.dismissible = true, + this.barrierSemanticsDismissible, + this.semanticsLabel, + this.onDismiss, + }) : assert(color.alpha != 0); + + final Color color; + final Animation animationDrivenOpacity; + final ValueListenable userDragDrivenOpacity; + final bool dismissible; + final bool? barrierSemanticsDismissible; + final String? semanticsLabel; + final VoidCallback? onDismiss; + + @override + State<_AnimatedModalExprollableRouteBarrier> createState() => + _AnimatedModalExprollableRouteBarrierState(); +} + +class _AnimatedModalExprollableRouteBarrierState + extends State<_AnimatedModalExprollableRouteBarrier> { + @override + void initState() { + super.initState(); + attachUserDrivenOpacity(widget.userDragDrivenOpacity); + attachAnimationDrivenOpacity(widget.animationDrivenOpacity); + } + + @override + void dispose() { + super.dispose(); + detachUserDrivenOpacity(widget.userDragDrivenOpacity); + detachAnimationDrivenOpacity(widget.animationDrivenOpacity); + } + + @override + void didUpdateWidget(_AnimatedModalExprollableRouteBarrier oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.userDragDrivenOpacity != oldWidget.userDragDrivenOpacity) { + detachUserDrivenOpacity(oldWidget.userDragDrivenOpacity); + attachUserDrivenOpacity(widget.userDragDrivenOpacity); + } + if (widget.animationDrivenOpacity != oldWidget.animationDrivenOpacity) { + detachAnimationDrivenOpacity(oldWidget.animationDrivenOpacity); + attachAnimationDrivenOpacity(widget.animationDrivenOpacity); + } + } + + void attachUserDrivenOpacity(ValueListenable opacity) => + opacity.addListener(handleUserDragDrivenOpacityUpdate); + + void detachUserDrivenOpacity(ValueListenable opacity) => + opacity.removeListener(handleUserDragDrivenOpacityUpdate); + + void attachAnimationDrivenOpacity(Animation opacity) => + opacity.addListener(handleAnimationDrivenOpacityUpdate); + + void detachAnimationDrivenOpacity(Animation opacity) => + opacity.removeListener(handleAnimationDrivenOpacityUpdate); + + void handleAnimationDrivenOpacityUpdate() => setState(() {}); + + void handleUserDragDrivenOpacityUpdate() { + if (!isAnimationRunning()) setState(() {}); + } + + bool isAnimationRunning() => + widget.animationDrivenOpacity.status == AnimationStatus.forward || + widget.animationDrivenOpacity.status == AnimationStatus.reverse; + + double currentOpacity() { + switch (widget.animationDrivenOpacity.status) { + case AnimationStatus.forward: + case AnimationStatus.reverse: + return widget.animationDrivenOpacity.value; + case AnimationStatus.completed: + return widget.userDragDrivenOpacity.value ?? widget.color.opacity; + case AnimationStatus.dismissed: + return widget.userDragDrivenOpacity.value ?? 0.0; + } + } + + @override + Widget build(BuildContext context) { + return ModalBarrier( + color: widget.color.withOpacity(currentOpacity()), + semanticsLabel: widget.semanticsLabel, + dismissible: widget.dismissible, + barrierSemanticsDismissible: widget.barrierSemanticsDismissible, + onDismiss: widget.onDismiss, + ); + } +} + +// TODO: Improve the logic +class _ModalExprollableDismissible extends StatefulWidget { + const _ModalExprollableDismissible({ + required this.dismissThresholdInset, + required this.onDismiss, + required this.child, + }); + + final Widget child; + final DismissThresholdInset dismissThresholdInset; + final VoidCallback? onDismiss; + + @override + State<_ModalExprollableDismissible> createState() => + _ModalExprollableDismissibleState(); +} + +class _ModalExprollableDismissibleState + extends State<_ModalExprollableDismissible> { + ViewportMetrics? _lastReportedViewportMetrics; + + bool _handleViewportMetricsUpdate(ViewportUpdateNotification notification) { + _lastReportedViewportMetrics = notification.metrics; + return false; + } + + void _handlePointerUp(PointerUpEvent event) { + if (shouldDismiss()) _handleDismiss(); + } + + void _handleDismiss() { + if (widget.onDismiss != null) { + widget.onDismiss!(); + } else { + Navigator.maybePop(context); + } + } + + bool shouldDismiss() { + final metrics = _lastReportedViewportMetrics; + if (metrics != null && metrics.hasDimensions) { + final threshold = widget.dismissThresholdInset.toConcreteValue(metrics); + if (metrics.inset > threshold) return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerUp: _handlePointerUp, + child: ScrollConfiguration( + behavior: const _ModalExprollableScrollBehavior(), + child: NotificationListener( + onNotification: _handleViewportMetricsUpdate, + child: widget.child, + ), + ), + ); + } +} diff --git a/package/lib/src/core/controller.dart b/package/lib/src/core/controller.dart index 6e3d5e9..d465167 100644 --- a/package/lib/src/core/controller.dart +++ b/package/lib/src/core/controller.dart @@ -1,8 +1,9 @@ import 'dart:math'; +import 'dart:ui'; import 'package:exprollable_page_view/src/internal/scroll.dart'; import 'package:exprollable_page_view/src/internal/utils.dart'; -import 'package:exprollable_page_view/src/core/view.dart'; +import 'package:exprollable_page_view/src/core/view/view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide Viewport; @@ -505,8 +506,22 @@ class DefaultViewportFractionBehavior implements ViewportFractionBehavior { final delta = viewport.shrunkInset - viewport.expandedInset; assert(delta > 0.0); final t = 1.0 - (pixels / delta).clamp(0.0, 1.0); - return curve.transform(t) * viewport.deltaFraction + viewport.minFraction; + return lerpDouble( + viewport.minFraction, + viewport.maxFraction, + curve.transform(t), + )!; } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DefaultViewportFractionBehavior && + runtimeType == other.runtimeType && + curve == other.curve); + + @override + int get hashCode => Object.hash(runtimeType, curve); } /// A configuration for the viewport. @@ -612,6 +627,34 @@ class ViewportConfiguration { /// If true, the page extends to the bottom of the viewport when it fully expanded, /// even if the viewport has non-zero bottom padding. final bool extendPage; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ViewportConfiguration && + runtimeType == other.runtimeType && + minFraction == other.minFraction && + maxFraction == other.maxFraction && + minInset == other.minInset && + maxInset == other.maxInset && + shrunkInset == other.shrunkInset && + expandedInset == other.expandedInset && + initialInset == other.initialInset && + listEquals(snapInsets, other.snapInsets) && + extendPage == other.extendPage); + + @override + int get hashCode => Object.hash( + runtimeType, + minFraction, + maxFraction, + minInset, + maxInset, + shrunkInset, + expandedInset, + initialInset, + snapInsets, + extendPage); } /// An object that represents the state of the viewport. @@ -753,9 +796,10 @@ class Viewport extends ChangeNotifier final preferredFraction = fractionBehavior.preferredFraction(this, newInset); final lowerBoundPageHeight = configuration.extendPage - ? dimensions.height - dimensions.padding.bottom - max(0.0, newInset) - : dimensions.height - max(0.0, newInset); - final lowerBoundFraction = lowerBoundPageHeight / pageDimensions.maxHeight; + ? dimensions.height - max(0.0, newInset) + : dimensions.height - dimensions.padding.bottom - max(0.0, newInset); + final lowerBoundFraction = (lowerBoundPageHeight / pageDimensions.maxHeight) + .clamp(minFraction, maxFraction); _fraction = max(lowerBoundFraction, preferredFraction); _inset = newInset; } diff --git a/package/lib/src/core/core.dart b/package/lib/src/core/core.dart index e26b83d..96abb0f 100644 --- a/package/lib/src/core/core.dart +++ b/package/lib/src/core/core.dart @@ -1,4 +1,4 @@ library core; -export 'package:exprollable_page_view/src/core/view.dart'; +export 'package:exprollable_page_view/src/core/view/view.dart'; export 'package:exprollable_page_view/src/core/controller.dart'; diff --git a/package/lib/src/core/view/default_page_configuration.dart b/package/lib/src/core/view/default_page_configuration.dart new file mode 100644 index 0000000..a5d8646 --- /dev/null +++ b/package/lib/src/core/view/default_page_configuration.dart @@ -0,0 +1,59 @@ +import 'package:exprollable_page_view/src/core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class InheritedDefaultPageConfiguration extends InheritedWidget { + const InheritedDefaultPageConfiguration({ + super.key, + required this.controller, + required super.child, + }); + + final ExprollablePageController controller; + + @override + bool updateShouldNotify(InheritedDefaultPageConfiguration oldWidget) => + controller != oldWidget.controller; + + static InheritedDefaultPageConfiguration? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType(); +} + +@internal +class DefaultPageConfiguration extends StatefulWidget { + const DefaultPageConfiguration({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => + _DefaultPageConfigurationState(); +} + +class _DefaultPageConfigurationState extends State { + late final ExprollablePageController controller; + + @override + void initState() { + super.initState(); + controller = ExprollablePageController(); + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return InheritedDefaultPageConfiguration( + controller: controller, + child: widget.child, + ); + } +} diff --git a/package/lib/src/core/view.dart b/package/lib/src/core/view/exprollable_page_view.dart similarity index 77% rename from package/lib/src/core/view.dart rename to package/lib/src/core/view/exprollable_page_view.dart index 1169ee7..56ebebf 100644 --- a/package/lib/src/core/view.dart +++ b/package/lib/src/core/view/exprollable_page_view.dart @@ -1,13 +1,16 @@ import 'dart:math'; import 'package:exprollable_page_view/src/core/controller.dart'; +import 'package:exprollable_page_view/src/core/view/default_page_configuration.dart'; +import 'package:exprollable_page_view/src/core/view/page_configuration.dart'; import 'package:exprollable_page_view/src/internal/paging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; /// A page view that expands the viewport of the current page while scrolling it. -class ExprollablePageView extends StatefulWidget { +class ExprollablePageView extends StatelessWidget { + /// Creates a page view. const ExprollablePageView({ super.key, required this.itemBuilder, @@ -92,32 +95,107 @@ class ExprollablePageView extends StatefulWidget { final void Function(int page)? onPageChanged; @override - State createState() => _ExprollablePageViewState(); + Widget build(BuildContext context) { + if (controller != null) { + return _build(controller!); + } + + final inheritedController = + InheritedPageConfiguration.of(context)?.controller; + if (inheritedController != null) { + return _build(inheritedController); + } + + return DefaultPageConfiguration( + child: Builder( + builder: (context) { + final defaultController = + InheritedDefaultPageConfiguration.of(context)?.controller; + return _build(defaultController!); + }, + ), + ); + } + + Widget _build(ExprollablePageController controller) { + return _ExprollablePageViewImpl( + controller: controller, + itemBuilder: itemBuilder, + itemCount: itemCount, + reverse: reverse, + physics: physics, + dragStartBehavior: dragStartBehavior, + allowImplicitScrolling: allowImplicitScrolling, + restorationId: restorationId, + clipBehavior: clipBehavior, + scrollBehavior: scrollBehavior, + padEnds: padEnds, + onViewportChanged: onViewportChanged, + onPageChanged: onPageChanged, + ); + } } -class _ExprollablePageViewState extends State { +class _ExprollablePageViewImpl extends StatefulWidget { + const _ExprollablePageViewImpl({ + required this.itemBuilder, + required this.itemCount, + required this.controller, + required this.reverse, + required this.physics, + required this.dragStartBehavior, + required this.allowImplicitScrolling, + required this.restorationId, + required this.clipBehavior, + required this.scrollBehavior, + required this.padEnds, + required this.onViewportChanged, + required this.onPageChanged, + }); + + final IndexedWidgetBuilder itemBuilder; + final int? itemCount; + final ExprollablePageController controller; + final bool reverse; + final ScrollPhysics? physics; + final DragStartBehavior dragStartBehavior; + final bool allowImplicitScrolling; + final String? restorationId; + final Clip clipBehavior; + final ScrollBehavior? scrollBehavior; + final bool padEnds; + final void Function(ViewportMetrics metrics)? onViewportChanged; + final void Function(int page)? onPageChanged; + + @override + State<_ExprollablePageViewImpl> createState() => + _ExprollablePageViewImplState(); +} + +class _ExprollablePageViewImplState extends State<_ExprollablePageViewImpl> { final ValueNotifier allowPaging = ValueNotifier(null); - late ExprollablePageController controller; + + ExprollablePageController get controller => widget.controller; @override void initState() { super.initState(); - attach(widget.controller ?? _DefaultPageController()); + attach(widget.controller); } @override void dispose() { super.dispose(); allowPaging.dispose(); - detach(controller); + detach(widget.controller); } @override - void didUpdateWidget(covariant ExprollablePageView oldWidget) { + void didUpdateWidget(_ExprollablePageViewImpl oldWidget) { super.didUpdateWidget(oldWidget); - if (!(controller is _DefaultPageController && widget.controller == null)) { - detach(controller); - attach(oldWidget.controller ?? _DefaultPageController()); + if (widget.controller != oldWidget.controller) { + detach(oldWidget.controller); + attach(widget.controller); } } @@ -125,13 +203,10 @@ class _ExprollablePageViewState extends State { controller ..viewport.removeListener(onViewportChanged) ..currentPage.removeListener(onPageChanged); - if (controller is _DefaultPageController) { - controller.dispose(); - } } void attach(ExprollablePageController controller) { - this.controller = controller + controller ..viewport.addListener(onViewportChanged) ..currentPage.addListener(onPageChanged); } @@ -214,10 +289,6 @@ class _ExprollablePageViewState extends State { } } -class _DefaultPageController extends ExprollablePageController { - _DefaultPageController(); -} - class _PageItemContainer extends StatefulWidget { const _PageItemContainer({ required this.page, diff --git a/package/lib/src/core/view/page_configuration.dart b/package/lib/src/core/view/page_configuration.dart new file mode 100644 index 0000000..c227043 --- /dev/null +++ b/package/lib/src/core/view/page_configuration.dart @@ -0,0 +1,109 @@ +import 'package:exprollable_page_view/src/core/controller.dart'; +import 'package:exprollable_page_view/src/core/view/exprollable_page_view.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class InheritedPageConfiguration extends InheritedWidget { + const InheritedPageConfiguration({ + super.key, + required this.controller, + required super.child, + }); + + final ExprollablePageController controller; + + @override + bool updateShouldNotify(InheritedPageConfiguration oldWidget) => + controller != oldWidget.controller; + + static InheritedPageConfiguration? of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); +} + +/// A widget that provides an [ExprollablePageController] +/// configured with the given parameters to the descendants in the tree. +/// +/// This is useful if you want to use an [ExprollablePageView] +/// with a custom configuration in a [StatelessWidget] without +/// explicitly creating a controller. +/// The controller given by this widget and attached to the descendant +/// page view can be obtained by using [ExprollablePageController.of] +/// from anywhere in the subtree of the page view. +class PageConfiguration extends StatefulWidget { + /// Creates a provider of a page controller configured with the given parameters. + const PageConfiguration({ + super.key, + this.viewportConfiguration = ViewportConfiguration.defaultConfiguration, + this.viewportFractionBehavior = const DefaultViewportFractionBehavior(), + this.keepPage = true, + this.initialPage = 0, + required this.child, + }); + + /// A configuration object that is passed to [ExprollablePageController.new]. + final ViewportConfiguration viewportConfiguration; + + /// A behavior object that is passed to [ExprollablePageController.new]. + final ViewportFractionBehavior viewportFractionBehavior; + + /// The `keepPage` flag that is passed to [ExprollablePageController.new]. + final bool keepPage; + + /// The `initialPage` value that is passed to [ExprollablePageController.new]. + final int initialPage; + + /// The widget below this widget in the tree. + /// + /// Typically, this will be an [ExprollablePageView] or + /// a widget that contains an [ExprollablePageView] in its descendants. + final Widget child; + + @override + State createState() => _PageConfigurationState(); +} + +class _PageConfigurationState extends State { + late ExprollablePageController controller; + + @override + void initState() { + super.initState(); + controller = createController(); + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + void didUpdateWidget(PageConfiguration oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.keepPage != oldWidget.keepPage || + widget.initialPage != oldWidget.initialPage || + widget.viewportConfiguration != oldWidget.viewportConfiguration || + widget.viewportFractionBehavior != oldWidget.viewportFractionBehavior) { + controller.dispose(); + controller = createController(); + } + } + + ExprollablePageController createController() { + return ExprollablePageController( + initialPage: widget.initialPage, + keepPage: widget.keepPage, + viewportConfiguration: widget.viewportConfiguration, + viewportFractionBehavior: widget.viewportFractionBehavior, + ); + } + + @override + Widget build(BuildContext context) { + return InheritedPageConfiguration( + controller: controller, + child: widget.child, + ); + } +} diff --git a/package/lib/src/core/view/view.dart b/package/lib/src/core/view/view.dart new file mode 100644 index 0000000..76ec5e5 --- /dev/null +++ b/package/lib/src/core/view/view.dart @@ -0,0 +1,3 @@ +export 'package:exprollable_page_view/src/core/view/exprollable_page_view.dart'; +export 'package:exprollable_page_view/src/core/view/page_configuration.dart' + hide InheritedPageConfiguration; diff --git a/package/pubspec.yaml b/package/pubspec.yaml index 8ca0640..2a1d003 100644 --- a/package/pubspec.yaml +++ b/package/pubspec.yaml @@ -1,6 +1,6 @@ name: exprollable_page_view description: Yet another PageView widget that expands its page while scrolling it. Exprollable is a coined word combining the words expandable and scrollable. -version: 1.0.0-rc.1 +version: 1.0.0-rc.2 repository: https://github.com/fujidaiti/exprollable_page_view environment: diff --git a/resources/hero-animations.gif b/resources/hero-animations.gif new file mode 100644 index 0000000..7cc83eb Binary files /dev/null and b/resources/hero-animations.gif differ