album grid prep
This commit is contained in:
parent
229b2e7b2b
commit
f952deff15
18 changed files with 339 additions and 226 deletions
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -22,7 +23,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
List<ImageEntry> _filteredEntries;
|
||||
List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
Map<dynamic, List<ImageEntry>> sections = Map.unmodifiable({});
|
||||
Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({});
|
||||
|
||||
CollectionLens({
|
||||
@required this.source,
|
||||
|
@ -138,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
case EntrySortFactor.date:
|
||||
switch (groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
sections = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
sections = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.monthTaken);
|
||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken));
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.dayTaken);
|
||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.dayTaken));
|
||||
break;
|
||||
case EntryGroupFactor.none:
|
||||
sections = Map.fromEntries([
|
||||
|
@ -159,8 +160,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
]);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName);
|
||||
final byAlbum = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<AlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
|
41
lib/model/source/section_keys.dart
Normal file
41
lib/model/source/section_keys.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SectionKey {
|
||||
const SectionKey();
|
||||
}
|
||||
|
||||
class AlbumSectionKey extends SectionKey {
|
||||
final String folderPath;
|
||||
|
||||
const AlbumSectionKey(this.folderPath);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AlbumSectionKey && other.folderPath == folderPath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => folderPath.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
|
||||
}
|
||||
|
||||
class DateSectionKey extends SectionKey {
|
||||
final DateTime date;
|
||||
|
||||
const DateSectionKey(this.date);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DateSectionKey && other.date == date;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => date.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{date=$date}';
|
||||
}
|
|
@ -1,17 +1,20 @@
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AlbumSectionHeader extends StatelessWidget {
|
||||
final String folderPath, albumName;
|
||||
|
||||
const AlbumSectionHeader({
|
||||
AlbumSectionHeader({
|
||||
Key key,
|
||||
@required CollectionSource source,
|
||||
@required this.folderPath,
|
||||
@required this.albumName,
|
||||
}) : super(key: key);
|
||||
}) : albumName = source.getUniqueAlbumName(folderPath),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -25,8 +28,8 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
child: albumIcon,
|
||||
);
|
||||
}
|
||||
return TitleSectionHeader(
|
||||
sectionKey: folderPath,
|
||||
return SectionHeader(
|
||||
sectionKey: AlbumSectionKey(folderPath),
|
||||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
||||
|
@ -38,4 +41,15 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, AlbumSectionKey sectionKey) {
|
||||
final folderPath = sectionKey.folderPath;
|
||||
return SectionHeader.getPreferredHeight(
|
||||
context: context,
|
||||
maxWidth: maxWidth,
|
||||
title: source.getUniqueAlbumName(folderPath),
|
||||
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular,
|
||||
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath),
|
||||
);
|
||||
}
|
||||
}
|
74
lib/widgets/collection/grid/headers/any.dart
Normal file
74
lib/widgets/collection/grid/headers/any.dart
Normal file
|
@ -0,0 +1,74 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/album.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/date.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CollectionSectionHeader extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final SectionKey sectionKey;
|
||||
final double height;
|
||||
|
||||
const CollectionSectionHeader({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = _buildHeader();
|
||||
return header != null
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
Widget _buildAlbumHeader() => AlbumSectionHeader(
|
||||
key: ValueKey(sectionKey),
|
||||
source: collection.source,
|
||||
folderPath: (sectionKey as AlbumSectionKey).folderPath,
|
||||
);
|
||||
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
return _buildAlbumHeader();
|
||||
case EntryGroupFactor.month:
|
||||
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date);
|
||||
case EntryGroupFactor.day:
|
||||
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date);
|
||||
case EntryGroupFactor.none:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
return _buildAlbumHeader();
|
||||
case EntrySortFactor.size:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) {
|
||||
var headerExtent = 0.0;
|
||||
if (sectionKey is AlbumSectionKey) {
|
||||
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
||||
headerExtent = AlbumSectionHeader.getPreferredHeight(context, maxWidth, source, sectionKey);
|
||||
}
|
||||
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
headerExtent = max(headerExtent, SectionHeader.leadingDimension * textScaleFactor) + SectionHeader.padding.vertical;
|
||||
return headerExtent;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
|
@ -35,8 +36,8 @@ class DaySectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TitleSectionHeader(
|
||||
sectionKey: date,
|
||||
return SectionHeader(
|
||||
sectionKey: DateSectionKey(date),
|
||||
title: text,
|
||||
);
|
||||
}
|
||||
|
@ -64,8 +65,8 @@ class MonthSectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TitleSectionHeader(
|
||||
sectionKey: date,
|
||||
return SectionHeader(
|
||||
sectionKey: DateSectionKey(date),
|
||||
title: text,
|
||||
);
|
||||
}
|
46
lib/widgets/collection/grid/section_layout.dart
Normal file
46
lib/widgets/collection/grid/section_layout.dart
Normal file
|
@ -0,0 +1,46 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> {
|
||||
final CollectionLens collection;
|
||||
|
||||
const SectionedEntryListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(ImageEntry entry) tileBuilder,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
child: child,
|
||||
);
|
||||
|
||||
@override
|
||||
bool needHeaders() => collection.showHeaders;
|
||||
|
||||
@override
|
||||
Map<SectionKey, List<ImageEntry>> getSections() => collection.sections;
|
||||
|
||||
@override
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||
return CollectionSectionHeader.getPreferredHeight(context, scrollableWidth, collection.source, sectionKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
|
||||
return CollectionSectionHeader(
|
||||
collection: collection,
|
||||
sectionKey: sectionKey,
|
||||
height: headerExtent,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import 'dart:math';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -135,7 +135,8 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||
// so we use custom layout computation instead to find the entry.
|
||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||
return context.read<SectionedListLayout>().getEntryAt(offset);
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
return sectionedListLayout.getEntryAt(offset);
|
||||
}
|
||||
|
||||
void _toggleSelectionToIndex(int toIndex) {
|
||||
|
|
|
@ -2,47 +2,19 @@ import 'package:aves/main.dart';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// 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
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
class CollectionListSliver extends StatelessWidget {
|
||||
const CollectionListSliver();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionLayouts = Provider.of<SectionedListLayout>(context).sectionLayouts;
|
||||
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
|
||||
return SliverKnownExtentList(
|
||||
sectionLayouts: sectionLayouts,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= childCount) return null;
|
||||
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridThumbnail extends StatelessWidget {
|
||||
class InteractiveThumbnail extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final ImageEntry entry;
|
||||
final double tileExtent;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
|
||||
const GridThumbnail({
|
||||
const InteractiveThumbnail({
|
||||
Key key,
|
||||
this.collection,
|
||||
@required this.entry,
|
|
@ -30,12 +30,12 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var child = entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
heroTag: heroTag,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
isScrollingNotifier: isScrollingNotifier,
|
||||
|
|
|
@ -8,14 +8,14 @@ import 'package:aves/widgets/collection/thumbnail/error.dart';
|
|||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThumbnailRasterImage extends StatefulWidget {
|
||||
class RasterImageThumbnail extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final int page;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
final Object heroTag;
|
||||
|
||||
const ThumbnailRasterImage({
|
||||
const RasterImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
|
@ -25,10 +25,10 @@ class ThumbnailRasterImage extends StatefulWidget {
|
|||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ThumbnailRasterImageState createState() => _ThumbnailRasterImageState();
|
||||
_RasterImageThumbnailState createState() => _RasterImageThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
@ -51,7 +51,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ThumbnailRasterImage oldWidget) {
|
||||
void didUpdateWidget(covariant RasterImageThumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.entry != entry) {
|
||||
_unregisterWidget(oldWidget);
|
||||
|
@ -65,12 +65,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(ThumbnailRasterImage widget) {
|
||||
void _registerWidget(RasterImageThumbnail widget) {
|
||||
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
||||
_initProvider();
|
||||
}
|
||||
|
||||
void _unregisterWidget(ThumbnailRasterImage widget) {
|
||||
void _unregisterWidget(RasterImageThumbnail widget) {
|
||||
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
||||
_pauseProvider();
|
||||
}
|
||||
|
|
|
@ -7,12 +7,12 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ThumbnailVectorImage extends StatelessWidget {
|
||||
class VectorImageThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final Object heroTag;
|
||||
|
||||
const ThumbnailVectorImage({
|
||||
const VectorImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
|
|
|
@ -12,12 +12,14 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/app_bar.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
|
@ -98,7 +100,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
highlightable: false,
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = Provider.of<SectionedListLayout>(context, listen: false);
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
||||
|
@ -115,12 +117,12 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
||||
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
thumbnailBuilder: (entry) => GridThumbnail(
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
|
@ -215,7 +217,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
child: _buildEmptyCollectionPlaceholder(collection),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: CollectionListSliver(),
|
||||
: SectionedListSliver<ImageEntry>(),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
|
|
|
@ -1,115 +1,19 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_album.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_date.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final dynamic sectionKey;
|
||||
final double height;
|
||||
|
||||
const SectionHeader({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget header;
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
header = _buildAlbumSectionHeader();
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||
break;
|
||||
case EntryGroupFactor.none:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
header = _buildAlbumSectionHeader();
|
||||
break;
|
||||
}
|
||||
return header != null
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildAlbumSectionHeader() {
|
||||
final folderPath = sectionKey as String;
|
||||
return AlbumSectionHeader(
|
||||
key: ValueKey(folderPath),
|
||||
folderPath: folderPath,
|
||||
albumName: collection.source.getUniqueAlbumName(folderPath),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO TLAD cache header extent computation?
|
||||
static double computeHeaderHeight(BuildContext context, CollectionSource source, dynamic sectionKey, double scrollableWidth) {
|
||||
var headerExtent = 0.0;
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
if (sectionKey is String) {
|
||||
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
||||
final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.regular;
|
||||
final hasTrailing = androidFileUtils.isOnRemovableStorage(sectionKey);
|
||||
final text = source.getUniqueAlbumName(sectionKey);
|
||||
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
|
||||
final para = RenderParagraph(
|
||||
TextSpan(
|
||||
children: [
|
||||
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
|
||||
// so we use a hair space times a magic number to match width
|
||||
TextSpan(
|
||||
text: '\u200A' * (hasLeading ? 23 : 1),
|
||||
// force a higher first line to match leading icon/selector dimension
|
||||
style: TextStyle(height: 2.3 * textScaleFactor),
|
||||
), // 23 hair spaces match a width of 40.0
|
||||
if (hasTrailing) TextSpan(text: '\u200A' * 17),
|
||||
TextSpan(
|
||||
text: text,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaleFactor: textScaleFactor,
|
||||
)..layout(BoxConstraints(maxWidth: maxWidth), parentUsesSize: true);
|
||||
headerExtent = para.getMaxIntrinsicHeight(maxWidth);
|
||||
}
|
||||
headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension * textScaleFactor) + TitleSectionHeader.padding.vertical;
|
||||
return headerExtent;
|
||||
}
|
||||
}
|
||||
|
||||
class TitleSectionHeader extends StatelessWidget {
|
||||
final dynamic sectionKey;
|
||||
final SectionKey sectionKey;
|
||||
final Widget leading, trailing;
|
||||
final String title;
|
||||
|
||||
const TitleSectionHeader({
|
||||
const SectionHeader({
|
||||
Key key,
|
||||
@required this.sectionKey,
|
||||
this.leading,
|
||||
|
@ -136,7 +40,7 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
children: [
|
||||
WidgetSpan(
|
||||
alignment: widgetSpanAlignment,
|
||||
child: SectionSelectableLeading(
|
||||
child: _SectionSelectableLeading(
|
||||
sectionKey: sectionKey,
|
||||
browsingBuilder: leading != null
|
||||
? (context) => Container(
|
||||
|
@ -178,21 +82,54 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
collection.addToSelection(sectionEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO TLAD cache header extent computation?
|
||||
static double getPreferredHeight({
|
||||
@required BuildContext context,
|
||||
@required double maxWidth,
|
||||
@required String title,
|
||||
bool hasLeading = false,
|
||||
bool hasTrailing = false,
|
||||
}) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal;
|
||||
final para = RenderParagraph(
|
||||
TextSpan(
|
||||
children: [
|
||||
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
|
||||
// so we use a hair space times a magic number to match width
|
||||
TextSpan(
|
||||
text: '\u200A' * (hasLeading ? 23 : 1),
|
||||
// force a higher first line to match leading icon/selector dimension
|
||||
style: TextStyle(height: 2.3 * textScaleFactor),
|
||||
), // 23 hair spaces match a width of 40.0
|
||||
if (hasTrailing) TextSpan(text: '\u200A' * 17),
|
||||
TextSpan(
|
||||
text: title,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaleFactor: textScaleFactor,
|
||||
)..layout(BoxConstraints(maxWidth: maxContentWidth), parentUsesSize: true);
|
||||
return para.getMaxIntrinsicHeight(maxContentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionSelectableLeading extends StatelessWidget {
|
||||
final dynamic sectionKey;
|
||||
class _SectionSelectableLeading extends StatelessWidget {
|
||||
final SectionKey sectionKey;
|
||||
final WidgetBuilder browsingBuilder;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const SectionSelectableLeading({
|
||||
const _SectionSelectableLeading({
|
||||
Key key,
|
||||
@required this.sectionKey,
|
||||
@required this.browsingBuilder,
|
||||
@required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
static const leadingDimension = TitleSectionHeader.leadingDimension;
|
||||
static const leadingDimension = SectionHeader.leadingDimension;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
|
@ -1,49 +1,46 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SectionedListLayoutProvider extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final int columnCount;
|
||||
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||
final double scrollableWidth;
|
||||
final int columnCount;
|
||||
final double tileExtent;
|
||||
final Widget Function(ImageEntry entry) thumbnailBuilder;
|
||||
final Widget Function(T entry) tileBuilder;
|
||||
final Widget child;
|
||||
|
||||
const SectionedListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required this.scrollableWidth,
|
||||
@required this.tileExtent,
|
||||
@required this.columnCount,
|
||||
@required this.thumbnailBuilder,
|
||||
@required this.tileExtent,
|
||||
@required this.tileBuilder,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider0<SectionedListLayout>(
|
||||
return ProxyProvider0<SectionedListLayout<T>>(
|
||||
update: (context, __) => _updateLayouts(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
SectionedListLayout _updateLayouts(BuildContext context) {
|
||||
final sectionLayouts = <SectionLayout>[];
|
||||
final showHeaders = collection.showHeaders;
|
||||
final source = collection.source;
|
||||
final sections = collection.sections;
|
||||
SectionedListLayout<T> _updateLayouts(BuildContext context) {
|
||||
final showHeaders = needHeaders();
|
||||
final sections = getSections();
|
||||
final sectionKeys = sections.keys.toList();
|
||||
|
||||
final sectionLayouts = <SectionLayout>[];
|
||||
var currentIndex = 0, currentOffset = 0.0;
|
||||
sectionKeys.forEach((sectionKey) {
|
||||
final sectionEntryCount = sections[sectionKey].length;
|
||||
final section = sections[sectionKey];
|
||||
final sectionEntryCount = section.length;
|
||||
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
|
||||
|
||||
final headerExtent = showHeaders ? SectionHeader.computeHeaderHeight(context, source, sectionKey, scrollableWidth) : 0.0;
|
||||
final headerExtent = showHeaders ? getHeaderExtent(context, sectionKey) : 0.0;
|
||||
|
||||
final sectionFirstIndex = currentIndex;
|
||||
currentIndex += sectionChildCount;
|
||||
|
@ -63,29 +60,30 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
headerExtent: headerExtent,
|
||||
tileExtent: tileExtent,
|
||||
builder: (context, listIndex) => _buildInSection(
|
||||
context,
|
||||
section,
|
||||
listIndex - sectionFirstIndex,
|
||||
collection,
|
||||
sectionKey,
|
||||
headerExtent,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return SectionedListLayout(
|
||||
collection: collection,
|
||||
return SectionedListLayout<T>(
|
||||
sections: sections,
|
||||
showHeaders: showHeaders,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
sectionLayouts: sectionLayouts,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInSection(int sectionChildIndex, CollectionLens collection, dynamic sectionKey, double headerExtent) {
|
||||
Widget _buildInSection(BuildContext context, List<T> section, int sectionChildIndex, SectionKey sectionKey, double headerExtent) {
|
||||
if (sectionChildIndex == 0) {
|
||||
return headerBuilder(collection, sectionKey, headerExtent);
|
||||
return headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink();
|
||||
}
|
||||
sectionChildIndex--;
|
||||
|
||||
final section = collection.sections[sectionKey];
|
||||
final sectionEntryCount = section.length;
|
||||
|
||||
final minEntryIndex = sectionChildIndex * columnCount;
|
||||
|
@ -93,7 +91,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
final children = <Widget>[];
|
||||
for (var i = minEntryIndex; i < maxEntryIndex; i++) {
|
||||
final entry = section[i];
|
||||
children.add(thumbnailBuilder(entry));
|
||||
children.add(tileBuilder(entry));
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
@ -101,39 +99,38 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget headerBuilder(CollectionLens collection, dynamic sectionKey, double headerExtent) {
|
||||
return collection.showHeaders
|
||||
? SectionHeader(
|
||||
collection: collection,
|
||||
sectionKey: sectionKey,
|
||||
height: headerExtent,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
bool needHeaders();
|
||||
|
||||
Map<SectionKey, List<T>> getSections();
|
||||
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey);
|
||||
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent);
|
||||
}
|
||||
|
||||
class SectionedListLayout {
|
||||
final CollectionLens collection;
|
||||
class SectionedListLayout<T> {
|
||||
final Map<SectionKey, List<T>> sections;
|
||||
final bool showHeaders;
|
||||
final int columnCount;
|
||||
final double tileExtent;
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SectionedListLayout({
|
||||
@required this.collection,
|
||||
@required this.sections,
|
||||
@required this.showHeaders,
|
||||
@required this.columnCount,
|
||||
@required this.tileExtent,
|
||||
@required this.sectionLayouts,
|
||||
});
|
||||
|
||||
Rect getTileRect(ImageEntry entry) {
|
||||
final section = collection.sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
|
||||
Rect getTileRect(T entry) {
|
||||
final section = sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
|
||||
if (section == null) return null;
|
||||
|
||||
final sectionKey = section.key;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final showHeaders = collection.showHeaders;
|
||||
final sectionEntryIndex = section.value.indexOf(entry);
|
||||
final column = sectionEntryIndex % columnCount;
|
||||
final row = (sectionEntryIndex / columnCount).floor();
|
||||
|
@ -144,12 +141,12 @@ class SectionedListLayout {
|
|||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||
}
|
||||
|
||||
ImageEntry getEntryAt(Offset position) {
|
||||
T getEntryAt(Offset position) {
|
||||
var dy = position.dy;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final section = collection.sections[sectionLayout.sectionKey];
|
||||
final section = sections[sectionLayout.sectionKey];
|
||||
if (section == null) return null;
|
||||
|
||||
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
|
||||
|
@ -165,7 +162,7 @@ class SectionedListLayout {
|
|||
}
|
||||
|
||||
class SectionLayout {
|
||||
final dynamic sectionKey;
|
||||
final SectionKey sectionKey;
|
||||
final int firstIndex, lastIndex;
|
||||
final double minOffset, maxOffset;
|
||||
final double headerExtent, tileExtent;
|
|
@ -1,32 +1,59 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
|
||||
// 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
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
class SectionedListSliver<T> extends StatelessWidget {
|
||||
const SectionedListSliver();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionLayouts = context.watch<SectionedListLayout<T>>().sectionLayouts;
|
||||
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
|
||||
return _SliverKnownExtentList(
|
||||
sectionLayouts: sectionLayouts,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= childCount) return null;
|
||||
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SliverKnownExtentList({
|
||||
const _SliverKnownExtentList({
|
||||
Key key,
|
||||
@required SliverChildDelegate delegate,
|
||||
@required this.sectionLayouts,
|
||||
}) : super(key: key, delegate: delegate);
|
||||
|
||||
@override
|
||||
RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
|
||||
_RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
|
||||
final element = context as SliverMultiBoxAdaptorElement;
|
||||
return RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
|
||||
return _RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderSliverKnownExtentBoxAdaptor renderObject) {
|
||||
void updateRenderObject(BuildContext context, _RenderSliverKnownExtentBoxAdaptor renderObject) {
|
||||
renderObject.sectionLayouts = sectionLayouts;
|
||||
}
|
||||
}
|
||||
|
||||
class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
List<SectionLayout> _sectionLayouts;
|
||||
|
||||
List<SectionLayout> get sectionLayouts => _sectionLayouts;
|
||||
|
@ -38,7 +65,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
markNeedsLayout();
|
||||
}
|
||||
|
||||
RenderSliverKnownExtentBoxAdaptor({
|
||||
_RenderSliverKnownExtentBoxAdaptor({
|
||||
@required RenderSliverBoxChildManager childManager,
|
||||
@required List<SectionLayout> sectionLayouts,
|
||||
}) : _sectionLayouts = sectionLayouts,
|
|
@ -43,11 +43,11 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
final backgroundImage = entry == null
|
||||
? Container(color: Colors.white)
|
||||
: entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
);
|
||||
|
|
|
@ -27,11 +27,11 @@ class ImageMarker extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbnail = entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
);
|
||||
|
|
|
@ -120,7 +120,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
child: Container(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: ThumbnailRasterImage(
|
||||
child: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
page: page,
|
||||
|
|
Loading…
Reference in a new issue