[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 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../web.dart' as web;
|
import '../web.dart' as web;
|
||||||
|
import '_web_image_info_web.dart';
|
||||||
import 'image_provider.dart' as image_provider;
|
import 'image_provider.dart' as image_provider;
|
||||||
import 'image_stream.dart';
|
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();
|
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.
|
// Method signature for _loadAsync decode callbacks.
|
||||||
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
|
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
|
||||||
|
|
||||||
/// Default HTTP client.
|
/// The default HTTP client.
|
||||||
web.XMLHttpRequest _httpClient() {
|
web.XMLHttpRequest _httpClient() {
|
||||||
return web.XMLHttpRequest();
|
return web.XMLHttpRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an overridable factory function.
|
/// Creates an overridable factory function.
|
||||||
|
@visibleForTesting
|
||||||
HttpRequestFactory httpRequestFactory = _httpClient;
|
HttpRequestFactory httpRequestFactory = _httpClient;
|
||||||
|
|
||||||
/// Restores to the default HTTP request factory.
|
/// Restores the default HTTP request factory.
|
||||||
|
@visibleForTesting
|
||||||
void debugRestoreHttpRequestFactory() {
|
void debugRestoreHttpRequestFactory() {
|
||||||
httpRequestFactory = _httpClient;
|
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].
|
/// The web implementation of [image_provider.NetworkImage].
|
||||||
///
|
///
|
||||||
/// NetworkImage on the web does not support decoding to a specified size.
|
/// NetworkImage on the web does not support decoding to a specified size.
|
||||||
@ -64,12 +88,14 @@ class NetworkImage
|
|||||||
final StreamController<ImageChunkEvent> chunkEvents =
|
final StreamController<ImageChunkEvent> chunkEvents =
|
||||||
StreamController<ImageChunkEvent>();
|
StreamController<ImageChunkEvent>();
|
||||||
|
|
||||||
return MultiFrameImageStreamCompleter(
|
return _ForwardingImageStreamCompleter(
|
||||||
chunkEvents: chunkEvents.stream,
|
_loadAsync(
|
||||||
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
|
key as NetworkImage,
|
||||||
scale: key.scale,
|
decode,
|
||||||
debugLabel: key.url,
|
chunkEvents,
|
||||||
|
),
|
||||||
informationCollector: _imageStreamInformationCollector(key),
|
informationCollector: _imageStreamInformationCollector(key),
|
||||||
|
debugLabel: key.url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,12 +106,14 @@ class NetworkImage
|
|||||||
// has been loaded or an error is thrown.
|
// has been loaded or an error is thrown.
|
||||||
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
|
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
|
||||||
|
|
||||||
return MultiFrameImageStreamCompleter(
|
return _ForwardingImageStreamCompleter(
|
||||||
chunkEvents: chunkEvents.stream,
|
_loadAsync(
|
||||||
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
|
key as NetworkImage,
|
||||||
scale: key.scale,
|
decode,
|
||||||
debugLabel: key.url,
|
chunkEvents,
|
||||||
|
),
|
||||||
informationCollector: _imageStreamInformationCollector(key),
|
informationCollector: _imageStreamInformationCollector(key),
|
||||||
|
debugLabel: key.url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,10 +129,10 @@ class NetworkImage
|
|||||||
return collector;
|
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
|
// here is ignored and `ui_web.createImageCodecFromUrl` will be used directly
|
||||||
// in place of the typical `instantiateImageCodec` method.
|
// in place of the typical `instantiateImageCodec` method.
|
||||||
Future<ui.Codec> _loadAsync(
|
Future<ImageStreamCompleter> _loadAsync(
|
||||||
NetworkImage key,
|
NetworkImage key,
|
||||||
_SimpleDecoderCallback decode,
|
_SimpleDecoderCallback decode,
|
||||||
StreamController<ImageChunkEvent> chunkEvents,
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
@ -117,15 +145,99 @@ class NetworkImage
|
|||||||
|
|
||||||
// We use a different method when headers are set because the
|
// We use a different method when headers are set because the
|
||||||
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
|
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
|
||||||
if (isSkiaWeb || containsNetworkImageHeaders) {
|
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
|
||||||
|
// [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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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 =
|
final Completer<web.XMLHttpRequest> completer =
|
||||||
Completer<web.XMLHttpRequest>();
|
Completer<web.XMLHttpRequest>();
|
||||||
final web.XMLHttpRequest request = httpRequestFactory();
|
final web.XMLHttpRequest request = httpRequestFactory();
|
||||||
|
|
||||||
request.open('GET', key.url, true);
|
request.open('GET', url, true);
|
||||||
request.responseType = 'arraybuffer';
|
request.responseType = 'arraybuffer';
|
||||||
if (containsNetworkImageHeaders) {
|
if (containsNetworkImageHeaders) {
|
||||||
key.headers!.forEach((String header, String value) {
|
headers!.forEach((String header, String value) {
|
||||||
request.setRequestHeader(header, value);
|
request.setRequestHeader(header, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -142,14 +254,19 @@ class NetworkImage
|
|||||||
if (success) {
|
if (success) {
|
||||||
completer.complete(request);
|
completer.complete(request);
|
||||||
} else {
|
} else {
|
||||||
completer.completeError(e);
|
completer.completeError(image_provider.NetworkImageLoadException(
|
||||||
throw image_provider.NetworkImageLoadException(
|
statusCode: status, uri: resolved));
|
||||||
statusCode: status, uri: resolved);
|
|
||||||
}
|
}
|
||||||
}.toJS);
|
}.toJS);
|
||||||
|
|
||||||
request.addEventListener('error',
|
request.addEventListener(
|
||||||
((JSObject e) => completer.completeError(e)).toJS);
|
'error',
|
||||||
|
((JSObject e) =>
|
||||||
|
completer.completeError(image_provider.NetworkImageLoadException(
|
||||||
|
statusCode: request.status,
|
||||||
|
uri: resolved,
|
||||||
|
))).toJS,
|
||||||
|
);
|
||||||
|
|
||||||
request.send();
|
request.send();
|
||||||
|
|
||||||
@ -161,16 +278,8 @@ class NetworkImage
|
|||||||
throw image_provider.NetworkImageLoadException(
|
throw image_provider.NetworkImageLoadException(
|
||||||
statusCode: request.status, uri: resolved);
|
statusCode: request.status, uri: resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
|
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));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -187,3 +296,61 @@ class NetworkImage
|
|||||||
@override
|
@override
|
||||||
String toString() => '${objectRuntimeType(this, 'NetworkImage')}("$url", scale: ${scale.toStringAsFixed(1)})';
|
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;
|
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
|
@mustCallSuper
|
||||||
void _maybeDispose() {
|
void _maybeDispose() {
|
||||||
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
|
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
|
||||||
@ -700,6 +718,7 @@ abstract class ImageStreamCompleter with Diagnosticable {
|
|||||||
_currentImage?.dispose();
|
_currentImage?.dispose();
|
||||||
_currentImage = null;
|
_currentImage = null;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
onDisposed();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkDisposed() {
|
void _checkDisposed() {
|
||||||
|
@ -67,6 +67,7 @@ extension type DOMTokenList._(JSObject _) implements JSObject {
|
|||||||
extension type Element._(JSObject _) implements Node, JSObject {
|
extension type Element._(JSObject _) implements Node, JSObject {
|
||||||
external DOMTokenList get classList;
|
external DOMTokenList get classList;
|
||||||
external void append(JSAny nodes);
|
external void append(JSAny nodes);
|
||||||
|
external void remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type Event._(JSObject _) implements JSObject {}
|
extension type Event._(JSObject _) implements JSObject {}
|
||||||
@ -83,6 +84,7 @@ extension type HTMLElement._(JSObject _) implements Element, JSObject {
|
|||||||
external String get innerText;
|
external String get innerText;
|
||||||
external set innerText(String value);
|
external set innerText(String value);
|
||||||
external CSSStyleDeclaration get style;
|
external CSSStyleDeclaration get style;
|
||||||
|
external HTMLElement cloneNode(bool deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type HTMLHeadElement._(JSObject _) implements HTMLElement, JSObject {}
|
extension type HTMLHeadElement._(JSObject _) implements HTMLElement, JSObject {}
|
||||||
@ -91,6 +93,22 @@ extension type HTMLStyleElement._(JSObject _) implements HTMLElement, JSObject {
|
|||||||
external CSSStyleSheet? get sheet;
|
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 {
|
extension type MediaQueryList._(JSObject _) implements EventTarget, JSObject {
|
||||||
external bool get matches;
|
external bool get matches;
|
||||||
}
|
}
|
||||||
@ -120,6 +138,7 @@ extension type Window._(JSObject _) implements EventTarget, JSObject {
|
|||||||
external Navigator get navigator;
|
external Navigator get navigator;
|
||||||
external MediaQueryList matchMedia(String query);
|
external MediaQueryList matchMedia(String query);
|
||||||
external Selection? getSelection();
|
external Selection? getSelection();
|
||||||
|
external String get origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type XMLHttpRequest._(JSObject _)
|
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/scheduler.dart';
|
||||||
import 'package:flutter/semantics.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 'basic.dart';
|
||||||
import 'binding.dart';
|
import 'binding.dart';
|
||||||
import 'disposable_build_context.dart';
|
import 'disposable_build_context.dart';
|
||||||
@ -1287,7 +1289,23 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget result = RawImage(
|
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.
|
// 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
|
// 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
|
// anymore, such as when it is unmounted or when the image stream pushes
|
||||||
@ -1309,6 +1327,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
|
|||||||
isAntiAlias: widget.isAntiAlias,
|
isAntiAlias: widget.isAntiAlias,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!widget.excludeFromSemantics) {
|
if (!widget.excludeFromSemantics) {
|
||||||
result = Semantics(
|
result = Semantics(
|
||||||
|
@ -2,10 +2,14 @@
|
|||||||
// Use of this source code is governed by a BSD-style license that can be
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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/_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/web.dart' as web_shim;
|
||||||
|
import 'package:flutter/src/widgets/_web_image_web.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
@ -14,6 +18,7 @@ import '_test_http_request.dart';
|
|||||||
void runTests() {
|
void runTests() {
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
debugRestoreHttpRequestFactory();
|
debugRestoreHttpRequestFactory();
|
||||||
|
debugRestoreImgElementFactory();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('loads an image from the network with headers',
|
testWidgets('loads an image from the network with headers',
|
||||||
@ -64,14 +69,21 @@ void runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(image);
|
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',
|
testWidgets('loads an image from the network with empty response',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
final TestHttpRequest testHttpRequest = TestHttpRequest()
|
final TestHttpRequest testHttpRequest = TestHttpRequest()
|
||||||
..status = 200
|
..status = 200
|
||||||
..mockEvent = MockEvent('load', web.Event('test error'))
|
..mockEvent = MockEvent('load', web.Event('successful load'))
|
||||||
..response = (Uint8List.fromList(<int>[])).buffer;
|
..response = (Uint8List.fromList(<int>[])).buffer;
|
||||||
|
|
||||||
httpRequestFactory = () {
|
httpRequestFactory = () {
|
||||||
@ -92,4 +104,201 @@ void runTests() {
|
|||||||
expect(tester.takeException().toString(),
|
expect(tester.takeException().toString(),
|
||||||
'HTTP request failed, statusCode: 200, https://www.example.com/images/frame3.png');
|
'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 String type;
|
||||||
final web.Event event;
|
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);
|
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);
|
expect(completer.debugLabel, url);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user