| // 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; |
| } |