diff --git a/gallery/gallery/lib/studies/shrine/app.dart b/gallery/gallery/lib/studies/shrine/app.dart index 71ca521d5..f1bdef3e1 100644 --- a/gallery/gallery/lib/studies/shrine/app.dart +++ b/gallery/gallery/lib/studies/shrine/app.dart @@ -14,6 +14,7 @@ import 'package:gallery/studies/shrine/login.dart'; import 'package:gallery/studies/shrine/model/app_state_model.dart'; import 'package:gallery/studies/shrine/page_status.dart'; import 'package:gallery/studies/shrine/scrim.dart'; +import 'package:gallery/studies/shrine/supplemental/layout_cache.dart'; import 'package:gallery/studies/shrine/theme.dart'; import 'package:scoped_model/scoped_model.dart'; @@ -36,6 +37,8 @@ class _ShrineAppState extends State with TickerProviderStateMixin { AppStateModel _model; + Map>> _layouts = {}; + @override void initState() { super.initState(); @@ -87,15 +90,18 @@ class _ShrineAppState extends State with TickerProviderStateMixin { navigatorKey: widget.navigatorKey, title: 'Shrine', debugShowCheckedModeBanner: false, - home: PageStatus( - menuController: _controller, - cartController: _expandingController, - child: HomePage( - backdrop: backdrop, - scrim: Scrim(controller: _expandingController), - expandingBottomSheet: ExpandingBottomSheet( - hideController: _controller, - expandingController: _expandingController, + home: LayoutCache( + layouts: _layouts, + child: PageStatus( + menuController: _controller, + cartController: _expandingController, + child: HomePage( + backdrop: backdrop, + scrim: Scrim(controller: _expandingController), + expandingBottomSheet: ExpandingBottomSheet( + hideController: _controller, + expandingController: _expandingController, + ), ), ), ), diff --git a/gallery/gallery/lib/studies/shrine/supplemental/asymmetric_view.dart b/gallery/gallery/lib/studies/shrine/supplemental/asymmetric_view.dart index af0e5bb6d..948ab7ca3 100644 --- a/gallery/gallery/lib/studies/shrine/supplemental/asymmetric_view.dart +++ b/gallery/gallery/lib/studies/shrine/supplemental/asymmetric_view.dart @@ -10,6 +10,7 @@ import 'package:gallery/data/gallery_options.dart'; import 'package:gallery/layout/text_scale.dart'; import 'package:gallery/studies/shrine/category_menu_page.dart'; import 'package:gallery/studies/shrine/model/product.dart'; +import 'package:gallery/studies/shrine/supplemental/balanced_layout.dart'; import 'package:gallery/studies/shrine/page_status.dart'; import 'package:gallery/studies/shrine/supplemental/desktop_product_columns.dart'; import 'package:gallery/studies/shrine/supplemental/product_columns.dart'; @@ -168,9 +169,6 @@ class DesktopAsymmetricView extends StatelessWidget { @override Widget build(BuildContext context) { - final Widget _gap = Container(width: 24); - final Widget _flex = Expanded(flex: 1, child: Container()); - // Determine the scale factor for the desktop asymmetric view. final double textScaleFactor = @@ -190,7 +188,7 @@ class DesktopAsymmetricView extends StatelessWidget { final double columnGapWidth = 24 * imageScaleFactor; final double windowWidth = MediaQuery.of(context).size.width; - final int columnCount = max( + final int idealColumnCount = max( 1, ((windowWidth + columnGapWidth - 2 * minimumBoundaryWidth - sidebar) / (columnWidth + columnGapWidth)) @@ -198,60 +196,100 @@ class DesktopAsymmetricView extends StatelessWidget { ); // Limit column width to fit within window when there is only one column. - final double actualColumnWidth = columnCount == 1 + final double actualColumnWidth = idealColumnCount == 1 ? min( columnWidth, windowWidth - sidebar - 2 * minimumBoundaryWidth, ) : columnWidth; - final List productCardColumns = - List.generate(columnCount, (currentColumn) { - final bool alignToEnd = - (currentColumn % 2 == 1) || (currentColumn == columnCount - 1); - final bool startLarge = (currentColumn % 2 == 1); - return DesktopProductCardColumn( - columnCount: columnCount, - currentColumn: currentColumn, - alignToEnd: alignToEnd, - startLarge: startLarge, - products: products, - largeImageWidth: actualColumnWidth, - smallImageWidth: - columnCount > 1 ? columnWidth - columnGapWidth : actualColumnWidth, - ); - }); + final int columnCount = min(idealColumnCount, max(products.length, 1)); return AnimatedBuilder( animation: PageStatus.of(context).cartController, builder: (context, child) => ExcludeSemantics( excluding: !productPageIsVisible(context), - child: ListView( - scrollDirection: Axis.vertical, + child: DesktopColumns( + columnCount: columnCount, + products: products, + largeImageWidth: actualColumnWidth, + smallImageWidth: columnCount > 1 + ? columnWidth - columnGapWidth + : actualColumnWidth, + ), + ), + ); + } +} + +class DesktopColumns extends StatelessWidget { + DesktopColumns({ + @required this.columnCount, + @required this.products, + @required this.largeImageWidth, + @required this.smallImageWidth, + }); + + final int columnCount; + final List products; + final double largeImageWidth; + final double smallImageWidth; + + @override + Widget build(BuildContext context) { + final Widget _gap = Container(width: 24); + + final List> productCardLists = balancedLayout( + context: context, + columnCount: columnCount, + products: products, + largeImageWidth: largeImageWidth, + smallImageWidth: smallImageWidth, + ); + + final List productCardColumns = + List.generate( + columnCount, + (column) { + final bool alignToEnd = + (column % 2 == 1) || (column == columnCount - 1); + final bool startLarge = (column % 2 == 1); + final bool lowerStart = (column % 2 == 1); + return DesktopProductCardColumn( + alignToEnd: alignToEnd, + startLarge: startLarge, + lowerStart: lowerStart, + products: productCardLists[column], + largeImageWidth: largeImageWidth, + smallImageWidth: smallImageWidth, + ); + }, + ); + + return ListView( + scrollDirection: Axis.vertical, + children: [ + Container(height: 60), + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(height: 60), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _flex, - ...List.generate( - 2 * columnCount - 1, - (generalizedColumnIndex) { - if (generalizedColumnIndex % 2 == 0) { - return productCardColumns[generalizedColumnIndex ~/ 2]; - } else { - return _gap; - } - }, - ), - _flex, - ], + Spacer(), + ...List.generate( + 2 * columnCount - 1, + (generalizedColumnIndex) { + if (generalizedColumnIndex % 2 == 0) { + return productCardColumns[generalizedColumnIndex ~/ 2]; + } else { + return _gap; + } + }, ), - Container(height: 60), + Spacer(), ], - physics: const AlwaysScrollableScrollPhysics(), ), - ), + Container(height: 60), + ], + physics: const AlwaysScrollableScrollPhysics(), ); } } diff --git a/gallery/gallery/lib/studies/shrine/supplemental/balanced_layout.dart b/gallery/gallery/lib/studies/shrine/supplemental/balanced_layout.dart new file mode 100644 index 000000000..161f63cc6 --- /dev/null +++ b/gallery/gallery/lib/studies/shrine/supplemental/balanced_layout.dart @@ -0,0 +1,298 @@ +// Copyright 2019 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'package:gallery/studies/shrine/model/product.dart'; +import 'package:gallery/studies/shrine/supplemental/desktop_product_columns.dart'; +import 'package:gallery/studies/shrine/supplemental/layout_cache.dart'; + +/// A placeholder id for an empty element. See [_iterateUntilBalanced] +/// for more information. +const _emptyElement = -1; + +/// To avoid infinite loops, improvements to the layout are only performed +/// when a column's height changes by more than +/// [_deviationImprovementThreshold] pixels. +const _deviationImprovementThreshold = 10; + +/// Height of a product image, paired with the product's id. +class _TaggedHeightData { + const _TaggedHeightData({ + @required this.index, + @required this.height, + }); + + /// The id of the corresponding product. + final int index; + + /// The height of the product image. + final double height; +} + +/// Converts a set of [_TaggedHeightData] elements to a list, +/// and add an empty element. +/// Used for iteration. +List<_TaggedHeightData> toListAndAddEmpty(Set<_TaggedHeightData> set) { + List<_TaggedHeightData> result = List<_TaggedHeightData>.from(set); + result.add(_TaggedHeightData(index: _emptyElement, height: 0)); + return result; +} + +/// Encode parameters for caching. +String _encodeParameters({ + @required int columnCount, + @required List products, + @required double largeImageWidth, + @required double smallImageWidth, +}) { + final String productString = + [for (final product in products) product.id.toString()].join(','); + return '$columnCount;$productString,$largeImageWidth,$smallImageWidth'; +} + +/// Given a layout, replace integers by their corresponding products. +List> _generateLayout({ + @required List products, + @required List> layout, +}) { + return [ + for (final column in layout) + [ + for (final index in column) products[index], + ] + ]; +} + +/// Returns the size of an [Image] widget. +Size _imageSize(Image imageWidget) { + Size result; + + imageWidget.image.resolve(ImageConfiguration()).addListener( + ImageStreamListener( + (info, synchronousCall) { + final finalImage = info.image; + result = Size( + finalImage.width.toDouble(), + finalImage.height.toDouble(), + ); + }, + ), + ); + + return result; +} + +/// Given [columnObjects], list of the set of objects in each column, +/// and [columnHeights], list of heights of each column, +/// [_iterateUntilBalanced] moves and swaps objects between columns +/// until their heights are sufficiently close to each other. +/// This prevents the layout having significant, avoidable gaps at the bottom. +void _iterateUntilBalanced( + List> columnObjects, + List columnHeights, +) { + int failedMoves = 0; + final int columnCount = columnObjects.length; + + // No need to rearrange a 1-column layout. + if (columnCount == 1) { + return; + } + + while (true) { + // Loop through all possible 2-combinations of columns. + for (int source = 0; source < columnCount; ++source) { + for (int target = source + 1; target < columnCount; ++target) { + // Tries to find an object A from source column + // and an object B from target column, such that switching them + // causes the height of the two columns to be closer. + + // A or B can be empty; in this case, moving an object from one + // column to the other is the best choice. + + bool success = false; + + final double bestHeight = + (columnHeights[source] + columnHeights[target]) / 2; + final double scoreLimit = (columnHeights[source] - bestHeight).abs(); + + final List<_TaggedHeightData> sourceObjects = + toListAndAddEmpty(columnObjects[source]); + final List<_TaggedHeightData> targetObjects = + toListAndAddEmpty(columnObjects[target]); + + _TaggedHeightData bestA, bestB; + double bestScore; + + for (final a in sourceObjects) { + for (final b in targetObjects) { + if (a.index == _emptyElement && b.index == _emptyElement) { + continue; + } else { + final double score = + (columnHeights[source] - a.height + b.height - bestHeight) + .abs(); + if (score < scoreLimit - _deviationImprovementThreshold) { + success = true; + if (bestScore == null || score < bestScore) { + bestScore = score; + bestA = a; + bestB = b; + } + } + } + } + } + + if (!success) { + ++failedMoves; + } else { + failedMoves = 0; + + // Switch A and B. + if (bestA.index != _emptyElement) { + columnObjects[source].remove(bestA); + columnObjects[target].add(bestA); + } + if (bestB.index != _emptyElement) { + columnObjects[target].remove(bestB); + columnObjects[source].add(bestB); + } + columnHeights[source] += bestB.height - bestA.height; + columnHeights[target] += bestA.height - bestB.height; + } + + // If no two columns' heights can be made closer by switching + // elements, the layout is sufficiently balanced. + if (failedMoves >= columnCount * (columnCount - 1) ~/ 2) { + return; + } + } + } + } +} + +/// Given a list of numbers [data], representing the heights of each image, +/// and a list of numbers [biases], representing the heights of the space +/// above each column, [_balancedDistribution] returns a layout of [data] +/// so that the height of each column is sufficiently close to each other, +/// represented as a list of lists of integers, each integer being an ID +/// for a product. +List> _balancedDistribution({ + int columnCount, + List data, + List biases, +}) { + assert(biases.length == columnCount); + + List> columnObjects = + List>.generate(columnCount, (column) => Set()); + + List columnHeights = List.from(biases); + + for (var i = 0; i < data.length; ++i) { + final int column = i % columnCount; + columnHeights[column] += data[i]; + columnObjects[column].add(_TaggedHeightData(index: i, height: data[i])); + } + + _iterateUntilBalanced(columnObjects, columnHeights); + + return [ + for (final column in columnObjects) + [for (final object in column) object.index]..sort(), + ]; +} + +/// Generates a balanced layout for [columnCount] columns, +/// with products specified by the list [products], +/// where the larger images have width [largeImageWidth] +/// and the smaller images have width [smallImageWidth]. +/// The current [context] is also given to allow caching. +List> balancedLayout({ + BuildContext context, + int columnCount, + List products, + double largeImageWidth, + double smallImageWidth, +}) { + final String encodedParameters = _encodeParameters( + columnCount: columnCount, + products: products, + largeImageWidth: largeImageWidth, + smallImageWidth: smallImageWidth, + ); + + // Check if this layout is cached. + + if (LayoutCache.of(context).containsKey(encodedParameters)) { + return _generateLayout( + products: products, + layout: LayoutCache.of(context)[encodedParameters], + ); + } + + final List productSizes = [ + for (var product in products) + _imageSize( + Image.asset( + product.assetName, + package: product.assetPackage, + ), + ), + ]; + + bool hasNullSize = false; + for (final productSize in productSizes) { + if (productSize == null) { + hasNullSize = true; + break; + } + } + + if (hasNullSize) { + // If some image sizes are not read, return default layout. + // Default layout is not cached. + + List> result = + List>.generate(columnCount, (columnIndex) => []); + for (var index = 0; index < products.length; ++index) { + result[index % columnCount].add(products[index]); + } + + return result; + } + + // All images have sizes. Use tailored layout. + + final List productHeights = [ + for (final productSize in productSizes) + productSize.height / + productSize.width * + (largeImageWidth + smallImageWidth) / + 2 + + productCardAdditionalHeight, + ]; + + final List> layout = _balancedDistribution( + columnCount: columnCount, + data: productHeights, + biases: List.generate( + columnCount, + (column) => (column % 2 == 0 ? 0 : columnTopSpace), + ), + ); + + // Add tailored layout to cache. + + LayoutCache.of(context)[encodedParameters] = layout; + + final List> result = _generateLayout( + products: products, + layout: layout, + ); + + return result; +} diff --git a/gallery/gallery/lib/studies/shrine/supplemental/desktop_product_columns.dart b/gallery/gallery/lib/studies/shrine/supplemental/desktop_product_columns.dart index ccf6812c5..75d8ea84d 100644 --- a/gallery/gallery/lib/studies/shrine/supplemental/desktop_product_columns.dart +++ b/gallery/gallery/lib/studies/shrine/supplemental/desktop_product_columns.dart @@ -9,23 +9,30 @@ import 'package:flutter/material.dart'; import 'package:gallery/studies/shrine/model/product.dart'; import 'package:gallery/studies/shrine/supplemental/product_card.dart'; +/// Height of the text below each product card. +const productCardAdditionalHeight = 84.0 * 2; + +/// Height of the divider between product cards. +const productCardDividerHeight = 84.0; + +/// Height of the space at the top of every other column. +const columnTopSpace = 84.0; + class DesktopProductCardColumn extends StatelessWidget { const DesktopProductCardColumn({ - @required this.columnCount, - @required this.currentColumn, @required this.alignToEnd, @required this.startLarge, + @required this.lowerStart, @required this.products, @required this.largeImageWidth, @required this.smallImageWidth, }); - final int columnCount; - final int currentColumn; final List products; final bool alignToEnd; final bool startLarge; + final bool lowerStart; final double largeImageWidth; final double smallImageWidth; @@ -33,8 +40,7 @@ class DesktopProductCardColumn extends StatelessWidget { @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { - final int currentColumnProductCount = - (products.length - currentColumn - 1 + columnCount) ~/ columnCount; + final int currentColumnProductCount = products.length; final int currentColumnWidgetCount = max(2 * currentColumnProductCount - 1, 0); @@ -44,15 +50,14 @@ class DesktopProductCardColumn extends StatelessWidget { crossAxisAlignment: alignToEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (currentColumn % 2 == 1) Container(height: 84), + if (lowerStart) Container(height: columnTopSpace), ...List.generate(currentColumnWidgetCount, (index) { Widget card; if (index % 2 == 0) { // This is a product. final int productCardIndex = index ~/ 2; card = DesktopProductCard( - product: - products[productCardIndex * columnCount + currentColumn], + product: products[productCardIndex], imageWidth: startLarge ? ((productCardIndex % 2 == 0) ? largeImageWidth @@ -64,7 +69,7 @@ class DesktopProductCardColumn extends StatelessWidget { } else { // This is just a divider. card = Container( - height: 84, + height: productCardDividerHeight, ); } return RepaintBoundary(child: card); diff --git a/gallery/gallery/lib/studies/shrine/supplemental/layout_cache.dart b/gallery/gallery/lib/studies/shrine/supplemental/layout_cache.dart new file mode 100644 index 000000000..f72228e76 --- /dev/null +++ b/gallery/gallery/lib/studies/shrine/supplemental/layout_cache.dart @@ -0,0 +1,22 @@ +// Copyright 2019 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class LayoutCache extends InheritedWidget { + const LayoutCache({ + Key key, + @required this.layouts, + @required Widget child, + }) : super(key: key, child: child); + + static Map>> of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType().layouts; + } + + final Map>> layouts; + + @override + bool updateShouldNotify(LayoutCache old) => true; +} diff --git a/gallery/gallery/pubspec.lock b/gallery/gallery/pubspec.lock index 0b30d1a6c..902d91fba 100644 --- a/gallery/gallery/pubspec.lock +++ b/gallery/gallery/pubspec.lock @@ -44,7 +44,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection url: "https://pub.dartlang.org" diff --git a/gallery/gallery/pubspec.yaml b/gallery/gallery/pubspec.yaml index 2e05f70e7..641ce95a9 100644 --- a/gallery/gallery/pubspec.yaml +++ b/gallery/gallery/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: url: git://github.com/guidezpl/flutter-localized-countries.git ref: 76bfbf9654c3842d735181383f7ab86c312a2483 shared_preferences: ^0.5.4+8 + collection: ^1.14.0 dev_dependencies: flutter_test: