perf(grids): rebuild media grids on width change, not every scroll tick
This commit is contained in:
@@ -13,6 +13,7 @@ import '../utils/grid_size_calculator.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/media_grid_delegate.dart';
|
||||
import '../widgets/skeleton_media_card.dart';
|
||||
import '../widgets/sliver_cross_axis_layout_builder.dart';
|
||||
|
||||
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
||||
/// Flutter widget Key.
|
||||
@@ -202,27 +203,22 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
);
|
||||
}
|
||||
|
||||
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
maxExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
density: libraryDensity,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: libraryDensity,
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final inFirstRow = GridSizeCalculator.isFirstRow(index, geometry.columnCount);
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
@@ -298,27 +294,22 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
);
|
||||
}
|
||||
|
||||
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
maxExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
density: libraryDensity,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: libraryDensity,
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: totalItems,
|
||||
itemBuilder: (context, index) => buildTile(
|
||||
index,
|
||||
inFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
|
||||
inFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
|
||||
disableScale: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../utils/provider_extensions.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../widgets/media_grid_delegate.dart';
|
||||
import '../widgets/sliver_cross_axis_layout_builder.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/loading_indicator_box.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
@@ -561,32 +562,21 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding(
|
||||
context,
|
||||
libraryDensity,
|
||||
16,
|
||||
);
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
density: libraryDensity,
|
||||
usePaddingAware: true,
|
||||
horizontalPadding: 16,
|
||||
useWideAspectRatio: useWideLayout,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
useWideLayout ? maxExtent * 1.8 : maxExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
);
|
||||
final columnCount = geometry.columnCount;
|
||||
|
||||
return SliverGrid(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: libraryDensity,
|
||||
usePaddingAware: true,
|
||||
horizontalPadding: 16,
|
||||
useWideAspectRatio: useWideLayout,
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _filteredItems[index];
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
@@ -34,8 +34,10 @@ import '../library_alpha_scroll_metrics.dart';
|
||||
import '../library_filter_sort_loader.dart';
|
||||
import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/focusable_filter_chip.dart';
|
||||
import '../../../widgets/listenable_selector.dart';
|
||||
import '../../../widgets/loading_indicator_box.dart';
|
||||
import '../../../widgets/media_grid_delegate.dart';
|
||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
||||
import '../../../widgets/media_card_list_layout.dart';
|
||||
import '../../../widgets/bottom_sheet_page_scaffold.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
@@ -1311,26 +1313,31 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
top: overlayTopPadding,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
// Select the derived letter rather than listening to the raw
|
||||
// index: the index changes every scrolled row, but the bar only
|
||||
// needs a rebuild when the letter itself flips.
|
||||
child: _isPhone(context)
|
||||
? ValueListenableBuilder<int>(
|
||||
valueListenable: _currentFirstVisibleIndex,
|
||||
builder: (context, visibleIndex, _) => ValueListenableBuilder<bool>(
|
||||
? ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: _isScrollActive,
|
||||
builder: (context, scrolling, _) => AlphaScrollHandle(
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: _alphaLetterFor(visibleIndex),
|
||||
currentLetter: currentLetter,
|
||||
descending: _isTitleSortDescending,
|
||||
isScrolling: scrolling,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ValueListenableBuilder<int>(
|
||||
valueListenable: _currentFirstVisibleIndex,
|
||||
builder: (context, visibleIndex, _) => AlphaJumpBar(
|
||||
: ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => AlphaJumpBar(
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: _alphaLetterFor(visibleIndex),
|
||||
currentLetter: currentLetter,
|
||||
descending: _isTitleSortDescending,
|
||||
focusNode: _alphaJumpBarFocusNode,
|
||||
onNavigateLeft: _navigateToGridNearScroll,
|
||||
@@ -1702,72 +1709,54 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
if (viewMode == ViewMode.list) {
|
||||
// In list view, all items are in a single column (first column)
|
||||
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio);
|
||||
return SliverPadding(
|
||||
padding: .fromLTRB(8, topPadding, rightPadding, 8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, _) {
|
||||
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio);
|
||||
return SliverList.builder(
|
||||
sliver: SliverList.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
final child = _buildMediaCardItem(
|
||||
index,
|
||||
isFirstRow: index == 0,
|
||||
isFirstColumn: true, // List view = single column
|
||||
isLastColumn: true,
|
||||
disableScale: true,
|
||||
columnCount: 1,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
final child = _buildMediaCardItem(
|
||||
index,
|
||||
isFirstRow: index == 0,
|
||||
isFirstColumn: true, // List view = single column
|
||||
isLastColumn: true,
|
||||
disableScale: true,
|
||||
columnCount: 1,
|
||||
itemCount: itemCount,
|
||||
);
|
||||
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
|
||||
},
|
||||
);
|
||||
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// In grid view, calculate columns and pass to item builder
|
||||
// Use 16:9 aspect ratio when browsing episodes with episode thumbnail mode
|
||||
final baseMaxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity);
|
||||
final effectiveMaxExtent = useWideRatio ? baseMaxExtent * 1.8 : baseMaxExtent;
|
||||
final hasAlphaBarReservation = rightPadding > 8.0;
|
||||
return SliverPadding(
|
||||
padding: .fromLTRB(8, topPadding, rightPadding, 8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
|
||||
// Compute column count from the width the grid would have without the alpha
|
||||
// bar's reservation, so toggling the bar doesn't repack the grid into one
|
||||
// fewer column and blow up poster size.
|
||||
final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
baselineWidth,
|
||||
effectiveMaxExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
// Compute column count from the width the grid would have without
|
||||
// the alpha bar's reservation, so toggling the bar doesn't repack
|
||||
// the grid into one fewer column and blow up poster size.
|
||||
crossAxisExtentForColumnCount: hasAlphaBarReservation ? crossAxisExtent + (rightPadding - 8.0) : null,
|
||||
density: libraryDensity,
|
||||
useWideAspectRatio: useWideRatio,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
final columnCount = geometry.columnCount;
|
||||
// Cache grid metrics for alpha jump bar scroll calculations
|
||||
final itemWidth = GridSizeCalculator.getCellWidthForColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
columnCount,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
);
|
||||
final itemHeight =
|
||||
itemWidth /
|
||||
MediaGridDelegate.aspectRatioFor(useWideAspectRatio: useWideRatio, fullBleedImage: fullCardLayout);
|
||||
_scrollMetrics = LibraryAlphaScrollMetrics(
|
||||
columnCount: columnCount,
|
||||
rowHeight: itemHeight + gridSpacing,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
rowHeight: geometry.itemHeight + geometry.spacing,
|
||||
itemWidth: geometry.itemWidth,
|
||||
itemHeight: geometry.itemHeight,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: libraryDensity,
|
||||
useWideAspectRatio: useWideRatio,
|
||||
fullBleedImage: fullCardLayout,
|
||||
maxCrossAxisExtentOverride: hasAlphaBarReservation ? itemWidth : null,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(
|
||||
index,
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_grid_delegate.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
@@ -147,25 +148,20 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
maxCrossAxisExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
density: density,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: density,
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(
|
||||
index,
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_grid_delegate.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
@@ -149,25 +150,20 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
||||
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
|
||||
return SliverPadding(
|
||||
padding: _effectivePadding,
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
constraints.crossAxisExtent,
|
||||
maxCrossAxisExtent,
|
||||
crossAxisSpacing: gridSpacing,
|
||||
sliver: SliverCrossAxisLayoutBuilder(
|
||||
builder: (context, crossAxisExtent) {
|
||||
final geometry = MediaGridGeometry.resolve(
|
||||
context: context,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
density: density,
|
||||
fullBleedImage: fullCardLayout,
|
||||
);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: density,
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
gridDelegate: geometry.delegate,
|
||||
itemCount: totalSize,
|
||||
itemBuilder: (context, index) => _buildPlaylistCard(
|
||||
index,
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
|
||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
|
||||
fullBleedImage: fullCardLayout,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -54,9 +54,11 @@ class GridSizeCalculator {
|
||||
/// is added to the denominator only, not the numerator:
|
||||
/// `(crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil()`
|
||||
///
|
||||
/// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder`
|
||||
/// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other
|
||||
/// elements that reduce the grid's actual width.
|
||||
/// [crossAxisExtent] should come from layout constraints (e.g.
|
||||
/// `SliverCrossAxisLayoutBuilder` or `LayoutBuilder`), not from `MediaQuery`,
|
||||
/// to account for sidebars or other elements that reduce the grid's actual
|
||||
/// width. Never use a plain `SliverLayoutBuilder` for this: its constraints
|
||||
/// include the scroll offset, so it rebuilds the whole grid every scroll tick.
|
||||
static int getColumnCount(
|
||||
double crossAxisExtent,
|
||||
double maxCrossAxisExtent, {
|
||||
|
||||
@@ -25,20 +25,15 @@ class MediaGridDelegate {
|
||||
final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage);
|
||||
final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage);
|
||||
|
||||
double maxCrossAxisExtent;
|
||||
if (maxCrossAxisExtentOverride != null) {
|
||||
maxCrossAxisExtent = maxCrossAxisExtentOverride;
|
||||
} else {
|
||||
maxCrossAxisExtent = usePaddingAware
|
||||
? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding)
|
||||
: GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
|
||||
// For wide aspect ratio (16:9), increase max extent so items are larger
|
||||
// and there are fewer per row (roughly 1.8x wider to maintain similar visual area)
|
||||
if (useWideAspectRatio) {
|
||||
maxCrossAxisExtent *= 1.8;
|
||||
}
|
||||
}
|
||||
final maxCrossAxisExtent =
|
||||
maxCrossAxisExtentOverride ??
|
||||
_maxCrossAxisExtentFor(
|
||||
context: context,
|
||||
density: density,
|
||||
usePaddingAware: usePaddingAware,
|
||||
horizontalPadding: horizontalPadding,
|
||||
useWideAspectRatio: useWideAspectRatio,
|
||||
);
|
||||
|
||||
return SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
@@ -48,6 +43,27 @@ class MediaGridDelegate {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the max cross-axis extent the way [createDelegate] does,
|
||||
/// including the 1.8x widening for 16:9 episode thumbnails.
|
||||
static double _maxCrossAxisExtentFor({
|
||||
required BuildContext context,
|
||||
required int density,
|
||||
required bool usePaddingAware,
|
||||
required double horizontalPadding,
|
||||
required bool useWideAspectRatio,
|
||||
}) {
|
||||
var maxCrossAxisExtent = usePaddingAware
|
||||
? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding)
|
||||
: GridSizeCalculator.getMaxCrossAxisExtent(context, density);
|
||||
|
||||
// For wide aspect ratio (16:9), increase max extent so items are larger
|
||||
// and there are fewer per row (roughly 1.8x wider to maintain similar visual area)
|
||||
if (useWideAspectRatio) {
|
||||
maxCrossAxisExtent *= 1.8;
|
||||
}
|
||||
return maxCrossAxisExtent;
|
||||
}
|
||||
|
||||
static double spacingFor({required BuildContext context, bool fullBleedImage = false}) {
|
||||
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
|
||||
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
|
||||
@@ -63,3 +79,85 @@ class MediaGridDelegate {
|
||||
return useWideAspectRatio ? GridLayoutConstants.episodeGridCellAspectRatio : GridLayoutConstants.posterAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid layout a media grid will render for a given cross-axis extent:
|
||||
/// column count, cell size, spacing, and the matching delegate.
|
||||
///
|
||||
/// Use with `SliverCrossAxisLayoutBuilder` so this is resolved once per
|
||||
/// width/settings change — never per scroll tick. [columnCount] follows the
|
||||
/// same formula [SliverGridDelegateWithMaxCrossAxisExtent] uses at layout
|
||||
/// time (see [GridSizeCalculator.getColumnCount], issue #1288), so d-pad row
|
||||
/// math and the rendered grid always agree.
|
||||
class MediaGridGeometry {
|
||||
final int columnCount;
|
||||
final double itemWidth;
|
||||
final double itemHeight;
|
||||
final double spacing;
|
||||
final SliverGridDelegateWithMaxCrossAxisExtent delegate;
|
||||
|
||||
const MediaGridGeometry._({
|
||||
required this.columnCount,
|
||||
required this.itemWidth,
|
||||
required this.itemHeight,
|
||||
required this.spacing,
|
||||
required this.delegate,
|
||||
});
|
||||
|
||||
/// Resolves the geometry for a grid laid out in [crossAxisExtent] (the
|
||||
/// sliver's width AFTER any wrapping [SliverPadding]).
|
||||
///
|
||||
/// [crossAxisExtentForColumnCount], when non-null, computes the column
|
||||
/// count from that width instead, and pins the delegate's cell width to the
|
||||
/// resulting [itemWidth] — used by the library browse grid so the alpha
|
||||
/// jump bar's reservation doesn't repack the grid into fewer columns.
|
||||
static MediaGridGeometry resolve({
|
||||
required BuildContext context,
|
||||
required double crossAxisExtent,
|
||||
required int density,
|
||||
double? crossAxisExtentForColumnCount,
|
||||
bool usePaddingAware = false,
|
||||
double horizontalPadding = 16,
|
||||
bool useWideAspectRatio = false,
|
||||
bool fullBleedImage = false,
|
||||
}) {
|
||||
final spacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullBleedImage);
|
||||
final aspectRatio = MediaGridDelegate.aspectRatioFor(
|
||||
useWideAspectRatio: useWideAspectRatio,
|
||||
fullBleedImage: fullBleedImage,
|
||||
);
|
||||
final maxCrossAxisExtent = MediaGridDelegate._maxCrossAxisExtentFor(
|
||||
context: context,
|
||||
density: density,
|
||||
usePaddingAware: usePaddingAware,
|
||||
horizontalPadding: horizontalPadding,
|
||||
useWideAspectRatio: useWideAspectRatio,
|
||||
);
|
||||
|
||||
final columnCount = GridSizeCalculator.getColumnCount(
|
||||
crossAxisExtentForColumnCount ?? crossAxisExtent,
|
||||
maxCrossAxisExtent,
|
||||
crossAxisSpacing: spacing,
|
||||
);
|
||||
final itemWidth = GridSizeCalculator.getCellWidthForColumnCount(
|
||||
crossAxisExtent,
|
||||
columnCount,
|
||||
crossAxisSpacing: spacing,
|
||||
);
|
||||
|
||||
return MediaGridGeometry._(
|
||||
columnCount: columnCount,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemWidth / aspectRatio,
|
||||
spacing: spacing,
|
||||
delegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
// When the column count is pinned to a different basis width, the
|
||||
// delegate must pack exactly [columnCount] columns into the real
|
||||
// extent, so cap cells at the derived width instead.
|
||||
maxCrossAxisExtent: crossAxisExtentForColumnCount != null ? itemWidth : maxCrossAxisExtent,
|
||||
childAspectRatio: aspectRatio,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A [SliverLayoutBuilder] variant whose builder only depends on the sliver's
|
||||
/// cross-axis extent.
|
||||
///
|
||||
/// [SliverConstraints] include the scroll offset, so a plain
|
||||
/// [SliverLayoutBuilder] re-invokes its builder on EVERY scroll tick — each
|
||||
/// tick rebuilds the returned sliver widget, which marks every realized grid
|
||||
/// child dirty for build AND layout (a 100-200ms frame on low-end TV boxes
|
||||
/// with ~30 cards realized). Grid geometry (column count, cell size) only
|
||||
/// depends on the cross-axis extent, so this widget keys the rebuild decision
|
||||
/// on [SliverConstraints.crossAxisExtent] alone: the builder re-runs when the
|
||||
/// width changes (resize, sidebar layout change) or when this widget itself
|
||||
/// is rebuilt (normal data/settings updates), never from scrolling.
|
||||
///
|
||||
/// The framework's [AbstractLayoutBuilder] contract handles the caching: the
|
||||
/// builder is skipped when [RenderAbstractLayoutBuilderMixin.layoutInfo]
|
||||
/// compares equal to its value from the previous layout pass.
|
||||
class SliverCrossAxisLayoutBuilder extends AbstractLayoutBuilder<double> {
|
||||
const SliverCrossAxisLayoutBuilder({super.key, required this.builder});
|
||||
|
||||
/// Called at layout time with the sliver's cross-axis extent. Must return
|
||||
/// a sliver.
|
||||
@override
|
||||
final Widget Function(BuildContext context, double crossAxisExtent) builder;
|
||||
|
||||
@override
|
||||
RenderAbstractLayoutBuilderMixin<double, RenderSliver> createRenderObject(BuildContext context) =>
|
||||
_RenderSliverCrossAxisLayoutBuilder();
|
||||
}
|
||||
|
||||
/// Pass-through render sliver — geometry, paint, and hit-testing mirror
|
||||
/// the framework's `_RenderSliverLayoutBuilder`; only [layoutInfo] differs.
|
||||
class _RenderSliverCrossAxisLayoutBuilder extends RenderSliver
|
||||
with
|
||||
RenderObjectWithChildMixin<RenderSliver>,
|
||||
RenderObjectWithLayoutCallbackMixin,
|
||||
RenderAbstractLayoutBuilderMixin<double, RenderSliver> {
|
||||
@override
|
||||
double get layoutInfo => constraints.crossAxisExtent;
|
||||
|
||||
@override
|
||||
double childMainAxisPosition(RenderObject child) {
|
||||
assert(child == this.child);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
runLayoutCallback();
|
||||
child?.layout(constraints, parentUsesSize: true);
|
||||
geometry = child?.geometry ?? SliverGeometry.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderObject child, Matrix4 transform) {
|
||||
assert(child == this.child);
|
||||
// The child's offset is always (0, 0); no transform needed.
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (child?.geometry?.visible ?? false) {
|
||||
context.paintChild(child!, offset);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(
|
||||
SliverHitTestResult result, {
|
||||
required double mainAxisPosition,
|
||||
required double crossAxisPosition,
|
||||
}) {
|
||||
return child != null &&
|
||||
child!.geometry!.hitTestExtent > 0 &&
|
||||
child!.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user