mirror of https://github.com/flutter/samples.git
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
parent
d71180683c
commit
82e462d9b8
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
Loading…
Reference in new issue