/
image_resolution.dart
433 lines (409 loc) · 16.9 KB
/
image_resolution.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'image_provider.dart';
const String _kAssetManifestFileName = 'AssetManifest.json';
/// A screen with a device-pixel ratio strictly less than this value is
/// considered a low-resolution screen (typically entry-level to mid-range
/// laptops, desktop screens up to QHD, low-end tablets such as Kindle Fire).
const double _kLowDprLimit = 2.0;
/// Fetches an image from an [AssetBundle], having determined the exact image to
/// use based on the context.
///
/// Given a main asset and a set of variants, AssetImage chooses the most
/// appropriate asset for the current context, based on the device pixel ratio
/// and size given in the configuration passed to [resolve].
///
/// To show a specific image from a bundle without any asset resolution, use an
/// [AssetBundleImageProvider].
///
/// ## Naming assets for matching with different pixel densities
///
/// Main assets are presumed to match a nominal pixel ratio of 1.0. To specify
/// assets targeting different pixel ratios, place the variant assets in
/// the application bundle under subdirectories named in the form "Nx", where
/// N is the nominal device pixel ratio for that asset.
///
/// For example, suppose an application wants to use an icon named
/// "heart.png". This icon has representations at 1.0 (the main icon), as well
/// as 2.0 and 4.0 pixel ratios (variants). The asset bundle should then
/// contain the following assets:
///
/// heart.png
/// 2.0x/heart.png
/// 4.0x/heart.png
///
/// On a device with a 1.0 device pixel ratio, the image chosen would be
/// heart.png; on a device with a 2.0 device pixel ratio, the image chosen
/// would be 2.0x/heart.png; on a device with a 4.0 device pixel ratio, the
/// image chosen would be 4.0x/heart.png.
///
/// On a device with a device pixel ratio that does not exactly match an
/// available asset the "best match" is chosen. Which asset is the best depends
/// on the screen. Low-resolution screens (those with device pixel ratio
/// strictly less than 2.0) use a different matching algorithm from the
/// high-resolution screen. Because in low-resolution screens the physical
/// pixels are visible to the user upscaling artifacts (e.g. blurred edges) are
/// more pronounced. Therefore, a higher resolution asset is chosen, if
/// available. For higher-resolution screens, where individual physical pixels
/// are not visible to the user, the asset variant with the pixel ratio that's
/// the closest to the screen's device pixel ratio is chosen.
///
/// For example, for a screen with device pixel ratio 1.25 the image chosen
/// would be 2.0x/heart.png, even though heart.png (i.e. 1.0) is closer. This
/// is because the screen is considered low-resolution. For a screen with
/// device pixel ratio of 2.25 the image chosen would also be 2.0x/heart.png.
/// This is because the screen is considered to be a high-resolution screen,
/// and therefore upscaling a 2.0x image to 2.25 won't result in visible
/// upscaling artifacts. However, for a screen with device-pixel ratio 3.25 the
/// image chosen would be 4.0x/heart.png because it's closer to 4.0 than it is
/// to 2.0.
///
/// Choosing a higher-resolution image than necessary may waste significantly
/// more memory if the difference between the screen device pixel ratio and
/// the device pixel ratio of the image is high. To reduce memory usage,
/// consider providing more variants of the image. In the example above adding
/// a 3.0x/heart.png variant would improve memory usage for screens with device
/// pixel ratios between 3.0 and 3.5.
///
/// [ImageConfiguration] can be used to customize the selection of the image
/// variant by setting [ImageConfiguration.devicePixelRatio] to value different
/// from the default. The default value is derived from
/// [MediaQueryData.devicePixelRatio] by [createLocalImageConfiguration].
///
/// The directory level of the asset does not matter as long as the variants are
/// at the equivalent level; that is, the following is also a valid bundle
/// structure:
///
/// icons/heart.png
/// icons/1.5x/heart.png
/// icons/2.0x/heart.png
///
/// assets/icons/3.0x/heart.png would be a valid variant of
/// assets/icons/heart.png.
///
/// ## Fetching assets
///
/// When fetching an image provided by the app itself, use the [assetName]
/// argument to name the asset to choose. For instance, consider the structure
/// above. First, the `pubspec.yaml` of the project should specify its assets in
/// the `flutter` section:
///
/// ```yaml
/// flutter:
/// assets:
/// - icons/heart.png
/// ```
///
/// Then, to fetch the image, use:
/// ```dart
/// const AssetImage('icons/heart.png')
/// ```
///
/// {@tool snippet}
///
/// The following shows the code required to write a widget that fully conforms
/// to the [AssetImage] and [Widget] protocols. (It is essentially a
/// bare-bones version of the [widgets.Image] widget made to work specifically for
/// an [AssetImage].)
///
/// ```dart
/// class MyImage extends StatefulWidget {
/// const MyImage({
/// super.key,
/// required this.assetImage,
/// });
///
/// final AssetImage assetImage;
///
/// @override
/// State<MyImage> createState() => _MyImageState();
/// }
///
/// class _MyImageState extends State<MyImage> {
/// ImageStream? _imageStream;
/// ImageInfo? _imageInfo;
///
/// @override
/// void didChangeDependencies() {
/// super.didChangeDependencies();
/// // We call _getImage here because createLocalImageConfiguration() needs to
/// // be called again if the dependencies changed, in case the changes relate
/// // to the DefaultAssetBundle, MediaQuery, etc, which that method uses.
/// _getImage();
/// }
///
/// @override
/// void didUpdateWidget(MyImage oldWidget) {
/// super.didUpdateWidget(oldWidget);
/// if (widget.assetImage != oldWidget.assetImage) {
/// _getImage();
/// }
/// }
///
/// void _getImage() {
/// final ImageStream? oldImageStream = _imageStream;
/// _imageStream = widget.assetImage.resolve(createLocalImageConfiguration(context));
/// if (_imageStream!.key != oldImageStream?.key) {
/// // If the keys are the same, then we got the same image back, and so we don't
/// // need to update the listeners. If the key changed, though, we must make sure
/// // to switch our listeners to the new image stream.
/// final ImageStreamListener listener = ImageStreamListener(_updateImage);
/// oldImageStream?.removeListener(listener);
/// _imageStream!.addListener(listener);
/// }
/// }
///
/// void _updateImage(ImageInfo imageInfo, bool synchronousCall) {
/// setState(() {
/// // Trigger a build whenever the image changes.
/// _imageInfo?.dispose();
/// _imageInfo = imageInfo;
/// });
/// }
///
/// @override
/// void dispose() {
/// _imageStream?.removeListener(ImageStreamListener(_updateImage));
/// _imageInfo?.dispose();
/// _imageInfo = null;
/// super.dispose();
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return RawImage(
/// image: _imageInfo?.image, // this is a dart:ui Image object
/// scale: _imageInfo?.scale ?? 1.0,
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// ## Assets in packages
///
/// To fetch an asset from a package, the [package] argument must be provided.
/// For instance, suppose the structure above is inside a package called
/// `my_icons`. Then to fetch the image, use:
///
/// ```dart
/// const AssetImage('icons/heart.png', package: 'my_icons')
/// ```
///
/// Assets used by the package itself should also be fetched using the [package]
/// argument as above.
///
/// If the desired asset is specified in the `pubspec.yaml` of the package, it
/// is bundled automatically with the app. In particular, assets used by the
/// package itself must be specified in its `pubspec.yaml`.
///
/// A package can also choose to have assets in its 'lib/' folder that are not
/// specified in its `pubspec.yaml`. In this case for those images to be
/// bundled, the app has to specify which ones to include. For instance a
/// package named `fancy_backgrounds` could have:
///
/// lib/backgrounds/background1.png
/// lib/backgrounds/background2.png
/// lib/backgrounds/background3.png
///
/// To include, say the first image, the `pubspec.yaml` of the app should specify
/// it in the `assets` section:
///
/// ```yaml
/// assets:
/// - packages/fancy_backgrounds/backgrounds/background1.png
/// ```
///
/// The `lib/` is implied, so it should not be included in the asset path.
///
/// See also:
///
/// * [Image.asset] for a shorthand of an [Image] widget backed by [AssetImage]
/// when used without a scale.
@immutable
class AssetImage extends AssetBundleImageProvider {
/// Creates an object that fetches an image from an asset bundle.
///
/// The [assetName] argument must not be null. It should name the main asset
/// from the set of images to choose from. The [package] argument must be
/// non-null when fetching an asset that is included in package. See the
/// documentation for the [AssetImage] class itself for details.
const AssetImage(
this.assetName, {
this.bundle,
this.package,
}) : assert(assetName != null);
/// The name of the main asset from the set of images to choose from. See the
/// documentation for the [AssetImage] class itself for details.
final String assetName;
/// The name used to generate the key to obtain the asset. For local assets
/// this is [assetName], and for assets from packages the [assetName] is
/// prefixed 'packages/<package_name>/'.
String get keyName => package == null ? assetName : 'packages/$package/$assetName';
/// The bundle from which the image will be obtained.
///
/// If the provided [bundle] is null, the bundle provided in the
/// [ImageConfiguration] passed to the [resolve] call will be used instead. If
/// that is also null, the [rootBundle] is used.
///
/// The image is obtained by calling [AssetBundle.load] on the given [bundle]
/// using the key given by [keyName].
final AssetBundle? bundle;
/// The name of the package from which the image is included. See the
/// documentation for the [AssetImage] class itself for details.
final String? package;
// We assume the main asset is designed for a device pixel ratio of 1.0
static const double _naturalResolution = 1.0;
@override
Future<AssetBundleImageKey> obtainKey(ImageConfiguration configuration) {
// This function tries to return a SynchronousFuture if possible. We do this
// because otherwise showing an image would always take at least one frame,
// which would be sad. (This code is called from inside build/layout/paint,
// which all happens in one call frame; using native Futures would guarantee
// that we resolve each future in a new call frame, and thus not in this
// build/layout/paint sequence.)
final AssetBundle chosenBundle = bundle ?? configuration.bundle ?? rootBundle;
Completer<AssetBundleImageKey>? completer;
Future<AssetBundleImageKey>? result;
chosenBundle.loadStructuredData<Map<String, List<String>>?>(_kAssetManifestFileName, manifestParser).then<void>(
(Map<String, List<String>>? manifest) {
final String chosenName = _chooseVariant(
keyName,
configuration,
manifest == null ? null : manifest[keyName],
)!;
final double chosenScale = _parseScale(chosenName);
final AssetBundleImageKey key = AssetBundleImageKey(
bundle: chosenBundle,
name: chosenName,
scale: chosenScale,
);
if (completer != null) {
// We already returned from this function, which means we are in the
// asynchronous mode. Pass the value to the completer. The completer's
// future is what we returned.
completer.complete(key);
} else {
// We haven't yet returned, so we must have been called synchronously
// just after loadStructuredData returned (which means it provided us
// with a SynchronousFuture). Let's return a SynchronousFuture
// ourselves.
result = SynchronousFuture<AssetBundleImageKey>(key);
}
},
).catchError((Object error, StackTrace stack) {
// We had an error. (This guarantees we weren't called synchronously.)
// Forward the error to the caller.
assert(completer != null);
assert(result == null);
completer!.completeError(error, stack);
});
if (result != null) {
// The code above ran synchronously, and came up with an answer.
// Return the SynchronousFuture that we created above.
return result!;
}
// The code above hasn't yet run its "then" handler yet. Let's prepare a
// completer for it to use when it does run.
completer = Completer<AssetBundleImageKey>();
return completer.future;
}
/// Parses the asset manifest string into a strongly-typed map.
@visibleForTesting
static Future<Map<String, List<String>>?> manifestParser(String? jsonData) {
if (jsonData == null) {
return SynchronousFuture<Map<String, List<String>>?>(null);
}
// TODO(ianh): JSON decoding really shouldn't be on the main thread.
final Map<String, dynamic> parsedJson = json.decode(jsonData) as Map<String, dynamic>;
final Iterable<String> keys = parsedJson.keys;
final Map<String, List<String>> parsedManifest = <String, List<String>> {
for (final String key in keys) key: List<String>.from(parsedJson[key] as List<dynamic>),
};
// TODO(ianh): convert that data structure to the right types.
return SynchronousFuture<Map<String, List<String>>?>(parsedManifest);
}
String? _chooseVariant(String main, ImageConfiguration config, List<String>? candidates) {
if (config.devicePixelRatio == null || candidates == null || candidates.isEmpty) {
return main;
}
// TODO(ianh): Consider moving this parsing logic into _manifestParser.
final SplayTreeMap<double, String> mapping = SplayTreeMap<double, String>();
for (final String candidate in candidates) {
mapping[_parseScale(candidate)] = candidate;
}
// TODO(ianh): implement support for config.locale, config.textDirection,
// config.size, config.platform (then document this over in the Image.asset
// docs)
return _findBestVariant(mapping, config.devicePixelRatio!);
}
// Returns the "best" asset variant amongst the available `candidates`.
//
// The best variant is chosen as follows:
// - Choose a variant whose key matches `value` exactly, if available.
// - If `value` is less than the lowest key, choose the variant with the
// lowest key.
// - If `value` is greater than the highest key, choose the variant with
// the highest key.
// - If the screen has low device pixel ratio, choose the variant with the
// lowest key higher than `value`.
// - If the screen has high device pixel ratio, choose the variant with the
// key nearest to `value`.
String? _findBestVariant(SplayTreeMap<double, String> candidates, double value) {
if (candidates.containsKey(value)) {
return candidates[value]!;
}
final double? lower = candidates.lastKeyBefore(value);
final double? upper = candidates.firstKeyAfter(value);
if (lower == null) {
return candidates[upper];
}
if (upper == null) {
return candidates[lower];
}
// On screens with low device-pixel ratios the artifacts from upscaling
// images are more visible than on screens with a higher device-pixel
// ratios because the physical pixels are larger. Choose the higher
// resolution image in that case instead of the nearest one.
if (value < _kLowDprLimit || value > (lower + upper) / 2) {
return candidates[upper];
} else {
return candidates[lower];
}
}
static final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$');
double _parseScale(String key) {
if (key == assetName) {
return _naturalResolution;
}
final Uri assetUri = Uri.parse(key);
String directoryPath = '';
if (assetUri.pathSegments.length > 1) {
directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2];
}
final Match? match = _extractRatioRegExp.firstMatch(directoryPath);
if (match != null && match.groupCount > 0) {
return double.parse(match.group(1)!);
}
return _naturalResolution; // i.e. default to 1.0x
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is AssetImage
&& other.keyName == keyName
&& other.bundle == bundle;
}
@override
int get hashCode => Object.hash(keyName, bundle);
@override
String toString() => '${objectRuntimeType(this, 'AssetImage')}(bundle: $bundle, name: "$keyName")';
}