From 82e462d9b8365eea5a3267fa23616d0b51e01797 Mon Sep 17 00:00:00 2001 From: Tianguang Date: Wed, 18 Dec 2019 19:31:13 +0100 Subject: [PATCH] Shrine: Remove Large Gaps at Bottom of Screen (#193) In the Shrine app, there used to be large gaps at the bottom of the screen. This PR implements an algorithm that rearranges the order of cards so that the heights of the columns are closer, reducing the size of gaps. --- gallery/gallery/lib/studies/shrine/app.dart | 24 +- .../shrine/supplemental/asymmetric_view.dart | 124 +++++--- .../shrine/supplemental/balanced_layout.dart | 298 ++++++++++++++++++ .../supplemental/desktop_product_columns.dart | 25 +- .../shrine/supplemental/layout_cache.dart | 22 ++ gallery/gallery/pubspec.lock | 2 +- gallery/gallery/pubspec.yaml | 1 + 7 files changed, 433 insertions(+), 63 deletions(-) create mode 100644 gallery/gallery/lib/studies/shrine/supplemental/balanced_layout.dart create mode 100644 gallery/gallery/lib/studies/shrine/supplemental/layout_cache.dart 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: