Custom render sliver for a completely lazyloading gallery and improved scroll performance

This commit is contained in:
ashilkn
2025-06-20 10:40:28 +05:30
parent 328b2f5961
commit 36880fac6d

View File

@@ -0,0 +1,315 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import "package:photos/models/gallery/fixed_extent_section_layout.dart";
// Using a Single SliverVariedExtentList or Using a combination where there are
// multiple sliver delegate builders in a CustomScrollView doesn't scale.
// With using a Single SliverKnownExtentList with each section being either a
// group header or a row of grid, the problem is that scrolling becomes janky
// the deeper the list is scrolled if the list is large enough.
// With using multiple slivers (and hence multiple SliverChildBuilderDelegates)
// in CustomScrollView, the deep scrolling issue exists and the first child
// of every builderDelegate is always initialized, even if it's not in viewport
// or in cacheExtent.
// https://github.com/flutter/flutter/issues/168442
// https://github.com/flutter/flutter/issues/95028
// Based on code from https://github.com/deckerst/aves
// A custom implementation of SliverMultiBoxAdaptorWidget
// adapted from SliverFixedExtentBoxAdaptor. Optimizations in layout solves
// the deep scrolling issue.
class SectionedListSliver<T> extends StatelessWidget {
final List<FixedExtentSectionLayout> sectionLayouts;
const SectionedListSliver({super.key, required this.sectionLayouts});
@override
Widget build(BuildContext context) {
final childCount =
sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
return _SliverKnownExtentList(
sectionLayouts: sectionLayouts,
delegate: SliverChildBuilderDelegate(
(context, index) {
//TODO:
// This could be optimized by using a combination of
//linear search and binary search depending on the index (use linear
//if index is small) or keep track on lastIndex of section and
//go to next section after the last index.
// Check if the optimization is required.
if (index >= childCount) return null;
final sectionLayout = sectionLayouts
.firstWhereOrNull((section) => section.hasChild(index));
return sectionLayout?.builder(context, index) ?? const SizedBox();
},
childCount: childCount,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
),
);
}
}
class _SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
final List<FixedExtentSectionLayout> sectionLayouts;
const _SliverKnownExtentList({
required super.delegate,
required this.sectionLayouts,
});
@override
_RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
final element = context as SliverMultiBoxAdaptorElement;
return _RenderSliverKnownExtentBoxAdaptor(
childManager: element,
sectionLayouts: sectionLayouts,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderSliverKnownExtentBoxAdaptor renderObject,
) {
renderObject.sectionLayouts = sectionLayouts;
}
}
class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
List<FixedExtentSectionLayout> _sectionLayouts;
List<FixedExtentSectionLayout> get sectionLayouts => _sectionLayouts;
set sectionLayouts(List<FixedExtentSectionLayout> value) {
if (_sectionLayouts == value) return;
_sectionLayouts = value;
markNeedsLayout();
}
_RenderSliverKnownExtentBoxAdaptor({
required super.childManager,
required List<FixedExtentSectionLayout> sectionLayouts,
}) : _sectionLayouts = sectionLayouts;
FixedExtentSectionLayout? sectionAtIndex(int index) =>
sectionLayouts.firstWhereOrNull((section) => section.hasChild(index));
FixedExtentSectionLayout? sectionAtOffset(double scrollOffset) =>
sectionLayouts.firstWhereOrNull(
(section) => section.hasChildAtOffset(scrollOffset),
) ??
sectionLayouts.lastOrNull;
double indexToLayoutOffset(int index) {
return (sectionAtIndex(index) ?? sectionLayouts.lastOrNull)
?.indexToLayoutOffset(index) ??
0;
}
int getMinChildIndexForScrollOffset(double scrollOffset) {
return sectionAtOffset(scrollOffset)
?.getMinChildIndexForScrollOffset(scrollOffset) ??
0;
}
int getMaxChildIndexForScrollOffset(double scrollOffset) {
return sectionAtOffset(scrollOffset)
?.getMaxChildIndexForScrollOffset(scrollOffset) ??
0;
}
double estimateMaxScrollOffset(
SliverConstraints constraints, {
int? firstIndex,
int? lastIndex,
double? leadingScrollOffset,
double? trailingScrollOffset,
}) {
// default implementation is an estimation via `childManager.estimateMaxScrollOffset()`
// but we have the accurate offset via pre-computed section layouts
return _sectionLayouts.last.maxOffset;
}
double computeMaxScrollOffset(SliverConstraints constraints) {
return sectionLayouts.last.maxOffset;
}
@override
void performLayout() {
final constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final targetEndScrollOffset = scrollOffset + remainingExtent;
// TODO: Potential improvement: Instead of using same contraints for all children,
// use a helper method that returns constrains for an index. So it should
// return the itemExtent of that index.
final childConstraints = constraints.asBoxConstraints();
final firstIndex = getMinChildIndexForScrollOffset(scrollOffset);
final targetLastIndex = targetEndScrollOffset.isFinite
? getMaxChildIndexForScrollOffset(targetEndScrollOffset)
: null;
if (firstChild != null) {
final leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
final trailingGarbage = targetLastIndex != null
? calculateTrailingGarbage(lastIndex: targetLastIndex)
: 0;
collectGarbage(leadingGarbage, trailingGarbage);
} else {
collectGarbage(0, 0);
}
if (firstChild == null) {
if (!addInitialChild(
index: firstIndex,
layoutOffset: indexToLayoutOffset(firstIndex),
)) {
// There are either no children, or we are past the end of all our children.
double max;
if (firstIndex <= 0) {
max = 0.0;
} else {
max = computeMaxScrollOffset(constraints);
}
geometry = SliverGeometry(
scrollExtent: max,
maxPaintExtent: max,
);
childManager.didFinishLayout();
return;
}
}
RenderBox? trailingChildWithLayout;
for (var index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
final child = insertAndLayoutLeadingChild(childConstraints);
if (child == null) {
// Items before the previously first child are no longer present.
// Reset the scroll offset to offset all items prior and up to the
// missing item. Let parent re-layout everything.
final layout = sectionAtIndex(index) ?? sectionLayouts.first;
geometry = SliverGeometry(
scrollOffsetCorrection: layout.indexToLayoutOffset(index),
);
return;
}
final childParentData =
child.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(index);
assert(childParentData.index == index);
trailingChildWithLayout ??= child;
}
if (trailingChildWithLayout == null) {
firstChild!.layout(childConstraints);
final childParentData =
firstChild!.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(firstIndex);
trailingChildWithLayout = firstChild;
}
var estimatedMaxScrollOffset = double.infinity;
for (var index = indexOf(trailingChildWithLayout!) + 1;
targetLastIndex == null || index <= targetLastIndex;
++index) {
var child = childAfter(trailingChildWithLayout!);
if (child == null || indexOf(child) != index) {
child = insertAndLayoutChild(
childConstraints,
after: trailingChildWithLayout,
);
if (child == null) {
// We have run out of children.
final layout = sectionAtIndex(index) ?? sectionLayouts.last;
estimatedMaxScrollOffset = layout.maxOffset;
break;
}
} else {
child.layout(childConstraints);
}
trailingChildWithLayout = child;
final childParentData =
child.parentData as SliverMultiBoxAdaptorParentData;
assert(childParentData.index == index);
childParentData.layoutOffset =
indexToLayoutOffset(childParentData.index!);
}
final lastIndex = indexOf(lastChild!);
final leadingScrollOffset = indexToLayoutOffset(firstIndex);
final trailingScrollOffset = indexToLayoutOffset(lastIndex + 1);
assert(
firstIndex == 0 ||
childScrollOffset(firstChild!)! - scrollOffset <=
precisionErrorTolerance,
);
assert(debugAssertChildListIsNonEmptyAndContiguous());
assert(indexOf(firstChild!) == firstIndex);
assert(targetLastIndex == null || lastIndex <= targetLastIndex);
estimatedMaxScrollOffset = math.min(
estimatedMaxScrollOffset,
estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
),
);
final paintExtent = calculatePaintOffset(
constraints,
from: math.min(constraints.scrollOffset, leadingScrollOffset),
to: trailingScrollOffset,
);
final cacheExtent = calculateCacheOffset(
constraints,
from: leadingScrollOffset,
to: trailingScrollOffset,
);
final targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
final targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
: null;
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: math.min(paintExtent, estimatedMaxScrollOffset),
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
hasVisualOverflow: (targetLastIndexForPaint != null &&
lastIndex >= targetLastIndexForPaint) ||
constraints.scrollOffset > 0.0,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (estimatedMaxScrollOffset == trailingScrollOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
}