custom SliverList to avoid performing layout on children
This commit is contained in:
parent
56f48f05d0
commit
02c9ac6a8e
13 changed files with 483 additions and 140 deletions
252
lib/labs/sliver_known_extent_list.dart
Normal file
252
lib/labs/sliver_known_extent_list.dart
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:aves/widgets/album/collection_list_sliver.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
|
||||||
|
final List<SectionLayout> sectionLayouts;
|
||||||
|
|
||||||
|
const SliverKnownExtentList({
|
||||||
|
Key key,
|
||||||
|
@required SliverChildDelegate delegate,
|
||||||
|
@required this.sectionLayouts,
|
||||||
|
}) : super(key: key, delegate: delegate);
|
||||||
|
|
||||||
|
@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<SectionLayout> _sectionLayouts;
|
||||||
|
|
||||||
|
List<SectionLayout> get sectionLayouts => _sectionLayouts;
|
||||||
|
|
||||||
|
set sectionLayouts(List<SectionLayout> value) {
|
||||||
|
assert(value != null);
|
||||||
|
if (_sectionLayouts == value) return;
|
||||||
|
_sectionLayouts = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderSliverKnownExtentBoxAdaptor({
|
||||||
|
@required RenderSliverBoxChildManager childManager,
|
||||||
|
@required List<SectionLayout> sectionLayouts,
|
||||||
|
}) : _sectionLayouts = sectionLayouts,
|
||||||
|
super(childManager: childManager);
|
||||||
|
|
||||||
|
SectionLayout sectionAtIndex(int index) => sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||||
|
|
||||||
|
SectionLayout sectionAtOffset(double scrollOffset) => sectionLayouts.firstWhere((section) => section.hasChildAtOffset(scrollOffset), orElse: () => null);
|
||||||
|
|
||||||
|
double indexToLayoutOffset(int index) {
|
||||||
|
return sectionAtIndex(index).indexToLayoutOffset(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||||
|
return sectionAtOffset(scrollOffset).getMinChildIndexForScrollOffset(scrollOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||||
|
return (sectionAtOffset(scrollOffset) ?? sectionLayouts.last).getMaxChildIndexForScrollOffset(scrollOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
double estimateMaxScrollOffset(
|
||||||
|
SliverConstraints constraints, {
|
||||||
|
int firstIndex,
|
||||||
|
int lastIndex,
|
||||||
|
double leadingScrollOffset,
|
||||||
|
double trailingScrollOffset,
|
||||||
|
}) {
|
||||||
|
return childManager.estimateMaxScrollOffset(
|
||||||
|
constraints,
|
||||||
|
firstIndex: firstIndex,
|
||||||
|
lastIndex: lastIndex,
|
||||||
|
leadingScrollOffset: leadingScrollOffset,
|
||||||
|
trailingScrollOffset: trailingScrollOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double computeMaxScrollOffset(SliverConstraints constraints) {
|
||||||
|
return sectionLayouts.last.maxOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _calculateLeadingGarbage(int firstIndex) {
|
||||||
|
var walker = firstChild;
|
||||||
|
var leadingGarbage = 0;
|
||||||
|
while (walker != null && indexOf(walker) < firstIndex) {
|
||||||
|
leadingGarbage += 1;
|
||||||
|
walker = childAfter(walker);
|
||||||
|
}
|
||||||
|
return leadingGarbage;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _calculateTrailingGarbage(int targetLastIndex) {
|
||||||
|
var walker = lastChild;
|
||||||
|
var trailingGarbage = 0;
|
||||||
|
while (walker != null && indexOf(walker) > targetLastIndex) {
|
||||||
|
trailingGarbage += 1;
|
||||||
|
walker = childBefore(walker);
|
||||||
|
}
|
||||||
|
return trailingGarbage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
final childConstraints = constraints.asBoxConstraints();
|
||||||
|
|
||||||
|
final firstIndex = getMinChildIndexForScrollOffset(scrollOffset);
|
||||||
|
final targetLastIndex = targetEndScrollOffset.isFinite ? getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;
|
||||||
|
|
||||||
|
if (firstChild != null) {
|
||||||
|
final leadingGarbage = _calculateLeadingGarbage(firstIndex);
|
||||||
|
final trailingGarbage = _calculateTrailingGarbage(targetLastIndex);
|
||||||
|
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.
|
||||||
|
// If it is the latter, we will need to find the first available child.
|
||||||
|
double max;
|
||||||
|
if (childManager.childCount != null) {
|
||||||
|
max = computeMaxScrollOffset(constraints);
|
||||||
|
} else if (firstIndex <= 0) {
|
||||||
|
max = 0.0;
|
||||||
|
} else {
|
||||||
|
// We will have to find it manually.
|
||||||
|
var possibleFirstIndex = firstIndex - 1;
|
||||||
|
while (possibleFirstIndex > 0 &&
|
||||||
|
!addInitialChild(
|
||||||
|
index: possibleFirstIndex,
|
||||||
|
layoutOffset: indexToLayoutOffset(possibleFirstIndex),
|
||||||
|
)) {
|
||||||
|
possibleFirstIndex -= 1;
|
||||||
|
}
|
||||||
|
max = sectionAtIndex(possibleFirstIndex).indexToLayoutOffset(possibleFirstIndex);
|
||||||
|
}
|
||||||
|
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.indexToMaxScrollOffset(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.indexToMaxScrollOffset(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.layout(childConstraints);
|
||||||
|
}
|
||||||
|
trailingChildWithLayout = child;
|
||||||
|
assert(child != null);
|
||||||
|
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: 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: paintExtent,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -162,10 +162,9 @@ class CollectionLens with ChangeNotifier {
|
||||||
break;
|
break;
|
||||||
case SortFactor.name:
|
case SortFactor.name:
|
||||||
final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory);
|
final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory);
|
||||||
final albums = byAlbum.keys.toSet();
|
|
||||||
final compare = (a, b) {
|
final compare = (a, b) {
|
||||||
final ua = CollectionSource.getUniqueAlbumName(a, albums);
|
final ua = source.getUniqueAlbumName(a);
|
||||||
final ub = CollectionSource.getUniqueAlbumName(b, albums);
|
final ub = source.getUniqueAlbumName(b);
|
||||||
return compareAsciiUpperCase(ua, ub);
|
return compareAsciiUpperCase(ua, ub);
|
||||||
};
|
};
|
||||||
sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare));
|
sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare));
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:path/path.dart';
|
||||||
|
|
||||||
class CollectionSource {
|
class CollectionSource {
|
||||||
final List<ImageEntry> _rawEntries;
|
final List<ImageEntry> _rawEntries;
|
||||||
|
final Set<String> _folderPaths = {};
|
||||||
final EventBus _eventBus = EventBus();
|
final EventBus _eventBus = EventBus();
|
||||||
|
|
||||||
List<String> sortedAlbums = List.unmodifiable(const Iterable.empty());
|
List<String> sortedAlbums = List.unmodifiable(const Iterable.empty());
|
||||||
|
@ -106,11 +107,10 @@ class CollectionSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateAlbums() {
|
void updateAlbums() {
|
||||||
final albums = _rawEntries.map((entry) => entry.directory).toSet();
|
final sorted = _folderPaths.toList()
|
||||||
final sorted = albums.toList()
|
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
final ua = getUniqueAlbumName(a, albums);
|
final ua = getUniqueAlbumName(a);
|
||||||
final ub = getUniqueAlbumName(b, albums);
|
final ub = getUniqueAlbumName(b);
|
||||||
return compareAsciiUpperCase(ua, ub);
|
return compareAsciiUpperCase(ua, ub);
|
||||||
});
|
});
|
||||||
sortedAlbums = List.unmodifiable(sorted);
|
sortedAlbums = List.unmodifiable(sorted);
|
||||||
|
@ -134,6 +134,7 @@ class CollectionSource {
|
||||||
entry.catalogDateMillis = savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
|
entry.catalogDateMillis = savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
|
||||||
});
|
});
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
|
_folderPaths.addAll(_rawEntries.map((entry) => entry.directory).toSet());
|
||||||
eventBus.fire(const EntryAddedEvent());
|
eventBus.fire(const EntryAddedEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,8 +147,8 @@ class CollectionSource {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String getUniqueAlbumName(String album, Iterable<String> albums) {
|
String getUniqueAlbumName(String album) {
|
||||||
final otherAlbums = albums?.where((item) => item != album) ?? [];
|
final otherAlbums = _folderPaths.where((item) => item != album);
|
||||||
final parts = album.split(separator);
|
final parts = album.split(separator);
|
||||||
var partCount = 0;
|
var partCount = 0;
|
||||||
String testName;
|
String testName;
|
||||||
|
|
|
@ -5,8 +5,8 @@ import 'package:aves/model/collection_source.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
|
import 'package:aves/model/filters/mime.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/mime_types.dart';
|
import 'package:aves/model/mime_types.dart';
|
||||||
import 'package:aves/model/settings.dart';
|
import 'package:aves/model/settings.dart';
|
||||||
|
@ -96,9 +96,9 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
|
||||||
final buildAlbumEntry = (album) => _FilteredCollectionNavTile(
|
final buildAlbumEntry = (album) => _FilteredCollectionNavTile(
|
||||||
source: source,
|
source: source,
|
||||||
leading: IconUtils.getAlbumIcon(context: context, album: album),
|
leading: IconUtils.getAlbumIcon(context: context, album: album),
|
||||||
title: CollectionSource.getUniqueAlbumName(album, source.sortedAlbums),
|
title: source.getUniqueAlbumName(album),
|
||||||
dense: true,
|
dense: true,
|
||||||
filter: AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, source.sortedAlbums)),
|
filter: AlbumFilter(album, source.getUniqueAlbumName(album)),
|
||||||
);
|
);
|
||||||
final buildTagEntry = (tag) => _FilteredCollectionNavTile(
|
final buildTagEntry = (tag) => _FilteredCollectionNavTile(
|
||||||
source: source,
|
source: source,
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/labs/sliver_known_extent_list.dart';
|
||||||
import 'package:aves/model/collection_lens.dart';
|
import 'package:aves/model/collection_lens.dart';
|
||||||
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/album/collection_section.dart';
|
import 'package:aves/widgets/album/collection_section.dart';
|
||||||
|
import 'package:aves/widgets/album/grid/header_generic.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up
|
// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up
|
||||||
// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
||||||
|
@ -10,33 +14,56 @@ import 'package:flutter/material.dart';
|
||||||
class CollectionListSliver extends StatelessWidget {
|
class CollectionListSliver extends StatelessWidget {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final bool showHeader;
|
final bool showHeader;
|
||||||
|
final double scrollableWidth;
|
||||||
final int columnCount;
|
final int columnCount;
|
||||||
final double tileExtent;
|
final double tileExtent;
|
||||||
|
|
||||||
const CollectionListSliver({
|
CollectionListSliver({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.collection,
|
@required this.collection,
|
||||||
@required this.showHeader,
|
@required this.showHeader,
|
||||||
@required this.columnCount,
|
@required this.scrollableWidth,
|
||||||
@required this.tileExtent,
|
@required this.tileExtent,
|
||||||
}) : super(key: key);
|
}) : columnCount = (scrollableWidth / tileExtent).round(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sectionLayouts = <_SectionLayout>[];
|
final sectionLayouts = <SectionLayout>[];
|
||||||
final sectionKeys = collection.sections.keys.toList();
|
final sectionKeys = collection.sections.keys.toList();
|
||||||
var firstIndex = 0;
|
final headerPadding = TitleSectionHeader.padding;
|
||||||
|
var currentIndex = 0, currentOffset = 0.0;
|
||||||
sectionKeys.forEach((sectionKey) {
|
sectionKeys.forEach((sectionKey) {
|
||||||
final sectionEntryCount = collection.sections[sectionKey].length;
|
final sectionEntryCount = collection.sections[sectionKey].length;
|
||||||
final rowCount = (sectionEntryCount / columnCount).ceil();
|
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
|
||||||
final widgetCount = rowCount + (showHeader ? 1 : 0);
|
|
||||||
// closure of `firstIndex` on `sectionFirstIndex`
|
var headerExtent = 0.0;
|
||||||
final sectionFirstIndex = firstIndex;
|
if (showHeader) {
|
||||||
|
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
||||||
|
if (sectionKey is String) {
|
||||||
|
final text = collection.source.getUniqueAlbumName(sectionKey);
|
||||||
|
headerExtent = SectionLayout.computeHeaderExtent(text, scrollableWidth - headerPadding.horizontal);
|
||||||
|
}
|
||||||
|
headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension) + headerPadding.vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sectionFirstIndex = currentIndex;
|
||||||
|
currentIndex += sectionChildCount;
|
||||||
|
final sectionLastIndex = currentIndex - 1;
|
||||||
|
|
||||||
|
final sectionMinOffset = currentOffset;
|
||||||
|
currentOffset += headerExtent + tileExtent * (sectionChildCount - 1);
|
||||||
|
final sectionMaxOffset = currentOffset;
|
||||||
|
|
||||||
sectionLayouts.add(
|
sectionLayouts.add(
|
||||||
_SectionLayout(
|
SectionLayout(
|
||||||
sectionKey: sectionKey,
|
sectionKey: sectionKey,
|
||||||
widgetCount: widgetCount,
|
|
||||||
firstIndex: sectionFirstIndex,
|
firstIndex: sectionFirstIndex,
|
||||||
|
lastIndex: sectionLastIndex,
|
||||||
|
minOffset: sectionMinOffset,
|
||||||
|
maxOffset: sectionMaxOffset,
|
||||||
|
headerExtent: headerExtent,
|
||||||
|
tileExtent: tileExtent,
|
||||||
builder: (context, listIndex) {
|
builder: (context, listIndex) {
|
||||||
listIndex -= sectionFirstIndex;
|
listIndex -= sectionFirstIndex;
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
|
@ -69,16 +96,16 @@ class CollectionListSliver extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
firstIndex += widgetCount;
|
|
||||||
});
|
});
|
||||||
final childCount = firstIndex;
|
final childCount = currentIndex;
|
||||||
|
|
||||||
return SliverList(
|
return SliverKnownExtentList(
|
||||||
|
sectionLayouts: sectionLayouts,
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
if (index >= childCount) return null;
|
if (index >= childCount) return null;
|
||||||
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index));
|
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||||
return sectionLayout.builder(context, index);
|
return sectionLayout?.builder(context, index) ?? const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
childCount: childCount,
|
childCount: childCount,
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
@ -87,19 +114,55 @@ class CollectionListSliver extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionLayout {
|
class SectionLayout {
|
||||||
final dynamic sectionKey;
|
final dynamic sectionKey;
|
||||||
final int widgetCount;
|
final int firstIndex, lastIndex;
|
||||||
final int firstIndex;
|
final double minOffset, maxOffset;
|
||||||
final int lastIndex;
|
final double headerExtent, tileExtent;
|
||||||
final IndexedWidgetBuilder builder;
|
final IndexedWidgetBuilder builder;
|
||||||
|
|
||||||
const _SectionLayout({
|
const SectionLayout({
|
||||||
@required this.sectionKey,
|
@required this.sectionKey,
|
||||||
@required this.widgetCount,
|
|
||||||
@required this.firstIndex,
|
@required this.firstIndex,
|
||||||
|
@required this.lastIndex,
|
||||||
|
@required this.minOffset,
|
||||||
|
@required this.maxOffset,
|
||||||
|
@required this.headerExtent,
|
||||||
|
@required this.tileExtent,
|
||||||
@required this.builder,
|
@required this.builder,
|
||||||
}) : lastIndex = firstIndex + widgetCount - 1;
|
});
|
||||||
|
|
||||||
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
||||||
|
|
||||||
|
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
|
||||||
|
|
||||||
|
double indexToLayoutOffset(int index) {
|
||||||
|
return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent);
|
||||||
|
}
|
||||||
|
|
||||||
|
double indexToMaxScrollOffset(int index) {
|
||||||
|
return minOffset + headerExtent + (index - firstIndex) * tileExtent;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||||
|
scrollOffset -= minOffset + headerExtent;
|
||||||
|
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor());
|
||||||
|
}
|
||||||
|
|
||||||
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||||
|
scrollOffset -= minOffset + headerExtent;
|
||||||
|
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO TLAD cache header extent computation?
|
||||||
|
static double computeHeaderExtent(String text, double scrollableWidth) {
|
||||||
|
final para = RenderParagraph(
|
||||||
|
TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: Constants.titleTextStyle,
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(BoxConstraints(maxWidth: scrollableWidth), parentUsesSize: true);
|
||||||
|
return para.getMaxIntrinsicHeight(scrollableWidth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import 'package:aves/model/collection_lens.dart';
|
import 'package:aves/model/collection_lens.dart';
|
||||||
import 'package:aves/model/collection_source.dart';
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/widgets/album/sections.dart';
|
import 'package:aves/widgets/album/grid/header_album.dart';
|
||||||
import 'package:aves/widgets/album/thumbnail.dart';
|
import 'package:aves/widgets/album/thumbnail.dart';
|
||||||
import 'package:aves/widgets/album/transparent_material_page_route.dart';
|
import 'package:aves/widgets/album/transparent_material_page_route.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
|
||||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -60,67 +58,3 @@ class ThumbnailMetadata {
|
||||||
|
|
||||||
const ThumbnailMetadata(this.index, this.entry);
|
const ThumbnailMetadata(this.index, this.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SectionHeader extends StatelessWidget {
|
|
||||||
final CollectionLens collection;
|
|
||||||
final Map<dynamic, List<ImageEntry>> sections;
|
|
||||||
final dynamic sectionKey;
|
|
||||||
|
|
||||||
const SectionHeader({
|
|
||||||
Key key,
|
|
||||||
@required this.collection,
|
|
||||||
@required this.sections,
|
|
||||||
@required this.sectionKey,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Widget header;
|
|
||||||
switch (collection.sortFactor) {
|
|
||||||
case SortFactor.date:
|
|
||||||
if (collection.sortFactor == SortFactor.date) {
|
|
||||||
switch (collection.groupFactor) {
|
|
||||||
case GroupFactor.album:
|
|
||||||
header = _buildAlbumSectionHeader(context);
|
|
||||||
break;
|
|
||||||
case GroupFactor.month:
|
|
||||||
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
|
||||||
break;
|
|
||||||
case GroupFactor.day:
|
|
||||||
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case SortFactor.size:
|
|
||||||
break;
|
|
||||||
case SortFactor.name:
|
|
||||||
header = _buildAlbumSectionHeader(context);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return header != null
|
|
||||||
? IgnorePointer(
|
|
||||||
child: header,
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumSectionHeader(BuildContext context) {
|
|
||||||
var albumIcon = IconUtils.getAlbumIcon(context: context, album: sectionKey as String);
|
|
||||||
if (albumIcon != null) {
|
|
||||||
albumIcon = Material(
|
|
||||||
type: MaterialType.circle,
|
|
||||||
elevation: 3,
|
|
||||||
color: Colors.transparent,
|
|
||||||
shadowColor: Colors.black,
|
|
||||||
child: albumIcon,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final title = CollectionSource.getUniqueAlbumName(sectionKey as String, sections.keys.cast<String>());
|
|
||||||
return TitleSectionHeader(
|
|
||||||
key: ValueKey(title),
|
|
||||||
leading: albumIcon,
|
|
||||||
title: title,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
31
lib/widgets/album/grid/header_album.dart
Normal file
31
lib/widgets/album/grid/header_album.dart
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:aves/widgets/album/grid/header_generic.dart';
|
||||||
|
import 'package:aves/widgets/common/icons.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AlbumSectionHeader extends StatelessWidget {
|
||||||
|
final String folderPath, albumName;
|
||||||
|
|
||||||
|
const AlbumSectionHeader({
|
||||||
|
Key key,
|
||||||
|
@required this.folderPath,
|
||||||
|
@required this.albumName,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath);
|
||||||
|
if (albumIcon != null) {
|
||||||
|
albumIcon = Material(
|
||||||
|
type: MaterialType.circle,
|
||||||
|
elevation: 3,
|
||||||
|
color: Colors.transparent,
|
||||||
|
shadowColor: Colors.black,
|
||||||
|
child: albumIcon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TitleSectionHeader(
|
||||||
|
leading: albumIcon,
|
||||||
|
title: albumName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:aves/utils/constants.dart';
|
|
||||||
import 'package:aves/utils/time_utils.dart';
|
import 'package:aves/utils/time_utils.dart';
|
||||||
import 'package:aves/widgets/common/fx/outlined_text.dart';
|
import 'package:aves/widgets/album/grid/header_generic.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
@ -48,33 +47,3 @@ class MonthSectionHeader extends StatelessWidget {
|
||||||
return TitleSectionHeader(title: text);
|
return TitleSectionHeader(title: text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TitleSectionHeader extends StatelessWidget {
|
|
||||||
final Widget leading;
|
|
||||||
final String title;
|
|
||||||
|
|
||||||
const TitleSectionHeader({Key key, this.leading, this.title}) : super(key: key);
|
|
||||||
|
|
||||||
static const leadingDimension = 32.0;
|
|
||||||
static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: OutlinedText(
|
|
||||||
leadingBuilder: leading != null
|
|
||||||
? (context, isShadow) => Container(
|
|
||||||
padding: leadingPadding,
|
|
||||||
width: leadingDimension,
|
|
||||||
height: leadingDimension,
|
|
||||||
child: isShadow ? null : leading,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
text: title,
|
|
||||||
style: Constants.titleTextStyle,
|
|
||||||
outlineWidth: 2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
95
lib/widgets/album/grid/header_generic.dart
Normal file
95
lib/widgets/album/grid/header_generic.dart
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import 'package:aves/model/collection_lens.dart';
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/utils/constants.dart';
|
||||||
|
import 'package:aves/widgets/album/grid/header_album.dart';
|
||||||
|
import 'package:aves/widgets/album/grid/header_date.dart';
|
||||||
|
import 'package:aves/widgets/common/fx/outlined_text.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SectionHeader extends StatelessWidget {
|
||||||
|
final CollectionLens collection;
|
||||||
|
final Map<dynamic, List<ImageEntry>> sections;
|
||||||
|
final dynamic sectionKey;
|
||||||
|
|
||||||
|
const SectionHeader({
|
||||||
|
Key key,
|
||||||
|
@required this.collection,
|
||||||
|
@required this.sections,
|
||||||
|
@required this.sectionKey,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget header;
|
||||||
|
switch (collection.sortFactor) {
|
||||||
|
case SortFactor.date:
|
||||||
|
if (collection.sortFactor == SortFactor.date) {
|
||||||
|
switch (collection.groupFactor) {
|
||||||
|
case GroupFactor.album:
|
||||||
|
header = _buildAlbumSectionHeader();
|
||||||
|
break;
|
||||||
|
case GroupFactor.month:
|
||||||
|
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||||
|
break;
|
||||||
|
case GroupFactor.day:
|
||||||
|
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SortFactor.size:
|
||||||
|
break;
|
||||||
|
case SortFactor.name:
|
||||||
|
header = _buildAlbumSectionHeader();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return header != null
|
||||||
|
? IgnorePointer(
|
||||||
|
child: header,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumSectionHeader() {
|
||||||
|
final folderPath = sectionKey as String;
|
||||||
|
return AlbumSectionHeader(
|
||||||
|
key: ValueKey(folderPath),
|
||||||
|
folderPath: folderPath,
|
||||||
|
albumName: collection.source.getUniqueAlbumName(folderPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TitleSectionHeader extends StatelessWidget {
|
||||||
|
final Widget leading;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const TitleSectionHeader({Key key, this.leading, this.title}) : super(key: key);
|
||||||
|
|
||||||
|
static const leadingDimension = 32.0;
|
||||||
|
static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4);
|
||||||
|
static const padding = EdgeInsets.all(16);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
padding: padding,
|
||||||
|
constraints: const BoxConstraints(minHeight: leadingDimension),
|
||||||
|
child: OutlinedText(
|
||||||
|
leadingBuilder: leading != null
|
||||||
|
? (context, isShadow) => Container(
|
||||||
|
padding: leadingPadding,
|
||||||
|
width: leadingDimension,
|
||||||
|
height: leadingDimension,
|
||||||
|
child: isShadow ? null : leading,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
text: title,
|
||||||
|
style: Constants.titleTextStyle,
|
||||||
|
outlineWidth: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:aves/model/collection_lens.dart';
|
import 'package:aves/model/collection_lens.dart';
|
||||||
import 'package:aves/model/collection_source.dart';
|
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
@ -68,7 +67,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
|
||||||
_buildFilterRow(
|
_buildFilterRow(
|
||||||
context: context,
|
context: context,
|
||||||
title: 'Albums',
|
title: 'Albums',
|
||||||
filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, CollectionSource.getUniqueAlbumName(s, source.sortedAlbums))).where((f) => containQuery(f.uniqueName)),
|
filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)),
|
||||||
),
|
),
|
||||||
_buildFilterRow(
|
_buildFilterRow(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -5,8 +5,8 @@ import 'package:aves/model/mime_types.dart';
|
||||||
import 'package:aves/widgets/album/collection_app_bar.dart';
|
import 'package:aves/widgets/album/collection_app_bar.dart';
|
||||||
import 'package:aves/widgets/album/collection_list_sliver.dart';
|
import 'package:aves/widgets/album/collection_list_sliver.dart';
|
||||||
import 'package:aves/widgets/album/collection_page.dart';
|
import 'package:aves/widgets/album/collection_page.dart';
|
||||||
import 'package:aves/widgets/album/collection_scaling.dart';
|
|
||||||
import 'package:aves/widgets/album/empty.dart';
|
import 'package:aves/widgets/album/empty.dart';
|
||||||
|
import 'package:aves/widgets/album/grid/scaling.dart';
|
||||||
import 'package:aves/widgets/album/tile_extent_manager.dart';
|
import 'package:aves/widgets/album/tile_extent_manager.dart';
|
||||||
import 'package:aves/widgets/common/scroll_thumb.dart';
|
import 'package:aves/widgets/common/scroll_thumb.dart';
|
||||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||||
|
@ -71,7 +71,8 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
: CollectionListSliver(
|
: CollectionListSliver(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
showHeader: showHeaders,
|
showHeader: showHeaders,
|
||||||
columnCount: (mqSize.width / tileExtent).round(),
|
// TODO TLAD get more precise width, considering MediaQuery padding
|
||||||
|
scrollableWidth: mqSize.width,
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:aves/model/collection_lens.dart';
|
import 'package:aves/model/collection_lens.dart';
|
||||||
import 'package:aves/model/collection_source.dart';
|
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
import 'package:aves/model/filters/mime.dart';
|
||||||
|
@ -53,7 +52,7 @@ class BasicSection extends StatelessWidget {
|
||||||
if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO),
|
if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO),
|
||||||
if (entry.isGif) MimeFilter(MimeTypes.GIF),
|
if (entry.isGif) MimeFilter(MimeTypes.GIF),
|
||||||
if (isFavourite) FavouriteFilter(),
|
if (isFavourite) FavouriteFilter(),
|
||||||
if (album != null) AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, collection?.source?.sortedAlbums)),
|
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
|
||||||
...tags.map((tag) => TagFilter(tag)),
|
...tags.map((tag) => TagFilter(tag)),
|
||||||
]..sort();
|
]..sort();
|
||||||
if (filters.isEmpty) return const SizedBox.shrink();
|
if (filters.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
Loading…
Reference in a new issue