rebuild performance review

This commit is contained in:
Thibault Deckers 2021-03-12 10:58:40 +09:00
parent 1def93bd1e
commit df474a3f66
14 changed files with 455 additions and 357 deletions

View file

@ -145,7 +145,7 @@ class Settings extends ChangeNotifier {
double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0;
// do not notify, as tile extents are only used internally by `TileExtentManager`
// do not notify, as tile extents are only used internally by `TileExtentController`
// and should not trigger rebuilding by change notification
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false);

View file

@ -24,127 +24,143 @@ import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class ThumbnailCollection extends StatelessWidget {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
static const columnCountDefault = 4;
static const extentMin = 46.0;
static const spacing = 0.0;
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final scrollView = AnimationLimiter(
child: CollectionScrollView(
scrollableKey: _scrollableKey,
collection: collection,
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: scrollController,
cacheExtent: cacheExtent,
),
);
final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
),
);
return sectionedListLayoutProvider;
},
);
},
child: TileExtentControllerProvider(
controller: TileExtentController(
settingsRouteKey: context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: 4,
extentMin: 46,
spacing: 0,
),
child: _ThumbnailCollectionContent(),
),
);
}
}
class _ThumbnailCollectionContent extends StatelessWidget {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
@override
Widget build(BuildContext context) {
final scrollController = PrimaryScrollController.of(context);
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final scrollView = AnimationLimiter(
child: CollectionScrollView(
scrollableKey: _scrollableKey,
collection: collection,
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: scrollController,
),
);
final scaler = _ThumbnailGridScaleGestureDetector(
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: context.select<TileExtentController, double>((controller) => controller.viewportSize.width),
tileExtent: tileExtent,
columnCount: context.select<TileExtentController, int>((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
);
},
);
return sectionedListLayoutProvider;
},
);
}
}
class _ThumbnailGridScaleGestureDetector extends StatelessWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final Widget child;
const _ThumbnailGridScaleGestureDetector({
@required this.scrollableKey,
@required this.appBarHeightNotifier,
@required this.child,
});
@override
Widget build(BuildContext context) {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileSpacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: child,
);
}
}
class CollectionScrollView extends StatefulWidget {
final GlobalKey scrollableKey;
final CollectionLens collection;
@ -152,7 +168,6 @@ class CollectionScrollView extends StatefulWidget {
final ValueNotifier<double> appBarHeightNotifier;
final ValueNotifier<bool> isScrollingNotifier;
final ScrollController scrollController;
final double cacheExtent;
const CollectionScrollView({
@required this.scrollableKey,
@ -161,7 +176,6 @@ class CollectionScrollView extends StatefulWidget {
@required this.appBarHeightNotifier,
@required this.isScrollingNotifier,
@required this.scrollController,
@required this.cacheExtent,
});
@override
@ -216,7 +230,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
// workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
cacheExtent: widget.cacheExtent,
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax * 2),
slivers: [
appBar,
collection.isEmpty

View file

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AvesCircleBorder {
static const borderColor = Colors.white30;
static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0;
static double _borderWidth(BuildContext context) => context.read<MediaQueryData>().devicePixelRatio > 2 ? 0.5 : 1.0;
static Border build(BuildContext context) {
return Border.fromBorderSide(buildSide(context));

View file

@ -27,6 +27,11 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout<T>>(
update: (context, _) => _updateLayouts(context),
updateShouldNotify: (previous, current) {
final previousLayouts = previous.sectionLayouts;
final currentLayouts = current.sectionLayouts;
return previousLayouts.length != currentLayouts.length || !previousLayouts.every(currentLayouts.contains);
},
child: child,
);
}
@ -138,6 +143,16 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
double getHeaderExtent(BuildContext context, SectionKey sectionKey);
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('scrollableWidth', scrollableWidth));
properties.add(IntProperty('columnCount', columnCount));
properties.add(DoubleProperty('spacing', spacing));
properties.add(DoubleProperty('tileExtent', tileExtent));
properties.add(DiagnosticsProperty<bool>('showHeaders', showHeaders));
}
}
class SectionedListLayout<T> {
@ -237,6 +252,12 @@ class SectionLayout {
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
}
@override
bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing;
@override
int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing);
@override
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}';
}

View file

@ -0,0 +1,32 @@
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class TileExtentControllerProvider extends StatelessWidget {
final TileExtentController controller;
final Widget child;
const TileExtentControllerProvider({
@required this.controller,
@required this.child,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return MultiProvider(
providers: [
ProxyProvider0(
update: (_, __) => constraints.biggest,
),
ProxyProvider<Size, TileExtentController>(
update: (_, viewportSize, __) => controller..applyTileExtent(viewportSize: viewportSize),
),
],
child: child,
);
},
);
}
}

View file

@ -2,9 +2,10 @@ import 'dart:ui' as ui;
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
// metadata to identify entry from RenderObject hit test during collection scaling
class ScalerMetadata<T> {
@ -14,10 +15,8 @@ class ScalerMetadata<T> {
}
class GridScaleGestureDetector<T> extends StatefulWidget {
final TileExtentManager tileExtentManager;
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final Size viewportSize;
final Widget Function(Offset center, double extent, Widget child) gridBuilder;
final Widget Function(T item, double extent) scaledBuilder;
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
@ -25,10 +24,8 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
final Widget child;
const GridScaleGestureDetector({
@required this.tileExtentManager,
@required this.scrollableKey,
@required this.appBarHeightNotifier,
@required this.viewportSize,
this.gridBuilder,
@required this.scaledBuilder,
@required this.getScaledItemTileRect,
@ -47,10 +44,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
OverlayEntry _overlayEntry;
ScalerMetadata<T> _metadata;
TileExtentManager get tileExtentManager => widget.tileExtentManager;
Size get viewportSize => widget.viewportSize;
@override
Widget build(BuildContext context) {
return GestureDetector(
@ -76,8 +69,9 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
// not the same as `MediaQuery.size.width`, because of screen insets/padding
final gridWidth = scrollableBox.size.width;
_extentMin = tileExtentManager.getEffectiveExtentMin(viewportSize);
_extentMax = tileExtentManager.getEffectiveExtentMax(viewportSize);
final tileExtentController = context.read<TileExtentController>();
_extentMin = tileExtentController.effectiveExtentMin;
_extentMax = tileExtentController.effectiveExtentMax;
final halfExtent = _startExtent / 2;
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
@ -105,10 +99,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
}
_applyingScale = true;
final oldExtent = tileExtentManager.extentNotifier.value;
final tileExtentController = context.read<TileExtentController>();
final oldExtent = tileExtentController.extentNotifier.value;
// sanitize and update grid layout if necessary
final newExtent = tileExtentManager.applyTileExtent(
viewportSize: widget.viewportSize,
final newExtent = tileExtentController.applyTileExtent(
userPreferredExtent: _scaledExtentNotifier.value,
);
_scaledExtentNotifier = null;
@ -195,59 +189,61 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: IgnorePointer(
child: AnimatedContainer(
decoration: _init
? BoxDecoration(
gradient: RadialGradient(
center: FractionalOffset.fromOffsetAndSize(center, MediaQuery.of(context).size),
radius: 1,
colors: [
Colors.black,
Colors.black54,
],
),
)
: BoxDecoration(
// provide dummy gradient to lerp to the other one during animation
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
],
),
),
duration: Durations.collectionScalingBackgroundAnimation,
child: ValueListenableBuilder<double>(
valueListenable: widget.scaledExtentNotifier,
builder: (context, extent, child) {
// keep scaled thumbnail within the screen
final xMin = MediaQuery.of(context).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);
}
final clampedCenter = center.translate(dx, 0);
var child = widget.builder(extent);
child = Stack(
children: [
Positioned(
left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2,
child: DefaultTextStyle(
style: TextStyle(),
child: child,
child: Builder(
builder: (context) => IgnorePointer(
child: AnimatedContainer(
decoration: _init
? BoxDecoration(
gradient: RadialGradient(
center: FractionalOffset.fromOffsetAndSize(center, context.select<MediaQueryData, Size>((mq) => mq.size)),
radius: 1,
colors: [
Colors.black,
Colors.black54,
],
),
)
: BoxDecoration(
// provide dummy gradient to lerp to the other one during animation
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
],
),
),
],
);
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
return child;
},
duration: Durations.collectionScalingBackgroundAnimation,
child: ValueListenableBuilder<double>(
valueListenable: widget.scaledExtentNotifier,
builder: (context, extent, child) {
// 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);
}
final clampedCenter = center.translate(dx, 0);
var child = widget.builder(extent);
child = Stack(
children: [
Positioned(
left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2,
child: DefaultTextStyle(
style: TextStyle(),
child: child,
),
),
],
);
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
return child;
},
),
),
),
),

View file

@ -0,0 +1,77 @@
import 'dart:math';
import 'package:aves/model/settings/settings.dart';
import 'package:flutter/widgets.dart';
class TileExtentController {
final String settingsRouteKey;
final int columnCountMin, columnCountDefault;
final double spacing, extentMin, extentMax;
final ValueNotifier<double> extentNotifier;
Size _viewportSize;
Size get viewportSize => _viewportSize;
TileExtentController({
@required this.settingsRouteKey,
@required this.extentNotifier,
this.columnCountMin = 2,
@required this.columnCountDefault,
@required this.extentMin,
this.extentMax = 300,
@required this.spacing,
});
double applyTileExtent({
Size viewportSize,
double userPreferredExtent = 0,
}) {
if (viewportSize != null) {
// sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size)
final viewportSizeMin = Size.square(extentMin * columnCountMin);
_viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height));
}
final oldUserPreferredExtent = settings.getTileExtent(settingsRouteKey);
final currentExtent = extentNotifier.value;
final targetExtent = userPreferredExtent > 0
? userPreferredExtent
: oldUserPreferredExtent > 0
? oldUserPreferredExtent
: currentExtent;
final columnCount = getEffectiveColumnCountForExtent(targetExtent);
final newExtent = _extentForColumnCount(columnCount);
if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) {
settings.setTileExtent(settingsRouteKey, newExtent);
}
if (extentNotifier.value != newExtent) {
extentNotifier.value = newExtent;
}
return newExtent;
}
double _extentMax() => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin);
double _columnCountForExtent(double extent) => (viewportSize.width + spacing) / (extent + spacing);
double _extentForColumnCount(int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount;
int _effectiveColumnCountMin() => _columnCountForExtent(_extentMax()).ceil();
int _effectiveColumnCountMax() => _columnCountForExtent(extentMin).floor();
double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax());
double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin());
int getEffectiveColumnCountForExtent(double extent) {
if (extent > 0) {
final columnCount = _columnCountForExtent(extent);
return columnCount.clamp(_effectiveColumnCountMin(), _effectiveColumnCountMax()).round();
}
return columnCountDefault;
}
}

View file

@ -1,71 +0,0 @@
import 'dart:math';
import 'package:aves/model/settings/settings.dart';
import 'package:flutter/widgets.dart';
class TileExtentManager {
final String settingsRouteKey;
final int columnCountMin, columnCountDefault;
final double spacing, extentMin, extentMax;
final ValueNotifier<double> extentNotifier;
const TileExtentManager({
@required this.settingsRouteKey,
@required this.extentNotifier,
this.columnCountMin = 2,
@required this.columnCountDefault,
@required this.extentMin,
this.extentMax = 300,
@required this.spacing,
});
double applyTileExtent({
@required Size viewportSize,
double userPreferredExtent = 0,
}) {
// sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size)
final viewportSizeMin = Size.square(extentMin * columnCountMin);
viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height));
final oldUserPreferredExtent = settings.getTileExtent(settingsRouteKey);
final currentExtent = extentNotifier.value;
final targetExtent = userPreferredExtent > 0
? userPreferredExtent
: oldUserPreferredExtent > 0
? oldUserPreferredExtent
: currentExtent;
final columnCount = getEffectiveColumnCountForExtent(viewportSize, targetExtent);
final newExtent = _extentForColumnCount(viewportSize, columnCount);
if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) {
settings.setTileExtent(settingsRouteKey, newExtent);
}
if (extentNotifier.value != newExtent) {
extentNotifier.value = newExtent;
}
return newExtent;
}
double _extentMax(Size viewportSize) => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin);
double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing);
double _extentForColumnCount(Size viewportSize, int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount;
int _effectiveColumnCountMin(Size viewportSize) => _columnCountForExtent(viewportSize, _extentMax(viewportSize)).ceil();
int _effectiveColumnCountMax(Size viewportSize) => _columnCountForExtent(viewportSize, extentMin).floor();
double getEffectiveExtentMin(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMax(viewportSize));
double getEffectiveExtentMax(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMin(viewportSize));
int getEffectiveColumnCountForExtent(Size viewportSize, double extent) {
if (extent > 0) {
final columnCount = _columnCountForExtent(viewportSize, extent);
return columnCount.clamp(_effectiveColumnCountMin(viewportSize), _effectiveColumnCountMax(viewportSize)).round();
}
return columnCountDefault;
}
}

View file

@ -1,5 +1,4 @@
import 'package:aves/services/service_policy.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:flutter/material.dart';
class DebugTaskQueueOverlay extends StatelessWidget {
@ -13,9 +12,6 @@ class DebugTaskQueueOverlay extends StatelessWidget {
child: SafeArea(
child: Container(
color: Colors.indigo[900].withAlpha(0xCC),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).effectiveBottomPadding,
),
padding: EdgeInsets.all(8),
child: StreamBuilder<QueueState>(
stream: servicePolicy.queueStream,

View file

@ -14,8 +14,9 @@ import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
@ -25,6 +26,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
typedef QueryTest<T extends CollectionFilter> = Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query);
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
@ -32,17 +35,13 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder;
final String settingsRouteKey;
final Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query) applyQuery;
final double appBarHeight;
final QueryTest<T> applyQuery;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
static const columnCountDefault = 2;
static const extentMin = 60.0;
static const spacing = 8.0;
FilterGridPage({
Key key,
@required this.appBar,
@ -52,12 +51,10 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
this.applyQuery,
@required this.emptyBuilder,
this.settingsRouteKey,
double appBarHeight = kToolbarHeight,
this.appBarHeight = kToolbarHeight,
@required this.onTap,
this.onLongPress,
}) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight;
}
}) : super(key: key);
static const Color detailColor = Color(0xFFE0E0E0);
@ -69,76 +66,25 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
final tileExtentManager = TileExtentManager(
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
}
});
}
final pinnedFilters = settings.pinnedFilters;
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
spacing: spacing,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
filter: filter,
extent: _tileExtentNotifier.value,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
child: _SectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleFilterSections: visibleFilterSections,
emptyBuilder: emptyBuilder,
viewportSize: viewportSize,
tileExtentManager: tileExtentManager,
scrollController: PrimaryScrollController.of(context),
),
),
);
return sectionedListLayoutProvider;
},
);
},
child: TileExtentControllerProvider(
controller: TileExtentController(
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: 2,
extentMin: 60,
spacing: 8,
),
child: _FilterGridPageContent<T>(
appBar: appBar,
filterSections: filterSections,
showHeaders: showHeaders,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
appBarHeight: appBarHeight,
onTap: onTap,
onLongPress: onLongPress,
),
),
),
),
@ -150,13 +96,100 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
}
}
class _FilterGridPageContent<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final bool showHeaders;
final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder;
final QueryTest<T> applyQuery;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
_FilterGridPageContent({
Key key,
@required this.appBar,
@required this.filterSections,
@required this.showHeaders,
@required this.queryNotifier,
@required this.applyQuery,
@required this.emptyBuilder,
@required double appBarHeight,
@required this.onTap,
@required this.onLongPress,
}) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight;
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
}
});
}
final pinnedFilters = settings.pinnedFilters;
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
final columnCount = context.select<TileExtentController, int>((controller) => controller.getEffectiveColumnCountForExtent(tileExtent));
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: context.select<TileExtentController, double>((controller) => controller.viewportSize.width),
tileExtent: tileExtent,
columnCount: columnCount,
spacing: tileSpacing,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
child: _SectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleFilterSections: visibleFilterSections,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context),
),
);
},
);
return sectionedListLayoutProvider;
},
);
}
}
class _SectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
final Widget Function() emptyBuilder;
final Size viewportSize;
final TileExtentManager tileExtentManager;
final ScrollController scrollController;
const _SectionedContent({
@ -164,8 +197,6 @@ class _SectionedContent<T extends CollectionFilter> extends StatefulWidget {
@required this.appBarHeightNotifier,
@required this.visibleFilterSections,
@required this.emptyBuilder,
@required this.viewportSize,
@required this.tileExtentManager,
@required this.scrollController,
});
@ -182,10 +213,6 @@ class _SectionedContentState<T extends CollectionFilter> extends State<_Sectione
Widget Function() get emptyBuilder => widget.emptyBuilder;
Size get viewportSize => widget.viewportSize;
TileExtentManager get tileExtentManager => widget.tileExtentManager;
ScrollController get scrollController => widget.scrollController;
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable');
@ -232,17 +259,15 @@ class _SectionedContentState<T extends CollectionFilter> extends State<_Sectione
@override
Widget build(BuildContext context) {
final pinnedFilters = settings.pinnedFilters;
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<FilterGridItem<T>>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
spacing: tileSpacing,
color: Colors.grey.shade700,
),
child: child,

View file

@ -1,3 +1,4 @@
import 'package:provider/provider.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -170,7 +171,7 @@ class OSMHotLayer extends StatelessWidget {
options: TileLayerOptions(
urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
subdomains: ['a', 'b', 'c'],
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
retinaMode: context.select<MediaQueryData, double>((mq) => mq.devicePixelRatio) > 1,
),
);
}
@ -183,7 +184,7 @@ class StamenTonerLayer extends StatelessWidget {
options: TileLayerOptions(
urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png',
subdomains: ['a', 'b', 'c', 'd'],
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
retinaMode: context.select<MediaQueryData, double>((mq) => mq.devicePixelRatio) > 1,
),
);
}
@ -196,7 +197,7 @@ class StamenWatercolorLayer extends StatelessWidget {
options: TileLayerOptions(
urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg',
subdomains: ['a', 'b', 'c', 'd'],
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
retinaMode: context.select<MediaQueryData, double>((mq) => mq.devicePixelRatio) > 1,
),
);
}

View file

@ -6,6 +6,7 @@ import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class ImageMarker extends StatelessWidget {
final AvesEntry entry;
@ -153,7 +154,7 @@ class _MarkerGeneratorWidgetState extends State<MarkerGeneratorWidget> {
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(MediaQuery.of(context).size.width, 0),
offset: Offset(context.select<MediaQueryData, double>((mq) => mq.size.width), 0),
child: Material(
type: MaterialType.transparency,
child: Stack(
@ -171,7 +172,7 @@ class _MarkerGeneratorWidgetState extends State<MarkerGeneratorWidget> {
}
Future<List<Uint8List>> _getBitmaps(BuildContext context) async {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final pixelRatio = context.read<MediaQueryData>().devicePixelRatio;
return Future.wait(_globalKeys.map((key) async {
RenderRepaintBoundary boundary = key.currentContext.findRenderObject();
final image = await boundary.toImage(pixelRatio: pixelRatio);

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum MetadataThumbnailSource { embedded, exif }
@ -47,7 +48,6 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
future: _loader,
builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
@ -55,7 +55,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
children: snapshot.data.map((bytes) {
return Image.memory(
bytes,
scale: devicePixelRatio,
scale: context.select<MediaQueryData, double>((mq) => mq.devicePixelRatio),
);
}).toList(),
),

View file

@ -3,12 +3,14 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/labeled_checkbox.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class WelcomePage extends StatefulWidget {
@ -30,12 +32,13 @@ class _WelcomePageState extends State<WelcomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16.0),
child: FutureBuilder<String>(
return MediaQueryDataProvider(
child: Scaffold(
body: SafeArea(
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16.0),
child: FutureBuilder<String>(
future: _termsLoader,
builder: (context, snapshot) {
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
@ -59,7 +62,9 @@ class _WelcomePageState extends State<WelcomePage> {
],
),
);
}),
},
),
),
),
),
);
@ -71,7 +76,7 @@ class _WelcomePageState extends State<WelcomePage> {
style: Theme.of(context).textTheme.headline5,
);
return [
...(MediaQuery.of(context).orientation == Orientation.portrait
...(context.select<MediaQueryData, Orientation>((mq) => mq.orientation) == Orientation.portrait
? [
AvesLogo(size: 64),
SizedBox(height: 16),
@ -126,7 +131,7 @@ class _WelcomePageState extends State<WelcomePage> {
child: Text(context.l10n.continueButtonLabel),
);
return MediaQuery.of(context).orientation == Orientation.portrait
return context.select<MediaQueryData, Orientation>((mq) => mq.orientation) == Orientation.portrait
? [
checkboxes,
button,