blob: fecbee208f669279284edb2cf818aa7c3a669db6 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. 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 '../model/product.dart';
import 'desktop_product_columns.dart';
import 'layout_cache.dart';
/// A placeholder id for an empty element. See [_iterateUntilBalanced]
/// for more information.
const int _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 int _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) {
final List<_TaggedHeightData> result = List<_TaggedHeightData>.from(set);
result.add(const _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 =
<String>[for (final Product 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 <List<Product>>[
for (final List<int> column in layout)
<Product>[
for (final int index in column) products[index],
]
];
}
/// 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 _TaggedHeightData a in sourceObjects) {
for (final _TaggedHeightData 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 != null && bestA.index != _emptyElement) {
columnObjects[source].remove(bestA);
columnObjects[target].add(bestA);
}
if (bestB != null && 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({
required int columnCount,
required List<double> data,
required List<double> biases,
}) {
assert(biases.length == columnCount);
final List<Set<_TaggedHeightData>> columnObjects = List<Set<_TaggedHeightData>>.generate(
columnCount, (int column) => <_TaggedHeightData>{});
final List<double> columnHeights = List<double>.from(biases);
for (int 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 <List<int>>[
for (final Set<_TaggedHeightData> column in columnObjects)
<int>[for (final _TaggedHeightData 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({
required BuildContext context,
required int columnCount,
required List<Product> products,
required double largeImageWidth,
required 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<double> productHeights = <double>[
for (final Product product in products)
1 / product.assetAspectRatio * (largeImageWidth + smallImageWidth) / 2 +
productCardAdditionalHeight,
];
final List<List<int>> layout = _balancedDistribution(
columnCount: columnCount,
data: productHeights,
biases: List<double>.generate(
columnCount,
(int column) => (column.isEven ? 0 : columnTopSpace),
),
);
// Add tailored layout to cache.
LayoutCache.of(context)[encodedParameters] = layout;
final List<List<Product>> result = _generateLayout(
products: products,
layout: layout,
);
return result;
}