import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/image_providers/uri_region_provider.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; final Size viewportSize; final ValueNotifier viewStateNotifier; final Widget baseChild; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, @required this.viewportSize, @required this.viewStateNotifier, @required this.baseChild, @required this.errorBuilder, }); @override _TiledImageViewState createState() => _TiledImageViewState(); } class _TiledImageViewState extends State { double _tileSide, _initialScale; int _maxSampleSize; Matrix4 _transform; ImageEntry get entry => widget.entry; Size get viewportSize => widget.viewportSize; ValueNotifier get viewStateNotifier => widget.viewStateNotifier; // margin around visible area to fetch surrounding tiles in advance static const preFetchMargin = 0.0; // magic number used to derive sample size from scale static const scaleFactor = 2.0; @override void initState() { super.initState(); _init(); } @override void didUpdateWidget(TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { _init(); } } void _init() { _tileSide = viewportSize.shortestSide * scaleFactor; _initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height); _maxSampleSize = _sampleSizeForScale(_initialScale); final rotationDegrees = entry.rotationDegrees; final isFlipped = entry.isFlipped; _transform = null; if (rotationDegrees != 0 || isFlipped) { _transform = Matrix4.identity() ..translate(entry.width / 2.0, entry.height / 2.0) ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) ..rotateZ(-toRadians(rotationDegrees.toDouble())) ..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0); } } @override Widget build(BuildContext context) { if (viewStateNotifier == null) return SizedBox.shrink(); final displayWidth = entry.displaySize.width; final displayHeight = entry.displaySize.height; return AnimatedBuilder( animation: viewStateNotifier, builder: (context, child) { final viewState = viewStateNotifier.value; var scale = viewState.scale; if (scale == 0.0) { // for initial scale as `PhotoViewComputedScale.contained` scale = _initialScale; } final centerOffset = viewState.position; final viewOrigin = Offset( ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), ); final viewRect = (viewOrigin & viewportSize).inflate(preFetchMargin); final tiles = []; var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { final layerRegionSize = Size.square(_tileSide * sampleSize); for (var x = 0.0; x < displayWidth; x += layerRegionSize.width) { for (var y = 0.0; y < displayHeight; y += layerRegionSize.height) { final regionOrigin = Offset(x, y); final nextOrigin = regionOrigin.translate(layerRegionSize.width, layerRegionSize.height); final thisRegionSize = Size( layerRegionSize.width - (nextOrigin.dx >= displayWidth ? nextOrigin.dx - displayWidth : 0), layerRegionSize.height - (nextOrigin.dy >= displayHeight ? nextOrigin.dy - displayHeight : 0), ); final tileRect = regionOrigin * scale & thisRegionSize * scale; // only build visible tiles if (viewRect.overlaps(tileRect)) { var regionRect = regionOrigin & thisRegionSize; // apply EXIF orientation if (_transform != null) { regionRect = Rect.fromPoints( MatrixUtils.transformPoint(_transform, regionRect.topLeft), MatrixUtils.transformPoint(_transform, regionRect.bottomRight), ); } tiles.add(RegionTile( entry: entry, tileRect: tileRect, regionRect: regionRect, sampleSize: sampleSize, )); } } } } return Stack( alignment: Alignment.center, children: [ SizedBox( width: displayWidth * scale, height: displayHeight * scale, child: widget.baseChild, ), ...tiles, ], ); }); } int _sampleSizeForScale(double scale) { var sample = 0; if (0 < scale && scale < 1) { sample = highestPowerOf2((1 / scale) / scaleFactor); } return max(1, sample); } } class RegionTile extends StatelessWidget { final ImageEntry entry; // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates final Rect tileRect, regionRect; final int sampleSize; const RegionTile({ @required this.entry, @required this.tileRect, @required this.regionRect, @required this.sampleSize, }); @override Widget build(BuildContext context) { Widget child = Image( image: UriRegion( uri: entry.uri, mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, sampleSize: sampleSize, rect: regionRect, ), width: tileRect.width, height: tileRect.height, fit: BoxFit.fill, // TODO TLAD remove when done with tiling // color: Color.fromARGB((0xff / sampleSize).floor(), 0, 0, 0xff), // colorBlendMode: BlendMode.color, ); // child = Container( // foregroundDecoration: BoxDecoration( // border: Border.all( // color: Colors.cyan, // ), // ), // // child: Text('$sampleSize'), // child: child, // ); // apply EXIF orientation final quarterTurns = entry.rotationDegrees ~/ 90; if (entry.isFlipped) { final rotated = quarterTurns % 2 != 0; final w = (rotated ? tileRect.height : tileRect.width) / 2.0; final h = (rotated ? tileRect.width : tileRect.height) / 2.0; final flipper = Matrix4.identity() ..translate(w, h) ..scale(-1.0, 1.0, 1.0) ..translate(-w, -h); child = Transform( transform: flipper, child: child, ); } if (quarterTurns != 0) { child = RotatedBox( quarterTurns: quarterTurns, child: child, ); } return Positioned.fromRect( rect: tileRect, child: child, ); } }