import 'dart:math'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; abstract class SectionedListLayoutProvider extends StatelessWidget { final double scrollableWidth; final int columnCount; final double spacing, tileExtent; final Widget Function(T item) tileBuilder; final Duration tileAnimationDelay; final Widget child; const SectionedListLayoutProvider({ Key? key, required this.scrollableWidth, required this.columnCount, required this.spacing, required this.tileExtent, required this.tileBuilder, required this.tileAnimationDelay, required this.child, }) : assert(scrollableWidth != 0), super(key: key); @override Widget build(BuildContext context) { return ProxyProvider0>( update: (context, _) => _updateLayouts(context), updateShouldNotify: (previous, current) { final previousLayouts = previous.sectionLayouts; final currentLayouts = current.sectionLayouts; return previousLayouts.length != currentLayouts.length || !previousLayouts.every(currentLayouts.contains); }, child: child, ); } SectionedListLayout _updateLayouts(BuildContext context) { final _showHeaders = showHeaders; final _sections = sections; final sectionKeys = _sections.keys.toList(); final animate = tileAnimationDelay > Duration.zero; final sectionLayouts = []; var currentIndex = 0; var currentOffset = 0.0; sectionKeys.forEach((sectionKey) { final section = _sections[sectionKey]!; final sectionItemCount = section.length; final rowCount = (sectionItemCount / columnCount).ceil(); final sectionChildCount = 1 + rowCount; final headerExtent = _showHeaders ? getHeaderExtent(context, sectionKey) : 0.0; final sectionFirstIndex = currentIndex; currentIndex += sectionChildCount; final sectionLastIndex = currentIndex - 1; final sectionMinOffset = currentOffset; currentOffset += headerExtent + tileExtent * rowCount + spacing * (rowCount - 1); final sectionMaxOffset = currentOffset; sectionLayouts.add( SectionLayout( sectionKey: sectionKey, firstIndex: sectionFirstIndex, lastIndex: sectionLastIndex, minOffset: sectionMinOffset, maxOffset: sectionMaxOffset, headerExtent: headerExtent, tileExtent: tileExtent, spacing: spacing, builder: (context, listIndex) => _buildInSection( context, section, listIndex * columnCount, listIndex - sectionFirstIndex, sectionKey, headerExtent, animate, ), ), ); }); return SectionedListLayout( sections: _sections, showHeaders: _showHeaders, columnCount: columnCount, tileExtent: tileExtent, spacing: spacing, sectionLayouts: sectionLayouts, ); } Widget _buildInSection( BuildContext context, List section, int sectionGridIndex, int sectionChildIndex, SectionKey sectionKey, double headerExtent, bool animate, ) { if (sectionChildIndex == 0) { final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : const SizedBox.shrink(); return animate ? _buildAnimation(sectionGridIndex, header) : header; } sectionChildIndex--; final sectionItemCount = section.length; final minItemIndex = sectionChildIndex * columnCount; final maxItemIndex = min(sectionItemCount, minItemIndex + columnCount); final children = []; for (var i = minItemIndex; i < maxItemIndex; i++) { final itemGridIndex = sectionGridIndex + i - minItemIndex; final item = RepaintBoundary( child: tileBuilder(section[i]), ); children.add(animate ? _buildAnimation(itemGridIndex, item) : item); } return _GridRow( extent: tileExtent, spacing: spacing, children: children, ); } Widget _buildAnimation(int index, Widget child) { return AnimationConfiguration.staggeredGrid( position: index, columnCount: columnCount, duration: Durations.staggeredAnimation, delay: tileAnimationDelay, child: SlideAnimation( verticalOffset: 50.0, child: FadeInAnimation( child: child, ), ), ); } bool get showHeaders; Map> get sections; double getHeaderExtent(BuildContext context, SectionKey sectionKey); Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); properties.add(IntProperty('columnCount', columnCount)); properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('tileExtent', tileExtent)); properties.add(DiagnosticsProperty('showHeaders', showHeaders)); } } class SectionedListLayout { final Map> sections; final bool showHeaders; final int columnCount; final double tileExtent, spacing; final List sectionLayouts; const SectionedListLayout({ required this.sections, required this.showHeaders, required this.columnCount, required this.tileExtent, required this.spacing, required this.sectionLayouts, }); Rect? getTileRect(T item) { final MapEntry>? section = sections.entries.firstWhereOrNull((kv) => kv.value.contains(item)); if (section == null) return null; final sectionKey = section.key; final sectionLayout = sectionLayouts.firstWhereOrNull((sl) => sl.sectionKey == sectionKey); if (sectionLayout == null) return null; final sectionItemIndex = section.value.indexOf(item); final column = sectionItemIndex % columnCount; final row = (sectionItemIndex / columnCount).floor(); final listIndex = sectionLayout.firstIndex + 1 + row; final left = tileExtent * column + spacing * (column - 1); final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } SectionLayout? getSectionAt(double offsetY) => sectionLayouts.firstWhereOrNull((sl) => offsetY < sl.maxOffset); T? getItemAt(Offset position) { var dy = position.dy; final sectionLayout = getSectionAt(dy); if (sectionLayout == null) return null; final section = sections[sectionLayout.sectionKey]; if (section == null) return null; dy -= sectionLayout.minOffset + sectionLayout.headerExtent; if (dy < 0) return null; final row = dy ~/ (tileExtent + spacing); final column = position.dx ~/ (tileExtent + spacing); final index = row * columnCount + column; if (index >= section.length) return null; return section[index]; } @override String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}'; } class SectionLayout { final SectionKey sectionKey; final int firstIndex, lastIndex, bodyFirstIndex; final double minOffset, maxOffset, bodyMinOffset; final double headerExtent, tileExtent, spacing, mainAxisStride; final IndexedWidgetBuilder builder; const SectionLayout({ required this.sectionKey, required this.firstIndex, required this.lastIndex, required this.minOffset, required this.maxOffset, required this.headerExtent, required this.tileExtent, required this.spacing, required this.builder, }) : bodyFirstIndex = firstIndex + 1, bodyMinOffset = minOffset + headerExtent, mainAxisStride = tileExtent + spacing; bool hasChild(int index) => firstIndex <= index && index <= lastIndex; bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset; double indexToLayoutOffset(int index) { index -= bodyFirstIndex; if (index < 0) return minOffset; return bodyMinOffset + index * mainAxisStride; } int getMinChildIndexForScrollOffset(double scrollOffset) { scrollOffset -= bodyMinOffset; if (scrollOffset < 0) return firstIndex; return bodyFirstIndex + scrollOffset ~/ mainAxisStride; } int getMaxChildIndexForScrollOffset(double scrollOffset) { scrollOffset -= bodyMinOffset; if (scrollOffset < 0) return firstIndex; return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; } @override bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing; @override int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing); @override String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}'; } class _GridRow extends MultiChildRenderObjectWidget { final double extent, spacing; _GridRow({ Key? key, required this.extent, required this.spacing, required List children, }) : super(key: key, children: children); @override RenderObject createRenderObject(BuildContext context) { return _RenderGridRow( extent: extent, spacing: spacing, ); } @override void updateRenderObject(BuildContext context, _RenderGridRow renderObject) { renderObject.extent = extent; renderObject.spacing = spacing; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DoubleProperty('extent', extent)); properties.add(DoubleProperty('spacing', spacing)); } } class _GridRowParentData extends ContainerBoxParentData {} class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { _RenderGridRow({ List? children, required double extent, required double spacing, }) : _extent = extent, _spacing = spacing { addAll(children); } double get extent => _extent; double _extent; set extent(double value) { if (_extent == value) return; _extent = value; markNeedsLayout(); } double get spacing => _spacing; double _spacing; set spacing(double value) { if (_spacing == value) return; _spacing = value; markNeedsLayout(); } @override void setupParentData(RenderBox child) { if (child.parentData is! _GridRowParentData) { child.parentData = _GridRowParentData(); } } double get intrinsicWidth => extent * childCount + spacing * (childCount - 1); @override double computeMinIntrinsicWidth(double height) => intrinsicWidth; @override double computeMaxIntrinsicWidth(double height) => intrinsicWidth; @override double computeMinIntrinsicHeight(double width) => extent; @override double computeMaxIntrinsicHeight(double width) => extent; @override void performLayout() { var child = firstChild; if (child == null) { size = constraints.smallest; return; } size = Size(constraints.maxWidth, extent); final childConstraints = BoxConstraints.tight(Size(extent, extent)); var offset = Offset.zero; while (child != null) { child.layout(childConstraints, parentUsesSize: false); final childParentData = child.parentData! as _GridRowParentData; childParentData.offset = offset; offset += Offset(extent + spacing, 0); child = childParentData.nextSibling; } } @override double? computeDistanceToActualBaseline(TextBaseline baseline) { return defaultComputeDistanceToHighestActualBaseline(baseline); } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { return defaultHitTestChildren(result, position: position); } @override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DoubleProperty('extent', extent)); properties.add(DoubleProperty('spacing', spacing)); } }