Created
January 28, 2026 01:01
-
-
Save pingbird/5bc9ae354d91b2c831cf6509e05b7470 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:math'; | |
| import 'package:boxy/boxy.dart'; | |
| import 'package:collection/collection.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| class SliverMosaic extends StatelessWidget { | |
| const SliverMosaic({ | |
| Key? key, | |
| required this.buildChild, | |
| required this.childCount, | |
| required this.maxCrossAxisExtent, | |
| required this.aspectRatio, | |
| required this.mainSpacing, | |
| required this.crossSpacing, | |
| }) : super(key: key); | |
| final IndexedWidgetBuilder buildChild; | |
| final int childCount; | |
| final double maxCrossAxisExtent; | |
| final double Function(int) aspectRatio; | |
| final double mainSpacing; | |
| final double crossSpacing; | |
| @override | |
| Widget build(BuildContext context) { | |
| return CustomBoxy.sliver( | |
| delegate: MosaicBoxy( | |
| buildChild: buildChild, | |
| childCount: childCount, | |
| maxCrossAxisExtent: maxCrossAxisExtent, | |
| aspectRatio: aspectRatio, | |
| mainSpacing: mainSpacing, | |
| crossSpacing: crossSpacing, | |
| ), | |
| ); | |
| } | |
| } | |
| class MosaicController { | |
| var lastCrossSize = 0.0; | |
| var lastCols = 0; | |
| final cachedChildren = <int, Widget>{}; | |
| final cachedChildCols = <int>[]; | |
| final cachedAspect = <double>[]; | |
| final cachedCols = <List<int>>[]; | |
| final cachedOffsets = <List<double>>[]; | |
| IndexedWidgetBuilder? lastBuildChild; | |
| void updateCrossSize(double crossSize, int numCols) { | |
| if (crossSize == lastCrossSize) return; | |
| lastCols = numCols; | |
| lastCrossSize = crossSize; | |
| cachedChildCols.clear(); | |
| cachedCols.clear(); | |
| cachedCols.addAll(Iterable.generate(numCols, (i) => [])); | |
| cachedOffsets.clear(); | |
| cachedOffsets.addAll(Iterable.generate(numCols, (i) => [])); | |
| } | |
| Widget getChildAt(IndexedWidgetBuilder builder, int index) { | |
| if (builder != lastBuildChild) { | |
| cachedChildren.clear(); | |
| } | |
| return cachedChildren[index] ??= | |
| Builder(builder: (context) => builder(context, index)); | |
| } | |
| double calculateSizes( | |
| double end, | |
| int childCount, | |
| double Function(int) aspectRatio, | |
| ) { | |
| var minExtent = cachedOffsets.fold<double>( | |
| 0.0, (n, e) => min(n, (e.isEmpty ? 0.0 : e.last))); | |
| while (minExtent < end && cachedChildCols.length != childCount) { | |
| final i = cachedChildCols.length; | |
| while (cachedAspect.length < i + 1) { | |
| final aspect = aspectRatio(cachedAspect.length); | |
| cachedAspect.add(aspect); | |
| } | |
| final height = 1 / cachedAspect[i]; | |
| var minCol = 0; | |
| for (var col = 1; col < lastCols; col++) { | |
| if ((cachedOffsets[col].lastOrNull ?? 0.0) < | |
| (cachedOffsets[minCol].lastOrNull ?? 0.0)) { | |
| minCol = col; | |
| } | |
| } | |
| cachedChildCols.add(minCol); | |
| cachedCols[minCol].add(i); | |
| final offset = (cachedOffsets[minCol].lastOrNull ?? 0.0) + height; | |
| cachedOffsets[minCol].add(offset); | |
| minExtent = min(minExtent, offset); | |
| } | |
| return minExtent; | |
| } | |
| int findColEnd(int col, double end, double spacing) { | |
| final offsets = cachedOffsets[col]; | |
| int n = 0; | |
| int m = offsets.length - 1; | |
| while (n < m) { | |
| final int mid = n + ((m - n) >> 1); | |
| final double offset = offsets[mid] + (spacing * mid); | |
| final int comp = offset.compareTo(end); | |
| if (comp < 0) { | |
| n = mid + 1; | |
| } else { | |
| m = mid; | |
| } | |
| } | |
| return min(n + 1, offsets.length - 1); | |
| } | |
| } | |
| class MosaicBoxy extends SliverBoxyDelegate<MosaicController> { | |
| MosaicBoxy({ | |
| required this.buildChild, | |
| required this.childCount, | |
| required this.maxCrossAxisExtent, | |
| required this.aspectRatio, | |
| required this.mainSpacing, | |
| required this.crossSpacing, | |
| }); | |
| final IndexedWidgetBuilder buildChild; | |
| final int childCount; | |
| final double maxCrossAxisExtent; | |
| final double Function(int) aspectRatio; | |
| final double mainSpacing; | |
| final double crossSpacing; | |
| MosaicController get controller => layoutData ??= MosaicController(); | |
| @override | |
| SliverGeometry layout() { | |
| final crossSize = constraints.crossAxisExtent; | |
| final numCols = (crossSize / maxCrossAxisExtent).ceil(); | |
| final colWidth = crossSize / numCols; | |
| final childWidth = (crossSize - crossSpacing * (numCols - 1)) / numCols; | |
| controller.updateCrossSize(crossSize, numCols); | |
| final startOffset = constraints.scrollOffset + constraints.cacheOrigin; | |
| final endOffset = constraints.scrollOffset + | |
| constraints.cacheOrigin + | |
| constraints.remainingCacheExtent; | |
| controller.calculateSizes( | |
| endOffset / childWidth, | |
| childCount, | |
| aspectRatio, | |
| ); | |
| for (var col = 0; col < numCols; col++) { | |
| for (var i = controller.findColEnd( | |
| col, | |
| endOffset / childWidth, | |
| mainSpacing / childWidth, | |
| ); | |
| i >= 0; | |
| i--) { | |
| final cachedOffset = controller.cachedOffsets[col][i]; | |
| final childIndex = controller.cachedCols[col][i]; | |
| final childHeight = childWidth / controller.cachedAspect[childIndex]; | |
| final mainOffset = i * mainSpacing + | |
| controller.cachedOffsets[col][i] * childWidth - | |
| (childHeight + constraints.scrollOffset); | |
| if (mainOffset + childHeight < 0) break; | |
| final child = inflate<BoxyChild>( | |
| controller.getChildAt(buildChild, childIndex), | |
| id: childIndex, | |
| ); | |
| child.layout( | |
| BoxConstraints.tight(Size( | |
| childWidth, | |
| childHeight, | |
| )), | |
| ); | |
| child.positionOnAxis( | |
| col * childWidth + col * crossSpacing, | |
| mainOffset, | |
| ); | |
| } | |
| } | |
| final maxExtent = controller.cachedOffsets.fold<double>( | |
| 0.0, | |
| (n, e) => max(n, | |
| (e.lastOrNull ?? 0.0) * childWidth + mainSpacing * (e.length - 1))); | |
| final estimatedExtent = maxExtent == 0 | |
| ? 0.0 | |
| : (maxExtent / controller.cachedChildCols.length) * childCount; | |
| return SliverGeometry( | |
| scrollExtent: estimatedExtent, | |
| paintExtent: render.calculatePaintOffset( | |
| constraints, | |
| from: 0.0, | |
| to: estimatedExtent, | |
| ), | |
| maxPaintExtent: estimatedExtent, | |
| hasVisualOverflow: true, | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment