aves/lib/widgets/viewer/visual/vector.dart
2024-06-17 21:03:55 +02:00

445 lines
14 KiB
Dart

import 'dart:math';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/images.dart';
import 'package:aves/model/settings/enums/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/view_state.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class VectorImageView extends StatefulWidget {
final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final ImageErrorWidgetBuilder errorBuilder;
const VectorImageView({
super.key,
required this.entry,
required this.viewStateNotifier,
required this.errorBuilder,
});
@override
State<VectorImageView> createState() => _VectorImageViewState();
}
class _VectorImageViewState extends State<VectorImageView> {
late Size _displaySize;
bool _isTilingInitialized = false;
late double _minScale;
late double _tileSide;
ImageStream? _fullImageStream;
late ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
ImageInfo? _fullImageInfo;
AvesEntry get entry => widget.entry;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
Rectangle<double> get fullImageRegion => Rectangle<double>(.0, .0, entry.width.toDouble(), entry.height.toDouble());
ImageProvider get fullImageProvider {
assert(_isTilingInitialized);
return entry.getRegion(
scale: _minScale,
region: fullImageRegion,
);
}
@override
void initState() {
super.initState();
_displaySize = entry.displaySize;
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
}
@override
void didUpdateWidget(covariant VectorImageView oldWidget) {
super.didUpdateWidget(oldWidget);
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
}
}
@override
void dispose() {
_fullImageLoaded.dispose();
_unregisterFullImage();
super.dispose();
}
void _registerFullImage() {
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
_fullImageStream!.addListener(_fullImageListener);
}
void _unregisterFullImage() {
_fullImageStream?.removeListener(_fullImageListener);
_fullImageStream = null;
_fullImageInfo?.dispose();
}
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
// implementer is responsible for disposing the provided `ImageInfo`
_unregisterFullImage();
_fullImageInfo = image;
_fullImageLoaded.value = true;
}
@override
Widget build(BuildContext context) {
if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null);
final devicePixelRatio = View.of(context).devicePixelRatio;
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && !_isTilingInitialized) {
_initTiling(
viewportSize: viewportSize!,
devicePixelRatio: devicePixelRatio,
);
}
return SizedBox.fromSize(
size: _displaySize * viewState.scale!,
child: Stack(
alignment: Alignment.center,
children: [
_buildLoading(),
..._getTiles(devicePixelRatio),
],
),
);
},
);
}
void _initTiling({
required Size viewportSize,
required double devicePixelRatio,
}) {
_tileSide = _displaySize.longestSide;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
_minScale = _imageScaleForViewScale(scale: containedScale, devicePixelRatio: devicePixelRatio);
_isTilingInitialized = true;
_registerFullImage();
}
Widget _buildLoading() {
return ValueListenableBuilder<bool>(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (fullImageLoaded) return const SizedBox();
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: thumbnailProvider,
fit: BoxFit.fill,
),
),
);
},
);
}
List<Widget> _getTiles(double devicePixelRatio) {
if (!_isTilingInitialized) return [];
final displayWidth = _displaySize.width;
final displayHeight = _displaySize.height;
final viewRect = _getViewRect(displayWidth, displayHeight);
final viewScale = viewState.scale!;
final background = settings.imageBackground;
Color? backgroundColor;
_BackgroundFrameBuilder? backgroundFrameBuilder;
if (background.isColor) {
backgroundColor = background.color;
} else if (background == EntryBackground.checkered) {
final viewportSize = viewState.viewportSize!;
final viewSize = _displaySize * viewState.scale!;
final backgroundSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
var backgroundOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
backgroundOffset = Offset(max(0, backgroundOffset.dx), max(0, backgroundOffset.dy));
backgroundOffset += ((backgroundSize - viewportSize) as Offset) / 2;
final side = viewportSize.shortestSide;
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
backgroundFrameBuilder = (child, frame, tileRect) {
return frame == null
? const SizedBox()
: DecoratedBox(
decoration: _CheckeredBackgroundDecoration(
viewportSize: viewportSize,
checkSize: checkSize,
offset: backgroundOffset - tileRect.topLeft,
),
child: child,
);
};
}
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image without tiling
final fullImageRegionTile = _RegionTile(
entry: entry,
tileRect: Rect.fromLTWH(0, 0, displayWidth * viewScale, displayHeight * viewScale),
regionRect: fullImageRegion,
scale: _minScale,
backgroundColor: backgroundColor,
backgroundFrameBuilder: backgroundFrameBuilder,
);
final tiles = <Widget>[fullImageRegionTile];
final maxSvgScale = max(_imageScaleForViewScale(scale: viewScale, devicePixelRatio: devicePixelRatio), _minScale);
double nextScale(double scale) => scale * 2;
// add `alpha` to the region side so that tiles do not align across layers,
// which helps the checkered background deflation workaround
// for the tile background bleeding issue
var alpha = 0;
for (var svgScale = nextScale(_minScale); svgScale <= maxSvgScale; svgScale = nextScale(svgScale)) {
final regionSide = (_tileSide + alpha++) / (svgScale / _minScale);
for (var x = .0; x < displayWidth; x += regionSide) {
for (var y = .0; y < displayHeight; y += regionSide) {
final rects = _getTileRects(
x: x,
y: y,
regionSide: regionSide,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: viewScale,
viewRect: viewRect,
);
if (rects != null) {
final (tileRect, regionRect) = rects;
tiles.add(_RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
scale: svgScale,
backgroundColor: backgroundColor,
backgroundFrameBuilder: backgroundFrameBuilder,
));
}
}
}
}
return tiles;
}
Rect _getViewRect(double displayWidth, double displayHeight) {
final scale = viewState.scale!;
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize!;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
return viewOrigin & viewportSize;
}
(Rect tileRect, Rectangle<double> regionRect)? _getTileRects({
required double x,
required double y,
required double regionSide,
required double displayWidth,
required double displayHeight,
required double scale,
required Rect viewRect,
}) {
final nextX = x + regionSide;
final nextY = y + regionSide;
final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = regionSide - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (!viewRect.overlaps(tileRect)) return null;
final regionRect = Rectangle<double>(x, y, thisRegionWidth, thisRegionHeight);
return (tileRect, regionRect);
}
double _imageScaleForViewScale({
required double scale,
required double devicePixelRatio,
}) =>
smallestPowerOf2(scale * devicePixelRatio, allowNegativePower: true).toDouble();
}
typedef _BackgroundFrameBuilder = Widget Function(Widget child, int? frame, Rect tileRect);
class _RegionTile extends StatefulWidget {
final AvesEntry entry;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
final Rect tileRect;
final Rectangle<double> regionRect;
final double scale;
final Color? backgroundColor;
final _BackgroundFrameBuilder? backgroundFrameBuilder;
const _RegionTile({
required this.entry,
required this.tileRect,
required this.regionRect,
required this.scale,
required this.backgroundColor,
required this.backgroundFrameBuilder,
});
@override
State<_RegionTile> createState() => _RegionTileState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('id', entry.id));
properties.add(IntProperty('contentId', entry.contentId));
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<double>>('regionRect', regionRect));
properties.add(DoubleProperty('scale', scale));
}
}
class _RegionTileState extends State<_RegionTile> {
late RegionProvider _provider;
AvesEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.scale != widget.scale || oldWidget.scale != widget.scale) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(_RegionTile widget) {
_initProvider();
}
void _unregisterWidget(_RegionTile widget) {
_pauseProvider();
}
void _initProvider() {
_provider = entry.getRegion(
scale: widget.scale,
region: widget.regionRect,
);
}
void _pauseProvider() => _provider.pause();
@override
Widget build(BuildContext context) {
final tileRect = widget.tileRect;
Widget child = Image(
image: _provider,
frameBuilder: (_, child, frame, __) => widget.backgroundFrameBuilder?.call(child, frame, tileRect) ?? child,
width: tileRect.width,
height: tileRect.height,
color: widget.backgroundColor,
colorBlendMode: BlendMode.dstOver,
fit: BoxFit.fill,
);
return Positioned.fromRect(
rect: tileRect,
child: child,
);
}
}
class _CheckeredBackgroundDecoration extends Decoration {
final Size viewportSize;
final double checkSize;
final Offset offset;
const _CheckeredBackgroundDecoration({
required this.viewportSize,
required this.checkSize,
required this.offset,
});
@override
_CheckeredBackgroundDecorationPainter createBoxPainter([VoidCallback? onChanged]) {
return _CheckeredBackgroundDecorationPainter(this, onChanged);
}
}
class _CheckeredBackgroundDecorationPainter extends BoxPainter {
final _CheckeredBackgroundDecoration decoration;
const _CheckeredBackgroundDecorationPainter(this.decoration, VoidCallback? onChanged) : super(onChanged);
static const deflation = Offset(.5, .5);
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final size = configuration.size;
if (size == null) return;
var decorated = offset & size;
// deflate background as a workaround for background bleeding beyond tile image
decorated = Rect.fromLTRB(
decorated.left + deflation.dx,
decorated.top + deflation.dy,
decorated.right - deflation.dx,
decorated.bottom - deflation.dy,
);
final visible = decorated.intersect(Offset.zero & decoration.viewportSize);
final checkOffset = decoration.offset + decorated.topLeft - visible.topLeft - deflation;
final translation = Offset(max(0, offset.dx + deflation.dx), max(0, offset.dy + deflation.dy));
canvas.translate(translation.dx, translation.dy);
CheckeredPainter(
checkSize: decoration.checkSize,
offset: checkOffset,
).paint(canvas, visible.size);
canvas.translate(-translation.dx, -translation.dy);
}
}