#38 changed decorated filter layout, with label below cover

This commit is contained in:
Thibault Deckers 2021-09-26 11:41:03 +09:00
parent 8f2a0a8247
commit 5939668fd5
9 changed files with 318 additions and 216 deletions

View file

@ -79,18 +79,17 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedEntryListLayoutProvider(
return Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return GridTheme(
extent: tileExtent,
child: SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
@ -104,16 +103,18 @@ class _CollectionGridContent extends StatelessWidget {
isScrollingNotifier: _isScrollingNotifier,
),
tileAnimationDelay: tileAnimationDelay,
child: _CollectionSectionedContent(
collection: collection,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context)!,
),
);
},
),
child: child!,
),
);
},
child: child,
);
},
child: _CollectionSectionedContent(
collection: collection,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context)!,
),
);
return sectionedListLayoutProvider;
},
@ -199,10 +200,11 @@ class _CollectionScaler extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey,
gridBuilder: (center, extent, child) => CustomPaint(
heightForWidth: (width) => width,
gridBuilder: (center, tileSize, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
tileSize: tileSize,
spacing: tileSpacing,
borderWidth: DecoratedThumbnail.borderWidth,
borderRadius: Radius.zero,
@ -210,7 +212,7 @@ class _CollectionScaler extends StatelessWidget {
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
scaledBuilder: (entry, tileSize) => DecoratedThumbnail(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,

View file

@ -23,7 +23,8 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: spacing,
tileExtent: tileExtent,
tileWidth: tileExtent,
tileHeight: tileExtent,
tileBuilder: tileBuilder,
tileAnimationDelay: tileAnimationDelay,
child: child,

View file

@ -23,7 +23,7 @@ class DraggableThumbLabel<T> extends StatelessWidget {
final section = sll.sections[sectionLayout.sectionKey]!;
final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent);
final itemIndex = dy < 0 ? 0 : (dy ~/ (sll.tileExtent + sll.spacing)) * sll.columnCount;
final itemIndex = dy < 0 ? 0 : (dy ~/ (sll.tileHeight + sll.spacing)) * sll.columnCount;
final item = section[itemIndex];
final lines = lineBuilder(context, item);

View file

@ -13,7 +13,7 @@ import 'package:provider/provider.dart';
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
final double scrollableWidth;
final int columnCount;
final double spacing, tileExtent;
final double spacing, tileWidth, tileHeight;
final Widget Function(T item) tileBuilder;
final Duration tileAnimationDelay;
final Widget child;
@ -23,7 +23,8 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
required this.scrollableWidth,
required this.columnCount,
required this.spacing,
required this.tileExtent,
required this.tileWidth,
required this.tileHeight,
required this.tileBuilder,
required this.tileAnimationDelay,
required this.child,
@ -60,7 +61,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
final sectionLastIndex = currentIndex - 1;
final sectionMinOffset = currentOffset;
currentOffset += headerExtent + tileExtent * rowCount + spacing * (rowCount - 1);
currentOffset += headerExtent + tileHeight * rowCount + spacing * (rowCount - 1);
final sectionMaxOffset = currentOffset;
sectionLayouts.add(
@ -71,7 +72,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
minOffset: sectionMinOffset,
maxOffset: sectionMaxOffset,
headerExtent: headerExtent,
tileExtent: tileExtent,
tileHeight: tileHeight,
spacing: spacing,
builder: (context, listIndex) => _buildInSection(
context,
@ -89,7 +90,8 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
sections: _sections,
showHeaders: _showHeaders,
columnCount: columnCount,
tileExtent: tileExtent,
tileWidth: tileWidth,
tileHeight: tileHeight,
spacing: spacing,
sectionLayouts: sectionLayouts,
);
@ -123,7 +125,8 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
children.add(animate ? _buildAnimation(itemGridIndex, item) : item);
}
return _GridRow(
extent: tileExtent,
width: tileWidth,
height: tileHeight,
spacing: spacing,
children: children,
);
@ -158,7 +161,8 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
properties.add(DoubleProperty('scrollableWidth', scrollableWidth));
properties.add(IntProperty('columnCount', columnCount));
properties.add(DoubleProperty('spacing', spacing));
properties.add(DoubleProperty('tileExtent', tileExtent));
properties.add(DoubleProperty('tileWidth', tileWidth));
properties.add(DoubleProperty('tileHeight', tileHeight));
properties.add(DiagnosticsProperty<bool>('showHeaders', showHeaders));
}
}
@ -167,14 +171,15 @@ class SectionedListLayout<T> {
final Map<SectionKey, List<T>> sections;
final bool showHeaders;
final int columnCount;
final double tileExtent, spacing;
final double tileWidth, tileHeight, spacing;
final List<SectionLayout> sectionLayouts;
const SectionedListLayout({
required this.sections,
required this.showHeaders,
required this.columnCount,
required this.tileExtent,
required this.tileWidth,
required this.tileHeight,
required this.spacing,
required this.sectionLayouts,
});
@ -192,9 +197,9 @@ class SectionedListLayout<T> {
final row = (sectionItemIndex / columnCount).floor();
final listIndex = sectionLayout.firstIndex + 1 + row;
final left = tileExtent * column + spacing * (column - 1);
final left = tileWidth * column + spacing * (column - 1);
final top = sectionLayout.indexToLayoutOffset(listIndex);
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
return Rect.fromLTWH(left, top, tileWidth, tileHeight);
}
SectionLayout? getSectionAt(double offsetY) => sectionLayouts.firstWhereOrNull((sl) => offsetY < sl.maxOffset);
@ -210,8 +215,8 @@ class SectionedListLayout<T> {
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
if (dy < 0) return null;
final row = dy ~/ (tileExtent + spacing);
final column = position.dx ~/ (tileExtent + spacing);
final row = dy ~/ (tileHeight + spacing);
final column = position.dx ~/ (tileWidth + spacing);
final index = row * columnCount + column;
if (index >= section.length) return null;
@ -219,7 +224,7 @@ class SectionedListLayout<T> {
}
@override
String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}';
String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileWidth=$tileWidth, tileHeight=$tileHeight}';
}
@immutable
@ -227,11 +232,11 @@ class SectionLayout extends Equatable {
final SectionKey sectionKey;
final int firstIndex, lastIndex, bodyFirstIndex;
final double minOffset, maxOffset, bodyMinOffset;
final double headerExtent, tileExtent, spacing, mainAxisStride;
final double headerExtent, tileHeight, spacing, mainAxisStride;
final IndexedWidgetBuilder builder;
@override
List<Object?> get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing];
List<Object?> get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileHeight, spacing];
const SectionLayout({
required this.sectionKey,
@ -240,12 +245,12 @@ class SectionLayout extends Equatable {
required this.minOffset,
required this.maxOffset,
required this.headerExtent,
required this.tileExtent,
required this.tileHeight,
required this.spacing,
required this.builder,
}) : bodyFirstIndex = firstIndex + 1,
bodyMinOffset = minOffset + headerExtent,
mainAxisStride = tileExtent + spacing;
mainAxisStride = tileHeight + spacing;
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
@ -271,11 +276,12 @@ class SectionLayout extends Equatable {
}
class _GridRow extends MultiChildRenderObjectWidget {
final double extent, spacing;
final double width, height, spacing;
_GridRow({
Key? key,
required this.extent,
required this.width,
required this.height,
required this.spacing,
required List<Widget> children,
}) : super(key: key, children: children);
@ -283,21 +289,24 @@ class _GridRow extends MultiChildRenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGridRow(
extent: extent,
width: width,
height: height,
spacing: spacing,
);
}
@override
void updateRenderObject(BuildContext context, _RenderGridRow renderObject) {
renderObject.extent = extent;
renderObject.width = width;
renderObject.height = height;
renderObject.spacing = spacing;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('extent', extent));
properties.add(DoubleProperty('width', width));
properties.add(DoubleProperty('height', height));
properties.add(DoubleProperty('spacing', spacing));
}
}
@ -307,19 +316,30 @@ class _GridRowParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox, _GridRowParentData>, RenderBoxContainerDefaultsMixin<RenderBox, _GridRowParentData> {
_RenderGridRow({
List<RenderBox>? children,
required double extent,
required double width,
required double height,
required double spacing,
}) : _extent = extent,
}) : _width = width,
_height = height,
_spacing = spacing {
addAll(children);
}
double get extent => _extent;
double _extent;
double get width => _width;
double _width;
set extent(double value) {
if (_extent == value) return;
_extent = value;
set width(double value) {
if (_width == value) return;
_width = value;
markNeedsLayout();
}
double get height => _height;
double _height;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
@ -339,7 +359,7 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox
}
}
double get intrinsicWidth => extent * childCount + spacing * (childCount - 1);
double get intrinsicWidth => width * childCount + spacing * (childCount - 1);
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@ -348,10 +368,10 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => extent;
double computeMinIntrinsicHeight(double width) => height;
@override
double computeMaxIntrinsicHeight(double width) => extent;
double computeMaxIntrinsicHeight(double width) => height;
@override
void performLayout() {
@ -360,14 +380,14 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox
size = constraints.smallest;
return;
}
size = Size(constraints.maxWidth, extent);
final childConstraints = BoxConstraints.tight(Size(extent, extent));
size = Size(constraints.maxWidth, height);
final childConstraints = BoxConstraints.tight(Size(width, height));
var offset = Offset.zero;
while (child != null) {
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _GridRowParentData;
childParentData.offset = offset;
offset += Offset(extent + spacing, 0);
offset += Offset(width + spacing, 0);
child = childParentData.nextSibling;
}
}
@ -390,7 +410,8 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('extent', extent));
properties.add(DoubleProperty('width', width));
properties.add(DoubleProperty('height', height));
properties.add(DoubleProperty('spacing', spacing));
}
}

View file

@ -6,8 +6,8 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -18,14 +18,28 @@ typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFil
enum HeroType { always, onTap, never }
@immutable
class AvesFilterDecoration {
final Widget widget;
final Radius radius;
const AvesFilterDecoration({
required this.widget,
required this.radius,
});
BorderRadius get textBorderRadius => BorderRadius.vertical(bottom: radius);
BorderRadius get chipBorderRadius => BorderRadius.all(radius);
}
class AvesFilterChip extends StatefulWidget {
final CollectionFilter filter;
final bool removable;
final bool showGenericIcon;
final Widget? background;
final AvesFilterDecoration? decoration;
final String? banner;
final Widget? details;
final BorderRadius? borderRadius;
final double padding;
final HeroType heroType;
final FilterCallback? onTap;
@ -37,16 +51,18 @@ class AvesFilterChip extends StatefulWidget {
static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80;
static const double maxChipWidth = 160;
static const double iconSize = 18;
static const double fontSize = 14;
static const double decoratedContentVerticalPadding = 5;
const AvesFilterChip({
Key? key,
required this.filter,
this.removable = false,
this.showGenericIcon = true,
this.background,
this.decoration,
this.banner,
this.details,
this.borderRadius,
this.padding = 6.0,
this.heroType = HeroType.onTap,
this.onTap,
@ -140,15 +156,15 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
@override
Widget build(BuildContext context) {
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = 20 * textScaleFactor;
final hasBackground = widget.background != null;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
final decoration = widget.decoration;
Widget content = Row(
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
mainAxisSize: decoration != null ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) ...[
@ -158,6 +174,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
Flexible(
child: Text(
filter.getLabel(context),
style: const TextStyle(
fontSize: AvesFilterChip.fontSize,
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
@ -170,36 +189,37 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
],
);
if (widget.details != null) {
final details = widget.details;
if (details != null) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
content,
Flexible(child: widget.details!),
Flexible(child: details),
],
);
}
content = Padding(
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2),
child: content,
);
if (hasBackground) {
content = Center(
child: ColoredBox(
color: Colors.black54,
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2!.copyWith(
shadows: Constants.embossShadows,
),
if (decoration != null) {
content = Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
borderRadius: decoration.textBorderRadius,
child: Container(
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: AvesFilterChip.decoratedContentVerticalPadding),
color: chipBackground,
child: content,
),
),
);
} else {
content = Padding(
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2),
child: content,
);
}
final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner;
Widget chip = Container(
constraints: const BoxConstraints(
@ -210,13 +230,13 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
child: Stack(
fit: StackFit.passthrough,
children: [
if (hasBackground)
if (decoration != null)
ClipRRect(
borderRadius: borderRadius,
child: widget.background,
borderRadius: decoration.chipBorderRadius,
child: decoration.widget,
),
Material(
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
color: decoration != null ? Colors.transparent : chipBackground,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
@ -248,7 +268,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
),
position: DecorationPosition.foreground,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8),
child: content,
),
);
@ -279,9 +299,11 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
chip = Hero(
tag: filter,
transitionOnUserGestures: true,
child: DefaultTextStyle(
style: const TextStyle(),
child: chip,
child: MediaQueryDataProvider(
child: DefaultTextStyle(
style: const TextStyle(),
child: chip,
),
),
);
}

View file

@ -19,14 +19,16 @@ class ScalerMetadata<T> {
class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
final Widget Function(T item, double extent) scaledBuilder;
final double Function(double width) heightForWidth;
final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder;
final Widget Function(T item, Size tileSize) scaledBuilder;
final Object Function(T item)? highlightItem;
final Widget child;
const GridScaleGestureDetector({
Key? key,
required this.scrollableKey,
required this.heightForWidth,
required this.gridBuilder,
required this.scaledBuilder,
this.highlightItem,
@ -38,9 +40,10 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
}
class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T>> {
double? _startExtent, _extentMin, _extentMax;
Size? _startSize;
double? _extentMin, _extentMax;
bool _applyingScale = false;
ValueNotifier<double>? _scaledExtentNotifier;
ValueNotifier<Size>? _scaledSizeNotifier;
OverlayEntry? _overlayEntry;
ScalerMetadata<T>? _metadata;
@ -63,8 +66,8 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
// abort if we cannot find an image to show on overlay
if (renderMetaData == null) return;
_metadata = renderMetaData.metaData;
_startExtent = renderMetaData.size.width;
_scaledExtentNotifier = ValueNotifier(_startExtent!);
_startSize = renderMetaData.size;
_scaledSizeNotifier = ValueNotifier(_startSize!);
// not the same as `MediaQuery.size.width`, because of screen insets/padding
final gridWidth = scrollableBox.size.width;
@ -73,33 +76,33 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
_extentMin = tileExtentController.effectiveExtentMin;
_extentMax = tileExtentController.effectiveExtentMax;
final halfExtent = _startExtent! / 2;
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
final halfSize = _startSize! / 2;
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height));
_overlayEntry = OverlayEntry(
builder: (context) => ScaleOverlay(
builder: (extent) => SizedBox(
width: extent,
height: extent,
builder: (scaledTileSize) => SizedBox.fromSize(
size: scaledTileSize,
child: GridTheme(
extent: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
extent: scaledTileSize.width,
child: widget.scaledBuilder(_metadata!.item, scaledTileSize),
),
),
center: thumbnailCenter,
viewportWidth: gridWidth,
gridBuilder: widget.gridBuilder,
scaledExtentNotifier: _scaledExtentNotifier!,
scaledSizeNotifier: _scaledSizeNotifier!,
),
);
Overlay.of(scrollableContext)!.insert(_overlayEntry!);
},
onScaleUpdate: (details) {
if (_scaledExtentNotifier == null) return;
if (_scaledSizeNotifier == null) return;
final s = details.scale;
_scaledExtentNotifier!.value = (_startExtent! * s).clamp(_extentMin!, _extentMax!);
final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!);
_scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth));
},
onScaleEnd: (details) {
if (_scaledExtentNotifier == null) return;
if (_scaledSizeNotifier == null) return;
if (_overlayEntry != null) {
_overlayEntry!.remove();
_overlayEntry = null;
@ -109,8 +112,8 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
final tileExtentController = context.read<TileExtentController>();
final oldExtent = tileExtentController.extentNotifier.value;
// sanitize and update grid layout if necessary
final newExtent = tileExtentController.setUserPreferredExtent(_scaledExtentNotifier!.value);
_scaledExtentNotifier = null;
final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width);
_scaledSizeNotifier = null;
if (newExtent == oldExtent) {
_applyingScale = false;
} else {
@ -138,18 +141,18 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
}
class ScaleOverlay extends StatefulWidget {
final Widget Function(double extent) builder;
final Widget Function(Size scaledTileSize) builder;
final Offset center;
final double viewportWidth;
final ValueNotifier<double> scaledExtentNotifier;
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
final ValueNotifier<Size> scaledSizeNotifier;
final Widget Function(Offset center, Size extent, Widget child) gridBuilder;
const ScaleOverlay({
Key? key,
required this.builder,
required this.center,
required this.viewportWidth,
required this.scaledExtentNotifier,
required this.scaledSizeNotifier,
required this.gridBuilder,
}) : super(key: key);
@ -197,26 +200,28 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
),
),
duration: Durations.collectionScalingBackgroundAnimation,
child: ValueListenableBuilder<double>(
valueListenable: widget.scaledExtentNotifier,
builder: (context, extent, child) {
child: ValueListenableBuilder<Size>(
valueListenable: widget.scaledSizeNotifier,
builder: (context, scaledSize, child) {
final width = scaledSize.width;
final height = scaledSize.height;
// keep scaled thumbnail within the screen
final xMin = context.select<MediaQueryData, double>((mq) => mq.padding.left);
final xMax = xMin + gridWidth;
var dx = .0;
if (center.dx - extent / 2 < xMin) {
dx = xMin - (center.dx - extent / 2);
} else if (center.dx + extent / 2 > xMax) {
dx = xMax - (center.dx + extent / 2);
if (center.dx - width / 2 < xMin) {
dx = xMin - (center.dx - width / 2);
} else if (center.dx + width / 2 > xMax) {
dx = xMax - (center.dx + width / 2);
}
final clampedCenter = center.translate(dx, 0);
var child = widget.builder(extent);
var child = widget.builder(scaledSize);
child = Stack(
children: [
Positioned(
left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2,
left: clampedCenter.dx - width / 2,
top: clampedCenter.dy - height / 2,
child: DefaultTextStyle(
style: const TextStyle(),
child: child,
@ -224,7 +229,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
),
],
);
child = widget.gridBuilder(clampedCenter, extent, child);
child = widget.gridBuilder(clampedCenter, scaledSize, child);
return child;
},
),
@ -237,13 +242,14 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
class GridPainter extends CustomPainter {
final Offset center;
final double extent, spacing, borderWidth;
final Size tileSize;
final double spacing, borderWidth;
final Radius borderRadius;
final Color color;
const GridPainter({
required this.center,
required this.extent,
required this.tileSize,
required this.spacing,
required this.borderWidth,
required this.borderRadius,
@ -252,12 +258,15 @@ class GridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final tileWidth = tileSize.width;
final tileHeight = tileSize.height;
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = borderWidth
..shader = ui.Gradient.radial(
center,
extent * 2,
tileWidth * 2,
[
color,
Colors.transparent,
@ -271,17 +280,18 @@ class GridPainter extends CustomPainter {
..style = PaintingStyle.fill
..color = color.withOpacity(.25);
final delta = extent + spacing;
final deltaX = tileWidth + spacing;
final deltaY = tileHeight + spacing;
for (var i = -2; i <= 2; i++) {
final dx = delta * i;
final dx = deltaX * i;
for (var j = -2; j <= 2; j++) {
if (i == 0 && j == 0) continue;
final dy = delta * j;
final dy = deltaY * j;
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: center + Offset(dx, dy),
width: extent,
height: extent,
width: tileWidth,
height: tileHeight,
),
borderRadius,
);

View file

@ -12,6 +12,7 @@ import 'package:aves/model/source/tag.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/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
@ -40,6 +41,22 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
}) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key);
static double tileHeight({required double extent, required double textScaleFactor}) {
return extent + infoHeight(extent: extent, textScaleFactor: textScaleFactor);
}
static double infoHeight({required double extent, required double textScaleFactor}) {
// height can actually be a little larger or smaller, when info includes icons or non-latin scripts
// but it's not worth measuring text metrics, as the widget is flexible enough to absorb the difference
return (AvesFilterChip.fontSize + detailFontSize(extent) + 4) * textScaleFactor + AvesFilterChip.decoratedContentVerticalPadding * 2;
}
static Radius radius(double extent) => Radius.circular(min<double>(AvesFilterChip.defaultRadius, extent / 4));
static double detailIconSize(double extent) => min<double>(AvesFilterChip.fontSize, extent / 8);
static double detailFontSize(double extent) => min<double>(AvesFilterChip.fontSize, extent / 6);
@override
Widget build(BuildContext context) {
return Consumer<CollectionSource>(
@ -50,7 +67,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final album = (filter as AlbumFilter).album;
return StreamBuilder<AlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<AlbumSummaryInvalidatedEvent>().where((event) => event.directories == null || event.directories!.contains(album)),
builder: (context, snapshot) => _buildChip(source),
builder: (context, snapshot) => _buildChip(context, source),
);
}
case LocationFilter:
@ -58,7 +75,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final countryCode = (filter as LocationFilter).countryCode;
return StreamBuilder<CountrySummaryInvalidatedEvent>(
stream: source.eventBus.on<CountrySummaryInvalidatedEvent>().where((event) => event.countryCodes == null || event.countryCodes!.contains(countryCode)),
builder: (context, snapshot) => _buildChip(source),
builder: (context, snapshot) => _buildChip(context, source),
);
}
case TagFilter:
@ -66,7 +83,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final tag = (filter as TagFilter).tag;
return StreamBuilder<TagSummaryInvalidatedEvent>(
stream: source.eventBus.on<TagSummaryInvalidatedEvent>().where((event) => event.tags == null || event.tags!.contains(tag)),
builder: (context, snapshot) => _buildChip(source),
builder: (context, snapshot) => _buildChip(context, source),
);
}
default:
@ -76,38 +93,53 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
);
}
static Radius radius(double extent) => Radius.circular(min<double>(AvesFilterChip.defaultRadius, extent / 4));
Widget _buildChip(CollectionSource source) {
Widget _buildChip(BuildContext context, CollectionSource source) {
final entry = coverEntry ?? source.coverEntry(filter);
final backgroundImage = entry == null
? Container(color: Colors.white)
: ThumbnailImage(
entry: entry,
extent: thumbnailExtent,
);
final titlePadding = min<double>(4.0, extent / 32);
return SizedBox(
width: extent,
height: extent,
child: AvesFilterChip(
filter: filter,
showGenericIcon: false,
background: backgroundImage,
banner: banner,
details: _buildDetails(source, filter),
borderRadius: BorderRadius.all(radius(extent)),
padding: titlePadding,
onTap: onTap,
onLongPress: null,
return AvesFilterChip(
filter: filter,
showGenericIcon: false,
decoration: AvesFilterDecoration(
widget: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.textScaleFactor,
builder: (context, textScaleFactor, child) {
return Padding(
padding: EdgeInsets.only(bottom: infoHeight(extent: extent, textScaleFactor: textScaleFactor)),
child: child,
);
},
child: entry == null
? Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white,
stringToColor(filter.getLabel(context)),
],
),
),
)
: ThumbnailImage(
entry: entry,
extent: thumbnailExtent,
),
),
radius: radius(extent),
),
banner: banner,
details: _buildDetails(source, filter),
padding: titlePadding,
onTap: onTap,
onLongPress: null,
);
}
Widget _buildDetails(CollectionSource source, T filter) {
final padding = min<double>(8.0, extent / 16);
final iconSize = min<double>(14.0, extent / 8);
final fontSize = min<double>(14.0, extent / 6);
final iconSize = detailIconSize(extent);
final fontSize = detailFontSize(extent);
return Row(
mainAxisSize: MainAxisSize.min,
children: [

View file

@ -238,56 +238,66 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedFilterListLayoutProvider<T>(
sections: visibleSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
return MetaData(
metaData: ScalerMetadata(gridItem),
child: FilterChipGridDecorator<T, FilterGridItem<T>>(
gridItem: gridItem,
extent: tileExtent,
child: CoveredFilterChip(
key: Key(filter.key),
filter: filter,
return Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return Selector<MediaQueryData, double>(
selector: (context, mq) => mq.textScaleFactor,
builder: (context, textScaleFactor, child) {
final tileHeight = CoveredFilterChip.tileHeight(extent: tileExtent, textScaleFactor: textScaleFactor);
return GridTheme(
extent: tileExtent,
child: SectionedFilterListLayoutProvider<T>(
sections: visibleSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileWidth: tileExtent,
tileHeight: tileHeight,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
return MetaData(
metaData: ScalerMetadata(gridItem),
child: FilterChipGridDecorator<T, FilterGridItem<T>>(
gridItem: gridItem,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
onTap: onTap,
child: CoveredFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
onTap: onTap,
),
),
),
);
},
tileAnimationDelay: tileAnimationDelay,
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleSections: visibleSections,
sortFactor: sortFactor,
selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
);
},
tileAnimationDelay: tileAnimationDelay,
child: child!,
),
);
}),
},
child: child,
);
},
child: child,
);
},
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleSections: visibleSections,
sortFactor: sortFactor,
selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
return sectionedListLayoutProvider;
},
@ -399,24 +409,26 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) {
final pinnedFilters = settings.pinnedFilters;
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
final textScaleFactor = context.select<MediaQueryData, double>((mq) => mq.textScaleFactor);
return GridScaleGestureDetector<FilterGridItem<T>>(
scrollableKey: scrollableKey,
gridBuilder: (center, extent, child) => CustomPaint(
heightForWidth: (width) => CoveredFilterChip.tileHeight(extent: width, textScaleFactor: textScaleFactor),
gridBuilder: (center, tileSize, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
tileSize: tileSize,
spacing: tileSpacing,
borderWidth: AvesFilterChip.outlineWidth,
borderRadius: CoveredFilterChip.radius(extent),
borderRadius: CoveredFilterChip.radius(tileSize.width),
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
scaledBuilder: (item, tileSize) {
final filter = item.filter;
return CoveredFilterChip(
filter: filter,
extent: extent,
extent: tileSize.width,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
pinned: pinnedFilters.contains(filter),
);

View file

@ -13,7 +13,8 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
required double scrollableWidth,
required int columnCount,
required double spacing,
required double tileExtent,
required double tileWidth,
required double tileHeight,
required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
required Duration tileAnimationDelay,
required Widget child,
@ -22,7 +23,8 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: spacing,
tileExtent: tileExtent,
tileWidth: tileWidth,
tileHeight: tileHeight,
tileBuilder: tileBuilder,
tileAnimationDelay: tileAnimationDelay,
child: child,