[Web] Allow specifying the strategy on when to use <img> element to display images (#159917)
This PR follows the discussion under https://github.com/flutter/flutter/pull/157755 and adds a flag to determine when `<img>` elements are used. By default the feature is turned off. Instead of just a boolean for on & off, I made it an enum to accept multiple options. Notably, an `always` option can be useful when the developer wants a unified experience regardless of the image origin (such as when displaying an image from arbitrary URLs.) ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
parent
3f981117eb
commit
c16b9c52e4
@ -21,7 +21,12 @@ typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer bu
|
|||||||
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage>
|
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage>
|
||||||
implements image_provider.NetworkImage {
|
implements image_provider.NetworkImage {
|
||||||
/// Creates an object that fetches the image at the given URL.
|
/// Creates an object that fetches the image at the given URL.
|
||||||
const NetworkImage(this.url, {this.scale = 1.0, this.headers});
|
const NetworkImage(
|
||||||
|
this.url, {
|
||||||
|
this.scale = 1.0,
|
||||||
|
this.headers,
|
||||||
|
this.webHtmlElementStrategy = image_provider.WebHtmlElementStrategy.never,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String url;
|
final String url;
|
||||||
@ -32,6 +37,9 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
@override
|
@override
|
||||||
final Map<String, String>? headers;
|
final Map<String, String>? headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final image_provider.WebHtmlElementStrategy webHtmlElementStrategy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
|
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
|
||||||
return SynchronousFuture<NetworkImage>(this);
|
return SynchronousFuture<NetworkImage>(this);
|
||||||
|
@ -18,9 +18,9 @@ import 'image_stream.dart';
|
|||||||
/// used for testing purposes.
|
/// used for testing purposes.
|
||||||
typedef HttpRequestFactory = web.XMLHttpRequest Function();
|
typedef HttpRequestFactory = web.XMLHttpRequest Function();
|
||||||
|
|
||||||
/// The type for an overridable factory function for creating <img> elements,
|
/// The type for an overridable factory function for creating HTML elements to
|
||||||
/// used for testing purposes.
|
/// display images, used for testing purposes.
|
||||||
typedef ImgElementFactory = web.HTMLImageElement Function();
|
typedef HtmlElementFactory = web.HTMLImageElement Function();
|
||||||
|
|
||||||
// Method signature for _loadAsync decode callbacks.
|
// Method signature for _loadAsync decode callbacks.
|
||||||
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
|
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
|
||||||
@ -40,17 +40,17 @@ void debugRestoreHttpRequestFactory() {
|
|||||||
httpRequestFactory = _httpClient;
|
httpRequestFactory = _httpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default <img> element factory.
|
/// The default HTML element factory.
|
||||||
web.HTMLImageElement _imgElementFactory() {
|
web.HTMLImageElement _imgElementFactory() {
|
||||||
return web.document.createElement('img') as web.HTMLImageElement;
|
return web.document.createElement('img') as web.HTMLImageElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The factory function that creates <img> elements, can be overridden for
|
/// The factory function that creates HTML elements, can be overridden for
|
||||||
/// tests.
|
/// tests.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
ImgElementFactory imgElementFactory = _imgElementFactory;
|
HtmlElementFactory imgElementFactory = _imgElementFactory;
|
||||||
|
|
||||||
/// Restores the default <img> element factory.
|
/// Restores the default HTML element factory.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugRestoreImgElementFactory() {
|
void debugRestoreImgElementFactory() {
|
||||||
imgElementFactory = _imgElementFactory;
|
imgElementFactory = _imgElementFactory;
|
||||||
@ -63,7 +63,12 @@ void debugRestoreImgElementFactory() {
|
|||||||
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage>
|
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage>
|
||||||
implements image_provider.NetworkImage {
|
implements image_provider.NetworkImage {
|
||||||
/// Creates an object that fetches the image at the given URL.
|
/// Creates an object that fetches the image at the given URL.
|
||||||
const NetworkImage(this.url, {this.scale = 1.0, this.headers});
|
const NetworkImage(
|
||||||
|
this.url, {
|
||||||
|
this.scale = 1.0,
|
||||||
|
this.headers,
|
||||||
|
this.webHtmlElementStrategy = image_provider.WebHtmlElementStrategy.never,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String url;
|
final String url;
|
||||||
@ -74,6 +79,9 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
@override
|
@override
|
||||||
final Map<String, String>? headers;
|
final Map<String, String>? headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final image_provider.WebHtmlElementStrategy webHtmlElementStrategy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
|
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
|
||||||
return SynchronousFuture<NetworkImage>(this);
|
return SynchronousFuture<NetworkImage>(this);
|
||||||
@ -136,19 +144,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
) async {
|
) async {
|
||||||
assert(key == this);
|
assert(key == this);
|
||||||
|
|
||||||
final Uri resolved = Uri.base.resolve(key.url);
|
Future<ImageStreamCompleter> loadViaDecode() async {
|
||||||
|
|
||||||
final bool containsNetworkImageHeaders = key.headers?.isNotEmpty ?? false;
|
|
||||||
|
|
||||||
// We use a different method when headers are set because the
|
|
||||||
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
|
|
||||||
if (containsNetworkImageHeaders) {
|
|
||||||
// It is not possible to load an <img> element and pass the headers with
|
|
||||||
// the request to fetch the image. Since the user has provided headers,
|
|
||||||
// this function should assume the headers are required to resolve to
|
|
||||||
// the correct resource and should not attempt to load the image in an
|
|
||||||
// <img> tag without the headers.
|
|
||||||
|
|
||||||
// Resolve the Codec before passing it to
|
// Resolve the Codec before passing it to
|
||||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
||||||
// twice (once from the MultiFrameImageStreamCompleter and again
|
// twice (once from the MultiFrameImageStreamCompleter and again
|
||||||
@ -161,34 +157,38 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
debugLabel: key.url,
|
debugLabel: key.url,
|
||||||
informationCollector: _imageStreamInformationCollector(key),
|
informationCollector: _imageStreamInformationCollector(key),
|
||||||
);
|
);
|
||||||
} else if (isSkiaWeb) {
|
}
|
||||||
try {
|
|
||||||
// Resolve the Codec before passing it to
|
Future<ImageStreamCompleter> loadViaImgElement() async {
|
||||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
// If we failed to fetch the bytes, try to load the image in an <img>
|
||||||
// twice (once from the MultiFrameImageStreamCompleter and again
|
// element instead.
|
||||||
// from the wrapping [ForwardingImageStreamCompleter]).
|
final web.HTMLImageElement imageElement = imgElementFactory();
|
||||||
final ui.Codec codec = await _fetchImageBytes(decode);
|
imageElement.src = key.url;
|
||||||
return MultiFrameImageStreamCompleter(
|
// Decode the <img> element before creating the ImageStreamCompleter
|
||||||
chunkEvents: chunkEvents.stream,
|
// to avoid double reporting the error.
|
||||||
codec: Future<ui.Codec>.value(codec),
|
await imageElement.decode().toDart;
|
||||||
scale: key.scale,
|
return OneFrameImageStreamCompleter(
|
||||||
debugLabel: key.url,
|
Future<ImageInfo>.value(WebImageInfo(imageElement, debugLabel: key.url)),
|
||||||
informationCollector: _imageStreamInformationCollector(key),
|
informationCollector: _imageStreamInformationCollector(key),
|
||||||
);
|
)..debugLabel = key.url;
|
||||||
} catch (e) {
|
}
|
||||||
// If we failed to fetch the bytes, try to load the image in an <img>
|
|
||||||
// element instead.
|
final bool containsNetworkImageHeaders = key.headers?.isNotEmpty ?? false;
|
||||||
final web.HTMLImageElement imageElement = imgElementFactory();
|
// When headers are set, the image can only be loaded by decoding.
|
||||||
imageElement.src = key.url;
|
//
|
||||||
// Decode the <img> element before creating the ImageStreamCompleter
|
// For the HTML renderer, `ui_web.createImageCodecFromUrl` method is not
|
||||||
// to avoid double reporting the error.
|
// capable of handling headers.
|
||||||
await imageElement.decode().toDart;
|
//
|
||||||
return OneFrameImageStreamCompleter(
|
// For CanvasKit and Skwasm, it is not possible to load an <img> element and
|
||||||
Future<ImageInfo>.value(WebImageInfo(imageElement, debugLabel: key.url)),
|
// pass the headers with the request to fetch the image. Since the user has
|
||||||
informationCollector: _imageStreamInformationCollector(key),
|
// provided headers, this function should assume the headers are required to
|
||||||
)..debugLabel = key.url;
|
// resolve to the correct resource and should not attempt to load the image
|
||||||
}
|
// in an <img> tag without the headers.
|
||||||
} else {
|
if (containsNetworkImageHeaders) {
|
||||||
|
return loadViaDecode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSkiaWeb) {
|
||||||
// This branch is only hit by the HTML renderer, which is deprecated. The
|
// This branch is only hit by the HTML renderer, which is deprecated. The
|
||||||
// HTML renderer supports loading images with CORS restrictions, so we
|
// HTML renderer supports loading images with CORS restrictions, so we
|
||||||
// don't need to catch errors and try loading the image in an <img> tag
|
// don't need to catch errors and try loading the image in an <img> tag
|
||||||
@ -198,6 +198,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
||||||
// twice (once from the MultiFrameImageStreamCompleter) and again
|
// twice (once from the MultiFrameImageStreamCompleter) and again
|
||||||
// from the wrapping [ForwardingImageStreamCompleter].
|
// from the wrapping [ForwardingImageStreamCompleter].
|
||||||
|
final Uri resolved = Uri.base.resolve(key.url);
|
||||||
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
|
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
|
||||||
resolved,
|
resolved,
|
||||||
chunkCallback: (int bytes, int total) {
|
chunkCallback: (int bytes, int total) {
|
||||||
@ -212,6 +213,21 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
|
|||||||
informationCollector: _imageStreamInformationCollector(key),
|
informationCollector: _imageStreamInformationCollector(key),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (webHtmlElementStrategy) {
|
||||||
|
case image_provider.WebHtmlElementStrategy.never:
|
||||||
|
return loadViaDecode();
|
||||||
|
case image_provider.WebHtmlElementStrategy.prefer:
|
||||||
|
return loadViaImgElement();
|
||||||
|
case image_provider.WebHtmlElementStrategy.fallback:
|
||||||
|
try {
|
||||||
|
// Await here so that errors occurred during the asynchronous process
|
||||||
|
// of `loadViaDecode` are caught and triggers `loadViaImgElement`.
|
||||||
|
return await loadViaDecode();
|
||||||
|
} catch (e) {
|
||||||
|
return loadViaImgElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.Codec> _fetchImageBytes(_SimpleDecoderCallback decode) async {
|
Future<ui.Codec> _fetchImageBytes(_SimpleDecoderCallback decode) async {
|
||||||
|
@ -7,12 +7,12 @@ import 'dart:ui' as ui;
|
|||||||
import 'image_stream.dart';
|
import 'image_stream.dart';
|
||||||
|
|
||||||
/// An [ImageInfo] object indicating that the image can only be displayed in
|
/// An [ImageInfo] object indicating that the image can only be displayed in
|
||||||
/// an <img> element, and no [dart:ui.Image] can be created for it.
|
/// an HTML element, and no [dart:ui.Image] can be created for it.
|
||||||
///
|
///
|
||||||
/// This occurs on the web when the image resource is from a different origin
|
/// This occurs on the web when the image resource is from a different origin
|
||||||
/// and is not configured for CORS. Since the image bytes cannot be directly
|
/// and is not configured for CORS. Since the image bytes cannot be directly
|
||||||
/// fetched, [ui.Image]s cannot be created from it. However, the image can
|
/// fetched, [ui.Image]s cannot be created from it. However, the image can
|
||||||
/// still be displayed if an <img> element is used.
|
/// still be displayed if an HTML element is used.
|
||||||
class WebImageInfo implements ImageInfo {
|
class WebImageInfo implements ImageInfo {
|
||||||
@override
|
@override
|
||||||
ImageInfo clone() => _unsupported();
|
ImageInfo clone() => _unsupported();
|
||||||
|
@ -8,18 +8,18 @@ import '../web.dart' as web;
|
|||||||
import 'image_stream.dart';
|
import 'image_stream.dart';
|
||||||
|
|
||||||
/// An [ImageInfo] object indicating that the image can only be displayed in
|
/// An [ImageInfo] object indicating that the image can only be displayed in
|
||||||
/// an <img> element, and no [dart:ui.Image] can be created for it.
|
/// an HTML element, and no [dart:ui.Image] can be created for it.
|
||||||
///
|
///
|
||||||
/// This occurs on the web when the image resource is from a different origin
|
/// This occurs on the web when the image resource is from a different origin
|
||||||
/// and is not configured for CORS. Since the image bytes cannot be directly
|
/// and is not configured for CORS. Since the image bytes cannot be directly
|
||||||
/// fetched, [Image]s cannot be created from it. However, the image can
|
/// fetched, [Image]s cannot be created from it. However, the image can
|
||||||
/// still be displayed if an <img> element is used.
|
/// still be displayed if an HTML element is used.
|
||||||
class WebImageInfo implements ImageInfo {
|
class WebImageInfo implements ImageInfo {
|
||||||
/// Creates a new [WebImageInfo] from a given <img> element.
|
/// Creates a new [WebImageInfo] from a given HTML element.
|
||||||
WebImageInfo(this.htmlImage, {this.debugLabel});
|
WebImageInfo(this.htmlImage, {this.debugLabel});
|
||||||
|
|
||||||
/// The <img> element used to display this image. This <img> element has
|
/// The HTML element used to display this image. This HTML element has already
|
||||||
/// already been decoded, so size information can be retrieved from it.
|
/// decoded the image, so size information can be retrieved from it.
|
||||||
final web.HTMLImageElement htmlImage;
|
final web.HTMLImageElement htmlImage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
// late BuildContext context;
|
// late BuildContext context;
|
||||||
|
|
||||||
/// @docImport 'package:flutter/widgets.dart';
|
/// @docImport 'package:flutter/widgets.dart';
|
||||||
|
/// @docImport '_web_image_info_io.dart';
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@ -1467,10 +1468,46 @@ class ResizeImage extends ImageProvider<ResizeImageKey> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The strategy for [Image.network] and [NetworkImage] to decide whether to
|
||||||
|
/// display images in HTML elements contained in a platform view instead of
|
||||||
|
/// fetching bytes.
|
||||||
|
///
|
||||||
|
/// See [Image.network] for more explanation on the impact.
|
||||||
|
///
|
||||||
|
/// This option is only effective on the Web platform. Other platforms always
|
||||||
|
/// display network images by fetching bytes.
|
||||||
|
enum WebHtmlElementStrategy {
|
||||||
|
/// Only show images by fetching bytes, and report errors if the fetch
|
||||||
|
/// encounters errors.
|
||||||
|
never,
|
||||||
|
|
||||||
|
/// Prefer fetching bytes to display images, and fall back to HTML elements
|
||||||
|
/// when fetching bytes is not available.
|
||||||
|
///
|
||||||
|
/// This strategy uses HTML elements only if `headers` is empty and the fetch
|
||||||
|
/// encounters errors. Errors may still be reported if neither approach works.
|
||||||
|
fallback,
|
||||||
|
|
||||||
|
/// Prefer HTML elements to display images, and fall back to fetching bytes
|
||||||
|
/// when HTML elements do not work.
|
||||||
|
///
|
||||||
|
/// This strategy fetches bytes only if `headers` is not empty, since HTML
|
||||||
|
/// elements do not support headers. Errors may still be reported if neither
|
||||||
|
/// approach works.
|
||||||
|
prefer,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the given URL from the network, associating it with the given scale.
|
/// Fetches the given URL from the network, associating it with the given scale.
|
||||||
///
|
///
|
||||||
/// The image will be cached regardless of cache headers from the server.
|
/// The image will be cached regardless of cache headers from the server.
|
||||||
///
|
///
|
||||||
|
/// Typically this class resolves to an image stream that ultimately produces
|
||||||
|
/// [dart:ui.Image]s. On the Web platform, the [webHtmlElementStrategy]
|
||||||
|
/// parameter can be used to make the image stream ultimately produce an
|
||||||
|
/// [WebImageInfo] instead, which makes [Image.network] display the image as an
|
||||||
|
/// HTML element in a platform view. The feature is by default turned off
|
||||||
|
/// ([WebHtmlElementStrategy.never]). See [Image.network] for more explanation.
|
||||||
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [Image.network] for a shorthand of an [Image] widget backed by [NetworkImage].
|
/// * [Image.network] for a shorthand of an [Image] widget backed by [NetworkImage].
|
||||||
@ -1484,8 +1521,15 @@ abstract class NetworkImage extends ImageProvider<NetworkImage> {
|
|||||||
///
|
///
|
||||||
/// The [scale] argument is the linear scale factor for drawing this image at
|
/// The [scale] argument is the linear scale factor for drawing this image at
|
||||||
/// its intended size. See [ImageInfo.scale] for more information.
|
/// its intended size. See [ImageInfo.scale] for more information.
|
||||||
const factory NetworkImage(String url, {double scale, Map<String, String>? headers}) =
|
///
|
||||||
network_image.NetworkImage;
|
/// The [webHtmlElementStrategy] option is by default
|
||||||
|
/// [WebHtmlElementStrategy.never].
|
||||||
|
const factory NetworkImage(
|
||||||
|
String url, {
|
||||||
|
double scale,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
WebHtmlElementStrategy webHtmlElementStrategy,
|
||||||
|
}) = network_image.NetworkImage;
|
||||||
|
|
||||||
/// The URL from which the image will be fetched.
|
/// The URL from which the image will be fetched.
|
||||||
String get url;
|
String get url;
|
||||||
@ -1498,6 +1542,17 @@ abstract class NetworkImage extends ImageProvider<NetworkImage> {
|
|||||||
/// When running Flutter on the web, headers are not used.
|
/// When running Flutter on the web, headers are not used.
|
||||||
Map<String, String>? get headers;
|
Map<String, String>? get headers;
|
||||||
|
|
||||||
|
/// On the Web platform, specifies when the image is loaded as a
|
||||||
|
/// [WebImageInfo], which causes [Image.network] to display the image in an
|
||||||
|
/// HTML element in a platform view.
|
||||||
|
///
|
||||||
|
/// See [Image.network] for more explanation.
|
||||||
|
///
|
||||||
|
/// Defaults to [WebHtmlElementStrategy.never].
|
||||||
|
///
|
||||||
|
/// Has no effect on other platforms, which always fetch bytes.
|
||||||
|
WebHtmlElementStrategy get webHtmlElementStrategy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadBuffer(NetworkImage key, DecoderBufferCallback decode);
|
ImageStreamCompleter loadBuffer(NetworkImage key, DecoderBufferCallback decode);
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import '../painting/_web_image_info_io.dart';
|
|||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
import 'framework.dart';
|
import 'framework.dart';
|
||||||
|
|
||||||
/// A [Widget] that displays an image that is backed by an <img> element.
|
/// A [Widget] that displays an image that is backed by an HTML element.
|
||||||
class RawWebImage extends StatelessWidget {
|
class RawWebImage extends StatelessWidget {
|
||||||
/// Creates a [RawWebImage].
|
/// Creates a [RawWebImage].
|
||||||
RawWebImage({
|
RawWebImage({
|
||||||
@ -22,7 +22,7 @@ class RawWebImage extends StatelessWidget {
|
|||||||
throw UnsupportedError('Cannot create a $RawWebImage when not running on the web');
|
throw UnsupportedError('Cannot create a $RawWebImage when not running on the web');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The underlying `<img>` element to be displayed.
|
/// The underlying HTML element to be displayed.
|
||||||
final WebImageInfo image;
|
final WebImageInfo image;
|
||||||
|
|
||||||
/// A debug label explaining the image.
|
/// A debug label explaining the image.
|
||||||
@ -34,7 +34,7 @@ class RawWebImage extends StatelessWidget {
|
|||||||
/// The requested height for this widget.
|
/// The requested height for this widget.
|
||||||
final double? height;
|
final double? height;
|
||||||
|
|
||||||
/// How the `<img>` should be inscribed in the box constraining it.
|
/// How the HTML element should be inscribed in the box constraining it.
|
||||||
final BoxFit? fit;
|
final BoxFit? fit;
|
||||||
|
|
||||||
/// How the image should be aligned in the box constraining it.
|
/// How the image should be aligned in the box constraining it.
|
||||||
|
@ -54,7 +54,8 @@ class ImgElementPlatformView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A widget which displays and lays out an underlying `<img>` platform view.
|
/// A widget which displays and lays out an underlying HTML element in a
|
||||||
|
/// platform view.
|
||||||
class RawWebImage extends SingleChildRenderObjectWidget {
|
class RawWebImage extends SingleChildRenderObjectWidget {
|
||||||
/// Creates a [RawWebImage].
|
/// Creates a [RawWebImage].
|
||||||
RawWebImage({
|
RawWebImage({
|
||||||
@ -68,7 +69,7 @@ class RawWebImage extends SingleChildRenderObjectWidget {
|
|||||||
this.matchTextDirection = false,
|
this.matchTextDirection = false,
|
||||||
}) : super(child: ImgElementPlatformView(image.htmlImage.src));
|
}) : super(child: ImgElementPlatformView(image.htmlImage.src));
|
||||||
|
|
||||||
/// The underlying `<img>` element to be displayed.
|
/// The underlying HTML element to be displayed.
|
||||||
final WebImageInfo image;
|
final WebImageInfo image;
|
||||||
|
|
||||||
/// A debug label explaining the image.
|
/// A debug label explaining the image.
|
||||||
@ -80,7 +81,7 @@ class RawWebImage extends SingleChildRenderObjectWidget {
|
|||||||
/// The requested height for this widget.
|
/// The requested height for this widget.
|
||||||
final double? height;
|
final double? height;
|
||||||
|
|
||||||
/// How the `<img>` should be inscribed in the box constraining it.
|
/// How the HTML element should be inscribed in the box constraining it.
|
||||||
final BoxFit? fit;
|
final BoxFit? fit;
|
||||||
|
|
||||||
/// How the image should be aligned in the box constraining it.
|
/// How the image should be aligned in the box constraining it.
|
||||||
@ -117,7 +118,7 @@ class RawWebImage extends SingleChildRenderObjectWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lays out and positions the child `<img>` element similarly to [RenderImage].
|
/// Lays out and positions the child HTML element similarly to [RenderImage].
|
||||||
class RenderWebImage extends RenderShiftedBox {
|
class RenderWebImage extends RenderShiftedBox {
|
||||||
/// Creates a new [RenderWebImage].
|
/// Creates a new [RenderWebImage].
|
||||||
RenderWebImage({
|
RenderWebImage({
|
||||||
|
@ -393,6 +393,34 @@ class Image extends StatefulWidget {
|
|||||||
/// In the case where the network image is on the Web platform, the [cacheWidth]
|
/// In the case where the network image is on the Web platform, the [cacheWidth]
|
||||||
/// and [cacheHeight] parameters are ignored as the web engine delegates
|
/// and [cacheHeight] parameters are ignored as the web engine delegates
|
||||||
/// image decoding to the web which does not support custom decode sizes.
|
/// image decoding to the web which does not support custom decode sizes.
|
||||||
|
///
|
||||||
|
/// ### Same-origin policy on Web
|
||||||
|
///
|
||||||
|
/// Due to browser restriction on Cross-Origin Resource Sharing (CORS),
|
||||||
|
/// Flutter on the Web platform can not fetch images from other origins
|
||||||
|
/// (domain, scheme, or port) than the origin that hosts the app, unless the
|
||||||
|
/// image hosting origin explicitly allows so. CORS errors can be resolved
|
||||||
|
/// by configuring the image hosting server. More information can be
|
||||||
|
/// found at Mozilla's introduction on
|
||||||
|
/// [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)
|
||||||
|
/// and
|
||||||
|
/// [CORS errors](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors).
|
||||||
|
///
|
||||||
|
/// If it's not possible to configure the host, such as when images are hosted
|
||||||
|
/// on a CDN or from arbitrary URLs, the app can set the
|
||||||
|
/// `webHtmlElementStrategy` parameter of [Image.network] to display the image
|
||||||
|
/// in an HTML element, which bypasses the same-origin policy.
|
||||||
|
///
|
||||||
|
/// The HTML element is placed in a platform view, and therefore has the
|
||||||
|
/// following drawbacks:
|
||||||
|
///
|
||||||
|
/// * Suboptimal performance.
|
||||||
|
/// * Can't be captured by screenshot widgets.
|
||||||
|
/// * The `headers` argument must be null or empty.
|
||||||
|
/// * Some image options are ignored, including [opacity], [colorBlendMode],
|
||||||
|
/// [repeat], filtering, and blurring.
|
||||||
|
///
|
||||||
|
/// By default, this feature is turned off ([WebHtmlElementStrategy.never]).
|
||||||
Image.network(
|
Image.network(
|
||||||
String src, {
|
String src, {
|
||||||
super.key,
|
super.key,
|
||||||
@ -418,10 +446,16 @@ class Image extends StatefulWidget {
|
|||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
int? cacheWidth,
|
int? cacheWidth,
|
||||||
int? cacheHeight,
|
int? cacheHeight,
|
||||||
|
WebHtmlElementStrategy webHtmlElementStrategy = WebHtmlElementStrategy.never,
|
||||||
}) : image = ResizeImage.resizeIfNeeded(
|
}) : image = ResizeImage.resizeIfNeeded(
|
||||||
cacheWidth,
|
cacheWidth,
|
||||||
cacheHeight,
|
cacheHeight,
|
||||||
NetworkImage(src, scale: scale, headers: headers),
|
NetworkImage(
|
||||||
|
src,
|
||||||
|
scale: scale,
|
||||||
|
headers: headers,
|
||||||
|
webHtmlElementStrategy: webHtmlElementStrategy,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
assert(cacheWidth == null || cacheWidth > 0),
|
assert(cacheWidth == null || cacheWidth > 0),
|
||||||
assert(cacheHeight == null || cacheHeight > 0);
|
assert(cacheHeight == null || cacheHeight > 0);
|
||||||
|
@ -97,7 +97,53 @@ void runTests() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('emits a WebImageInfo if the image is cross-origin', (WidgetTester tester) async {
|
testWidgets('When strategy is default, emits an error if the image is cross-origin', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final TestHttpRequest failingRequest =
|
||||||
|
TestHttpRequest()
|
||||||
|
..status = 500
|
||||||
|
..mockEvent = MockEvent('load', web.Event('bytes inaccessible'))
|
||||||
|
..response = (Uint8List.fromList(<int>[])).buffer;
|
||||||
|
|
||||||
|
httpRequestFactory = () {
|
||||||
|
return failingRequest.getMock() as web_shim.XMLHttpRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
imgElementFactory = () {
|
||||||
|
throw UnimplementedError();
|
||||||
|
};
|
||||||
|
|
||||||
|
const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame4.png');
|
||||||
|
ImageInfo? imageInfo;
|
||||||
|
Object? recordedError;
|
||||||
|
Completer<void>? imageCompleter;
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
imageCompleter = Completer<void>();
|
||||||
|
final ImageStream stream = networkImage.resolve(ImageConfiguration.empty);
|
||||||
|
stream.addListener(
|
||||||
|
ImageStreamListener(
|
||||||
|
(ImageInfo info, bool isSync) {
|
||||||
|
imageInfo = info;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace? stackTrace) {
|
||||||
|
recordedError = error;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await imageCompleter!.future;
|
||||||
|
});
|
||||||
|
expect(recordedError, isNotNull);
|
||||||
|
expect(imageInfo, isNull);
|
||||||
|
}, skip: !isSkiaWeb);
|
||||||
|
|
||||||
|
testWidgets('When strategy is .fallback, emits a WebImageInfo if the image is cross-origin', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
final TestHttpRequest failingRequest =
|
final TestHttpRequest failingRequest =
|
||||||
TestHttpRequest()
|
TestHttpRequest()
|
||||||
..status = 500
|
..status = 500
|
||||||
@ -113,7 +159,10 @@ void runTests() {
|
|||||||
return testImg.getMock() as web_shim.HTMLImageElement;
|
return testImg.getMock() as web_shim.HTMLImageElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame4.png');
|
const NetworkImage networkImage = NetworkImage(
|
||||||
|
'https://www.example.com/images/frame5.png',
|
||||||
|
webHtmlElementStrategy: WebHtmlElementStrategy.fallback,
|
||||||
|
);
|
||||||
ImageInfo? imageInfo;
|
ImageInfo? imageInfo;
|
||||||
Object? recordedError;
|
Object? recordedError;
|
||||||
Completer<void>? imageCompleter;
|
Completer<void>? imageCompleter;
|
||||||
@ -141,28 +190,82 @@ void runTests() {
|
|||||||
expect(imageInfo, isA<WebImageInfo>());
|
expect(imageInfo, isA<WebImageInfo>());
|
||||||
|
|
||||||
final WebImageInfo webImageInfo = imageInfo! as WebImageInfo;
|
final WebImageInfo webImageInfo = imageInfo! as WebImageInfo;
|
||||||
expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame4.png'));
|
expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame5.png'));
|
||||||
}, skip: !isSkiaWeb);
|
}, skip: !isSkiaWeb);
|
||||||
|
|
||||||
testWidgets('emits an error if the image is cross-origin but fails to decode', (
|
testWidgets(
|
||||||
|
'When strategy is .fallback, emits an error if the image is cross-origin but fails to decode',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final TestHttpRequest failingRequest =
|
||||||
|
TestHttpRequest()
|
||||||
|
..status = 500
|
||||||
|
..mockEvent = MockEvent('load', web.Event('bytes inaccessible'))
|
||||||
|
..response = (Uint8List.fromList(<int>[])).buffer;
|
||||||
|
final TestImgElement testImg = TestImgElement();
|
||||||
|
|
||||||
|
httpRequestFactory = () {
|
||||||
|
return failingRequest.getMock() as web_shim.XMLHttpRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
imgElementFactory = () {
|
||||||
|
return testImg.getMock() as web_shim.HTMLImageElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NetworkImage networkImage = NetworkImage(
|
||||||
|
'https://www.example.com/images/frame6.png',
|
||||||
|
webHtmlElementStrategy: WebHtmlElementStrategy.fallback,
|
||||||
|
);
|
||||||
|
ImageInfo? imageInfo;
|
||||||
|
Object? recordedError;
|
||||||
|
Completer<void>? imageCompleter;
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
imageCompleter = Completer<void>();
|
||||||
|
final ImageStream stream = networkImage.resolve(ImageConfiguration.empty);
|
||||||
|
stream.addListener(
|
||||||
|
ImageStreamListener(
|
||||||
|
(ImageInfo info, bool isSync) {
|
||||||
|
imageInfo = info;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace? stackTrace) {
|
||||||
|
recordedError = error;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
testImg.decodeFailure();
|
||||||
|
await imageCompleter!.future;
|
||||||
|
});
|
||||||
|
expect(recordedError, isNotNull);
|
||||||
|
expect(imageInfo, isNull);
|
||||||
|
},
|
||||||
|
skip: !isSkiaWeb,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('When strategy is .prefer, emits an WebImageInfo if the image is same-origin', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) async {
|
||||||
final TestHttpRequest failingRequest =
|
final TestHttpRequest testHttpRequest =
|
||||||
TestHttpRequest()
|
TestHttpRequest()
|
||||||
..status = 500
|
..status = 200
|
||||||
..mockEvent = MockEvent('load', web.Event('bytes inaccessible'))
|
..mockEvent = MockEvent('load', web.Event('test error'))
|
||||||
..response = (Uint8List.fromList(<int>[])).buffer;
|
..response = (Uint8List.fromList(kTransparentImage)).buffer;
|
||||||
final TestImgElement testImg = TestImgElement();
|
final TestImgElement testImg = TestImgElement();
|
||||||
|
|
||||||
httpRequestFactory = () {
|
httpRequestFactory = () {
|
||||||
return failingRequest.getMock() as web_shim.XMLHttpRequest;
|
return testHttpRequest.getMock() as web_shim.XMLHttpRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
imgElementFactory = () {
|
imgElementFactory = () {
|
||||||
return testImg.getMock() as web_shim.HTMLImageElement;
|
return testImg.getMock() as web_shim.HTMLImageElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame5.png');
|
const NetworkImage networkImage = NetworkImage(
|
||||||
|
'https://www.example.com/images/frame7.png',
|
||||||
|
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
|
||||||
|
);
|
||||||
ImageInfo? imageInfo;
|
ImageInfo? imageInfo;
|
||||||
Object? recordedError;
|
Object? recordedError;
|
||||||
Completer<void>? imageCompleter;
|
Completer<void>? imageCompleter;
|
||||||
@ -183,11 +286,65 @@ void runTests() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
testImg.decodeFailure();
|
testImg.decodeSuccess();
|
||||||
await imageCompleter!.future;
|
await imageCompleter!.future;
|
||||||
});
|
});
|
||||||
expect(recordedError, isNotNull);
|
expect(recordedError, isNull);
|
||||||
expect(imageInfo, isNull);
|
expect(imageInfo, isA<WebImageInfo>());
|
||||||
|
|
||||||
|
final WebImageInfo webImageInfo = imageInfo! as WebImageInfo;
|
||||||
|
expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame7.png'));
|
||||||
|
}, skip: !isSkiaWeb);
|
||||||
|
|
||||||
|
testWidgets('When strategy is .prefer, emits a normal image if headers is not null', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final TestHttpRequest testHttpRequest =
|
||||||
|
TestHttpRequest()
|
||||||
|
..status = 200
|
||||||
|
..mockEvent = MockEvent('load', web.Event('test error'))
|
||||||
|
..response = (Uint8List.fromList(kTransparentImage)).buffer;
|
||||||
|
final TestImgElement testImg = TestImgElement();
|
||||||
|
|
||||||
|
httpRequestFactory = () {
|
||||||
|
return testHttpRequest.getMock() as web_shim.XMLHttpRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
imgElementFactory = () {
|
||||||
|
return testImg.getMock() as web_shim.HTMLImageElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NetworkImage networkImage = NetworkImage(
|
||||||
|
'https://www.example.com/images/frame8.png',
|
||||||
|
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
|
||||||
|
headers: <String, String>{'flutter': 'flutter', 'second': 'second'},
|
||||||
|
);
|
||||||
|
ImageInfo? imageInfo;
|
||||||
|
Object? recordedError;
|
||||||
|
Completer<void>? imageCompleter;
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
imageCompleter = Completer<void>();
|
||||||
|
final ImageStream stream = networkImage.resolve(ImageConfiguration.empty);
|
||||||
|
stream.addListener(
|
||||||
|
ImageStreamListener(
|
||||||
|
(ImageInfo info, bool isSync) {
|
||||||
|
imageInfo = info;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace? stackTrace) {
|
||||||
|
recordedError = error;
|
||||||
|
imageCompleter!.complete();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
testImg.decodeSuccess();
|
||||||
|
await imageCompleter!.future;
|
||||||
|
});
|
||||||
|
expect(recordedError, isNull);
|
||||||
|
expect(imageInfo, isNotNull);
|
||||||
|
expect(imageInfo, isNot(isA<WebImageInfo>()));
|
||||||
}, skip: !isSkiaWeb);
|
}, skip: !isSkiaWeb);
|
||||||
|
|
||||||
testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo', (
|
testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo', (
|
||||||
|
@ -129,10 +129,23 @@ class TestImgElement {
|
|||||||
int naturalWidth = -1;
|
int naturalWidth = -1;
|
||||||
int naturalHeight = -1;
|
int naturalHeight = -1;
|
||||||
|
|
||||||
late JSFunction _resolveFunc;
|
// Either `decode` or `decodeSuccess/Failure` may be called first.
|
||||||
late JSFunction _rejectFunc;
|
// The following fields allow properly handling either case.
|
||||||
|
bool _callbacksAssigned = false;
|
||||||
|
late final JSFunction _resolveFunc;
|
||||||
|
late final JSFunction _rejectFunc;
|
||||||
|
|
||||||
|
bool _resultAssigned = false;
|
||||||
|
late final bool _resultSuccessful;
|
||||||
|
|
||||||
JSPromise<JSAny?> decode() {
|
JSPromise<JSAny?> decode() {
|
||||||
|
if (_resultAssigned) {
|
||||||
|
return switch (_resultSuccessful) {
|
||||||
|
true => Future<JSAny?>.value().toJS,
|
||||||
|
false => Future<JSAny?>.error(Error()).toJS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_callbacksAssigned = true;
|
||||||
return JSPromise<JSAny?>(
|
return JSPromise<JSAny?>(
|
||||||
(JSFunction resolveFunc, JSFunction rejectFunc) {
|
(JSFunction resolveFunc, JSFunction rejectFunc) {
|
||||||
_resolveFunc = resolveFunc;
|
_resolveFunc = resolveFunc;
|
||||||
@ -142,11 +155,21 @@ class TestImgElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void decodeSuccess() {
|
void decodeSuccess() {
|
||||||
_resolveFunc.callAsFunction();
|
if (_callbacksAssigned) {
|
||||||
|
_resolveFunc.callAsFunction();
|
||||||
|
} else {
|
||||||
|
_resultAssigned = true;
|
||||||
|
_resultSuccessful = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void decodeFailure() {
|
void decodeFailure() {
|
||||||
_rejectFunc.callAsFunction();
|
if (_callbacksAssigned) {
|
||||||
|
_rejectFunc.callAsFunction();
|
||||||
|
} else {
|
||||||
|
_resultAssigned = true;
|
||||||
|
_resultSuccessful = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
web.HTMLImageElement getMock() => _mock as web.HTMLImageElement;
|
web.HTMLImageElement getMock() => _mock as web.HTMLImageElement;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user