perf(grids): rebuild media grids on width change, not every scroll tick

This commit is contained in:
edde746
2026-06-12 07:18:34 +02:00
parent 2b34f4a9d7
commit 03c623e8c7
9 changed files with 359 additions and 149 deletions
+19 -28
View File
@@ -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,
),
);
+11 -21
View File
@@ -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,
),
);
+5 -3
View File
@@ -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, {
+112 -14
View File
@@ -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);
}
}