From bf4abbeff3aefb7aa2c1b1f2655703ce567f2509 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 10 Jul 2025 21:14:59 -0300 Subject: [PATCH 1/6] Add support for variable page sizes. (Resolves #59) --- example/android/app/build.gradle | 19 +- example/android/build.gradle | 13 - .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 30 +- example/lib/main_variable_page_size.dart | 172 +++ lib/page_list_viewport.dart | 1 + lib/src/page_list_viewport.dart | 154 +- lib/src/page_list_viewport_gestures.dart | 2 +- lib/src/page_list_viewport_variable_size.dart | 1239 +++++++++++++++++ 9 files changed, 1556 insertions(+), 76 deletions(-) create mode 100644 example/lib/main_variable_page_size.dart create mode 100644 lib/src/page_list_viewport_variable_size.dart diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b52183a..55d1153 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,13 +22,11 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion + namespace "com.example.example" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -65,7 +64,3 @@ android { flutter { source '../..' } - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/example/android/build.gradle b/example/android/build.gradle index 713d7f6..bc157bd 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 3c472b9..5e6b542 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 44e62bc..446857b 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" // apply true + id "com.android.application" version "8.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.10" apply false +} + +include ":app" \ No newline at end of file diff --git a/example/lib/main_variable_page_size.dart b/example/lib/main_variable_page_size.dart new file mode 100644 index 0000000..2771384 --- /dev/null +++ b/example/lib/main_variable_page_size.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:page_list_viewport/page_list_viewport.dart'; + +void main() { + PageListViewportLogs.initLoggers(Level.ALL, {}); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Page List Viewport Variable Size Demo', + home: MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + }); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State with TickerProviderStateMixin { + static const _pageCount = 200; + + static const _pageSizes = [ + Size(8.5, 11), // Letter + Size(11, 8.5), // Letter (inverse) + ]; + + late final PageListViewportWithVariableSizeController _controller; + + @override + void initState() { + super.initState(); + _controller = PageListViewportWithVariableSizeController.startAtPage(pageIndex: 5, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 125, + child: _buildThumbnailList(), + ), + Expanded( + child: Center( + child: SizedBox( + height: _pageSizes[0].height * 72 * MediaQuery.of(context).devicePixelRatio, + width: _pageSizes[0].width * 72 * MediaQuery.of(context).devicePixelRatio, + child: _buildViewport(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildViewport() { + return Stack( + children: [ + PageListViewportGestures( + controller: _controller, + lockPanAxis: true, + child: PageListViewport.variedPageSized( + controller: _controller, + pageCount: _pageCount, + onGetNaturalPageSize: (pageIndex) => + _pageSizes[pageIndex % _pageSizes.length] * 72 * MediaQuery.of(context).devicePixelRatio, + pageLayoutCacheCount: 3, + pagePaintCacheCount: 3, + builder: (BuildContext context, int pageIndex) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => print('tapped at pageIndex: $pageIndex'), + child: _buildPage(pageIndex), + ), + ), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildThumbnailList() { + return RepaintBoundary( + child: ColoredBox( + color: Colors.grey.shade800, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _pageCount, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: GestureDetector( + onTap: () { + _controller.animateToPage(index, const Duration(milliseconds: 250)); + }, + child: AspectRatio( + aspectRatio: _pageSizes[index % _pageSizes.length].aspectRatio, + child: _buildPage(index), + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildPage(int pageIndex) { + final pageSize = Size( + _pageSizes[pageIndex % _pageSizes.length].width * 72, + _pageSizes[pageIndex % _pageSizes.length].height * 72, + ); + + return Container( + color: Colors.white, + child: Container( + color: Colors.primaries[pageIndex % Colors.primaries.length], + width: pageSize.width, + height: pageSize.height, + child: Center( + child: LayoutBuilder(builder: (context, constraints) { + return Container( + width: 300 * (constraints.maxWidth / pageSize.width), + height: 300 * (constraints.maxHeight / pageSize.height), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: FittedBox( + child: Text( + 'Page $pageIndex', + style: const TextStyle(fontSize: 24), + ), + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/lib/page_list_viewport.dart b/lib/page_list_viewport.dart index 10badd1..9f40d1a 100644 --- a/lib/page_list_viewport.dart +++ b/lib/page_list_viewport.dart @@ -1,6 +1,7 @@ library page_list_viewport; export 'src/page_list_viewport.dart'; +export 'src/page_list_viewport_variable_size.dart'; export 'src/page_list_performance_optimizer.dart'; export 'src/logging.dart'; diff --git a/lib/src/page_list_viewport.dart b/lib/src/page_list_viewport.dart index 73b83b6..bf9969f 100644 --- a/lib/src/page_list_viewport.dart +++ b/lib/src/page_list_viewport.dart @@ -6,7 +6,8 @@ import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'package:vector_math/vector_math_64.dart'; +import 'package:page_list_viewport/src/page_list_viewport_variable_size.dart'; +//import 'package:vector_math/vector_math_64.dart'; import 'logging.dart'; @@ -36,7 +37,22 @@ class PageListViewport extends RenderObjectWidget { this.pagePaintCacheCount = 0, required this.builder, this.rebuildOnOrientationChange = false, - }) : assert(pageLayoutCacheCount >= pagePaintCacheCount); + }) : assert(pageLayoutCacheCount >= pagePaintCacheCount), + _variablePageSize = false, + onGetNaturalPageSize = null; + + const PageListViewport.variedPageSized({ + super.key, + required PageListViewportWithVariableSizeController this.controller, + required this.pageCount, + required this.onGetNaturalPageSize, + this.pageLayoutCacheCount = 0, + this.pagePaintCacheCount = 0, + required this.builder, + this.rebuildOnOrientationChange = false, + }) : assert(pageLayoutCacheCount >= pagePaintCacheCount), + _variablePageSize = true, + naturalPageSize = null; /// Controller that pans and zooms the page content. final OrientationController controller; @@ -45,7 +61,8 @@ class PageListViewport extends RenderObjectWidget { final int pageCount; /// The size of a single page, if no constraints were applied. - final Size naturalPageSize; + final Size? naturalPageSize; + final PageSizeResolver? onGetNaturalPageSize; /// The number of pages above and below the viewport that should /// be laid out, even though they aren't visible. @@ -70,38 +87,65 @@ class PageListViewport extends RenderObjectWidget { /// on relative position or scale. final bool rebuildOnOrientationChange; + final bool _variablePageSize; + @override RenderObjectElement createElement() { PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport element"); + if (_variablePageSize) { + return PageListViewportWithVariableSizeElement(this); + } + return PageListViewportElement(this); } @override RenderObject createRenderObject(BuildContext context) { PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport render object"); + if (_variablePageSize) { + return RenderPageListVariableSizeViewport( + element: context as PageListViewportWithVariableSizeElement, + controller: controller, + pageCount: pageCount, + pageSizeResolver: onGetNaturalPageSize, + pageLayoutCacheCount: pageLayoutCacheCount, + pagePaintCacheCount: pagePaintCacheCount, + ); + } + return RenderPageListViewport( element: context as PageListViewportElement, controller: controller, pageCount: pageCount, - pageSize: naturalPageSize, + pageSize: naturalPageSize!, pageLayoutCacheCount: pageLayoutCacheCount, pagePaintCacheCount: pagePaintCacheCount, ); } @override - void updateRenderObject(BuildContext context, RenderPageListViewport renderObject) { + void updateRenderObject(BuildContext context, RenderObject renderObject) { PageListViewportLogs.pagesList.finest(() => "Updating PageListViewport render object"); - renderObject // - ..pageCount = pageCount - ..naturalPageSize = naturalPageSize - ..pageLayoutCacheCount = pageLayoutCacheCount - ..pagePaintCacheCount = pagePaintCacheCount - ..controller = controller; + if (renderObject is RenderPageListVariableSizeViewport) { + renderObject // + ..pageCount = pageCount + ..pageSizeResolver = onGetNaturalPageSize + ..pageLayoutCacheCount = pageLayoutCacheCount + ..pagePaintCacheCount = pagePaintCacheCount + ..controller = controller; + } else { + (renderObject as RenderPageListViewport) // + ..pageCount = pageCount + ..naturalPageSize = naturalPageSize! + ..pageLayoutCacheCount = pageLayoutCacheCount + ..pagePaintCacheCount = pagePaintCacheCount + ..controller = controller; + } } } typedef PageBuilder = Widget Function(BuildContext context, int pageIndex); +typedef PageSizeResolver = Size Function(int pageIndex); class PageListViewportController extends OrientationController { PageListViewportController.startAtPage({ @@ -279,9 +323,9 @@ class PageListViewportController extends OrientationController { } @override - RenderPageListViewport? get viewport => _viewport; + PageListViewportLayout? get viewport => _viewport; - RenderPageListViewport? _viewport; + PageListViewportLayout? _viewport; /// Sets the [RenderPageListViewport] whose content transform is controlled /// by this controller. @@ -291,7 +335,7 @@ class PageListViewportController extends OrientationController { /// making the content smaller than the viewport. @override @protected - set viewport(RenderPageListViewport? viewport) { + set viewport(PageListViewportLayout? viewport) { if (_viewport == viewport) { return; } @@ -303,10 +347,11 @@ class PageListViewportController extends OrientationController { stopSimulation(); } - Size? get _viewportSize => _viewport?.size; + Size? get _viewportSize => _viewport?.getSize(); bool _isFirstLayoutForController = true; + @override bool get isRunningOrientationSimulation => _activeSimulation != null; OrientationSimulation? _activeSimulation; Ticker? _simulationTicker; @@ -318,18 +363,20 @@ class PageListViewportController extends OrientationController { void onViewportLayout() { disableNotifications(); - final minimumScaleToFillViewport = _viewport!.size.width / _viewport!._naturalPageSize.width; + final viewportSize = _viewportSize!; + + final minimumScaleToFillViewport = viewportSize.width / _viewport!.getNaturalPageSize(0).width; minimumScale = minimumScaleToFillViewport; - if (_isFirstLayoutForController && _viewport!._pageCount > 0) { + if (_isFirstLayoutForController && _viewport!.getPageCount() > 0) { scale = minimumScaleToFillViewport; final totalContentHeight = _viewport!.calculateContentHeight(scale); - if (totalContentHeight < _viewport!.size.height) { + if (totalContentHeight < viewportSize.height) { // We don't have enough content to fill the viewport. Center the content, vertically. origin = Offset( origin.dx, - (_viewport!.size.height - totalContentHeight) / 2, + (viewportSize.height - totalContentHeight) / 2, ); } @@ -377,7 +424,7 @@ class PageListViewportController extends OrientationController { Offset _getPageOffset(int pageIndex, [double? zoomLevel]) { final desiredZoomLevel = zoomLevel ?? scale; - final pageSizeAtZoomLevel = _viewport!.calculatePageSize(desiredZoomLevel); + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(0, desiredZoomLevel); final desiredPageTopLeftInViewport = (_viewportSize!).center(Offset.zero) - Offset(pageSizeAtZoomLevel.width / 2, pageSizeAtZoomLevel.height / 2); final contentAboveDesiredPage = pageSizeAtZoomLevel.height * pageIndex; @@ -401,7 +448,7 @@ class PageListViewportController extends OrientationController { stopSimulation(); final desiredZoomLevel = zoomLevel ?? scale; - final pageSizeAtZoomLevel = _viewport!.calculatePageSize(desiredZoomLevel); + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(0, desiredZoomLevel); final pageFocalPointAtZoomLevel = pixelOffsetInPage * desiredZoomLevel; final desiredPageTopLeftInViewport = (_viewportSize!).center(-pageFocalPointAtZoomLevel); final contentAboveDesiredPage = pageSizeAtZoomLevel.height * pageIndex; @@ -440,7 +487,7 @@ class PageListViewportController extends OrientationController { // on-going orientation simulations. stopSimulation(); - final centerOfPage = _viewport!.calculatePageSize(1.0).center(Offset.zero); + final centerOfPage = _viewport!.calculatePageSize(0, 1.0).center(Offset.zero); return animateToOffsetInPage(pageIndex, centerOfPage, duration); } @@ -472,7 +519,7 @@ class PageListViewportController extends OrientationController { stopSimulation(); final desiredZoomLevel = zoomLevel ?? scale; - final pageSizeAtZoomLevel = _viewport!.calculatePageSize(desiredZoomLevel); + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(0, desiredZoomLevel); final pageFocalPointAtZoomLevel = pixelOffsetInPage * desiredZoomLevel; final desiredPageTopLeftInViewport = (_viewportSize!).center(-pageFocalPointAtZoomLevel); final contentAboveDesiredPage = pageSizeAtZoomLevel.height * pageIndex; @@ -516,13 +563,14 @@ class PageListViewportController extends OrientationController { notifyListeners(); } + @override void translate(Offset deltaInScreenSpace) { PageListViewportLogs.pagesListController.fine(() => "Translation requested for delta: $deltaInScreenSpace"); final desiredOrigin = _origin + deltaInScreenSpace; PageListViewportLogs.pagesListController.fine(() => "Origin before adjustment: $_origin. Content height: ${_viewport!.calculateContentHeight(scale)}, Scale: $scale"); - PageListViewportLogs.pagesListController - .fine(() => "Viewport size: ${_viewport!.size}, scaled page width: ${_viewport!.calculatePageWidth(scale)}"); + PageListViewportLogs.pagesListController.fine(() => + "Viewport size: ${_viewport!.getSize()}, scaled page width: ${_viewport!.calculatePageSize(0, scale).width}"); // Stop any on-going orientation animation so that we can translate from the current orientation. _animationController.stop(); @@ -554,6 +602,7 @@ class PageListViewportController extends OrientationController { notifyListeners(); } + @override void setScale(double newScale, Offset focalPointInViewport) { assert(newScale > 0.0); PageListViewportLogs.pagesListController @@ -593,6 +642,7 @@ class PageListViewportController extends OrientationController { /// /// Any manual adjustment of the orientation will cause this simulation to immediately /// cease controlling the orientation. + @override void driveWithSimulation(OrientationSimulation simulation) { if (_activeSimulation != null) { stopSimulation(); @@ -648,6 +698,7 @@ class PageListViewportController extends OrientationController { } /// Stops any on-going orientation simulation, started by [driveWithSimulation]. + @override void stopSimulation() { _activeSimulation = null; @@ -667,9 +718,9 @@ class PageListViewportController extends OrientationController { double originX = desiredOrigin.dx; double originY = desiredOrigin.dy; - final contentWidth = _viewport!.calculatePageWidth(scale); + final contentWidth = _viewport!.calculatePageSize(0, scale).width; final contentHeight = _viewport!.calculateContentHeight(scale); - final viewportSize = _viewport!.size; + final viewportSize = _viewport!.getSize(); if (contentWidth <= viewportSize.width) { originX = (viewportSize.width - contentWidth) / 2; @@ -737,6 +788,13 @@ abstract class OrientationController with ChangeNotifier { double get scale; set scale(double newScale); + void setScale(double newScale, Offset focalPointInViewport); + void translate(Offset deltaInScreenSpace); + void driveWithSimulation(OrientationSimulation simulation); + void stopSimulation(); + + bool get isRunningOrientationSimulation; + /// The [RenderPageListViewport] whose content transform is controlled /// by this controller. /// @@ -744,7 +802,7 @@ abstract class OrientationController with ChangeNotifier { /// move or scale in ways that violates the viewport's constraints, such as /// making the content smaller than the viewport. @protected - RenderPageListViewport? get viewport; + PageListViewportLayout? get viewport; /// Sets the [RenderPageListViewport] whose content transform is controlled /// by this controller. @@ -753,7 +811,7 @@ abstract class OrientationController with ChangeNotifier { /// move or scale in ways that violates the viewport's constraints, such as /// making the content smaller than the viewport. @protected - set viewport(RenderPageListViewport? viewport); + set viewport(PageListViewportLayout? viewport); @protected void onViewportLayout(); @@ -801,7 +859,15 @@ abstract class OrientationController with ChangeNotifier { } } -class RenderPageListViewport extends RenderBox { +abstract class PageListViewportLayout { + Size calculatePageSize(int pageIndex, double scale); + double calculateContentHeight(double scale); + Size getSize(); + int getPageCount(); + Size getNaturalPageSize(int pageIndex); +} + +class RenderPageListViewport extends RenderBox implements PageListViewportLayout { RenderPageListViewport({ required PageListViewportElement element, required OrientationController controller, @@ -970,13 +1036,11 @@ class RenderPageListViewport extends RenderBox { } } - Size calculatePageSize(double scale) => _naturalPageSize * scale; - - double calculatePageWidth(double scale) => _naturalPageSize.width * scale; - - double calculatePageHeight(double scale) => _naturalPageSize.height * scale; + @override + Size calculatePageSize(int pageIndex, double scale) => _naturalPageSize * scale; - double calculateContentHeight(double scale) => calculatePageHeight(scale) * _pageCount; + @override + double calculateContentHeight(double scale) => calculatePageSize(0, scale).height * _pageCount; @override void performLayout() { @@ -994,10 +1058,7 @@ class RenderPageListViewport extends RenderBox { _createAndCullVisibleAndCachedPages(); - final pageSize = Size( - calculatePageWidth(_controller!.scale), - calculatePageHeight(_controller!.scale), - ); + final pageSize = calculatePageSize(0, _controller!.scale); _visitLayoutChildren((pageIndex, childElement) { if (childElement == null) { @@ -1132,7 +1193,7 @@ class RenderPageListViewport extends RenderBox { Timeline.startSync("Local to global"); } - final pageOriginVec = transform.transform3(Vector3(0, 0, 0)); + //final pageOriginVec = transform.transform3(Vector3(0, 0, 0)); // PageListViewportLogs.pagesList.finer("Painting page index: $pageIndex"); // PageListViewportLogs.pagesList.finer(" - child element: $childElement"); // PageListViewportLogs.pagesList.finer(" - scaled page size: $_scaledPageSize"); @@ -1199,6 +1260,15 @@ class RenderPageListViewport extends RenderBox { int _findLastCachedPageIndex() { return math.min(_findLastVisiblePageIndex() + _pageLayoutCacheCount, _pageCount - 1); } + + @override + Size getSize() => size; + + @override + Size getNaturalPageSize(int pageIndex) => _naturalPageSize; + + @override + int getPageCount() => _pageCount; } class PageListViewportElement extends RenderObjectElement { @@ -1303,6 +1373,7 @@ class PageListViewportElement extends RenderObjectElement { @override void insertRenderObjectChild(RenderObject child, Object? slot) { PageListViewportLogs.pagesList.finest(() => "Viewport adopting render object child: $child"); + // ignore: invalid_use_of_protected_member renderObject.adoptChild(child); } @@ -1315,6 +1386,7 @@ class PageListViewportElement extends RenderObjectElement { void removeRenderObjectChild(RenderObject child, Object? slot) { PageListViewportLogs.pagesList .finest(() => "removeRenderObjectChild() - child: $child, slot: $slot, is attached? ${child.attached}"); + // ignore: invalid_use_of_protected_member renderObject.dropChild(child); } } diff --git a/lib/src/page_list_viewport_gestures.dart b/lib/src/page_list_viewport_gestures.dart index 7e24431..62b7496 100644 --- a/lib/src/page_list_viewport_gestures.dart +++ b/lib/src/page_list_viewport_gestures.dart @@ -30,7 +30,7 @@ class PageListViewportGestures extends StatefulWidget { this.clock = const Clock(), required this.child, }) : super(key: key); - final PageListViewportController controller; + final OrientationController controller; // All of these methods were added because our client needs to // respond to them, and we internally respond to other gestures. diff --git a/lib/src/page_list_viewport_variable_size.dart b/lib/src/page_list_viewport_variable_size.dart new file mode 100644 index 0000000..bf75ed3 --- /dev/null +++ b/lib/src/page_list_viewport_variable_size.dart @@ -0,0 +1,1239 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:developer'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:page_list_viewport/page_list_viewport.dart'; + +class RenderPageListVariableSizeViewport extends RenderBox implements PageListViewportLayout { + RenderPageListVariableSizeViewport({ + required PageListViewportWithVariableSizeElement element, + required OrientationController controller, + required int pageCount, + PageSizeResolver? pageSizeResolver, + int pageLayoutCacheCount = 0, + int pagePaintCacheCount = 0, + }) : _element = element, + _pageCount = pageCount, + _pageSizeResolver = pageSizeResolver, + _pageLayoutCacheCount = pageLayoutCacheCount, + _pagePaintCacheCount = pagePaintCacheCount { + // Run controller assignment through the public method + // so that we attach ourselves to it. + this.controller = controller; + } + + @override + void dispose() { + _controller?.removeListener(_onOrientationChange); + _element = null; + super.dispose(); + } + + PageListViewportWithVariableSizeElement? _element; + int _pageCount; + + set pageCount(int newCount) { + if (newCount == _pageCount) { + return; + } + + _pageCount = newCount; + markNeedsLayout(); + } + + PageSizeResolver? _pageSizeResolver; + set pageSizeResolver(PageSizeResolver? newPageSizeResolver) { + if (newPageSizeResolver == _pageSizeResolver) { + return; + } + + _pageSizeResolver = newPageSizeResolver; + markNeedsLayout(); + } + + @override + Size getNaturalPageSize(int pageIndex) => _pageSizeResolver!(pageIndex); + + int _pageLayoutCacheCount; + + set pageLayoutCacheCount(int newCount) { + assert(newCount >= 0); + if (newCount == _pageLayoutCacheCount) { + return; + } + + if (newCount > _pageLayoutCacheCount) { + // Only request a layout if we want MORE pages cached + // than we did before. Otherwise, it costs us nothing + // to wait until the next pass and throw away what we + // don't need. + markNeedsLayout(); + } + + _pageLayoutCacheCount = newCount; + } + + int _pagePaintCacheCount; + + set pagePaintCacheCount(int newCount) { + assert(newCount >= 0); + if (newCount == _pagePaintCacheCount) { + return; + } + + if (newCount > _pagePaintCacheCount) { + // Only request a paint if we want MORE pages cached + // than we did before. Otherwise, it costs us nothing + // to wait until the next pass and throw away what we + // don't need. + markNeedsPaint(); + } + + _pagePaintCacheCount = newCount; + } + + OrientationController? _controller; + + set controller(OrientationController newController) { + if (_controller == newController) { + return; + } + + _controller?.removeListener(_onOrientationChange); + // ignore: invalid_use_of_protected_member + _controller?.viewport = null; + + _controller = newController; + // ignore: invalid_use_of_protected_member + _controller!.viewport = this; + _controller!.addListener(_onOrientationChange); + + markNeedsLayout(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + ClipRectLayer? get layer => super.layer as ClipRectLayer?; + + @override + set layer(ContainerLayer? newLayer) => super.layer = newLayer as ClipRectLayer?; + + final _pageBaselineScales = []; + + @override + void attach(PipelineOwner owner) { + PageListViewportLogs.pagesList.finest(() => "attach()'ing viewport render object to pipeline"); + super.attach(owner); + + visitChildren((child) { + child.attach(owner); + }); + } + + @override + void detach() { + PageListViewportLogs.pagesList.finest(() => "detach()'ing viewport render object from pipeline"); + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + void _onOrientationChange() { + markNeedsLayout(); + + // When the viewport only translates (no scale), the children won't have their performLayout() + // function called because their constraints didn't change, and no one marked them dirty. + // + // But, child pages that care about the viewport translation, such as pages that cull their + // content, need to re-run layout, even when their size doesn't change. + // + // For now, we force all of our children to re-run layout whenever we pan. + // FIXME: find another way to trigger relevant child page relayout, or at least add a way to + // opt-in to this behavior, instead of forcing relayout on all pages of all types. + visitChildren((child) { + child.markNeedsLayout(); + }); + } + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! ViewportPageParentData) { + child.parentData = ViewportPageParentData(pageIndex: -1); + } + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + final children = _element!._childElements.values.toList(); + for (final child in children) { + visitor(child!.renderObject!); + } + } + + @override + Size calculatePageSize(int pageIndex, double scale) => + getNaturalPageSize(pageIndex) * scale * _pageBaselineScales[pageIndex]; + + @override + double calculateContentHeight(double scale) { + double totalContentHeight = 0.0; + for (int i = 0; i < _pageCount; i++) { + totalContentHeight += calculatePageSize(i, scale).height; + } + return totalContentHeight; + } + + @override + void performLayout() { + size = constraints.biggest; + if (size.width == 0) { + // Our content calculations depend on a non-zero width. If we have no width, there's + // nothing to layout or paint anyway. Bail out now and avoid adding code to account + // for zero width. + return; + } + + // First we need to calculate the baseline scale for each page. Since the page sizes can vary, + // to determine the first visible page, we need to know the scaled size of each page. + _pageBaselineScales.clear(); + for (int i = 0; i < _pageCount; i++) { + final pageBaselineScale = size.width / getNaturalPageSize(i).width; + _pageBaselineScales.add(pageBaselineScale); + } + + // We must let the controller do its layout work before we create and cull the pages, + // because the controller might change the offset of the viewport. + // ignore: invalid_use_of_protected_member + _controller!.onViewportLayout(); + + _createAndCullVisibleAndCachedPages(); + + Offset offset = Offset.zero; + + // Layout the visible and cached pages. + _visitLayoutChildren((pageIndex, childElement) { + if (childElement == null) { + return; + } + + final pageSize = calculatePageSize(pageIndex, _controller!.scale); + + final child = childElement.renderObject! as RenderBox; + final pageParentData = child.parentData as ViewportPageParentData; + pageParentData + ..viewportSize = size + ..pageIndex = pageIndex + ..offset = offset; + + offset += Offset(0, pageSize.height); + PageListViewportLogs.pagesList.finest(() => "Laying out child (at $pageIndex): $child"); + child.layout(BoxConstraints.tight(pageSize), parentUsesSize: true); + PageListViewportLogs.pagesList.finest(() => " - child size: ${child.size}"); + }); + } + + // This page list needs to build and layout any pages that should + // be visible in the viewport. It also needs to build and layout + // any pages that sit near the viewport (based on our cache policy). + // + // This method finds any relevant pages that have yet to be built, + // and then builds those pages, and adds their new `RenderObject`s + // as children. + void _createAndCullVisibleAndCachedPages() { + invokeLayoutCallback((constraints) { + // Create new pages in visual and cache range. + _visitLayoutChildren((pageIndex, childElement) { + if (childElement == null) { + // We call invokeLayoutCallback() because that's the only way we're + // allowed to adopt children during layout. + _element!.createPage(pageIndex); + } + }); + + // Remove pages outside of cache range. + final firstPageIndex = _findFirstCachedPageIndex(); + final lastPageIndex = _findLastCachedPageIndex(); + _element!.removePagesOutsideRange(firstPageIndex, lastPageIndex); + }); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + bool didHitChild = false; + _visitLayoutChildren((pageIndex, childElement) { + if (childElement == null) { + return; + } + if (didHitChild) { + return; + } + + final childRenderBox = childElement.renderObject as RenderBox; + final childTransform = Matrix4.identity(); + applyPaintTransform(childRenderBox, childTransform); + didHitChild = result.addWithPaintTransform( + transform: childTransform, + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + return childRenderBox.hitTest(result, position: position); + }, + ); + }); + if (didHitChild) { + return true; + } + + if (hitTestSelf(position)) { + result.add(BoxHitTestEntry(this, position)); + return true; + } + + return false; + } + + @override + bool hitTestSelf(Offset position) { + return size.contains(position); + } + + void _visitLayoutChildren(Function(int pageIndex, Element? childElement) visitor) { + final firstPageIndexToLayout = _findFirstCachedPageIndex(); + final lastPageIndexToLayout = _findLastCachedPageIndex(); + for (int pageIndex = firstPageIndexToLayout; pageIndex <= lastPageIndexToLayout; pageIndex += 1) { + visitor(pageIndex, _element!._childElements[pageIndex]); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (size.width == 0) { + // Our content calculations depend on a non-zero width. If we have no width, there's + // nothing to layout or paint anyway. Bail out now and avoid adding code to account + // for zero width. + return; + } + + final childElements = _element!._childElements; + final firstPageToPaintIndex = _findFirstPaintedPageIndex(); + final lastPageToPaintIndex = _findLastPaintedPageIndex(); + + PageListViewportLogs.pagesList.finest(() => "Painting children at scale: ${_controller!.scale}"); + + layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + oldLayer: layer, + (context, offset) { + // Paint all the pages that are visible or cached. + for (int pageIndex = firstPageToPaintIndex; pageIndex <= lastPageToPaintIndex; pageIndex += 1) { + if (debugProfilePaintsEnabled) { + Timeline.startSync("Paint page $pageIndex"); + Timeline.startSync("Vars"); + } + + final childElement = childElements[pageIndex]!; + final childRenderBox = childElement.renderObject! as RenderBox; + final transform = Matrix4.identity(); + + if (debugProfilePaintsEnabled) { + Timeline.finishSync(); + Timeline.startSync("Paint transform"); + } + + applyPaintTransform(childRenderBox, transform); + + if (debugProfilePaintsEnabled) { + Timeline.finishSync(); + Timeline.startSync("Local to global"); + } + + //final pageOriginVec = transform.transform3(Vector3(0, 0, 0)); + // PageListViewportLogs.pagesList.finer("Painting page index: $pageIndex"); + // PageListViewportLogs.pagesList.finer(" - child element: $childElement"); + // PageListViewportLogs.pagesList.finer(" - scaled page size: $_scaledPageSize"); + // PageListViewportLogs.pagesList.finer(" - page origin: $pageOriginVec"); + // PageListViewportLogs.pagesList.finer(" - scaled origin: ${pageOriginVec * _contentScale}"); + // PageListViewportLogs.pagesList.finer("Painting child render object: $childRenderBox"); + if (debugProfilePaintsEnabled) { + Timeline.finishSync(); + } + + final parentData = childRenderBox.parentData as ViewportPageParentData; + parentData.transformLayerHandle.layer = context.pushTransform( + needsCompositing, + offset, + transform, + oldLayer: parentData.transformLayerHandle.layer, + // Calling context.paintChild() seems to be necessary. Without it, it seems that our children + // might need to paint and yet we don't paint them. Not sure why. + (context, offset) => context.paintChild(childRenderBox, offset), + ); + + if (debugProfilePaintsEnabled) { + Timeline.finishSync(); + } + } + }, + ); + PageListViewportLogs.pagesList.finest(() => "Done with viewport paint"); + } + + // The transform in this method is used to map from global-to-local, and + // local-to-global. We need to report the position of our [child] through + // the given [transform]. + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final pageIndex = (child.parentData as ViewportPageParentData).pageIndex; + + double dy = 0; + for (int i = 0; i < pageIndex; i++) { + dy += calculatePageSize(i, _controller!.scale).height; + } + + final pageOrigin = Offset( + _controller!.origin.dx, + _controller!.origin.dy + dy, + ); + transform.translate(pageOrigin.dx, pageOrigin.dy); + } + + int _findFirstVisiblePageIndex() { + return _findFirstVisiblePageAtY(_controller!.origin.dy.abs()); + } + + int _findLastVisiblePageIndex() { + return _findFirstVisiblePageAtY(_controller!.origin.dy.abs() + size.height); + } + + int _findFirstVisiblePageAtY(double y) { + double currentContentHeight = 0.0; + int index = 0; + while (index < _pageCount) { + final pageHeight = calculatePageSize(index, _controller!.scale).height; + currentContentHeight += pageHeight; + + if (currentContentHeight >= y) { + break; + } + + index += 1; + } + + return index; + } + + int _findFirstPaintedPageIndex() { + return math.max(_findFirstVisiblePageIndex() - _pagePaintCacheCount, 0); + } + + int _findLastPaintedPageIndex() { + return math.min(_findLastVisiblePageIndex() + _pagePaintCacheCount, _pageCount - 1); + } + + int _findFirstCachedPageIndex() { + return math.max(_findFirstVisiblePageIndex() - _pageLayoutCacheCount, 0); + } + + int _findLastCachedPageIndex() { + return math.min(_findLastVisiblePageIndex() + _pageLayoutCacheCount, _pageCount - 1); + } + + @override + int getPageCount() => _pageCount; + + @override + Size getSize() => size; +} + +class PageListViewportWithVariableSizeElement extends RenderObjectElement { + PageListViewportWithVariableSizeElement(super.widget); + + bool get hasChildren => _childElements.isNotEmpty; + + int get childCount => _childElements.length; + + final SplayTreeMap _childElements = SplayTreeMap(); + + @override + RenderPageListVariableSizeViewport get renderObject => super.renderObject as RenderPageListVariableSizeViewport; + + @override + void visitChildren(ElementVisitor visitor) { + for (final childElement in _childElements.values) { + visitor(childElement!); + } + } + + // TODO: check if we can use multi child element to automatically rebuild + // our children at the appropriate time. + @override + void update(RenderObjectWidget newWidget) { + PageListViewportLogs.pagesList.finest(() => "update() on element"); + super.update(newWidget); + + if (Widget.canUpdate(widget, newWidget)) { + performRebuild(); + } + } + + @override + Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) { + PageListViewportLogs.pagesList.finest(() => "updateChild(): $newWidget"); + return super.updateChild(child, newWidget, newSlot); + } + + @override + void performRebuild() { + PageListViewportLogs.pagesList.finest(() => "performRebuild()"); + super.performRebuild(); + + // rebuild() is where typical RenderObjects add and remove children + // based on its widget. We add children during layout(), so we can't + // do that here. However, if the widget has reduced the number of + // desired pages, we can remove extra pages here. + final pageListViewport = widget as PageListViewport; + if (pageListViewport.pageCount < childCount) { + for (int i = childCount - 1; i >= pageListViewport.pageCount; i -= 1) { + forgetChild(_childElements[i]!); + _childElements.remove(i); + } + } + + for (final childEntry in _childElements.entries) { + final pageIndex = childEntry.key; + final pageWidget = pageListViewport.builder(this, pageIndex); + + _childElements[pageIndex] = updateChild( + _childElements[pageIndex], + pageWidget, + pageIndex, + ); + } + } + + void createPage(int pageIndex) { + owner!.buildScope(this, () { + Element? newChild; + try { + newChild = updateChild( + _childElements[pageIndex], + (widget as PageListViewport).builder(this, pageIndex), + pageIndex, + ); + } finally {} + if (newChild != null) { + _childElements[pageIndex] = newChild; + } else { + _childElements.remove(pageIndex); + } + }); + } + + void removePagesOutsideRange(int firstPageIndex, int lastPageIndex) { + assert(firstPageIndex <= lastPageIndex); + + final pageIndices = _childElements.keys.toList(growable: false); + for (final pageIndex in pageIndices) { + if (pageIndex >= firstPageIndex && pageIndex <= lastPageIndex) { + continue; + } + + // Remove this page because it isn't in the desired range. + deactivateChild(_childElements[pageIndex]!); + _childElements.remove(pageIndex); + } + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + PageListViewportLogs.pagesList.finest(() => "Viewport adopting render object child: $child"); + // ignore: invalid_use_of_protected_member + renderObject.adoptChild(child); + } + + @override + void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { + // no-op + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + PageListViewportLogs.pagesList + .finest(() => "removeRenderObjectChild() - child: $child, slot: $slot, is attached? ${child.attached}"); + // ignore: invalid_use_of_protected_member + renderObject.dropChild(child); + } +} + +class ViewportPageWithVariableSizeParentData extends ContainerBoxParentData + with ContainerParentDataMixin { + ViewportPageWithVariableSizeParentData({ + required this.pageIndex, + }); + + late Size viewportSize; + int pageIndex; + + final transformLayerHandle = LayerHandle(); + + @override + void detach() { + transformLayerHandle.layer = null; + super.detach(); + } +} + +class PageListViewportWithVariableSizeController extends OrientationController { + PageListViewportWithVariableSizeController.startAtPage({ + required TickerProvider vsync, + required int pageIndex, + double scale = 1.0, + double minimumScale = 0.1, + double maximumScale = double.infinity, + }) : assert(pageIndex >= 0, "The initial page index must be >= 0"), + _tickerProvider = vsync, + _initialPageIndex = pageIndex, + _origin = Offset.zero, + _previousOrigin = Offset.zero, + _velocityStopwatch = Stopwatch(), + _scale = scale, + previousScale = scale, + _scaleVelocity = 0.0, + _scaleVelocityStopwatch = Stopwatch(), + _minimumScale = minimumScale, + _maximumScale = maximumScale { + initController(vsync); + } + + PageListViewportWithVariableSizeController({ + required TickerProvider vsync, + Offset origin = Offset.zero, + double scale = 1.0, + double minimumScale = 0.1, + double maximumScale = double.infinity, + }) : _tickerProvider = vsync, + _origin = origin, + _previousOrigin = origin, + _velocityStopwatch = Stopwatch(), + _scale = scale, + previousScale = scale, + _scaleVelocity = 0.0, + _scaleVelocityStopwatch = Stopwatch(), + _minimumScale = minimumScale, + _maximumScale = maximumScale { + initController(vsync); + } + + @protected + void initController(TickerProvider vsync) { + _animationController = AnimationController(vsync: vsync) // + ..addListener(_onOrientationAnimationChange) + ..addStatusListener((status) { + switch (status) { + case AnimationStatus.dismissed: + case AnimationStatus.completed: + _onOrientationAnimationEnd(); + break; + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + }); + + _velocityStopwatch.start(); + _scaleVelocityStopwatch.start(); + } + + @override + void dispose() { + _animationController.dispose(); + _velocityStopwatch.stop(); + _velocityResetTimer?.cancel(); + _scaleVelocityStopwatch.stop(); + super.dispose(); + } + + final TickerProvider _tickerProvider; + + late final AnimationController _animationController; + Animation? _offsetAnimation; + Animation? _scaleAnimation; + + /// The index of the page that this controller will jump to, when attached to its first + /// viewport. + /// + /// This value is cleared after it's applied. This controller won't jump to this page, + /// again. + // TODO: should we have a way to set this so that the controller can be attached to new + // viewports? + int? _initialPageIndex; + + /// The (x,y) offset of the top-left corner of the first page in + /// the page list, measured in un-scaled pixels. + @override + Offset get origin => _origin; + + Offset _origin; + Offset _previousOrigin; // used to calculate velocity + + @override + set origin(Offset newOrigin) { + if (newOrigin == _origin) { + return; + } + + // Stop any on-going orientation animation so that the origin stays at + // the new offset. + _animationController.stop(); + stopSimulation(); + + _origin = newOrigin; + notifyListeners(); + } + + /// The velocity of the translation of the viewport origin. + Offset get velocity => _velocity; + Offset _velocity = Offset.zero; + final Stopwatch _velocityStopwatch; + Timer? _velocityResetTimer; // resets the velocity to zero if we haven't received a translation recently + + Offset get acceleration => _acceleration; + Offset _acceleration = Offset.zero; + + /// The scale of the content in the viewport. + @override + double get scale => _scale; + double _scale; + @override + set scale(double newScale) { + if (newScale == _scale) { + return; + } + + // An external source has changed the scale. Stop any ongoing orientation simulation. + stopSimulation(); + + _scale = newScale; + notifyListeners(); + } + + @protected + double previousScale; + + double get scaleVelocity => _scaleVelocity; + double _scaleVelocity; + final Stopwatch _scaleVelocityStopwatch; + + /// The largest that the viewport content is allowed to be. + double get maximumScale => _maximumScale; + double _maximumScale; + + set maximumScale(double newMaximumScale) { + if (newMaximumScale == _maximumScale) { + return; + } + + _maximumScale = maximumScale; + if (_scale > _maximumScale) { + _scale = _maximumScale; + } + + notifyListeners(); + } + + /// The smallest that the viewport content is allowed to be. + double get minimumScale => _minimumScale; + double _minimumScale; + + set minimumScale(double newMinimumScale) { + if (newMinimumScale == _minimumScale) { + return; + } + + _minimumScale = newMinimumScale; + if (_scale < _minimumScale) { + _scale = _minimumScale; + } + + notifyListeners(); + } + + @override + PageListViewportLayout? get viewport => _viewport; + + PageListViewportLayout? _viewport; + + /// Sets the [RenderPageListViewport] whose content transform is controlled + /// by this controller. + /// + /// A connection to the viewport is needed to ensure that content doesn't + /// move or scale in ways that violates the viewport's constraints, such as + /// making the content smaller than the viewport. + @override + @protected + set viewport(PageListViewportLayout? viewport) { + if (_viewport == viewport) { + return; + } + + _viewport = viewport; + + // Stop any on-going orientation animation because we received a new viewport. + _animationController.stop(); + stopSimulation(); + } + + Size? get _viewportSize => _viewport?.getSize(); + + bool _isFirstLayoutForController = true; + + @override + bool get isRunningOrientationSimulation => _activeSimulation != null; + OrientationSimulation? _activeSimulation; + Ticker? _simulationTicker; + Duration? _previousSimulationTime; + AxisAlignedOrientation? _previousSimulationOrientation; + + @override + @protected + void onViewportLayout() { + disableNotifications(); + + final viewportSize = _viewportSize!; + + if (_isFirstLayoutForController && _viewport!.getPageCount() > 0) { + scale = 1.0; + + final totalContentHeight = _viewport!.calculateContentHeight(scale); + if (totalContentHeight < viewportSize.height) { + // We don't have enough content to fill the viewport. Center the content, vertically. + origin = Offset( + origin.dx, + (viewportSize.height - totalContentHeight) / 2, + ); + } + + _isFirstLayoutForController = false; + } else if (scale < 1.0) { + // Update the private property so that we don't markNeedsLayout during layout. + scale = 1.0; + } + + if (_initialPageIndex != null) { + // Jump to the desired page on viewport attachment. + _origin = _getPageOffset(_initialPageIndex!); + _initialPageIndex = null; + } else { + _origin = _constrainOriginToViewportBounds(_origin); + } + + enableNotifications( + notifyIfNotificationsWereBlocked: true, + notifyImmediately: false, + ); + } + + /// Immediately changes the viewport offset so that the page at the given [pageIndex] is positioned as close + /// as possible to the center of the viewport. + /// + /// To change the zoom level at the same time, provide a [zoomLevel]. + void jumpToPage(int pageIndex, [double? zoomLevel]) { + if (_viewport == null) { + PageListViewportLogs.pagesList + .warning("Tried to jump to a PDF page but the controller isn't connected to a page list viewport"); + return; + } + + // Stop any on-going orientation animation so that we can jump to the desired page. + _animationController.stop(); + stopSimulation(); + + _origin = _getPageOffset(pageIndex, zoomLevel); + _velocity = Offset.zero; + _velocityStopwatch.reset(); + + notifyListeners(); + } + + Offset _getPageOffset(int pageIndex, [double? zoomLevel]) { + final desiredZoomLevel = zoomLevel ?? scale; + + double contentAboveDesiredPage = 0.0; + for (int i = 0; i < pageIndex; i++) { + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); + contentAboveDesiredPage += pageSizeAtZoomLevel.height; + } + + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(pageIndex, desiredZoomLevel); + final desiredPageTopLeftInViewport = + (_viewportSize!).center(Offset.zero) - Offset(pageSizeAtZoomLevel.width / 2, pageSizeAtZoomLevel.height / 2); + + final desiredOrigin = Offset(0, -contentAboveDesiredPage) + desiredPageTopLeftInViewport; + return _constrainOriginToViewportBounds(desiredOrigin); + } + + /// Immediately changes the viewport offset so that the given [pixelOffsetInPage], within the page at the given + /// [pageIndex], is positioned as close as possible to the center of the viewport. + /// + /// To change the zoom level at the same time, provide a [zoomLevel]. + void jumpToOffsetInPage(int pageIndex, Offset pixelOffsetInPage, [double? zoomLevel]) { + if (_viewport == null) { + PageListViewportLogs.pagesList + .warning("Tried to jump to a PDF page but the controller isn't connected to a page list viewport"); + return; + } + + // Stop any on-going orientation animation so that we can jump to the desired page. + _animationController.stop(); + stopSimulation(); + + final desiredZoomLevel = zoomLevel ?? scale; + + double contentAboveDesiredPage = 0.0; + for (int i = 0; i < pageIndex; i++) { + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); + contentAboveDesiredPage += pageSizeAtZoomLevel.height; + } + + final pageFocalPointAtZoomLevel = pixelOffsetInPage * desiredZoomLevel; + final desiredPageTopLeftInViewport = (_viewportSize!).center(-pageFocalPointAtZoomLevel); + final desiredOrigin = Offset(0, -contentAboveDesiredPage) + desiredPageTopLeftInViewport; + + _origin = _constrainOriginToViewportBounds(desiredOrigin); + _velocity = Offset.zero; + _velocityStopwatch.reset(); + + notifyListeners(); + } + + /// Animates the viewport offset so that the page at the given [pageIndex] is positioned as close + /// as possible to the center of the viewport. + /// + /// By default, the animation runs with the given [duration]. To increase or decrease the duration based on + /// the overall panning distance, pass `true` for [applyDurationPerPage]. + /// + /// To change the zoom level at the same time, provide a [zoomLevel]. + /// + /// Use [curve] to apply an animation curve. + Future animateToPage( + int pageIndex, + Duration duration, { + double? zoomLevel, + Curve curve = Curves.easeOut, + bool applyDurationPerPage = false, + }) { + if (_viewport == null) { + PageListViewportLogs.pagesList + .warning("Tried to jump to a PDF page but the controller isn't connected to a page list viewport"); + return Future.value(); + } + + // We want to animate to a page, which means we don't want to continue with any + // on-going orientation simulations. + stopSimulation(); + + final centerOfPage = _viewport!.calculatePageSize(pageIndex, 1.0).center(Offset.zero); + return animateToOffsetInPage( + pageIndex, + centerOfPage, + duration, + applyDurationPerPage: applyDurationPerPage, + zoomLevel: zoomLevel, + curve: curve, + ); + } + + /// Animates the viewport offset so that the [pixelOffsetInPage], within the page at the given [pageIndex], is + /// positioned as close as possible to the center of the viewport. + /// + /// By default, the animation runs with the given [duration]. To increase or decrease the duration based on + /// the overall panning distance, pass `true` for [applyDurationPerPage]. + /// + /// To change the zoom level at the same time, provide a [zoomLevel]. + /// + /// Use [curve] to apply an animation curve. + Future animateToOffsetInPage( + int pageIndex, + Offset pixelOffsetInPage, + Duration duration, { + double? zoomLevel, + Curve curve = Curves.easeOut, + bool applyDurationPerPage = false, + }) { + if (_viewport == null) { + PageListViewportLogs.pagesList + .warning("Tried to jump to a PDF page but the controller isn't connected to a page list viewport"); + return Future.value(); + } + + // Stop any on-going orientation animation so that we can jump to the desired page. + _animationController.stop(); + stopSimulation(); + + final desiredZoomLevel = zoomLevel ?? scale; + + final pageSizes = []; + double contentAboveDesiredPage = 0.0; + for (int i = 0; i < pageIndex; i++) { + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); + contentAboveDesiredPage += pageSizeAtZoomLevel.height; + pageSizes.add(pageSizeAtZoomLevel); + } + + final pageFocalPointAtZoomLevel = pixelOffsetInPage * desiredZoomLevel; + final desiredPageTopLeftInViewport = (_viewportSize!).center(-pageFocalPointAtZoomLevel); + final destinationOffset = + _constrainOriginToViewportBounds(Offset(0, -contentAboveDesiredPage) + desiredPageTopLeftInViewport); + + _previousOrigin = _origin; + _velocityStopwatch.reset(); + _offsetAnimation = Tween(begin: _origin, end: destinationOffset).animate( + CurvedAnimation(parent: _animationController, curve: curve), + ); + + _scaleAnimation = Tween(begin: scale, end: desiredZoomLevel).animate( + CurvedAnimation(parent: _animationController, curve: curve), + ); + + //final animationDuration = duration; + Duration animationDuration = duration; + if (applyDurationPerPage) { + final currentFirstVisiblePageIndex = _findFirstVisiblePageAtY(_origin.dy.abs()); + final newFirstVisiblePageIndex = _findFirstVisiblePageAtY(destinationOffset.dy.abs()); + final pageCount = (newFirstVisiblePageIndex - currentFirstVisiblePageIndex).abs() + 1; + print('pagecount: $pageCount'); + + animationDuration *= pageCount; + } + + _animationController.duration = animationDuration; + return _animationController.forward(from: 0); + } + + void _onOrientationAnimationChange() { + _origin = _offsetAnimation!.value; + _scale = _scaleAnimation!.value; + + if (_velocityStopwatch.elapsedMilliseconds > 0) { + _velocity = (_offsetAnimation!.value - _previousOrigin) / (_velocityStopwatch.elapsedMilliseconds / 1000); + _velocityStopwatch.reset(); + } + _previousOrigin = _offsetAnimation!.value; + + notifyListeners(); + } + + void _onOrientationAnimationEnd() { + _velocity = Offset.zero; + _velocityStopwatch.reset(); + + notifyListeners(); + } + + @override + void translate(Offset deltaInScreenSpace) { + PageListViewportLogs.pagesListController.fine(() => "Translation requested for delta: $deltaInScreenSpace"); + final desiredOrigin = _origin + deltaInScreenSpace; + PageListViewportLogs.pagesListController.fine(() => + "Origin before adjustment: $_origin. Content height: ${_viewport!.calculateContentHeight(scale)}, Scale: $scale"); + PageListViewportLogs.pagesListController.fine(() => "Viewport size: ${_viewport!.getSize()}"); + + // Stop any on-going orientation animation so that we can translate from the current orientation. + _animationController.stop(); + stopSimulation(); + + final newOrigin = _constrainOriginToViewportBounds(desiredOrigin); + + _previousOrigin = _origin; + _origin = newOrigin; + + // Update velocity tracking. + if (_velocityStopwatch.elapsedMilliseconds > 0) { + _velocity = (newOrigin - _previousOrigin) / (_velocityStopwatch.elapsedMicroseconds / 1000000); + + _velocityStopwatch.reset(); + _velocityResetTimer?.cancel(); + + if (_velocity.distance > 0) { + // When the user is panning, we won't know when the final translation comes in. + // Therefore, to eventually report a velocity of zero, we need to assume that the + // absence of a message across a couple of frames indicates that we're done moving. + _velocityResetTimer = Timer(const Duration(milliseconds: 32), () { + _velocity = Offset.zero; + notifyListeners(); + }); + } + } + + notifyListeners(); + } + + @override + void setScale(double newScale, Offset focalPointInViewport) { + assert(newScale > 0.0); + PageListViewportLogs.pagesListController + .fine(() => "Scale requested with desired scale: $newScale, min scale: $_minimumScale"); + + // Stop any on-going orientation animation so that we honor the desired scale. + _animationController.stop(); + stopSimulation(); + + newScale = newScale.clamp(_minimumScale, maximumScale); + + final scaleDiff = newScale / _scale; + + // When the scale changes, the origin offset needs to move accordingly. + // The distance that the origin offset moves depends on how far the + // origin sits from the scaling focal point. For example, when the + // origin sits exactly at the focal point, the origin shouldn't move + // at all. + final focalPointToOrigin = _origin - focalPointInViewport; + _origin = focalPointInViewport + (focalPointToOrigin * scaleDiff); + + // Update our scale. + PageListViewportLogs.pagesListController.fine(() => "Setting scale to $newScale"); + previousScale = _scale; + _scale = newScale; + _scaleVelocity = (_scale - previousScale) / (_scaleVelocityStopwatch.elapsedMilliseconds / 1000); + _scaleVelocityStopwatch.reset(); + + // Snap the content back to the viewport edges. + _origin = _constrainOriginToViewportBounds(_origin); + + notifyListeners(); + } + + /// The given [simulation] takes control of the orientation of the content associated + /// with this controller. + /// + /// Any manual adjustment of the orientation will cause this simulation to immediately + /// cease controlling the orientation. + @override + void driveWithSimulation(OrientationSimulation simulation) { + if (_activeSimulation != null) { + stopSimulation(); + } + + _activeSimulation = simulation; + _previousSimulationTime = Duration.zero; + _previousSimulationOrientation = AxisAlignedOrientation(_origin, _scale); + _simulationTicker ??= _tickerProvider.createTicker(_onSimulationTick); + + _simulationTicker!.start(); + } + + void _onSimulationTick(Duration elapsedTime) { + if (!isRunningOrientationSimulation) { + return; + } + if (elapsedTime == Duration.zero) { + return; + } + + // Calculate a new orientation based on the time that's passed. + final orientation = _activeSimulation!.orientationAt(elapsedTime); + + // Update the velocity calculations. + final dt = elapsedTime - (_previousSimulationTime ?? Duration.zero); + final dtInSeconds = dt.inMicroseconds.toDouble() / 1e6; + final velocity = (orientation.origin - _previousSimulationOrientation!.origin) / dtInSeconds; + _acceleration = velocity - _velocity; + _velocity = velocity; + _scaleVelocity = (orientation.scale - _previousSimulationOrientation!.scale) / dtInSeconds; + + // Update the content origin and scale. + _scale = orientation.scale; + _previousOrigin = _origin; + _origin = _constrainOriginToViewportBounds(orientation.origin); + + // Check if the simulation is close enough to complete for us to stop it. + if ((_origin - _previousOrigin).distance.abs() < 0.01) { + stopSimulation(); + + // Ensure that we always report a zero velocity at the end of the simulation. + _acceleration = Offset.zero; + _velocity = Offset.zero; + _scaleVelocity = 0; + } + + // Update our previous-frame accounting, for the next simulation frame. + _previousSimulationTime = elapsedTime; + _previousSimulationOrientation = AxisAlignedOrientation(_origin, _scale); + + notifyListeners(); + } + + /// Stops any on-going orientation simulation, started by [driveWithSimulation]. + @override + void stopSimulation() { + _activeSimulation = null; + + _simulationTicker?.stop(); + _simulationTicker = null; + + _previousSimulationTime = null; + _previousSimulationOrientation = null; + } + + Offset _constrainOriginToViewportBounds(Offset desiredOrigin) { + // If content is thinner than a viewport dimension, that content should be centered. + // + // If content is as wide, or wider than a viewport dimension, that content offset should + // be constrained so that no white space ever appears on either side of the content along + // that dimension. + double originX = desiredOrigin.dx; + double originY = desiredOrigin.dy; + + final contentWidth = _viewport!.calculatePageSize(0, scale).width; + final contentHeight = _viewport!.calculateContentHeight(scale); + final viewportSize = _viewport!.getSize(); + + if (contentWidth <= viewportSize.width) { + originX = (viewportSize.width - contentWidth) / 2; + } else { + const maxOriginX = 0.0; + final minOriginX = viewportSize.width - contentWidth; + originX = originX.clamp(minOriginX, maxOriginX); + } + + if (contentHeight <= viewportSize.height) { + originY = (viewportSize.height - contentHeight) / 2; + } else { + const maxOriginY = 0.0; + final minOriginY = viewportSize.height - contentHeight; + originY = originY.clamp(minOriginY, maxOriginY); + } + + return Offset(originX, originY); + } + + int _findFirstVisiblePageAtY(double y) { + double currentContentHeight = 0.0; + int index = 0; + final pageCount = _viewport!.getPageCount(); + while (index < pageCount) { + final pageHeight = _viewport!.calculatePageSize(index, scale).height; + currentContentHeight += pageHeight; + + if (currentContentHeight >= y) { + break; + } + + index += 1; + } + + return index; + } +} From a68c458909bee9666919466dd261e2ba224224e8 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Wed, 16 Jul 2025 21:02:43 -0300 Subject: [PATCH 2/6] Fix zoom out bug --- lib/src/page_list_viewport_variable_size.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/src/page_list_viewport_variable_size.dart b/lib/src/page_list_viewport_variable_size.dart index bf75ed3..25097a3 100644 --- a/lib/src/page_list_viewport_variable_size.dart +++ b/lib/src/page_list_viewport_variable_size.dart @@ -825,10 +825,7 @@ class PageListViewportWithVariableSizeController extends OrientationController { ); } - _isFirstLayoutForController = false; - } else if (scale < 1.0) { - // Update the private property so that we don't markNeedsLayout during layout. - scale = 1.0; + _isFirstLayoutForController = false; } if (_initialPageIndex != null) { @@ -1090,7 +1087,8 @@ class PageListViewportWithVariableSizeController extends OrientationController { _animationController.stop(); stopSimulation(); - newScale = newScale.clamp(_minimumScale, maximumScale); + // The scale cannot be less than 1.0, otherwise the content won't fill the viewport's width. + newScale = newScale.clamp(math.max(_minimumScale, 1.0).toDouble(), maximumScale); final scaleDiff = newScale / _scale; From 35b6acc48bfd4d90ac7ea5e4293704ae76c051a4 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 19 Jul 2025 13:31:19 -0300 Subject: [PATCH 3/6] Create dedicated widgets for fixed and variable page size --- example/lib/main_list.dart | 2 +- example/lib/main_long_list.dart | 2 +- example/lib/main_minimal_viewport.dart | 2 +- example/lib/main_single_page_orientation.dart | 2 +- example/lib/main_variable_page_size.dart | 2 +- .../main_page_list_images_inspector.dart | 2 +- lib/src/page_list_viewport.dart | 116 +++++++++--------- lib/src/page_list_viewport_gestures.dart | 3 +- lib/src/page_list_viewport_variable_size.dart | 83 ++++++++++++- test/panning_simulation_test.dart | 2 +- test/scroll_test.dart | 2 +- 11 files changed, 149 insertions(+), 69 deletions(-) diff --git a/example/lib/main_list.dart b/example/lib/main_list.dart index 75e62f5..9e54c42 100644 --- a/example/lib/main_list.dart +++ b/example/lib/main_list.dart @@ -75,7 +75,7 @@ class _MyHomePageState extends State with TickerProviderStateMixin { PageListViewportGestures( controller: _controller, lockPanAxis: true, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: _controller, pageCount: _pageCount, naturalPageSize: _naturalPageSizeInInches * 72 * MediaQuery.of(context).devicePixelRatio, diff --git a/example/lib/main_long_list.dart b/example/lib/main_long_list.dart index f0474e0..f36fdfc 100644 --- a/example/lib/main_long_list.dart +++ b/example/lib/main_long_list.dart @@ -75,7 +75,7 @@ class _MyHomePageState extends State with TickerProviderStateMixin { PageListViewportGestures( controller: _controller, lockPanAxis: true, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: _controller, pageCount: _pageCount, naturalPageSize: _naturalPageSizeInInches * 72 * MediaQuery.of(context).devicePixelRatio, diff --git a/example/lib/main_minimal_viewport.dart b/example/lib/main_minimal_viewport.dart index 90cfdaf..3df4762 100644 --- a/example/lib/main_minimal_viewport.dart +++ b/example/lib/main_minimal_viewport.dart @@ -36,7 +36,7 @@ class _MinimalViewportDemoState extends State<_MinimalViewportDemo> with TickerP return Scaffold( body: PageListViewportGestures( controller: _controller, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: _controller, pageCount: 10, naturalPageSize: const Size(8.5, 11) * 72, diff --git a/example/lib/main_single_page_orientation.dart b/example/lib/main_single_page_orientation.dart index b88c037..a223e17 100644 --- a/example/lib/main_single_page_orientation.dart +++ b/example/lib/main_single_page_orientation.dart @@ -54,7 +54,7 @@ class _MyHomePageState extends State with TickerProviderStateMixin { children: [ PageListViewportGestures( controller: _controller, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: _controller, pageCount: 1, naturalPageSize: const Size(8.5, 11) * 72 * MediaQuery.of(context).devicePixelRatio, diff --git a/example/lib/main_variable_page_size.dart b/example/lib/main_variable_page_size.dart index 2771384..204b665 100644 --- a/example/lib/main_variable_page_size.dart +++ b/example/lib/main_variable_page_size.dart @@ -81,7 +81,7 @@ class _MyHomePageState extends State with TickerProviderStateMixin { PageListViewportGestures( controller: _controller, lockPanAxis: true, - child: PageListViewport.variedPageSized( + child: PageListViewport.variedPages( controller: _controller, pageCount: _pageCount, onGetNaturalPageSize: (pageIndex) => diff --git a/example/lib/pages_with_tiled_images/main_page_list_images_inspector.dart b/example/lib/pages_with_tiled_images/main_page_list_images_inspector.dart index ad89df5..d2aa72b 100644 --- a/example/lib/pages_with_tiled_images/main_page_list_images_inspector.dart +++ b/example/lib/pages_with_tiled_images/main_page_list_images_inspector.dart @@ -168,7 +168,7 @@ class _PageListImagesInspectorDemoState extends State= pagePaintCacheCount), - _variablePageSize = false, - onGetNaturalPageSize = null; - - const PageListViewport.variedPageSized({ - super.key, - required PageListViewportWithVariableSizeController this.controller, - required this.pageCount, - required this.onGetNaturalPageSize, - this.pageLayoutCacheCount = 0, - this.pagePaintCacheCount = 0, - required this.builder, - this.rebuildOnOrientationChange = false, - }) : assert(pageLayoutCacheCount >= pagePaintCacheCount), - _variablePageSize = true, - naturalPageSize = null; + }) : assert(pageLayoutCacheCount >= pagePaintCacheCount); /// Controller that pans and zooms the page content. final OrientationController controller; @@ -61,8 +91,7 @@ class PageListViewport extends RenderObjectWidget { final int pageCount; /// The size of a single page, if no constraints were applied. - final Size? naturalPageSize; - final PageSizeResolver? onGetNaturalPageSize; + final Size naturalPageSize; /// The number of pages above and below the viewport that should /// be laid out, even though they aren't visible. @@ -87,65 +116,38 @@ class PageListViewport extends RenderObjectWidget { /// on relative position or scale. final bool rebuildOnOrientationChange; - final bool _variablePageSize; - @override RenderObjectElement createElement() { PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport element"); - if (_variablePageSize) { - return PageListViewportWithVariableSizeElement(this); - } - return PageListViewportElement(this); } @override RenderObject createRenderObject(BuildContext context) { PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport render object"); - if (_variablePageSize) { - return RenderPageListVariableSizeViewport( - element: context as PageListViewportWithVariableSizeElement, - controller: controller, - pageCount: pageCount, - pageSizeResolver: onGetNaturalPageSize, - pageLayoutCacheCount: pageLayoutCacheCount, - pagePaintCacheCount: pagePaintCacheCount, - ); - } - return RenderPageListViewport( element: context as PageListViewportElement, controller: controller, pageCount: pageCount, - pageSize: naturalPageSize!, + pageSize: naturalPageSize, pageLayoutCacheCount: pageLayoutCacheCount, pagePaintCacheCount: pagePaintCacheCount, ); } @override - void updateRenderObject(BuildContext context, RenderObject renderObject) { + void updateRenderObject(BuildContext context, RenderPageListViewport renderObject) { PageListViewportLogs.pagesList.finest(() => "Updating PageListViewport render object"); - if (renderObject is RenderPageListVariableSizeViewport) { - renderObject // - ..pageCount = pageCount - ..pageSizeResolver = onGetNaturalPageSize - ..pageLayoutCacheCount = pageLayoutCacheCount - ..pagePaintCacheCount = pagePaintCacheCount - ..controller = controller; - } else { - (renderObject as RenderPageListViewport) // - ..pageCount = pageCount - ..naturalPageSize = naturalPageSize! - ..pageLayoutCacheCount = pageLayoutCacheCount - ..pagePaintCacheCount = pagePaintCacheCount - ..controller = controller; - } + renderObject + ..pageCount = pageCount + ..naturalPageSize = naturalPageSize + ..pageLayoutCacheCount = pageLayoutCacheCount + ..pagePaintCacheCount = pagePaintCacheCount + ..controller = controller; } } typedef PageBuilder = Widget Function(BuildContext context, int pageIndex); -typedef PageSizeResolver = Size Function(int pageIndex); class PageListViewportController extends OrientationController { PageListViewportController.startAtPage({ @@ -1317,7 +1319,7 @@ class PageListViewportElement extends RenderObjectElement { // based on its widget. We add children during layout(), so we can't // do that here. However, if the widget has reduced the number of // desired pages, we can remove extra pages here. - final pageListViewport = widget as PageListViewport; + final pageListViewport = widget as PageListViewportWithFixedPageSize; if (pageListViewport.pageCount < childCount) { for (int i = childCount - 1; i >= pageListViewport.pageCount; i -= 1) { forgetChild(_childElements[i]!); @@ -1343,7 +1345,7 @@ class PageListViewportElement extends RenderObjectElement { try { newChild = updateChild( _childElements[pageIndex], - (widget as PageListViewport).builder(this, pageIndex), + (widget as PageListViewportWithFixedPageSize).builder(this, pageIndex), pageIndex, ); } finally {} diff --git a/lib/src/page_list_viewport_gestures.dart b/lib/src/page_list_viewport_gestures.dart index 62b7496..4262e29 100644 --- a/lib/src/page_list_viewport_gestures.dart +++ b/lib/src/page_list_viewport_gestures.dart @@ -6,9 +6,10 @@ import 'package:flutter/widgets.dart'; import 'logging.dart'; import 'page_list_viewport.dart'; +import 'page_list_viewport_variable_size.dart'; /// Controls a [PageListViewportController] with scale gestures to pan and zoom the -/// associated [PageListViewport]. +/// associated [PageListViewportWithFixedPageSize] or [PageListViewportWithVariablePageSize]. class PageListViewportGestures extends StatefulWidget { const PageListViewportGestures({ Key? key, diff --git a/lib/src/page_list_viewport_variable_size.dart b/lib/src/page_list_viewport_variable_size.dart index 25097a3..6f5502f 100644 --- a/lib/src/page_list_viewport_variable_size.dart +++ b/lib/src/page_list_viewport_variable_size.dart @@ -8,6 +8,81 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:page_list_viewport/page_list_viewport.dart'; +class PageListViewportWithVariablePageSize extends RenderObjectWidget { + const PageListViewportWithVariablePageSize({ + super.key, + required PageListViewportWithVariableSizeController this.controller, + required this.pageCount, + required this.onGetNaturalPageSize, + this.pageLayoutCacheCount = 0, + this.pagePaintCacheCount = 0, + required this.builder, + this.rebuildOnOrientationChange = false, + }) : assert(pageLayoutCacheCount >= pagePaintCacheCount); + + /// Controller that pans and zooms the page content. + final OrientationController controller; + + /// The number of pages displayed in this viewport. + final int pageCount; + + /// The size of a single page, if no constraints were applied. + final PageSizeResolver? onGetNaturalPageSize; + + /// The number of pages above and below the viewport that should + /// be laid out, even though they aren't visible. + final int pageLayoutCacheCount; + + /// The number of pages above and below the viewport that should + /// be painted, even though they aren't visible. + final int pagePaintCacheCount; + + /// [PageBuilder], which lazily builds the widgets for each page in + /// this viewport. + final PageBuilder builder; + + /// Whether the pages in this viewport should rebuild every time the orientation + /// changes. + /// + /// Orientation changes include panning (horizontal and vertical movement), and + /// zooming (scale up/down). Orientation changes happen rapidly. When rebuilding + /// on orientation change, the visible and cached pages will rebuild at 60 fps + /// during the duration of the orientation change. You should only rebuild during + /// orientation change if your page widgets alter their layout or painting based + /// on relative position or scale. + final bool rebuildOnOrientationChange; + + @override + RenderObjectElement createElement() { + PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport element"); + return PageListViewportWithVariableSizeElement(this); + } + + @override + RenderObject createRenderObject(BuildContext context) { + PageListViewportLogs.pagesList.finest(() => "Creating PageListViewport render object"); + return RenderPageListVariableSizeViewport( + element: context as PageListViewportWithVariableSizeElement, + controller: controller, + pageCount: pageCount, + pageSizeResolver: onGetNaturalPageSize, + pageLayoutCacheCount: pageLayoutCacheCount, + pagePaintCacheCount: pagePaintCacheCount, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderPageListVariableSizeViewport renderObject) { + PageListViewportLogs.pagesList.finest(() => "Updating PageListViewport render object"); + renderObject // + ..pageCount = pageCount + ..pageSizeResolver = onGetNaturalPageSize + ..pageLayoutCacheCount = pageLayoutCacheCount + ..pagePaintCacheCount = pagePaintCacheCount + ..controller = controller; + } +} + class RenderPageListVariableSizeViewport extends RenderBox implements PageListViewportLayout { RenderPageListVariableSizeViewport({ required PageListViewportWithVariableSizeElement element, @@ -503,7 +578,7 @@ class PageListViewportWithVariableSizeElement extends RenderObjectElement { // based on its widget. We add children during layout(), so we can't // do that here. However, if the widget has reduced the number of // desired pages, we can remove extra pages here. - final pageListViewport = widget as PageListViewport; + final pageListViewport = widget as PageListViewportWithVariablePageSize; if (pageListViewport.pageCount < childCount) { for (int i = childCount - 1; i >= pageListViewport.pageCount; i -= 1) { forgetChild(_childElements[i]!); @@ -529,7 +604,7 @@ class PageListViewportWithVariableSizeElement extends RenderObjectElement { try { newChild = updateChild( _childElements[pageIndex], - (widget as PageListViewport).builder(this, pageIndex), + (widget as PageListViewportWithVariablePageSize).builder(this, pageIndex), pageIndex, ); } finally {} @@ -825,7 +900,7 @@ class PageListViewportWithVariableSizeController extends OrientationController { ); } - _isFirstLayoutForController = false; + _isFirstLayoutForController = false; } if (_initialPageIndex != null) { @@ -1235,3 +1310,5 @@ class PageListViewportWithVariableSizeController extends OrientationController { return index; } } + +typedef PageSizeResolver = Size Function(int pageIndex); diff --git a/test/panning_simulation_test.dart b/test/panning_simulation_test.dart index eb145f3..b8d830e 100644 --- a/test/panning_simulation_test.dart +++ b/test/panning_simulation_test.dart @@ -40,7 +40,7 @@ Future _pumpPageListViewport( home: Scaffold( body: PageListViewportGestures( controller: controller, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: controller, pageCount: pageCount, naturalPageSize: naturalPageSize, diff --git a/test/scroll_test.dart b/test/scroll_test.dart index ba85b7f..45e59cd 100644 --- a/test/scroll_test.dart +++ b/test/scroll_test.dart @@ -246,7 +246,7 @@ Future _pumpPageListViewport( controller: controller, scrollSettleBehavior: scrollSettlingBehavior, lockPanAxis: true, - child: PageListViewport( + child: PageListViewport.sameSizePages( controller: controller, pageCount: 100, naturalPageSize: const Size(8.5, 11) * 72 * widgetTester.view.devicePixelRatio, From 9c7461162a185e91e4cccb80399ff18383f369bd Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 19 Jul 2025 14:10:04 -0300 Subject: [PATCH 4/6] Add docs --- lib/src/page_list_viewport.dart | 33 +++++++++++++++++++ lib/src/page_list_viewport_variable_size.dart | 25 ++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/src/page_list_viewport.dart b/lib/src/page_list_viewport.dart index 3952e88..cdaa674 100644 --- a/lib/src/page_list_viewport.dart +++ b/lib/src/page_list_viewport.dart @@ -11,7 +11,15 @@ import 'package:page_list_viewport/src/page_list_viewport_variable_size.dart'; import 'logging.dart'; +/// A viewport that displays pages of content, arranged in a vertical list. +/// +/// Use [PageListViewport.sameSizePages] if all pages have the same natural +/// size. Otherwise, use [PageListViewport.variedPages]. +/// +/// {@macro page_list_viewport} class PageListViewport { + /// Creates a viewport that displays [pageCount] pages of content, arranged in a vertical list, + /// where each page has the same [naturalPageSize]. static PageListViewportWithFixedPageSize sameSizePages({ Key? key, required OrientationController controller, @@ -34,6 +42,8 @@ class PageListViewport { ); } + /// Creates a viewport that displays [pageCount] pages of content, arranged in a vertical list, + /// where each page can have its own natural size, as determined by [onGetNaturalPageSize]. static PageListViewportWithVariablePageSize variedPages({ Key? key, required PageListViewportWithVariableSizeController controller, @@ -59,6 +69,7 @@ class PageListViewport { /// A viewport that displays [pageCount] pages of content, arranged in a vertical /// list, with a given [naturalPageSize]. /// +/// {@template page_list_viewport} /// Each page is built lazily, by calling [builder]. /// /// A [PageListViewportController] can translate and scale the pages in this @@ -72,6 +83,7 @@ class PageListViewport { /// of the page, and the edge of the viewport. /// /// To control the [controller] with gestures, see [PageListViewportGestures]. +/// {@endtemplate} class PageListViewportWithFixedPageSize extends RenderObjectWidget { const PageListViewportWithFixedPageSize({ super.key, @@ -861,11 +873,32 @@ abstract class OrientationController with ChangeNotifier { } } +/// The layout contract for a paginated viewport. +/// +/// This interface exists to allow the implementation of fixed-size page and +/// variable-size page layouts. abstract class PageListViewportLayout { + /// Calculates the size of a page at the specified [pageIndex] and [scale]. + /// + /// The [scale] parameter represents the zoom or scaling factor to apply to the page. Size calculatePageSize(int pageIndex, double scale); + + /// Calculates the total height of all pages at the given [scale]. + /// + /// The [scale] parameter represents the zoom or scaling factor to apply to the content. double calculateContentHeight(double scale); + + /// Returns the current size of the viewport. + /// + /// This represents the visible area in which pages are rendered. Size getSize(); + + /// Returns the total number of pages available in the viewport. + /// + /// This includes both visible and non-visible pages. int getPageCount(); + + /// Returns the natural (unscaled) size of a page at the specified [pageIndex]. Size getNaturalPageSize(int pageIndex); } diff --git a/lib/src/page_list_viewport_variable_size.dart b/lib/src/page_list_viewport_variable_size.dart index 6f5502f..ed483bb 100644 --- a/lib/src/page_list_viewport_variable_size.dart +++ b/lib/src/page_list_viewport_variable_size.dart @@ -8,10 +8,15 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:page_list_viewport/page_list_viewport.dart'; +/// A viewport that displays [pageCount] pages of content, arranged in a vertical. +/// +/// Each page can have its own natural page size, which is determined by [onGetNaturalPageSize]. +/// +/// {@macro page_list_viewport} class PageListViewportWithVariablePageSize extends RenderObjectWidget { const PageListViewportWithVariablePageSize({ super.key, - required PageListViewportWithVariableSizeController this.controller, + required this.controller, required this.pageCount, required this.onGetNaturalPageSize, this.pageLayoutCacheCount = 0, @@ -21,13 +26,13 @@ class PageListViewportWithVariablePageSize extends RenderObjectWidget { }) : assert(pageLayoutCacheCount >= pagePaintCacheCount); /// Controller that pans and zooms the page content. - final OrientationController controller; + final PageListViewportWithVariableSizeController controller; /// The number of pages displayed in this viewport. final int pageCount; /// The size of a single page, if no constraints were applied. - final PageSizeResolver? onGetNaturalPageSize; + final PageSizeResolver onGetNaturalPageSize; /// The number of pages above and below the viewport that should /// be laid out, even though they aren't visible. @@ -199,6 +204,9 @@ class RenderPageListVariableSizeViewport extends RenderBox implements PageListVi @override set layer(ContainerLayer? newLayer) => super.layer = newLayer as ClipRectLayer?; + /// The baseline scale for each page, which is the ratio of the viewport width to the natural page width. + /// + /// This is the scale needed for each page to fill the viewport horizontally. final _pageBaselineScales = []; @override @@ -889,6 +897,8 @@ class PageListViewportWithVariableSizeController extends OrientationController { final viewportSize = _viewportSize!; if (_isFirstLayoutForController && _viewport!.getPageCount() > 0) { + // 1.0 means we want to use the page's baseline scale, which will make it + // fill the available width. scale = 1.0; final totalContentHeight = _viewport!.calculateContentHeight(scale); @@ -942,6 +952,8 @@ class PageListViewportWithVariableSizeController extends OrientationController { Offset _getPageOffset(int pageIndex, [double? zoomLevel]) { final desiredZoomLevel = zoomLevel ?? scale; + // Since each page can have its own size, to determine the offset we need to + // comput the page size for each page above the desired page index. double contentAboveDesiredPage = 0.0; for (int i = 0; i < pageIndex; i++) { final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); @@ -1056,12 +1068,10 @@ class PageListViewportWithVariableSizeController extends OrientationController { final desiredZoomLevel = zoomLevel ?? scale; - final pageSizes = []; double contentAboveDesiredPage = 0.0; for (int i = 0; i < pageIndex; i++) { final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); contentAboveDesiredPage += pageSizeAtZoomLevel.height; - pageSizes.add(pageSizeAtZoomLevel); } final pageFocalPointAtZoomLevel = pixelOffsetInPage * desiredZoomLevel; @@ -1079,13 +1089,13 @@ class PageListViewportWithVariableSizeController extends OrientationController { CurvedAnimation(parent: _animationController, curve: curve), ); - //final animationDuration = duration; Duration animationDuration = duration; if (applyDurationPerPage) { + // Since we want to apply the duration per page, we need to find out how many pages + // are between the current first visible page and the new first visible page. final currentFirstVisiblePageIndex = _findFirstVisiblePageAtY(_origin.dy.abs()); final newFirstVisiblePageIndex = _findFirstVisiblePageAtY(destinationOffset.dy.abs()); final pageCount = (newFirstVisiblePageIndex - currentFirstVisiblePageIndex).abs() + 1; - print('pagecount: $pageCount'); animationDuration *= pageCount; } @@ -1311,4 +1321,5 @@ class PageListViewportWithVariableSizeController extends OrientationController { } } +/// A method that returns the size of a page at the given [pageIndex]. typedef PageSizeResolver = Size Function(int pageIndex); From 2163bcf0f7de0e7d30984e29214b9bbbc7821521 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 19 Jul 2025 14:19:11 -0300 Subject: [PATCH 5/6] Make _constrainOriginToViewportBounds use the width of the page at the desired offset --- lib/src/page_list_viewport_variable_size.dart | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/lib/src/page_list_viewport_variable_size.dart b/lib/src/page_list_viewport_variable_size.dart index ed483bb..5f74dfd 100644 --- a/lib/src/page_list_viewport_variable_size.dart +++ b/lib/src/page_list_viewport_variable_size.dart @@ -949,25 +949,6 @@ class PageListViewportWithVariableSizeController extends OrientationController { notifyListeners(); } - Offset _getPageOffset(int pageIndex, [double? zoomLevel]) { - final desiredZoomLevel = zoomLevel ?? scale; - - // Since each page can have its own size, to determine the offset we need to - // comput the page size for each page above the desired page index. - double contentAboveDesiredPage = 0.0; - for (int i = 0; i < pageIndex; i++) { - final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); - contentAboveDesiredPage += pageSizeAtZoomLevel.height; - } - - final pageSizeAtZoomLevel = _viewport!.calculatePageSize(pageIndex, desiredZoomLevel); - final desiredPageTopLeftInViewport = - (_viewportSize!).center(Offset.zero) - Offset(pageSizeAtZoomLevel.width / 2, pageSizeAtZoomLevel.height / 2); - - final desiredOrigin = Offset(0, -contentAboveDesiredPage) + desiredPageTopLeftInViewport; - return _constrainOriginToViewportBounds(desiredOrigin); - } - /// Immediately changes the viewport offset so that the given [pixelOffsetInPage], within the page at the given /// [pageIndex], is positioned as close as possible to the center of the viewport. /// @@ -1104,26 +1085,6 @@ class PageListViewportWithVariableSizeController extends OrientationController { return _animationController.forward(from: 0); } - void _onOrientationAnimationChange() { - _origin = _offsetAnimation!.value; - _scale = _scaleAnimation!.value; - - if (_velocityStopwatch.elapsedMilliseconds > 0) { - _velocity = (_offsetAnimation!.value - _previousOrigin) / (_velocityStopwatch.elapsedMilliseconds / 1000); - _velocityStopwatch.reset(); - } - _previousOrigin = _offsetAnimation!.value; - - notifyListeners(); - } - - void _onOrientationAnimationEnd() { - _velocity = Offset.zero; - _velocityStopwatch.reset(); - - notifyListeners(); - } - @override void translate(Offset deltaInScreenSpace) { PageListViewportLogs.pagesListController.fine(() => "Translation requested for delta: $deltaInScreenSpace"); @@ -1270,6 +1231,50 @@ class PageListViewportWithVariableSizeController extends OrientationController { _previousSimulationOrientation = null; } + Offset _getPageOffset(int pageIndex, [double? zoomLevel]) { + final desiredZoomLevel = zoomLevel ?? scale; + + // Since each page can have its own size, to determine the offset we need to + // comput the page size for each page above the desired page index. + double contentAboveDesiredPage = 0.0; + for (int i = 0; i < pageIndex; i++) { + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(i, desiredZoomLevel); + contentAboveDesiredPage += pageSizeAtZoomLevel.height; + } + + final pageSizeAtZoomLevel = _viewport!.calculatePageSize(pageIndex, desiredZoomLevel); + final desiredPageTopLeftInViewport = + (_viewportSize!).center(Offset.zero) - Offset(pageSizeAtZoomLevel.width / 2, pageSizeAtZoomLevel.height / 2); + + final desiredOrigin = Offset(0, -contentAboveDesiredPage) + desiredPageTopLeftInViewport; + return _constrainOriginToViewportBounds(desiredOrigin); + } + + int _getPageIndexAtOffset(Offset offset) { + // Find the first page that is visible at the given offset. + return _findFirstVisiblePageAtY(offset.dy.abs()); + } + + void _onOrientationAnimationChange() { + _origin = _offsetAnimation!.value; + _scale = _scaleAnimation!.value; + + if (_velocityStopwatch.elapsedMilliseconds > 0) { + _velocity = (_offsetAnimation!.value - _previousOrigin) / (_velocityStopwatch.elapsedMilliseconds / 1000); + _velocityStopwatch.reset(); + } + _previousOrigin = _offsetAnimation!.value; + + notifyListeners(); + } + + void _onOrientationAnimationEnd() { + _velocity = Offset.zero; + _velocityStopwatch.reset(); + + notifyListeners(); + } + Offset _constrainOriginToViewportBounds(Offset desiredOrigin) { // If content is thinner than a viewport dimension, that content should be centered. // @@ -1279,7 +1284,8 @@ class PageListViewportWithVariableSizeController extends OrientationController { double originX = desiredOrigin.dx; double originY = desiredOrigin.dy; - final contentWidth = _viewport!.calculatePageSize(0, scale).width; + final pageIndex = _getPageIndexAtOffset(desiredOrigin); + final contentWidth = _viewport!.calculatePageSize(pageIndex, scale).width; final contentHeight = _viewport!.calculateContentHeight(scale); final viewportSize = _viewport!.getSize(); From 43ccd06fd120f8e07a971e1f616e3220371c352f Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 19 Jul 2025 16:16:24 -0300 Subject: [PATCH 6/6] Add tests --- .github/workflows/pr_validation.yaml | 24 ++ example/lib/main_variable_page_size.dart | 5 + golden_tester.Dockerfile | 23 ++ pubspec.yaml | 3 +- test/variable_page_size_test.dart | 156 ++++++++++++ .../variable_page_size_animates_to_page.png | Bin 0 -> 55236 bytes .../variable_page_size_initial_page.png | Bin 0 -> 31624 bytes .../variable_page_size_jumps_to_page.png | Bin 0 -> 30673 bytes ...ble_page_size_performs_zoom_in_and_out.png | Bin 0 -> 37370 bytes test_goldens/variable_page_size_test.dart | 235 ++++++++++++++++++ 10 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 golden_tester.Dockerfile create mode 100644 test/variable_page_size_test.dart create mode 100644 test_goldens/goldens/variable_page_size_animates_to_page.png create mode 100644 test_goldens/goldens/variable_page_size_initial_page.png create mode 100644 test_goldens/goldens/variable_page_size_jumps_to_page.png create mode 100644 test_goldens/goldens/variable_page_size_performs_zoom_in_and_out.png create mode 100644 test_goldens/variable_page_size_test.dart diff --git a/.github/workflows/pr_validation.yaml b/.github/workflows/pr_validation.yaml index 10c18a0..f63d375 100644 --- a/.github/workflows/pr_validation.yaml +++ b/.github/workflows/pr_validation.yaml @@ -22,3 +22,27 @@ jobs: # Run all tests - run: flutter test + + test_goldens: + runs-on: ubuntu-latest + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all tests + - run: flutter test test_goldens + + # Archive golden failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-failures + path: "**/failures/**/*.png" diff --git a/example/lib/main_variable_page_size.dart b/example/lib/main_variable_page_size.dart index 204b665..5f49d84 100644 --- a/example/lib/main_variable_page_size.dart +++ b/example/lib/main_variable_page_size.dart @@ -20,6 +20,11 @@ class MyApp extends StatelessWidget { } } +/// A widget that displays a [PageListViewportWithVariablePageSize], intercalating +/// between vertical and horizontal aspect ratios for each page. +/// +/// Each page has a centered circle, which scales its size based on the incoming constraints, +/// and the natural size of the page. class MyHomePage extends StatefulWidget { const MyHomePage({ super.key, diff --git a/golden_tester.Dockerfile b/golden_tester.Dockerfile new file mode 100644 index 0000000..282c211 --- /dev/null +++ b/golden_tester.Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:latest + +ENV FLUTTER_HOME=${HOME}/sdks/flutter +ENV PATH ${PATH}:${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin + +USER root + +RUN apt update + +RUN apt install -y git curl unzip + +# Print the Ubuntu version. Useful when there are failing tests. +RUN cat /etc/lsb-release + +# Invalidate the cache when flutter pushes a new commit. +ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/stable ./flutter-latest-stable + +RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} + +RUN flutter doctor + +# Copy the whole repo. +COPY ./ /golden_tester \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ad4488c..5d178e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.0.1 homepage: environment: - sdk: '>=2.18.6 <3.0.0' + sdk: ">=2.18.6 <3.0.0" flutter: ">=1.17.0" dependencies: @@ -17,6 +17,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + flutter_test_goldens: ^0.0.7 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/variable_page_size_test.dart b/test/variable_page_size_test.dart new file mode 100644 index 0000000..f89dc7c --- /dev/null +++ b/test/variable_page_size_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:page_list_viewport/page_list_viewport.dart'; + +void main() { + group('PageListViewport > variable page size >', () { + testWidgets('hit tests the pages', (tester) async { + // Holds the page indices that were tapped. + final tappedPages = []; + + await tester.pumpWidget( + MaterialApp( + home: _TestAppPage( + onPageTapped: (pageIndex) => tappedPages.add(pageIndex), + ), + ), + ); + + // Tap on the first page. + await tester.tap(find.text('0')); + await tester.pump(); + + // Jump to the eleventh page. + final controller = _findPageController(tester); + controller.jumpToPage(11); + await tester.pumpAndSettle(); + + // Tap on the the eleventh page. + await tester.tap(find.text('11')); + await tester.pump(); + + // Jump to the fiftieth page. + controller.jumpToPage(50); + await tester.pumpAndSettle(); + + // Tap on the the fiftieth page. + await tester.tap(find.text('50')); + await tester.pump(); + + // Ensure the taps were reported for the correct pages. + expect(tappedPages, [0, 11, 50]); + }); + }); +} + +PageListViewportWithVariableSizeController _findPageController(WidgetTester tester) { + final state = tester.state<_TestAppPageState>(find.byType(_TestAppPage)); + return state.controller; +} + +/// A widget that displays a [PageListViewportWithVariablePageSize], intercalating +/// between vertical and horizontal aspect ratios for each page. +/// +/// Each page has a centered circle, which scales its size based on the incoming constraints, +/// and the natural size of the page. +class _TestAppPage extends StatefulWidget { + const _TestAppPage({this.onPageTapped}); + + final void Function(int pageIndex)? onPageTapped; + + @override + State<_TestAppPage> createState() => _TestAppPageState(); +} + +class _TestAppPageState extends State<_TestAppPage> with TickerProviderStateMixin { + static const _pageCount = 200; + + static const _pageSizes = [ + Size(8.5, 11), // Letter + Size(11, 8.5), // Letter (inverse) + ]; + + late final PageListViewportWithVariableSizeController controller; + + @override + void initState() { + super.initState(); + controller = PageListViewportWithVariableSizeController(vsync: this); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _buildViewport(), + ); + } + + Widget _buildViewport() { + return Stack( + children: [ + PageListViewportGestures( + controller: controller, + lockPanAxis: true, + child: PageListViewport.variedPages( + controller: controller, + pageCount: _pageCount, + onGetNaturalPageSize: (pageIndex) => + _pageSizes[pageIndex % _pageSizes.length] * 72 * MediaQuery.of(context).devicePixelRatio, + pageLayoutCacheCount: 3, + pagePaintCacheCount: 3, + builder: (BuildContext context, int pageIndex) { + return GestureDetector( + onTap: () => widget.onPageTapped?.call(pageIndex), + child: _buildPage(pageIndex), + ); + }, + ), + ), + ], + ); + } + + Widget _buildPage(int pageIndex) { + final pageSize = Size( + _pageSizes[pageIndex % _pageSizes.length].width * 72, + _pageSizes[pageIndex % _pageSizes.length].height * 72, + ); + + return Container( + color: Colors.white, + child: Container( + color: Colors.primaries[pageIndex % Colors.primaries.length], + width: pageSize.width, + height: pageSize.height, + child: Center( + child: LayoutBuilder(builder: (context, constraints) { + return Container( + width: 300 * (constraints.maxWidth / pageSize.width), + height: 300 * (constraints.maxHeight / pageSize.height), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: FittedBox( + child: Text( + '$pageIndex', + style: const TextStyle(fontSize: 24), + ), + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/test_goldens/goldens/variable_page_size_animates_to_page.png b/test_goldens/goldens/variable_page_size_animates_to_page.png new file mode 100644 index 0000000000000000000000000000000000000000..5121a995bb6c742dc5c91e0644d4eab675f57b5e GIT binary patch literal 55236 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 f(0G-1X)6B{=B literal 0 HcmV?d00001 diff --git a/test_goldens/goldens/variable_page_size_initial_page.png b/test_goldens/goldens/variable_page_size_initial_page.png new file mode 100644 index 0000000000000000000000000000000000000000..04f50696c51b7122f52d59914c7c8275e55bb64b GIT binary patch literal 31624 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 literal 0 HcmV?d00001 diff --git a/test_goldens/goldens/variable_page_size_jumps_to_page.png b/test_goldens/goldens/variable_page_size_jumps_to_page.png new file mode 100644 index 0000000000000000000000000000000000000000..49d9996e54540f901be0938b8d2fa8ed2ea01422 GIT binary patch literal 30673 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 literal 0 HcmV?d00001 diff --git a/test_goldens/goldens/variable_page_size_performs_zoom_in_and_out.png b/test_goldens/goldens/variable_page_size_performs_zoom_in_and_out.png new file mode 100644 index 0000000000000000000000000000000000000000..b59070b70a13cccee1b54a7a576267e44b24daff GIT binary patch literal 37370 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 bC-=WFI}`7twvtPzXF9E^b3Ek_^IQJ{ variable page size >', () { + testGoldenScene('renders the desired page at first layout', (WidgetTester tester) async { + await Gallery( + 'Starts at the desided page', + fileName: 'variable_page_size_initial_page', + layout: const FlexSceneLayout.row(), + ) + .itemFromWidget( + description: 'Starts at page 11 (vertically centered)', + widget: const _TestAppPage( + initialPage: 11, + ), + constraints: BoxConstraints.tight(const Size(800, 800)), + ) + .itemFromWidget( + description: 'Starts at page 20', + widget: const _TestAppPage( + initialPage: 20, + ), + constraints: BoxConstraints.tight(const Size(800, 800)), + ) + .run(tester); + }); + + testGoldenScene('jumps to page', (WidgetTester tester) async { + await Timeline( + 'Jumps to page', + fileName: 'variable_page_size_jumps_to_page', + layout: const FlexSceneLayout.row(), + windowSize: const Size(800, 800), + ) // + .setupWithWidget(const _TestAppPage(initialPage: 6)) + .takePhoto('Starts at page 6') + .modifyScene( + (tester, context) async { + final controller = _findPageController(tester); + controller.jumpToPage(49); + await tester.pump(); + }, + ) + .takePhoto('Jumps to page 49 (vertically centered)') + .run(tester); + }); + + testGoldenScene('animates to page', (WidgetTester tester) async { + await Timeline( + 'Animates to page', + fileName: 'variable_page_size_animates_to_page', + layout: const FlexSceneLayout.row(), + windowSize: const Size(800, 800), + ) // + .setupWithWidget(const _TestAppPage(initialPage: 6)) + .takePhoto('Starts at page 6') + .modifyScene( + (tester, context) async { + final controller = _findPageController(tester); + controller.animateToPage(49, const Duration(milliseconds: 300)); + // Pump to allow the animation to start. + await tester.pump(); + }, + ) + .takePhotos(3, const Duration(milliseconds: 100), 'Animates to page 49 (vertically centered) - frame') + .run(tester); + }); + + testGoldenScene('performs zoom in and out', (WidgetTester tester) async { + late TestGesture firstFingerGesture; + late TestGesture secondFingerGesture; + + await Timeline( + 'Performs zoom in and out', + fileName: 'variable_page_size_performs_zoom_in_and_out', + layout: const FlexSceneLayout.row(), + windowSize: const Size(800, 800), + ) // + .setupWithWidget(const _TestAppPage(initialPage: 5)) + .takePhoto('Baseline scale') + .modifyScene( + (tester, context) async { + // Start two finger gestures at the same time. + firstFingerGesture = await tester.startGesture(const Offset(350, 400)); + secondFingerGesture = await tester.startGesture(const Offset(450, 400)); + + // Move fingers apart to zoom in. + await firstFingerGesture.moveBy(const Offset(-50, 0)); + await secondFingerGesture.moveBy(const Offset(50, 0)); + await tester.pump(); + }, + ) + .takePhoto('Zooms in') + .modifyScene( + (tester, context) async { + // Move fingers back together to zoom out, closer together than the original position, + // to ensure that the viewport forces the page to fill the viewport's width. + await firstFingerGesture.moveBy(const Offset(90, 0)); + await secondFingerGesture.moveBy(const Offset(-90, 0)); + await tester.pump(); + }, + ) + .takePhoto('Zooms out') + .modifyScene((tester, context) async { + // Releases the fingers. + await firstFingerGesture.up(); + await secondFingerGesture.up(); + }) + .run(tester); + }); + }); +} + +PageListViewportWithVariableSizeController _findPageController(WidgetTester tester) { + final state = tester.state<_TestAppPageState>(find.byType(_TestAppPage)); + return state.controller; +} + +class _TestAppPage extends StatefulWidget { + const _TestAppPage({ + this.initialPage, + }); + + final int? initialPage; + + @override + State<_TestAppPage> createState() => _TestAppPageState(); +} + +/// A widget that displays a [PageListViewportWithVariablePageSize], intercalating +/// between vertical and horizontal aspect ratios for each page. +/// +/// Each page has a centered circle, which scales its size based on the incoming constraints, +/// and the natural size of the page. +class _TestAppPageState extends State<_TestAppPage> with TickerProviderStateMixin { + static const _pageCount = 200; + + static const _pageSizes = [ + Size(8.5, 11), // Letter + Size(11, 8.5), // Letter (inverse) + ]; + + late final PageListViewportWithVariableSizeController controller; + + @override + void initState() { + super.initState(); + controller = widget.initialPage != null + ? PageListViewportWithVariableSizeController.startAtPage( + pageIndex: widget.initialPage!, + vsync: this, + ) + : PageListViewportWithVariableSizeController(vsync: this); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _buildViewport(), + ); + } + + Widget _buildViewport() { + return Stack( + children: [ + PageListViewportGestures( + controller: controller, + lockPanAxis: true, + child: PageListViewport.variedPages( + controller: controller, + pageCount: _pageCount, + onGetNaturalPageSize: (pageIndex) => + _pageSizes[pageIndex % _pageSizes.length] * 72 * MediaQuery.of(context).devicePixelRatio, + pageLayoutCacheCount: 3, + pagePaintCacheCount: 3, + builder: (BuildContext context, int pageIndex) { + return _buildPage(pageIndex); + }, + ), + ), + ], + ); + } + + Widget _buildPage(int pageIndex) { + final pageSize = Size( + _pageSizes[pageIndex % _pageSizes.length].width * 72, + _pageSizes[pageIndex % _pageSizes.length].height * 72, + ); + + return Container( + color: Colors.white, + child: Container( + color: Colors.primaries[pageIndex % Colors.primaries.length], + width: pageSize.width, + height: pageSize.height, + child: Center( + child: LayoutBuilder(builder: (context, constraints) { + return Container( + width: 300 * (constraints.maxWidth / pageSize.width), + height: 300 * (constraints.maxHeight / pageSize.height), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: FittedBox( + child: Text( + '$pageIndex', + style: const TextStyle( + fontSize: 24, + fontFamily: TestFonts.openSans, + color: Colors.black, + ), + ), + ), + ), + ); + }), + ), + ), + ); + } +}