SVG migration: viewer
This commit is contained in:
parent
92178ca409
commit
88d3fa7991
19 changed files with 654 additions and 160 deletions
|
@ -7,6 +7,7 @@ import com.bumptech.glide.Glide
|
|||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
|
@ -113,6 +114,13 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
val regionRect = Rect(x, y, x + width, y + height)
|
||||
when (mimeType) {
|
||||
MimeTypes.SVG -> SvgRegionFetcher(activity).fetch(
|
||||
uri = uri,
|
||||
regionRect = regionRect,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
result = result,
|
||||
)
|
||||
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
||||
uri = uri,
|
||||
page = pageId ?: 0,
|
||||
|
|
|
@ -124,9 +124,9 @@ class RegionFetcher internal constructor(
|
|||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class LastDecoderRef(
|
||||
val uri: Uri,
|
||||
val decoder: BitmapRegionDecoder,
|
||||
)
|
||||
private data class LastDecoderRef(
|
||||
val uri: Uri,
|
||||
val decoder: BitmapRegionDecoder,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package deckers.thibault.aves.channel.calls.fetchers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import com.caverock.androidsvg.PreserveAspectRatio
|
||||
import com.caverock.androidsvg.RenderOptions
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.SVGParseException
|
||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.math.ceil
|
||||
|
||||
class SvgRegionFetcher internal constructor(
|
||||
private val context: Context,
|
||||
) {
|
||||
private var lastSvgRef: LastSvgRef? = null
|
||||
|
||||
suspend fun fetch(
|
||||
uri: Uri,
|
||||
regionRect: Rect,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
var currentSvgRef = lastSvgRef
|
||||
if (currentSvgRef != null && currentSvgRef.uri != uri) {
|
||||
currentSvgRef = null
|
||||
}
|
||||
|
||||
try {
|
||||
if (currentSvgRef == null) {
|
||||
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
try {
|
||||
SVG.getFromInputStream(input)
|
||||
} catch (ex: SVGParseException) {
|
||||
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (newSvg == null) {
|
||||
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
|
||||
newSvg.normalizeSize()
|
||||
currentSvgRef = LastSvgRef(uri, newSvg)
|
||||
}
|
||||
val svg = currentSvgRef.svg
|
||||
lastSvgRef = currentSvgRef
|
||||
|
||||
// we scale the requested region accordingly to the viewbox size
|
||||
val viewBox = svg.documentViewBox
|
||||
val svgWidth = viewBox.width()
|
||||
val svgHeight = viewBox.height()
|
||||
val xf = imageWidth / ceil(svgWidth)
|
||||
val yf = imageHeight / ceil(svgHeight)
|
||||
// some SVG paths do not respect the rendering viewbox and do not reach its edges
|
||||
// so we render to a slightly larger bitmap, using a slightly larger viewbox,
|
||||
// and crop that bitmap to the target region size
|
||||
val bleedX = xf.toInt()
|
||||
val bleedY = yf.toInt()
|
||||
val effectiveRect = RectF(
|
||||
(regionRect.left - bleedX) / xf,
|
||||
(regionRect.top - bleedY) / yf,
|
||||
(regionRect.right + bleedX) / xf,
|
||||
(regionRect.bottom + bleedY) / yf,
|
||||
)
|
||||
|
||||
val renderOptions = RenderOptions()
|
||||
renderOptions.viewBox(effectiveRect.left, effectiveRect.top, effectiveRect.width(), effectiveRect.height())
|
||||
renderOptions.preserveAspectRatio(PreserveAspectRatio.FULLSCREEN_START)
|
||||
|
||||
val targetBitmapWidth = regionRect.width()
|
||||
val targetBitmapHeight = regionRect.height()
|
||||
var bitmap = Bitmap.createBitmap(
|
||||
targetBitmapWidth + bleedX * 2,
|
||||
targetBitmapHeight + bleedY * 2,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(bitmap)
|
||||
svg.renderToCanvas(canvas, renderOptions)
|
||||
|
||||
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
||||
|
||||
if (bitmap != null) {
|
||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||
} else {
|
||||
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private data class LastSvgRef(
|
||||
val uri: Uri,
|
||||
val svg: SVG,
|
||||
)
|
||||
}
|
|
@ -18,6 +18,7 @@ import com.bumptech.glide.module.LibraryGlideModule
|
|||
import com.bumptech.glide.signature.ObjectKey
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.SVGParseException
|
||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
@ -52,11 +53,20 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
|
|||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
try {
|
||||
SVG.getFromInputStream(input)?.let { svg ->
|
||||
val svgWidth = svg.documentWidth
|
||||
val svgHeight = svg.documentHeight
|
||||
svg.normalizeSize()
|
||||
val viewBox = svg.documentViewBox
|
||||
val svgWidth = viewBox.width()
|
||||
val svgHeight = viewBox.height()
|
||||
|
||||
val bitmapWidth = if (svgWidth > 0) ceil(svgWidth).toInt() else width
|
||||
val bitmapHeight = if (svgHeight > 0) ceil(svgHeight).toInt() else height
|
||||
val bitmapWidth: Int
|
||||
val bitmapHeight: Int
|
||||
if (width / height > svgWidth / svgHeight) {
|
||||
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
||||
bitmapHeight = height;
|
||||
} else {
|
||||
bitmapWidth = width
|
||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||
|
||||
val canvas = Canvas(bitmap)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import com.caverock.androidsvg.SVG
|
||||
|
||||
object SvgHelper {
|
||||
fun SVG.normalizeSize() {
|
||||
if (documentViewBox == null) {
|
||||
setDocumentViewBox(0f, 0f, documentWidth, documentHeight)
|
||||
}
|
||||
setDocumentWidth("100%")
|
||||
setDocumentHeight("100%")
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
|
||||
},
|
||||
|
@ -71,7 +71,6 @@ class RegionProviderKey {
|
|||
final bool isFlipped;
|
||||
final Rectangle<int> region;
|
||||
final Size imageSize;
|
||||
final double scale;
|
||||
|
||||
const RegionProviderKey({
|
||||
required this.uri,
|
||||
|
@ -82,13 +81,12 @@ class RegionProviderKey {
|
|||
required this.sampleSize,
|
||||
required this.region,
|
||||
required this.imageSize,
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -101,9 +99,8 @@ class RegionProviderKey {
|
|||
sampleSize,
|
||||
region,
|
||||
imageSize,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
|
||||
}
|
||||
|
|
|
@ -435,8 +435,8 @@ class AvesEntry {
|
|||
final size = await SvgMetadataService.getSize(this);
|
||||
if (size != null) {
|
||||
await _applyNewFields({
|
||||
'width': size.width.round(),
|
||||
'height': size.height.round(),
|
||||
'width': size.width.ceil(),
|
||||
'height': size.height.ceil(),
|
||||
}, persist: persist);
|
||||
}
|
||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||
|
|
|
@ -27,21 +27,22 @@ extension ExtraAvesEntry on AvesEntry {
|
|||
);
|
||||
}
|
||||
|
||||
RegionProvider getRegion({required int sampleSize, Rectangle<int>? region}) {
|
||||
return RegionProvider(_getRegionProviderKey(sampleSize, region));
|
||||
}
|
||||
|
||||
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int>? region) {
|
||||
return RegionProviderKey(
|
||||
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
|
||||
return RegionProvider(RegionProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
sampleSize: sampleSize,
|
||||
region: region ?? Rectangle<int>(0, 0, width, height),
|
||||
imageSize: Size(width.toDouble(), height.toDouble()),
|
||||
);
|
||||
region: Rectangle(
|
||||
(region.left * scale).round(),
|
||||
(region.top * scale).round(),
|
||||
(region.width * scale).round(),
|
||||
(region.height * scale).round(),
|
||||
),
|
||||
imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()),
|
||||
));
|
||||
}
|
||||
|
||||
UriImage get uriImage => UriImage(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
enum CoordinateFormat { dms, decimal }
|
||||
|
||||
enum EntryBackground { black, white, transparent, checkered }
|
||||
enum EntryBackground { black, white, checkered }
|
||||
|
||||
enum HomePageSetting { collection, albums }
|
||||
|
||||
|
|
|
@ -297,7 +297,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// rendering
|
||||
|
||||
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values);
|
||||
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.white, EntryBackground.values);
|
||||
|
||||
set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
|
||||
|
||||
|
|
|
@ -26,12 +26,9 @@ class SvgMetadataService {
|
|||
String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value;
|
||||
double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
|
||||
|
||||
final width = tryParseWithoutUnit(getAttribute('width'));
|
||||
final height = tryParseWithoutUnit(getAttribute('height'));
|
||||
if (width != null && height != null) {
|
||||
return Size(width, height);
|
||||
}
|
||||
// prefer the viewbox over the viewport to determine size
|
||||
|
||||
// viewbox
|
||||
final viewBox = getAttribute('viewBox');
|
||||
if (viewBox != null) {
|
||||
final parts = viewBox.split(RegExp(r'[\s,]+'));
|
||||
|
@ -43,6 +40,13 @@ class SvgMetadataService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// viewport
|
||||
final width = tryParseWithoutUnit(getAttribute('width'));
|
||||
final height = tryParseWithoutUnit(getAttribute('height'));
|
||||
if (width != null && height != null) {
|
||||
return Size(width, height);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import 'dart:math';
|
||||
|
||||
final double _log2 = log(2);
|
||||
const double _piOver180 = pi / 180.0;
|
||||
|
||||
double toDegrees(num radians) => radians / _piOver180;
|
||||
|
||||
double toRadians(num degrees) => degrees * _piOver180;
|
||||
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()) as int;
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
|
||||
|
||||
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();
|
||||
|
||||
double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||
|
|
|
@ -21,8 +21,8 @@ class CheckeredPainter extends CustomPainter {
|
|||
final dx = offset.dx % (checkSize * 2);
|
||||
final dy = offset.dy % (checkSize * 2);
|
||||
|
||||
final xMax = size.width / checkSize;
|
||||
final yMax = size.height / checkSize;
|
||||
final xMax = (size.width / checkSize).ceil();
|
||||
final yMax = (size.height / checkSize).ceil();
|
||||
for (var x = -2; x < xMax; x++) {
|
||||
for (var y = -2; y < yMax; y++) {
|
||||
if ((x + y) % 2 == 0) {
|
||||
|
|
|
@ -41,29 +41,7 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
|||
EntryBackground.white,
|
||||
EntryBackground.black,
|
||||
EntryBackground.checkered,
|
||||
EntryBackground.transparent,
|
||||
].map((selected) {
|
||||
Widget? child;
|
||||
switch (selected) {
|
||||
case EntryBackground.transparent:
|
||||
child = const Icon(
|
||||
Icons.clear,
|
||||
size: 20,
|
||||
color: Colors.white30,
|
||||
);
|
||||
break;
|
||||
case EntryBackground.checkered:
|
||||
child = ClipOval(
|
||||
child: CustomPaint(
|
||||
painter: CheckeredPainter(
|
||||
checkSize: radius,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return DropdownMenuItem<EntryBackground>(
|
||||
value: selected,
|
||||
child: Container(
|
||||
|
@ -74,7 +52,15 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
|||
border: AvesBorder.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: child,
|
||||
child: selected == EntryBackground.checkered
|
||||
? ClipOval(
|
||||
child: CustomPaint(
|
||||
painter: CheckeredPainter(
|
||||
checkSize: radius,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -7,7 +6,6 @@ import 'package:aves/widgets/viewer/debug/db.dart';
|
|||
import 'package:aves/widgets/viewer/debug/metadata.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -132,36 +130,30 @@ class ViewerDebugPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildThumbnailsTabView() {
|
||||
final children = <Widget>[];
|
||||
if (entry.isSvg) {
|
||||
const extent = 128.0;
|
||||
children.addAll([
|
||||
const Text('SVG ($extent)'),
|
||||
SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
)
|
||||
]);
|
||||
} else {
|
||||
children.addAll(
|
||||
entry.cachedThumbnails.expand((provider) => [
|
||||
Text('Raster (${provider.key.extent})'),
|
||||
Center(
|
||||
child: Image(
|
||||
image: provider,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: children,
|
||||
children: entry.cachedThumbnails
|
||||
.expand((provider) => [
|
||||
Text('Extent: ${provider.key.extent}'),
|
||||
Center(
|
||||
child: Image(
|
||||
image: provider,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
return Container(
|
||||
foregroundDecoration: const BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: Colors.amber,
|
||||
width: .1,
|
||||
)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
])
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
|
@ -25,7 +22,6 @@ import 'package:aves/widgets/viewer/visual/vector.dart';
|
|||
import 'package:aves/widgets/viewer/visual/video.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryPageView extends StatefulWidget {
|
||||
|
@ -163,28 +159,19 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
}
|
||||
|
||||
Widget _buildSvgView() {
|
||||
final background = settings.vectorBackground;
|
||||
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
|
||||
|
||||
var child = _buildMagnifier(
|
||||
maxScale: const ScaleLevel(factor: double.infinity),
|
||||
maxScale: const ScaleLevel(factor: 25),
|
||||
scaleStateCycle: _vectorScaleStateCycle,
|
||||
child: SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
colorFilter: colorFilter,
|
||||
applyScale: false,
|
||||
child: VectorImageView(
|
||||
entry: entry,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||||
entry: entry,
|
||||
onTap: _onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (background == EntryBackground.checkered) {
|
||||
child = VectorViewCheckeredBackground(
|
||||
displaySize: entry.displaySize,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ class RasterImageView extends StatefulWidget {
|
|||
|
||||
class _RasterImageViewState extends State<RasterImageView> {
|
||||
late Size _displaySize;
|
||||
late bool _useTiles;
|
||||
bool _isTilingInitialized = false;
|
||||
late int _maxSampleSize;
|
||||
late double _tileSide;
|
||||
|
@ -44,16 +45,19 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
|
||||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||||
|
||||
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
|
||||
|
||||
ViewState get viewState => viewStateNotifier.value;
|
||||
|
||||
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
|
||||
|
||||
Rectangle<int> get fullImageRegion => Rectangle<int>(0, 0, entry.width, entry.height);
|
||||
|
||||
ImageProvider get fullImageProvider {
|
||||
if (entry.useTiles) {
|
||||
if (_useTiles) {
|
||||
assert(_isTilingInitialized);
|
||||
return entry.getRegion(sampleSize: _maxSampleSize);
|
||||
return entry.getRegion(
|
||||
sampleSize: _maxSampleSize,
|
||||
region: fullImageRegion,
|
||||
);
|
||||
} else {
|
||||
return entry.uriImage;
|
||||
}
|
||||
|
@ -66,8 +70,9 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_displaySize = entry.displaySize;
|
||||
_useTiles = entry.useTiles;
|
||||
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||
if (!entry.useTiles) _registerFullImage();
|
||||
if (!_useTiles) _registerFullImage();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -106,23 +111,23 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final useTiles = entry.useTiles;
|
||||
return ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
final viewportSized = viewportSize?.isEmpty == false;
|
||||
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
|
||||
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
|
||||
|
||||
return SizedBox.fromSize(
|
||||
size: _displaySize * viewState.scale!,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (useBackground && viewportSized) _buildBackground(),
|
||||
if (entry.canHaveAlpha && viewportSized) _buildBackground(),
|
||||
_buildLoading(),
|
||||
if (useTiles) ..._getTiles(),
|
||||
if (!useTiles)
|
||||
if (_useTiles)
|
||||
..._getTiles()
|
||||
else
|
||||
Image(
|
||||
image: fullImageProvider,
|
||||
gaplessPlayback: true,
|
||||
|
@ -230,9 +235,10 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
|
||||
// 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(
|
||||
final fullImageRegionTile = _RegionTile(
|
||||
entry: entry,
|
||||
tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale),
|
||||
regionRect: fullImageRegion,
|
||||
sampleSize: _maxSampleSize,
|
||||
);
|
||||
final tiles = [fullImageRegionTile];
|
||||
|
@ -253,7 +259,7 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
viewRect: viewRect,
|
||||
);
|
||||
if (rects != null) {
|
||||
tiles.add(RegionTile(
|
||||
tiles.add(_RegionTile(
|
||||
entry: entry,
|
||||
tileRect: rects.item1,
|
||||
regionRect: rects.item2,
|
||||
|
@ -320,20 +326,20 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
}
|
||||
}
|
||||
|
||||
class RegionTile extends StatefulWidget {
|
||||
class _RegionTile extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
|
||||
// `tileRect` uses Flutter view coordinates
|
||||
// `regionRect` uses the raw image pixel coordinates
|
||||
final Rect tileRect;
|
||||
final Rectangle<int>? regionRect;
|
||||
final Rectangle<int> regionRect;
|
||||
final int sampleSize;
|
||||
|
||||
const RegionTile({
|
||||
const _RegionTile({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.tileRect,
|
||||
this.regionRect,
|
||||
required this.regionRect,
|
||||
required this.sampleSize,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -350,7 +356,7 @@ class RegionTile extends StatefulWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _RegionTileState extends State<RegionTile> {
|
||||
class _RegionTileState extends State<_RegionTile> {
|
||||
late RegionProvider _provider;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
@ -362,7 +368,7 @@ class _RegionTileState extends State<RegionTile> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RegionTile oldWidget) {
|
||||
void didUpdateWidget(covariant _RegionTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
|
||||
_unregisterWidget(oldWidget);
|
||||
|
@ -376,11 +382,11 @@ class _RegionTileState extends State<RegionTile> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(RegionTile widget) {
|
||||
void _registerWidget(_RegionTile widget) {
|
||||
_initProvider();
|
||||
}
|
||||
|
||||
void _unregisterWidget(RegionTile widget) {
|
||||
void _unregisterWidget(_RegionTile widget) {
|
||||
_pauseProvider();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,54 +1,426 @@
|
|||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/image_providers/region_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.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/widgets/viewer/visual/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class VectorViewCheckeredBackground extends StatelessWidget {
|
||||
final Size displaySize;
|
||||
class VectorImageView extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
final Widget child;
|
||||
final ImageErrorWidgetBuilder errorBuilder;
|
||||
|
||||
const VectorViewCheckeredBackground({
|
||||
const VectorImageView({
|
||||
Key? key,
|
||||
required this.displaySize,
|
||||
required this.entry,
|
||||
required this.viewStateNotifier,
|
||||
required this.child,
|
||||
required this.errorBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_VectorImageViewState 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);
|
||||
|
||||
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() {
|
||||
_unregisterFullImage();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerFullImage() {
|
||||
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
|
||||
_fullImageStream!.addListener(_fullImageListener);
|
||||
}
|
||||
|
||||
void _unregisterFullImage() {
|
||||
_fullImageStream?.removeListener(_fullImageListener);
|
||||
_fullImageStream = null;
|
||||
}
|
||||
|
||||
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
|
||||
_unregisterFullImage();
|
||||
_fullImageLoaded.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
if (viewportSize == null) return child!;
|
||||
final viewportSized = viewportSize?.isEmpty == false;
|
||||
if (viewportSized && !_isTilingInitialized) _initTiling(viewportSize!);
|
||||
|
||||
final side = viewportSize.shortestSide;
|
||||
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
|
||||
|
||||
final viewSize = displaySize * viewState.scale!;
|
||||
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
|
||||
final offset = ((decorationSize - viewportSize) as Offset) / 2;
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(
|
||||
width: decorationSize.width,
|
||||
height: decorationSize.height,
|
||||
child: CustomPaint(
|
||||
painter: CheckeredPainter(
|
||||
checkSize: checkSize,
|
||||
offset: offset,
|
||||
),
|
||||
),
|
||||
),
|
||||
child!,
|
||||
],
|
||||
return SizedBox.fromSize(
|
||||
size: _displaySize * viewState.scale!,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_buildLoading(),
|
||||
..._getTiles(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _initTiling(Size viewportSize) {
|
||||
_tileSide = _displaySize.longestSide;
|
||||
// scale for initial state `contained`
|
||||
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
|
||||
_minScale = _imageScaleForViewScale(containedScale);
|
||||
|
||||
_isTilingInitialized = true;
|
||||
_registerFullImage();
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _fullImageLoaded,
|
||||
builder: (context, fullImageLoaded, child) {
|
||||
if (fullImageLoaded) return const SizedBox.shrink();
|
||||
|
||||
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() {
|
||||
if (!_isTilingInitialized) return [];
|
||||
|
||||
final displayWidth = _displaySize.width;
|
||||
final displayHeight = _displaySize.height;
|
||||
final viewRect = _getViewRect(displayWidth, displayHeight);
|
||||
final viewScale = viewState.scale!;
|
||||
final background = settings.vectorBackground;
|
||||
|
||||
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(viewScale), _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) {
|
||||
tiles.add(_RegionTile(
|
||||
entry: entry,
|
||||
tileRect: rects.item1,
|
||||
regionRect: rects.item2,
|
||||
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;
|
||||
}
|
||||
|
||||
Tuple2<Rect, Rectangle<double>>? _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 Tuple2<Rect, Rectangle<double>>(tileRect, regionRect);
|
||||
}
|
||||
|
||||
double _imageScaleForViewScale(double scale) => smallestPowerOf2(scale * window.devicePixelRatio).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({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.tileRect,
|
||||
required this.regionRect,
|
||||
required this.scale,
|
||||
required this.backgroundColor,
|
||||
required this.backgroundFrameBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_RegionTileState createState() => _RegionTileState();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,17 @@ void main() {
|
|||
expect(highestPowerOf2(42), 32);
|
||||
expect(highestPowerOf2(0), 0);
|
||||
expect(highestPowerOf2(-42), 0);
|
||||
expect(highestPowerOf2(.5), 0);
|
||||
expect(highestPowerOf2(1.5), 1);
|
||||
});
|
||||
|
||||
test('smallest power of 2 that is larger than or equal to the number', () {
|
||||
expect(smallestPowerOf2(1024), 1024);
|
||||
expect(smallestPowerOf2(42), 64);
|
||||
expect(smallestPowerOf2(0), 1);
|
||||
expect(smallestPowerOf2(-42), 1);
|
||||
expect(smallestPowerOf2(.5), 1);
|
||||
expect(smallestPowerOf2(1.5), 2);
|
||||
});
|
||||
|
||||
test('rounding to a given precision after the decimal', () {
|
||||
|
|
Loading…
Reference in a new issue