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.
pull/196/head
Tianguang 6 years ago committed by GitHub
parent d71180683c
commit 82e462d9b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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<ShrineApp> with TickerProviderStateMixin {
AppStateModel _model;
Map<String, List<List<int>>> _layouts = {};
@override
void initState() {
super.initState();
@ -87,15 +90,18 @@ class _ShrineAppState extends State<ShrineApp> 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,
),
),
),
),

@ -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<DesktopProductCardColumn> productCardColumns =
List<DesktopProductCardColumn>.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<Product> products;
final double largeImageWidth;
final double smallImageWidth;
@override
Widget build(BuildContext context) {
final Widget _gap = Container(width: 24);
final List<List<Product>> productCardLists = balancedLayout(
context: context,
columnCount: columnCount,
products: products,
largeImageWidth: largeImageWidth,
smallImageWidth: smallImageWidth,
);
final List<DesktopProductCardColumn> productCardColumns =
List<DesktopProductCardColumn>.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<Widget>.generate(
2 * columnCount - 1,
(generalizedColumnIndex) {
if (generalizedColumnIndex % 2 == 0) {
return productCardColumns[generalizedColumnIndex ~/ 2];
} else {
return _gap;
}
},
),
_flex,
],
Spacer(),
...List<Widget>.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(),
);
}
}

@ -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<Product> 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<List<Product>> _generateLayout({
@required List<Product> products,
@required List<List<int>> 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<Set<_TaggedHeightData>> columnObjects,
List<double> 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<List<int>> _balancedDistribution({
int columnCount,
List<double> data,
List<double> biases,
}) {
assert(biases.length == columnCount);
List<Set<_TaggedHeightData>> columnObjects =
List<Set<_TaggedHeightData>>.generate(columnCount, (column) => Set());
List<double> columnHeights = List<double>.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<List<Product>> balancedLayout({
BuildContext context,
int columnCount,
List<Product> 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<Size> 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<List<Product>> result =
List<List<Product>>.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<double> productHeights = [
for (final productSize in productSizes)
productSize.height /
productSize.width *
(largeImageWidth + smallImageWidth) /
2 +
productCardAdditionalHeight,
];
final List<List<int>> layout = _balancedDistribution(
columnCount: columnCount,
data: productHeights,
biases: List<double>.generate(
columnCount,
(column) => (column % 2 == 0 ? 0 : columnTopSpace),
),
);
// Add tailored layout to cache.
LayoutCache.of(context)[encodedParameters] = layout;
final List<List<Product>> result = _generateLayout(
products: products,
layout: layout,
);
return result;
}

@ -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<Product> 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<Widget>.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);

@ -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<String, List<List<int>>> of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LayoutCache>().layouts;
}
final Map<String, List<List<int>>> layouts;
@override
bool updateShouldNotify(LayoutCache old) => true;
}

@ -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"

@ -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:

Loading…
Cancel
Save