diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 9b54aa18f..42bbfcaae 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -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 _filteredEntries; List _subscriptions = []; - Map> sections = Map.unmodifiable({}); + Map> 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(_filteredEntries, (entry) => entry.directory); + sections = groupBy(_filteredEntries, (entry) => AlbumSectionKey(entry.directory)); break; case EntryGroupFactor.month: - sections = groupBy(_filteredEntries, (entry) => entry.monthTaken); + sections = groupBy(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken)); break; case EntryGroupFactor.day: - sections = groupBy(_filteredEntries, (entry) => entry.dayTaken); + sections = groupBy(_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(_filteredEntries, (entry) => entry.directory); - sections = SplayTreeMap>.of(byAlbum, source.compareAlbumsByName); + final byAlbum = groupBy(_filteredEntries, (entry) => AlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); break; } sections = Map.unmodifiable(sections); diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart new file mode 100644 index 000000000..5af4ef493 --- /dev/null +++ b/lib/model/source/section_keys.dart @@ -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}'; +} diff --git a/lib/widgets/collection/grid/header_album.dart b/lib/widgets/collection/grid/headers/album.dart similarity index 52% rename from lib/widgets/collection/grid/header_album.dart rename to lib/widgets/collection/grid/headers/album.dart index d5ef85b6a..272d80216 100644 --- a/lib/widgets/collection/grid/header_album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -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), + ); + } } diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart new file mode 100644 index 000000000..7091fb389 --- /dev/null +++ b/lib/widgets/collection/grid/headers/any.dart @@ -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; + } +} diff --git a/lib/widgets/collection/grid/header_date.dart b/lib/widgets/collection/grid/headers/date.dart similarity index 87% rename from lib/widgets/collection/grid/header_date.dart rename to lib/widgets/collection/grid/headers/date.dart index d9dcc3533..d1d3a5af9 100644 --- a/lib/widgets/collection/grid/header_date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -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, ); } diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart new file mode 100644 index 000000000..2c55d9cb0 --- /dev/null +++ b/lib/widgets/collection/grid/section_layout.dart @@ -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 { + 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> 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, + ); + } +} diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/collection/grid/selector.dart index 8e7b5e4a3..dda6c2b05 100644 --- a/lib/widgets/collection/grid/selector.dart +++ b/lib/widgets/collection/grid/selector.dart @@ -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().getEntryAt(offset); + final sectionedListLayout = context.read>(); + return sectionedListLayout.getEntryAt(offset); } void _toggleSelectionToIndex(int toIndex) { diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/thumbnail.dart similarity index 55% rename from lib/widgets/collection/grid/list_sliver.dart rename to lib/widgets/collection/grid/thumbnail.dart index 46c83d3ee..64866df0b 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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(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 isScrollingNotifier; - const GridThumbnail({ + const InteractiveThumbnail({ Key key, this.collection, @required this.entry, diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 073656a6a..724000503 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -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, diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 39be15767..660a1159d 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -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 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 { +class _RasterImageThumbnailState extends State { ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider; ImageEntry get entry => widget.entry; @@ -51,7 +51,7 @@ class _ThumbnailRasterImageState extends State { } @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 { 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(); } diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index 5b05bff6c..54cb811b9 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -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, diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index 28adb4b24..403533c2f 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -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(context, listen: false); + final sectionedListLayout = context.read>(); return sectionedListLayout.getTileRect(entry) ?? Rect.zero; }, onScaled: (entry) => Provider.of(context, listen: false).add(entry), @@ -115,12 +117,12 @@ class ThumbnailCollection extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( 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 { child: _buildEmptyCollectionPlaceholder(collection), hasScrollBody: false, ) - : CollectionListSliver(), + : SectionedListSliver(), SliverToBoxAdapter( child: Selector( selector: (context, mq) => mq.viewInsets.bottom, diff --git a/lib/widgets/collection/grid/header_generic.dart b/lib/widgets/common/grid/header.dart similarity index 60% rename from lib/widgets/collection/grid/header_generic.dart rename to lib/widgets/common/grid/header.dart index 2396cf2c3..8b4ad2b1d 100644 --- a/lib/widgets/collection/grid/header_generic.dart +++ b/lib/widgets/common/grid/header.dart @@ -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) { diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/common/grid/section_layout.dart similarity index 72% rename from lib/widgets/collection/grid/list_section_layout.dart rename to lib/widgets/common/grid/section_layout.dart index 08069dcb6..ed1292495 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -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 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( + return ProxyProvider0>( update: (context, __) => _updateLayouts(context), child: child, ); } - SectionedListLayout _updateLayouts(BuildContext context) { - final sectionLayouts = []; - final showHeaders = collection.showHeaders; - final source = collection.source; - final sections = collection.sections; + SectionedListLayout _updateLayouts(BuildContext context) { + final showHeaders = needHeaders(); + final sections = getSections(); final sectionKeys = sections.keys.toList(); + + final sectionLayouts = []; 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( + 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 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 = []; 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> getSections(); + + double getHeaderExtent(BuildContext context, SectionKey sectionKey); + + Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); } -class SectionedListLayout { - final CollectionLens collection; +class SectionedListLayout { + final Map> sections; + final bool showHeaders; final int columnCount; final double tileExtent; final List 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; diff --git a/lib/widgets/collection/grid/list_known_extent.dart b/lib/widgets/common/grid/sliver.dart similarity index 82% rename from lib/widgets/collection/grid/list_known_extent.dart rename to lib/widgets/common/grid/sliver.dart index 6ea65c1f8..e0ab4ebe0 100644 --- a/lib/widgets/collection/grid/list_known_extent.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -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 extends StatelessWidget { + const SectionedListSliver(); + + @override + Widget build(BuildContext context) { + final sectionLayouts = context.watch>().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 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 _sectionLayouts; List get sectionLayouts => _sectionLayouts; @@ -38,7 +65,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { markNeedsLayout(); } - RenderSliverKnownExtentBoxAdaptor({ + _RenderSliverKnownExtentBoxAdaptor({ @required RenderSliverBoxChildManager childManager, @required List sectionLayouts, }) : _sectionLayouts = sectionLayouts, diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index 385b4a37b..17ff41cdd 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -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, ); diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart index daae72f50..9e3a42629 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -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, ); diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index 2f62d1bc1..a5569967d 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -120,7 +120,7 @@ class _MultiPageOverlayState extends State { child: Container( width: extent, height: extent, - child: ThumbnailRasterImage( + child: RasterImageThumbnail( entry: entry, extent: extent, page: page,