diff --git a/packages/flutter/lib/src/painting/_network_image_web.dart b/packages/flutter/lib/src/painting/_network_image_web.dart index 257e48e047..eecfc9a6b3 100644 --- a/packages/flutter/lib/src/painting/_network_image_web.dart +++ b/packages/flutter/lib/src/painting/_network_image_web.dart @@ -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 elements, +/// used for testing purposes. +typedef ImgElementFactory = web.HTMLImageElement Function(); + // Method signature for _loadAsync decode callbacks. typedef _SimpleDecoderCallback = Future 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 element factory. +web.HTMLImageElement _imgElementFactory() { + return web.document.createElement('img') as web.HTMLImageElement; +} + +/// The factory function that creates elements, can be overridden for +/// tests. +@visibleForTesting +ImgElementFactory imgElementFactory = _imgElementFactory; + +/// Restores the default 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 chunkEvents = StreamController(); - 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 chunkEvents = StreamController(); - 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 _loadAsync( + Future _loadAsync( NetworkImage key, _SimpleDecoderCallback decode, StreamController 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 completer = - Completer(); - final web.XMLHttpRequest request = httpRequestFactory(); + if (containsNetworkImageHeaders) { + // It is not possible to load an 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 + // 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.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.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 + // element instead. + final web.HTMLImageElement imageElement = imgElementFactory(); + imageElement.src = key.url; + // Decode the element before creating the ImageStreamCompleter + // to avoid double reporting the error. + await imageElement.decode().toDart; + return OneFrameImageStreamCompleter( + Future.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 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.value(codec), + scale: key.scale, + debugLabel: key.url, + informationCollector: _imageStreamInformationCollector(key), ); } } + Future _fetchImageBytes( + _SimpleDecoderCallback decode, + ) async { + final Uri resolved = Uri.base.resolve(url); + + final bool containsNetworkImageHeaders = headers?.isNotEmpty ?? false; + + final Completer completer = + Completer(); + 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 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(); + } +} diff --git a/packages/flutter/lib/src/painting/_web_image_info_io.dart b/packages/flutter/lib/src/painting/_web_image_info_io.dart new file mode 100644 index 0000000000..a88b52b70e --- /dev/null +++ b/packages/flutter/lib/src/painting/_web_image_info_io.dart @@ -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 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 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.'); +} diff --git a/packages/flutter/lib/src/painting/_web_image_info_web.dart b/packages/flutter/lib/src/painting/_web_image_info_web.dart new file mode 100644 index 0000000000..ee8468351c --- /dev/null +++ b/packages/flutter/lib/src/painting/_web_image_info_web.dart @@ -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 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 element is used. +class WebImageInfo implements ImageInfo { + /// Creates a new [WebImageInfo] from a given element. + WebImageInfo(this.htmlImage, {this.debugLabel}); + + /// The element used to display this image. This 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 element here. We create + // another reference to the 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 + // 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 element. + return other.htmlImage == htmlImage && other.debugLabel == debugLabel; + } + + @override + double get scale => 1.0; + + @override + int get sizeBytes => + (4 * htmlImage.naturalWidth * htmlImage.naturalHeight).toInt(); +} diff --git a/packages/flutter/lib/src/painting/image_stream.dart b/packages/flutter/lib/src/painting/image_stream.dart index f43942a8c3..8cb03f2af7 100644 --- a/packages/flutter/lib/src/painting/image_stream.dart +++ b/packages/flutter/lib/src/painting/image_stream.dart @@ -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() { diff --git a/packages/flutter/lib/src/web.dart b/packages/flutter/lib/src/web.dart index 9bfb3e3497..05de1572fa 100644 --- a/packages/flutter/lib/src/web.dart +++ b/packages/flutter/lib/src/web.dart @@ -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 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 _) diff --git a/packages/flutter/lib/src/widgets/_web_image_io.dart b/packages/flutter/lib/src/widgets/_web_image_io.dart new file mode 100644 index 0000000000..0023629593 --- /dev/null +++ b/packages/flutter/lib/src/widgets/_web_image_io.dart @@ -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 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 `` 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 `` 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'); + } +} diff --git a/packages/flutter/lib/src/widgets/_web_image_web.dart b/packages/flutter/lib/src/widgets/_web_image_web.dart new file mode 100644 index 0000000000..ae7af6c42f --- /dev/null +++ b/packages/flutter/lib/src/widgets/_web_image_web.dart @@ -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 `` element with `src` set to [src]. +class ImgElementPlatformView extends StatelessWidget { + /// Creates a platform view backed with an `` 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 paramsMap = params! as Map; + // Create a new 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 `` tag. + final String? src; + + @override + Widget build(BuildContext context) { + if (src == null) { + return const SizedBox.expand(); + } + return HtmlElementView( + viewType: _viewType, + creationParams: {'src': src}, + ); + } +} + +/// A widget which displays and lays out an underlying `` 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 `` 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 `` 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 `` 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('image', image)); + properties.add(DoubleProperty('width', width, defaultValue: null)); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(EnumProperty('fit', fit, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'alignment', alignment, + defaultValue: null)); + } +} diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 0bc1796433..f1a93fe1c3 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -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 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( diff --git a/packages/flutter/test/painting/_network_image_test_web.dart b/packages/flutter/test/painting/_network_image_test_web.dart index a5aa25881e..f55143c7b5 100644 --- a/packages/flutter/test/painting/_network_image_test_web.dart +++ b/packages/flutter/test/painting/_network_image_test_web.dart @@ -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().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([])).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([])).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? imageCompleter; + await tester.runAsync(() async { + imageCompleter = Completer(); + 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()); + + 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([])).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? imageCompleter; + await tester.runAsync(() async { + imageCompleter = Completer(); + 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 { + _TestImageProvider({required ImageStreamCompleter streamCompleter}) { + _streamCompleter = streamCompleter; + } + + final Completer _completer = Completer(); + late ImageStreamCompleter _streamCompleter; + + @override + Future 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 listeners = {}; + + @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 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 localListeners = listeners.toList(); + for (final ImageStreamListener listener in localListeners) { + listener.onError?.call(exception, stackTrace); + } + } + + void dispose() { + final List listenersCopy = listeners.toList(); + listenersCopy.forEach(removeListener); + } } diff --git a/packages/flutter/test/painting/_test_http_request.dart b/packages/flutter/test/painting/_test_http_request.dart index 86e03ae80c..bb3e3baa8e 100644 --- a/packages/flutter/test/painting/_test_http_request.dart +++ b/packages/flutter/test/painting/_test_http_request.dart @@ -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', + { + 'get': (() => src).toJS, + 'set': ((JSString newValue) { + src = newValue.toDart; + }).toJS, + }.jsify()!, + ); + objectDefineProperty(mock, 'naturalWidth', + { + 'get': (() => naturalWidth).toJS, + 'set': ((JSNumber newValue) { + naturalWidth = newValue.toDartInt; + }).toJS, + }.jsify()!, + ); + objectDefineProperty(mock, 'naturalHeight', + { + '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 decode() { + return JSPromise((JSFunction resolveFunc, JSFunction rejectFunc) { + _resolveFunc = resolveFunc; + _rejectFunc = rejectFunc; + }.toJS); + } + + void decodeSuccess() { + _resolveFunc.callAsFunction(); + } + + void decodeFailure() { + _rejectFunc.callAsFunction(); + } + + web.HTMLImageElement getMock() => _mock as web.HTMLImageElement; +} diff --git a/packages/flutter/test/painting/image_provider_network_image_test.dart b/packages/flutter/test/painting/image_provider_network_image_test.dart index 2c56a0a5d1..18559f2f75 100644 --- a/packages/flutter/test/painting/image_provider_network_image_test.dart +++ b/packages/flutter/test/painting/image_provider_network_image_test.dart @@ -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); });