[web] On the web platform, use an <img> tag to show an image if it can't be accessed with CORS (#157755)
When using `Image.network`, if the given URL points to an image that is cross-origin and not set up to allow CORS access, then we are unable to make a [ui.Image] from it. In this case, render the image using a platform view. This is the last remaining checklist item for https://github.com/flutter/flutter/issues/145954 Fixes https://github.com/flutter/flutter/issues/149843 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] 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
d541354936
commit
c519383bcc
@ -10,28 +10,52 @@ import 'dart:ui_web' as ui_web;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../web.dart' as web;
|
||||
import '_web_image_info_web.dart';
|
||||
import 'image_provider.dart' as image_provider;
|
||||
import 'image_stream.dart';
|
||||
|
||||
/// Creates a type for an overridable factory function for testing purposes.
|
||||
/// The type for an overridable factory function for creating an HTTP request,
|
||||
/// used for testing purposes.
|
||||
typedef HttpRequestFactory = web.XMLHttpRequest Function();
|
||||
|
||||
/// The type for an overridable factory function for creating <img> elements,
|
||||
/// used for testing purposes.
|
||||
typedef ImgElementFactory = web.HTMLImageElement Function();
|
||||
|
||||
// Method signature for _loadAsync decode callbacks.
|
||||
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
|
||||
|
||||
/// Default HTTP client.
|
||||
/// The default HTTP client.
|
||||
web.XMLHttpRequest _httpClient() {
|
||||
return web.XMLHttpRequest();
|
||||
}
|
||||
|
||||
/// Creates an overridable factory function.
|
||||
@visibleForTesting
|
||||
HttpRequestFactory httpRequestFactory = _httpClient;
|
||||
|
||||
/// Restores to the default HTTP request factory.
|
||||
/// Restores the default HTTP request factory.
|
||||
@visibleForTesting
|
||||
void debugRestoreHttpRequestFactory() {
|
||||
httpRequestFactory = _httpClient;
|
||||
}
|
||||
|
||||
/// The default <img> element factory.
|
||||
web.HTMLImageElement _imgElementFactory() {
|
||||
return web.document.createElement('img') as web.HTMLImageElement;
|
||||
}
|
||||
|
||||
/// The factory function that creates <img> elements, can be overridden for
|
||||
/// tests.
|
||||
@visibleForTesting
|
||||
ImgElementFactory imgElementFactory = _imgElementFactory;
|
||||
|
||||
/// Restores the default <img> element factory.
|
||||
@visibleForTesting
|
||||
void debugRestoreImgElementFactory() {
|
||||
imgElementFactory = _imgElementFactory;
|
||||
}
|
||||
|
||||
/// The web implementation of [image_provider.NetworkImage].
|
||||
///
|
||||
/// NetworkImage on the web does not support decoding to a specified size.
|
||||
@ -64,12 +88,14 @@ class NetworkImage
|
||||
final StreamController<ImageChunkEvent> chunkEvents =
|
||||
StreamController<ImageChunkEvent>();
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
chunkEvents: chunkEvents.stream,
|
||||
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
|
||||
scale: key.scale,
|
||||
debugLabel: key.url,
|
||||
return _ForwardingImageStreamCompleter(
|
||||
_loadAsync(
|
||||
key as NetworkImage,
|
||||
decode,
|
||||
chunkEvents,
|
||||
),
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
debugLabel: key.url,
|
||||
);
|
||||
}
|
||||
|
||||
@ -80,12 +106,14 @@ class NetworkImage
|
||||
// has been loaded or an error is thrown.
|
||||
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
chunkEvents: chunkEvents.stream,
|
||||
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
|
||||
scale: key.scale,
|
||||
debugLabel: key.url,
|
||||
return _ForwardingImageStreamCompleter(
|
||||
_loadAsync(
|
||||
key as NetworkImage,
|
||||
decode,
|
||||
chunkEvents,
|
||||
),
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
debugLabel: key.url,
|
||||
);
|
||||
}
|
||||
|
||||
@ -101,10 +129,10 @@ class NetworkImage
|
||||
return collector;
|
||||
}
|
||||
|
||||
// Html renderer does not support decoding network images to a specified size. The decode parameter
|
||||
// HTML renderer does not support decoding network images to a specified size. The decode parameter
|
||||
// here is ignored and `ui_web.createImageCodecFromUrl` will be used directly
|
||||
// in place of the typical `instantiateImageCodec` method.
|
||||
Future<ui.Codec> _loadAsync(
|
||||
Future<ImageStreamCompleter> _loadAsync(
|
||||
NetworkImage key,
|
||||
_SimpleDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
@ -117,62 +145,143 @@ class NetworkImage
|
||||
|
||||
// We use a different method when headers are set because the
|
||||
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
|
||||
if (isSkiaWeb || containsNetworkImageHeaders) {
|
||||
final Completer<web.XMLHttpRequest> completer =
|
||||
Completer<web.XMLHttpRequest>();
|
||||
final web.XMLHttpRequest request = httpRequestFactory();
|
||||
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.
|
||||
|
||||
request.open('GET', key.url, true);
|
||||
request.responseType = 'arraybuffer';
|
||||
if (containsNetworkImageHeaders) {
|
||||
key.headers!.forEach((String header, String value) {
|
||||
request.setRequestHeader(header, value);
|
||||
});
|
||||
// Resolve the Codec before passing it to
|
||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
||||
// twice (once from the MultiFrameImageStreamCompleter and again
|
||||
// from the wrapping [ForwardingImageStreamCompleter]).
|
||||
final ui.Codec codec = await _fetchImageBytes(decode);
|
||||
return MultiFrameImageStreamCompleter(
|
||||
chunkEvents: chunkEvents.stream,
|
||||
codec: Future<ui.Codec>.value(codec),
|
||||
scale: key.scale,
|
||||
debugLabel: key.url,
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
);
|
||||
} else if (isSkiaWeb) {
|
||||
try {
|
||||
// Resolve the Codec before passing it to
|
||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
||||
// twice (once from the MultiFrameImageStreamCompleter and again
|
||||
// from the wrapping [ForwardingImageStreamCompleter]).
|
||||
final ui.Codec codec = await _fetchImageBytes(decode);
|
||||
return MultiFrameImageStreamCompleter(
|
||||
chunkEvents: chunkEvents.stream,
|
||||
codec: Future<ui.Codec>.value(codec),
|
||||
scale: key.scale,
|
||||
debugLabel: key.url,
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
);
|
||||
} catch (e) {
|
||||
// If we failed to fetch the bytes, try to load the image in an <img>
|
||||
// element instead.
|
||||
final web.HTMLImageElement imageElement = imgElementFactory();
|
||||
imageElement.src = key.url;
|
||||
// Decode the <img> element before creating the ImageStreamCompleter
|
||||
// to avoid double reporting the error.
|
||||
await imageElement.decode().toDart;
|
||||
return OneFrameImageStreamCompleter(
|
||||
Future<ImageInfo>.value(
|
||||
WebImageInfo(
|
||||
imageElement,
|
||||
debugLabel: key.url,
|
||||
),
|
||||
),
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
)..debugLabel = key.url;
|
||||
}
|
||||
|
||||
request.addEventListener('load', (web.Event e) {
|
||||
final int status = request.status;
|
||||
final bool accepted = status >= 200 && status < 300;
|
||||
final bool fileUri = status == 0; // file:// URIs have status of 0.
|
||||
final bool notModified = status == 304;
|
||||
final bool unknownRedirect = status > 307 && status < 400;
|
||||
final bool success =
|
||||
accepted || fileUri || notModified || unknownRedirect;
|
||||
|
||||
if (success) {
|
||||
completer.complete(request);
|
||||
} else {
|
||||
completer.completeError(e);
|
||||
throw image_provider.NetworkImageLoadException(
|
||||
statusCode: status, uri: resolved);
|
||||
}
|
||||
}.toJS);
|
||||
|
||||
request.addEventListener('error',
|
||||
((JSObject e) => completer.completeError(e)).toJS);
|
||||
|
||||
request.send();
|
||||
|
||||
await completer.future;
|
||||
|
||||
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
|
||||
|
||||
if (bytes.lengthInBytes == 0) {
|
||||
throw image_provider.NetworkImageLoadException(
|
||||
statusCode: request.status, uri: resolved);
|
||||
}
|
||||
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
|
||||
} else {
|
||||
return ui_web.createImageCodecFromUrl(
|
||||
resolved,
|
||||
chunkCallback: (int bytes, int total) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
|
||||
},
|
||||
// This branch is only hit by the HTML renderer, which is deprecated. The
|
||||
// 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
|
||||
// in this case.
|
||||
|
||||
// Resolve the Codec before passing it to
|
||||
// [MultiFrameImageStreamCompleter] so any errors aren't reported
|
||||
// twice (once from the MultiFrameImageStreamCompleter) and again
|
||||
// from the wrapping [ForwardingImageStreamCompleter].
|
||||
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
|
||||
resolved,
|
||||
chunkCallback: (int bytes, int total) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
|
||||
},
|
||||
);
|
||||
return MultiFrameImageStreamCompleter(
|
||||
chunkEvents: chunkEvents.stream,
|
||||
codec: Future<ui.Codec>.value(codec),
|
||||
scale: key.scale,
|
||||
debugLabel: key.url,
|
||||
informationCollector: _imageStreamInformationCollector(key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Codec> _fetchImageBytes(
|
||||
_SimpleDecoderCallback decode,
|
||||
) async {
|
||||
final Uri resolved = Uri.base.resolve(url);
|
||||
|
||||
final bool containsNetworkImageHeaders = headers?.isNotEmpty ?? false;
|
||||
|
||||
final Completer<web.XMLHttpRequest> completer =
|
||||
Completer<web.XMLHttpRequest>();
|
||||
final web.XMLHttpRequest request = httpRequestFactory();
|
||||
|
||||
request.open('GET', url, true);
|
||||
request.responseType = 'arraybuffer';
|
||||
if (containsNetworkImageHeaders) {
|
||||
headers!.forEach((String header, String value) {
|
||||
request.setRequestHeader(header, value);
|
||||
});
|
||||
}
|
||||
|
||||
request.addEventListener('load', (web.Event e) {
|
||||
final int status = request.status;
|
||||
final bool accepted = status >= 200 && status < 300;
|
||||
final bool fileUri = status == 0; // file:// URIs have status of 0.
|
||||
final bool notModified = status == 304;
|
||||
final bool unknownRedirect = status > 307 && status < 400;
|
||||
final bool success =
|
||||
accepted || fileUri || notModified || unknownRedirect;
|
||||
|
||||
if (success) {
|
||||
completer.complete(request);
|
||||
} else {
|
||||
completer.completeError(image_provider.NetworkImageLoadException(
|
||||
statusCode: status, uri: resolved));
|
||||
}
|
||||
}.toJS);
|
||||
|
||||
request.addEventListener(
|
||||
'error',
|
||||
((JSObject e) =>
|
||||
completer.completeError(image_provider.NetworkImageLoadException(
|
||||
statusCode: request.status,
|
||||
uri: resolved,
|
||||
))).toJS,
|
||||
);
|
||||
|
||||
request.send();
|
||||
|
||||
await completer.future;
|
||||
|
||||
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
|
||||
|
||||
if (bytes.lengthInBytes == 0) {
|
||||
throw image_provider.NetworkImageLoadException(
|
||||
statusCode: request.status, uri: resolved);
|
||||
}
|
||||
|
||||
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) {
|
||||
@ -187,3 +296,61 @@ class NetworkImage
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'NetworkImage')}("$url", scale: ${scale.toStringAsFixed(1)})';
|
||||
}
|
||||
|
||||
/// An [ImageStreamCompleter] that delegates to another [ImageStreamCompleter]
|
||||
/// that is loaded asynchronously.
|
||||
///
|
||||
/// This completer keeps its child completer alive until this completer is disposed.
|
||||
class _ForwardingImageStreamCompleter extends ImageStreamCompleter {
|
||||
_ForwardingImageStreamCompleter(this.task,
|
||||
{InformationCollector? informationCollector, String? debugLabel}) {
|
||||
this.debugLabel = debugLabel;
|
||||
task.then((ImageStreamCompleter value) {
|
||||
resolved = true;
|
||||
if (_disposed) {
|
||||
// Add a listener since the delegate completer won't dispose if it never
|
||||
// had a listener.
|
||||
value.addListener(ImageStreamListener((_, __) {}));
|
||||
value.maybeDispose();
|
||||
return;
|
||||
}
|
||||
completer = value;
|
||||
handle = completer.keepAlive();
|
||||
completer.addListener(ImageStreamListener(
|
||||
(ImageInfo image, bool synchronousCall) {
|
||||
setImage(image);
|
||||
},
|
||||
onChunk: (ImageChunkEvent event) {
|
||||
reportImageChunkEvent(event);
|
||||
},
|
||||
onError:(Object exception, StackTrace? stackTrace) {
|
||||
reportError(exception: exception, stack: stackTrace);
|
||||
},
|
||||
));
|
||||
}, onError: (Object error, StackTrace stack) {
|
||||
reportError(
|
||||
context: ErrorDescription('resolving an image stream completer'),
|
||||
exception: error,
|
||||
stack: stack,
|
||||
informationCollector: informationCollector,
|
||||
silent: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final Future<ImageStreamCompleter> task;
|
||||
bool resolved = false;
|
||||
late final ImageStreamCompleter completer;
|
||||
late final ImageStreamCompleterHandle handle;
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void onDisposed() {
|
||||
if (resolved) {
|
||||
handle.dispose();
|
||||
}
|
||||
_disposed = true;
|
||||
super.onDisposed();
|
||||
}
|
||||
}
|
||||
|
40
packages/flutter/lib/src/painting/_web_image_info_io.dart
Normal file
40
packages/flutter/lib/src/painting/_web_image_info_io.dart
Normal file
@ -0,0 +1,40 @@
|
||||
// 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:ui' as ui;
|
||||
|
||||
import 'image_stream.dart';
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// fetched, [ui.Image]s cannot be created from it. However, the image can
|
||||
/// still be displayed if an <img> element is used.
|
||||
class WebImageInfo implements ImageInfo {
|
||||
@override
|
||||
ImageInfo clone() => _unsupported();
|
||||
|
||||
@override
|
||||
String? get debugLabel => _unsupported();
|
||||
|
||||
@override
|
||||
void dispose() => _unsupported();
|
||||
|
||||
@override
|
||||
ui.Image get image => _unsupported();
|
||||
|
||||
@override
|
||||
bool isCloneOf(ImageInfo other) => _unsupported();
|
||||
|
||||
@override
|
||||
double get scale => _unsupported();
|
||||
|
||||
@override
|
||||
int get sizeBytes => _unsupported();
|
||||
|
||||
Never _unsupported() => throw UnsupportedError(
|
||||
'WebImageInfo should never be instantiated in a non-web context.');
|
||||
}
|
70
packages/flutter/lib/src/painting/_web_image_info_web.dart
Normal file
70
packages/flutter/lib/src/painting/_web_image_info_web.dart
Normal file
@ -0,0 +1,70 @@
|
||||
// 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:ui';
|
||||
|
||||
import '../web.dart' as web;
|
||||
import 'image_stream.dart';
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// fetched, [ui.Image]s cannot be created from it. However, the image can
|
||||
/// still be displayed if an <img> element is used.
|
||||
class WebImageInfo implements ImageInfo {
|
||||
/// Creates a new [WebImageInfo] from a given <img> element.
|
||||
WebImageInfo(this.htmlImage, {this.debugLabel});
|
||||
|
||||
/// The <img> element used to display this image. This <img> element has
|
||||
/// already been decoded, so size information can be retrieved from it.
|
||||
final web.HTMLImageElement htmlImage;
|
||||
|
||||
@override
|
||||
final String? debugLabel;
|
||||
|
||||
@override
|
||||
WebImageInfo clone() {
|
||||
// There is no need to actually clone the <img> element here. We create
|
||||
// another reference to the <img> element and let the browser garbage
|
||||
// collect it when there are no more live references.
|
||||
return WebImageInfo(
|
||||
htmlImage,
|
||||
debugLabel: debugLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// There is nothing to do here. There is no way to delete an element
|
||||
// directly, the most we can do is remove it from the DOM. But the <img>
|
||||
// element here is never even added to the DOM. The browser will
|
||||
// automatically garbage collect the element when there are no longer any
|
||||
// live references to it.
|
||||
}
|
||||
|
||||
@override
|
||||
Image get image => throw UnsupportedError(
|
||||
'Could not create image data for this image because access to it is '
|
||||
'restricted by the Same-Origin Policy.\n'
|
||||
'See https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy');
|
||||
|
||||
@override
|
||||
bool isCloneOf(ImageInfo other) {
|
||||
if (other is! WebImageInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// It is a clone if it points to the same <img> element.
|
||||
return other.htmlImage == htmlImage && other.debugLabel == debugLabel;
|
||||
}
|
||||
|
||||
@override
|
||||
double get scale => 1.0;
|
||||
|
||||
@override
|
||||
int get sizeBytes =>
|
||||
(4 * htmlImage.naturalWidth * htmlImage.naturalHeight).toInt();
|
||||
}
|
@ -690,6 +690,24 @@ abstract class ImageStreamCompleter with Diagnosticable {
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
/// Called when this [ImageStreamCompleter] has actually been disposed.
|
||||
///
|
||||
/// Subclasses should override this if they need to clean up resources when
|
||||
/// they are disposed.
|
||||
@mustCallSuper
|
||||
@protected
|
||||
void onDisposed() {}
|
||||
|
||||
/// Disposes this [ImageStreamCompleter] unless:
|
||||
/// 1. It has never had a listener
|
||||
/// 2. It is already disposed
|
||||
/// 3. It has listeners.
|
||||
/// 4. It has active "keep alive" handles.
|
||||
@nonVirtual
|
||||
void maybeDispose() {
|
||||
_maybeDispose();
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
void _maybeDispose() {
|
||||
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
|
||||
@ -700,6 +718,7 @@ abstract class ImageStreamCompleter with Diagnosticable {
|
||||
_currentImage?.dispose();
|
||||
_currentImage = null;
|
||||
_disposed = true;
|
||||
onDisposed();
|
||||
}
|
||||
|
||||
void _checkDisposed() {
|
||||
|
@ -67,6 +67,7 @@ extension type DOMTokenList._(JSObject _) implements JSObject {
|
||||
extension type Element._(JSObject _) implements Node, JSObject {
|
||||
external DOMTokenList get classList;
|
||||
external void append(JSAny nodes);
|
||||
external void remove();
|
||||
}
|
||||
|
||||
extension type Event._(JSObject _) implements JSObject {}
|
||||
@ -83,6 +84,7 @@ extension type HTMLElement._(JSObject _) implements Element, JSObject {
|
||||
external String get innerText;
|
||||
external set innerText(String value);
|
||||
external CSSStyleDeclaration get style;
|
||||
external HTMLElement cloneNode(bool deep);
|
||||
}
|
||||
|
||||
extension type HTMLHeadElement._(JSObject _) implements HTMLElement, JSObject {}
|
||||
@ -91,6 +93,22 @@ extension type HTMLStyleElement._(JSObject _) implements HTMLElement, JSObject {
|
||||
external CSSStyleSheet? get sheet;
|
||||
}
|
||||
|
||||
extension type HTMLImageElement._(JSObject _) implements HTMLElement, JSObject {
|
||||
external String get src;
|
||||
external set src(String value);
|
||||
external num get naturalWidth;
|
||||
external num get naturalHeight;
|
||||
external JSPromise<JSAny?> decode();
|
||||
}
|
||||
|
||||
extension type HTMLCanvasElement._(JSObject _) implements HTMLElement, JSObject {
|
||||
external int get width;
|
||||
external set width(int value);
|
||||
external int get height;
|
||||
external set height(int value);
|
||||
external String toDataURL();
|
||||
}
|
||||
|
||||
extension type MediaQueryList._(JSObject _) implements EventTarget, JSObject {
|
||||
external bool get matches;
|
||||
}
|
||||
@ -120,6 +138,7 @@ extension type Window._(JSObject _) implements EventTarget, JSObject {
|
||||
external Navigator get navigator;
|
||||
external MediaQueryList matchMedia(String query);
|
||||
external Selection? getSelection();
|
||||
external String get origin;
|
||||
}
|
||||
|
||||
extension type XMLHttpRequest._(JSObject _)
|
||||
|
50
packages/flutter/lib/src/widgets/_web_image_io.dart
Normal file
50
packages/flutter/lib/src/widgets/_web_image_io.dart
Normal file
@ -0,0 +1,50 @@
|
||||
// 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 '../painting/_web_image_info_io.dart';
|
||||
import 'basic.dart';
|
||||
import 'framework.dart';
|
||||
|
||||
/// A [Widget] that displays an image that is backed by an <img> element.
|
||||
class RawWebImage extends StatelessWidget {
|
||||
/// Creates a [RawWebImage].
|
||||
RawWebImage({
|
||||
super.key,
|
||||
required this.image,
|
||||
this.debugImageLabel,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.matchTextDirection = false,
|
||||
}) {
|
||||
throw UnsupportedError('Cannot create a $RawWebImage when not running on the web');
|
||||
}
|
||||
|
||||
/// The underlying `<img>` element to be displayed.
|
||||
final WebImageInfo image;
|
||||
|
||||
/// A debug label explaining the image.
|
||||
final String? debugImageLabel;
|
||||
|
||||
/// The requested width for this widget.
|
||||
final double? width;
|
||||
|
||||
/// The requested height for this widget.
|
||||
final double? height;
|
||||
|
||||
/// How the `<img>` should be inscribed in the box constraining it.
|
||||
final BoxFit? fit;
|
||||
|
||||
/// How the image should be aligned in the box constraining it.
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
/// Whether or not the alignment of the image should match the text direction.
|
||||
final bool matchTextDirection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
throw UnsupportedError('It is impossible to instantiate a RawWebImage when not running on the web');
|
||||
}
|
||||
}
|
382
packages/flutter/lib/src/widgets/_web_image_web.dart
Normal file
382
packages/flutter/lib/src/widgets/_web_image_web.dart
Normal file
@ -0,0 +1,382 @@
|
||||
// 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:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../painting/_web_image_info_web.dart';
|
||||
import '../rendering/box.dart';
|
||||
import '../rendering/shifted_box.dart';
|
||||
import '../web.dart' as web;
|
||||
import 'basic.dart';
|
||||
import 'framework.dart';
|
||||
import 'platform_view.dart';
|
||||
|
||||
/// Displays an `<img>` element with `src` set to [src].
|
||||
class ImgElementPlatformView extends StatelessWidget {
|
||||
/// Creates a platform view backed with an `<img>` element.
|
||||
ImgElementPlatformView(this.src, {super.key}) {
|
||||
if (!_registered) {
|
||||
_register();
|
||||
}
|
||||
}
|
||||
|
||||
static const String _viewType = 'Flutter__ImgElementImage__';
|
||||
static bool _registered = false;
|
||||
|
||||
static void _register() {
|
||||
assert(!_registered);
|
||||
_registered = true;
|
||||
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId,
|
||||
{Object? params}) {
|
||||
final Map<Object?, Object?> paramsMap = params! as Map<Object?, Object?>;
|
||||
// Create a new <img> element. The browser is able to display the image
|
||||
// without fetching it over the network again.
|
||||
final web.HTMLImageElement img =
|
||||
web.document.createElement('img') as web.HTMLImageElement;
|
||||
img.src = paramsMap['src']! as String;
|
||||
return img;
|
||||
});
|
||||
}
|
||||
|
||||
/// The `src` URL for the `<img>` tag.
|
||||
final String? src;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (src == null) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
return HtmlElementView(
|
||||
viewType: _viewType,
|
||||
creationParams: <String, String?>{'src': src},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget which displays and lays out an underlying `<img>` platform view.
|
||||
class RawWebImage extends SingleChildRenderObjectWidget {
|
||||
/// Creates a [RawWebImage].
|
||||
RawWebImage({
|
||||
super.key,
|
||||
required this.image,
|
||||
this.debugImageLabel,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.matchTextDirection = false,
|
||||
}) : super(child: ImgElementPlatformView(image.htmlImage.src));
|
||||
|
||||
/// The underlying `<img>` element to be displayed.
|
||||
final WebImageInfo image;
|
||||
|
||||
/// A debug label explaining the image.
|
||||
final String? debugImageLabel;
|
||||
|
||||
/// The requested width for this widget.
|
||||
final double? width;
|
||||
|
||||
/// The requested height for this widget.
|
||||
final double? height;
|
||||
|
||||
/// How the `<img>` should be inscribed in the box constraining it.
|
||||
final BoxFit? fit;
|
||||
|
||||
/// How the image should be aligned in the box constraining it.
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
/// Whether or not the alignment of the image should match the text direction.
|
||||
final bool matchTextDirection;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderWebImage(
|
||||
image: image.htmlImage,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
alignment: alignment,
|
||||
matchTextDirection: matchTextDirection,
|
||||
textDirection: matchTextDirection || alignment is! Alignment
|
||||
? Directionality.of(context)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderWebImage renderObject) {
|
||||
renderObject
|
||||
..image = image.htmlImage
|
||||
..width = width
|
||||
..height = height
|
||||
..fit = fit
|
||||
..alignment = alignment
|
||||
..matchTextDirection = matchTextDirection
|
||||
..textDirection = matchTextDirection || alignment is! Alignment
|
||||
? Directionality.of(context)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out and positions the child `<img>` element similarly to [RenderImage].
|
||||
class RenderWebImage extends RenderShiftedBox {
|
||||
/// Creates a new [RenderWebImage].
|
||||
RenderWebImage({
|
||||
RenderBox? child,
|
||||
required web.HTMLImageElement image,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit? fit,
|
||||
AlignmentGeometry alignment = Alignment.center,
|
||||
bool matchTextDirection = false,
|
||||
TextDirection? textDirection,
|
||||
}) : _image = image,
|
||||
_width = width,
|
||||
_height = height,
|
||||
_fit = fit,
|
||||
_alignment = alignment,
|
||||
_matchTextDirection = matchTextDirection,
|
||||
_textDirection = textDirection,
|
||||
super(child);
|
||||
|
||||
Alignment? _resolvedAlignment;
|
||||
bool? _flipHorizontally;
|
||||
|
||||
void _resolve() {
|
||||
if (_resolvedAlignment != null) {
|
||||
return;
|
||||
}
|
||||
_resolvedAlignment = alignment.resolve(textDirection);
|
||||
_flipHorizontally =
|
||||
matchTextDirection && textDirection == TextDirection.rtl;
|
||||
}
|
||||
|
||||
void _markNeedResolution() {
|
||||
_resolvedAlignment = null;
|
||||
_flipHorizontally = null;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
/// Whether to paint the image in the direction of the [TextDirection].
|
||||
///
|
||||
/// If this is true, then in [TextDirection.ltr] contexts, the image will be
|
||||
/// drawn with its origin in the top left (the "normal" painting direction for
|
||||
/// images); and in [TextDirection.rtl] contexts, the image will be drawn with
|
||||
/// a scaling factor of -1 in the horizontal direction so that the origin is
|
||||
/// in the top right.
|
||||
///
|
||||
/// This is occasionally used with images in right-to-left environments, for
|
||||
/// images that were designed for left-to-right locales. Be careful, when
|
||||
/// using this, to not flip images with integral shadows, text, or other
|
||||
/// effects that will look incorrect when flipped.
|
||||
///
|
||||
/// If this is set to true, [textDirection] must not be null.
|
||||
bool get matchTextDirection => _matchTextDirection;
|
||||
bool _matchTextDirection;
|
||||
set matchTextDirection(bool value) {
|
||||
if (value == _matchTextDirection) {
|
||||
return;
|
||||
}
|
||||
_matchTextDirection = value;
|
||||
_markNeedResolution();
|
||||
}
|
||||
|
||||
/// The text direction with which to resolve [alignment].
|
||||
///
|
||||
/// This may be changed to null, but only after the [alignment] and
|
||||
/// [matchTextDirection] properties have been changed to values that do not
|
||||
/// depend on the direction.
|
||||
TextDirection? get textDirection => _textDirection;
|
||||
TextDirection? _textDirection;
|
||||
set textDirection(TextDirection? value) {
|
||||
if (_textDirection == value) {
|
||||
return;
|
||||
}
|
||||
_textDirection = value;
|
||||
_markNeedResolution();
|
||||
}
|
||||
|
||||
/// The image to display.
|
||||
web.HTMLImageElement get image => _image;
|
||||
web.HTMLImageElement _image;
|
||||
set image(web.HTMLImageElement value) {
|
||||
if (value == _image) {
|
||||
return;
|
||||
}
|
||||
// If we get a clone of our image, it's the same underlying native data -
|
||||
// return early.
|
||||
if (value.src == _image.src) {
|
||||
return;
|
||||
}
|
||||
final bool sizeChanged = _image.naturalWidth != value.naturalWidth ||
|
||||
_image.naturalHeight != value.naturalHeight;
|
||||
_image = value;
|
||||
markNeedsPaint();
|
||||
if (sizeChanged && (_width == null || _height == null)) {
|
||||
markNeedsLayout();
|
||||
}
|
||||
}
|
||||
|
||||
/// If non-null, requires the image to have this width.
|
||||
///
|
||||
/// If null, the image will pick a size that best preserves its intrinsic
|
||||
/// aspect ratio.
|
||||
double? get width => _width;
|
||||
double? _width;
|
||||
set width(double? value) {
|
||||
if (value == _width) {
|
||||
return;
|
||||
}
|
||||
_width = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// If non-null, require the image to have this height.
|
||||
///
|
||||
/// If null, the image will pick a size that best preserves its intrinsic
|
||||
/// aspect ratio.
|
||||
double? get height => _height;
|
||||
double? _height;
|
||||
set height(double? value) {
|
||||
if (value == _height) {
|
||||
return;
|
||||
}
|
||||
_height = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How to inscribe the image into the space allocated during layout.
|
||||
///
|
||||
/// The default varies based on the other fields. See the discussion at
|
||||
/// [paintImage].
|
||||
BoxFit? get fit => _fit;
|
||||
BoxFit? _fit;
|
||||
set fit(BoxFit? value) {
|
||||
if (value == _fit) {
|
||||
return;
|
||||
}
|
||||
_fit = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
/// How to align the image within its bounds.
|
||||
///
|
||||
/// If this is set to a text-direction-dependent value, [textDirection] must
|
||||
/// not be null.
|
||||
AlignmentGeometry get alignment => _alignment;
|
||||
AlignmentGeometry _alignment;
|
||||
set alignment(AlignmentGeometry value) {
|
||||
if (value == _alignment) {
|
||||
return;
|
||||
}
|
||||
_alignment = value;
|
||||
_markNeedResolution();
|
||||
}
|
||||
|
||||
/// Find a size for the render image within the given constraints.
|
||||
///
|
||||
/// - The dimensions of the RenderImage must fit within the constraints.
|
||||
/// - The aspect ratio of the RenderImage matches the intrinsic aspect
|
||||
/// ratio of the image.
|
||||
/// - The RenderImage's dimension are maximal subject to being smaller than
|
||||
/// the intrinsic size of the image.
|
||||
Size _sizeForConstraints(BoxConstraints constraints) {
|
||||
// Folds the given |width| and |height| into |constraints| so they can all
|
||||
// be treated uniformly.
|
||||
constraints = BoxConstraints.tightFor(
|
||||
width: _width,
|
||||
height: _height,
|
||||
).enforce(constraints);
|
||||
|
||||
return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(
|
||||
_image.naturalWidth.toDouble(),
|
||||
_image.naturalHeight.toDouble(),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) {
|
||||
assert(height >= 0.0);
|
||||
if (_width == null && _height == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return _sizeForConstraints(BoxConstraints.tightForFinite(height: height))
|
||||
.width;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) {
|
||||
assert(height >= 0.0);
|
||||
return _sizeForConstraints(BoxConstraints.tightForFinite(height: height))
|
||||
.width;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) {
|
||||
assert(width >= 0.0);
|
||||
if (_width == null && _height == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return _sizeForConstraints(BoxConstraints.tightForFinite(width: width))
|
||||
.height;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) {
|
||||
assert(width >= 0.0);
|
||||
return _sizeForConstraints(BoxConstraints.tightForFinite(width: width))
|
||||
.height;
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestSelf(Offset position) => true;
|
||||
|
||||
@override
|
||||
@protected
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
return _sizeForConstraints(constraints);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
_resolve();
|
||||
assert(_resolvedAlignment != null);
|
||||
assert(_flipHorizontally != null);
|
||||
size = _sizeForConstraints(constraints);
|
||||
|
||||
if (child == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Size inputSize =
|
||||
Size(image.naturalWidth.toDouble(), image.naturalHeight.toDouble());
|
||||
fit ??= BoxFit.scaleDown;
|
||||
final FittedSizes fittedSizes = applyBoxFit(fit!, inputSize, size);
|
||||
final Size childSize = fittedSizes.destination;
|
||||
child!.layout(BoxConstraints.tight(childSize));
|
||||
final double halfWidthDelta = (size.width - childSize.width) / 2.0;
|
||||
final double halfHeightDelta = (size.height - childSize.height) / 2.0;
|
||||
final double dx = halfWidthDelta +
|
||||
(_flipHorizontally! ? -_resolvedAlignment!.x : _resolvedAlignment!.x) *
|
||||
halfWidthDelta;
|
||||
final double dy =
|
||||
halfHeightDelta + _resolvedAlignment!.y * halfHeightDelta;
|
||||
final BoxParentData childParentData = child!.parentData! as BoxParentData;
|
||||
childParentData.offset = Offset(dx, dy);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DiagnosticsProperty<web.HTMLImageElement>('image', image));
|
||||
properties.add(DoubleProperty('width', width, defaultValue: null));
|
||||
properties.add(DoubleProperty('height', height, defaultValue: null));
|
||||
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<AlignmentGeometry>(
|
||||
'alignment', alignment,
|
||||
defaultValue: null));
|
||||
}
|
||||
}
|
@ -17,6 +17,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
import '../painting/_web_image_info_io.dart' if (dart.library.js_util) '../painting/_web_image_info_web.dart';
|
||||
import '_web_image_io.dart' if (dart.library.js_util) '_web_image_web.dart';
|
||||
import 'basic.dart';
|
||||
import 'binding.dart';
|
||||
import 'disposable_build_context.dart';
|
||||
@ -1287,28 +1289,45 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Widget result = RawImage(
|
||||
// Do not clone the image, because RawImage is a stateless wrapper.
|
||||
// The image will be disposed by this state object when it is not needed
|
||||
// anymore, such as when it is unmounted or when the image stream pushes
|
||||
// a new image.
|
||||
image: _imageInfo?.image,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
late Widget result;
|
||||
if (_imageInfo case final WebImageInfo webImage) {
|
||||
// TODO(harryterkelsen): Support the remaining properties that are
|
||||
// supported by `RawImage` but not `RawWebImage`. See the following issue
|
||||
// above for a discussion of the missing properties and suggestions for
|
||||
// how they can be implemented, https://github.com/flutter/flutter/issues/159565.
|
||||
result = RawWebImage(
|
||||
image: webImage,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
);
|
||||
} else {
|
||||
result = RawImage(
|
||||
// Do not clone the image, because RawImage is a stateless wrapper.
|
||||
// The image will be disposed by this state object when it is not needed
|
||||
// anymore, such as when it is unmounted or when the image stream pushes
|
||||
// a new image.
|
||||
image: _imageInfo?.image,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.excludeFromSemantics) {
|
||||
result = Semantics(
|
||||
|
@ -2,10 +2,14 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart' hide NetworkImage;
|
||||
import 'package:flutter/src/painting/_network_image_web.dart';
|
||||
import 'package:flutter/src/painting/_web_image_info_web.dart';
|
||||
import 'package:flutter/src/web.dart' as web_shim;
|
||||
import 'package:flutter/src/widgets/_web_image_web.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
@ -14,6 +18,7 @@ import '_test_http_request.dart';
|
||||
void runTests() {
|
||||
tearDown(() {
|
||||
debugRestoreHttpRequestFactory();
|
||||
debugRestoreImgElementFactory();
|
||||
});
|
||||
|
||||
testWidgets('loads an image from the network with headers',
|
||||
@ -64,14 +69,21 @@ void runTests() {
|
||||
);
|
||||
|
||||
await tester.pumpWidget(image);
|
||||
expect((tester.takeException() as web.ProgressEvent).type, 'test error');
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isA<NetworkImageLoadException>().having(
|
||||
(NetworkImageLoadException e) => e.statusCode,
|
||||
'status code',
|
||||
404,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('loads an image from the network with empty response',
|
||||
(WidgetTester tester) async {
|
||||
final TestHttpRequest testHttpRequest = TestHttpRequest()
|
||||
..status = 200
|
||||
..mockEvent = MockEvent('load', web.Event('test error'))
|
||||
..mockEvent = MockEvent('load', web.Event('successful load'))
|
||||
..response = (Uint8List.fromList(<int>[])).buffer;
|
||||
|
||||
httpRequestFactory = () {
|
||||
@ -92,4 +104,201 @@ void runTests() {
|
||||
expect(tester.takeException().toString(),
|
||||
'HTTP request failed, statusCode: 200, https://www.example.com/images/frame3.png');
|
||||
});
|
||||
|
||||
testWidgets('emits a WebImageInfo 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;
|
||||
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/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 {
|
||||
testImg.decodeSuccess();
|
||||
await imageCompleter!.future;
|
||||
});
|
||||
expect(recordedError, isNull);
|
||||
expect(imageInfo, isA<WebImageInfo>());
|
||||
|
||||
final WebImageInfo webImageInfo = imageInfo! as WebImageInfo;
|
||||
expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame4.png'));
|
||||
}, skip: !isSkiaWeb);
|
||||
|
||||
testWidgets('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/frame5.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 {
|
||||
testImg.decodeFailure();
|
||||
await imageCompleter!.future;
|
||||
});
|
||||
expect(recordedError, isNotNull);
|
||||
expect(imageInfo, isNull);
|
||||
}, skip: !isSkiaWeb);
|
||||
|
||||
testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo',
|
||||
(WidgetTester tester) async {
|
||||
final TestImgElement testImg = TestImgElement();
|
||||
|
||||
final _TestImageStreamCompleter streamCompleter = _TestImageStreamCompleter();
|
||||
final _TestImageProvider imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Image(
|
||||
image: imageProvider,
|
||||
),
|
||||
);
|
||||
|
||||
// Before getting a WebImageInfo, the Image resolves to a RawImage.
|
||||
expect(find.byType(RawImage), findsOneWidget);
|
||||
expect(find.byType(RawWebImage), findsNothing);
|
||||
expect(find.byType(PlatformViewLink), findsNothing);
|
||||
streamCompleter.setData(imageInfo: WebImageInfo(testImg.getMock() as web_shim.HTMLImageElement));
|
||||
await tester.pump();
|
||||
expect(find.byType(RawImage), findsNothing);
|
||||
// After getting a WebImageInfo, the Image uses a Platform View to render.
|
||||
expect(find.byType(RawWebImage), findsOneWidget);
|
||||
expect(find.byType(PlatformViewLink), findsOneWidget);
|
||||
}, skip: !isSkiaWeb);
|
||||
}
|
||||
|
||||
class _TestImageProvider extends ImageProvider<Object> {
|
||||
_TestImageProvider({required ImageStreamCompleter streamCompleter}) {
|
||||
_streamCompleter = streamCompleter;
|
||||
}
|
||||
|
||||
final Completer<ImageInfo> _completer = Completer<ImageInfo>();
|
||||
late ImageStreamCompleter _streamCompleter;
|
||||
|
||||
@override
|
||||
Future<Object> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<_TestImageProvider>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) {
|
||||
return _streamCompleter;
|
||||
}
|
||||
|
||||
void complete(web_shim.HTMLImageElement image) {
|
||||
_completer.complete(WebImageInfo(image));
|
||||
}
|
||||
|
||||
void fail(Object exception, StackTrace? stackTrace) {
|
||||
_completer.completeError(exception, stackTrace);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '${describeIdentity(this)}()';
|
||||
}
|
||||
|
||||
/// An [ImageStreamCompleter] that gives access to the added listeners.
|
||||
///
|
||||
/// Such an access to listeners is hacky,
|
||||
/// because it breaks encapsulation by allowing to invoke listeners without
|
||||
/// taking care about lifecycle of the created images, that may result in not disposed images.
|
||||
///
|
||||
/// That's why some tests that use it are opted out from leak tracking.
|
||||
class _TestImageStreamCompleter extends ImageStreamCompleter {
|
||||
_TestImageStreamCompleter();
|
||||
|
||||
ImageInfo? _currentImage;
|
||||
final Set<ImageStreamListener> listeners = <ImageStreamListener>{};
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
listeners.add(listener);
|
||||
if (_currentImage != null) {
|
||||
listener.onImage(_currentImage!.clone(), true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
void setData({
|
||||
ImageInfo? imageInfo,
|
||||
ImageChunkEvent? chunkEvent,
|
||||
}) {
|
||||
if (imageInfo != null) {
|
||||
_currentImage?.dispose();
|
||||
_currentImage = imageInfo;
|
||||
}
|
||||
final List<ImageStreamListener> localListeners = listeners.toList();
|
||||
for (final ImageStreamListener listener in localListeners) {
|
||||
if (imageInfo != null) {
|
||||
listener.onImage(imageInfo.clone(), false);
|
||||
}
|
||||
if (chunkEvent != null && listener.onChunk != null) {
|
||||
listener.onChunk!(chunkEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setError({
|
||||
required Object exception,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
final List<ImageStreamListener> localListeners = listeners.toList();
|
||||
for (final ImageStreamListener listener in localListeners) {
|
||||
listener.onError?.call(exception, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final List<ImageStreamListener> listenersCopy = listeners.toList();
|
||||
listenersCopy.forEach(removeListener);
|
||||
}
|
||||
}
|
||||
|
@ -83,3 +83,69 @@ class MockEvent {
|
||||
final String type;
|
||||
final web.Event event;
|
||||
}
|
||||
|
||||
@JS()
|
||||
@staticInterop
|
||||
@anonymous
|
||||
class ImgElementMock {
|
||||
external factory ImgElementMock({
|
||||
JSFunction? decode,
|
||||
});
|
||||
}
|
||||
|
||||
class TestImgElement {
|
||||
TestImgElement() {
|
||||
_mock = ImgElementMock(decode: decode.toJS);
|
||||
final JSAny mock = _mock as JSAny;
|
||||
objectDefineProperty(mock, 'src',
|
||||
<String, JSFunction>{
|
||||
'get': (() => src).toJS,
|
||||
'set': ((JSString newValue) {
|
||||
src = newValue.toDart;
|
||||
}).toJS,
|
||||
}.jsify()!,
|
||||
);
|
||||
objectDefineProperty(mock, 'naturalWidth',
|
||||
<String, JSFunction>{
|
||||
'get': (() => naturalWidth).toJS,
|
||||
'set': ((JSNumber newValue) {
|
||||
naturalWidth = newValue.toDartInt;
|
||||
}).toJS,
|
||||
}.jsify()!,
|
||||
);
|
||||
objectDefineProperty(mock, 'naturalHeight',
|
||||
<String, JSFunction>{
|
||||
'get': (() => naturalHeight).toJS,
|
||||
'set': ((JSNumber newValue) {
|
||||
naturalHeight = newValue.toDartInt;
|
||||
}).toJS,
|
||||
}.jsify()!,
|
||||
);
|
||||
}
|
||||
|
||||
late ImgElementMock _mock;
|
||||
|
||||
String src = '';
|
||||
int naturalWidth = -1;
|
||||
int naturalHeight = -1;
|
||||
|
||||
late JSFunction _resolveFunc;
|
||||
late JSFunction _rejectFunc;
|
||||
|
||||
JSPromise<JSAny?> decode() {
|
||||
return JSPromise<JSAny?>((JSFunction resolveFunc, JSFunction rejectFunc) {
|
||||
_resolveFunc = resolveFunc;
|
||||
_rejectFunc = rejectFunc;
|
||||
}.toJS);
|
||||
}
|
||||
|
||||
void decodeSuccess() {
|
||||
_resolveFunc.callAsFunction();
|
||||
}
|
||||
|
||||
void decodeFailure() {
|
||||
_rejectFunc.callAsFunction();
|
||||
}
|
||||
|
||||
web.HTMLImageElement getMock() => _mock as web.HTMLImageElement;
|
||||
}
|
||||
|
@ -242,7 +242,7 @@ void main() {
|
||||
|
||||
const NetworkImage provider = NetworkImage(url);
|
||||
|
||||
final MultiFrameImageStreamCompleter completer = provider.loadBuffer(provider, noOpDecoderBufferCallback) as MultiFrameImageStreamCompleter;
|
||||
final ImageStreamCompleter completer = provider.loadBuffer(provider, noOpDecoderBufferCallback);
|
||||
|
||||
expect(completer.debugLabel, url);
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user