From ed67c47982b65909ee8e833a31a2e02ab846be4d Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Mar 2020 13:36:03 -0700 Subject: [PATCH] Add error callbacks to other image resolving code (#53329) --- .../lib/src/material/circle_avatar.dart | 15 ++++- .../lib/src/material/ink_decoration.dart | 5 +- packages/flutter/lib/src/material/switch.dart | 67 +++++++++++++++++-- .../lib/src/painting/decoration_image.dart | 14 +++- .../lib/src/widgets/fade_in_image.dart | 26 ++++++- .../test/painting/decoration_test.dart | 36 ++++++++++ 6 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/material/circle_avatar.dart b/packages/flutter/lib/src/material/circle_avatar.dart index 8534bc8a30..ff06621fb9 100644 --- a/packages/flutter/lib/src/material/circle_avatar.dart +++ b/packages/flutter/lib/src/material/circle_avatar.dart @@ -17,6 +17,9 @@ import 'theme_data.dart'; /// such an image, the user's initials. A given user's initials should /// always be paired with the same background color, for consistency. /// +/// The [onBackgroundImageError] parameter must be null if the [backgroundImage] +/// is null. +/// /// {@tool snippet} /// /// If the avatar is to have an image, the image should be specified in the @@ -57,11 +60,13 @@ class CircleAvatar extends StatelessWidget { this.child, this.backgroundColor, this.backgroundImage, + this.onBackgroundImageError, this.foregroundColor, this.radius, this.minRadius, this.maxRadius, }) : assert(radius == null || (minRadius == null && maxRadius == null)), + assert(backgroundImage != null || onBackgroundImageError == null), super(key: key); /// The widget below this widget in the tree. @@ -93,6 +98,10 @@ class CircleAvatar extends StatelessWidget { /// If the [CircleAvatar] is to have the user's initials, use [child] instead. final ImageProvider backgroundImage; + /// An optional error callback for errors emitted when loading + /// [backgroundImage]. + final ImageErrorListener onBackgroundImageError; + /// The size of the avatar, expressed as the radius (half the diameter). /// /// If [radius] is specified, then neither [minRadius] nor [maxRadius] may be @@ -200,7 +209,11 @@ class CircleAvatar extends StatelessWidget { decoration: BoxDecoration( color: effectiveBackgroundColor, image: backgroundImage != null - ? DecorationImage(image: backgroundImage, fit: BoxFit.cover) + ? DecorationImage( + image: backgroundImage, + onError: onBackgroundImageError, + fit: BoxFit.cover, + ) : null, shape: BoxShape.circle, ), diff --git a/packages/flutter/lib/src/material/ink_decoration.dart b/packages/flutter/lib/src/material/ink_decoration.dart index cf4d6412db..5365797228 100644 --- a/packages/flutter/lib/src/material/ink_decoration.dart +++ b/packages/flutter/lib/src/material/ink_decoration.dart @@ -145,7 +145,8 @@ class Ink extends StatefulWidget { /// /// The `image` argument must not be null. If there is no /// intention to render anything on this image, consider using a - /// [Container] with a [BoxDecoration.image] instead. + /// [Container] with a [BoxDecoration.image] instead. The `onImageError` + /// argument may be provided to listen for errors when resolving the image. /// /// The `alignment`, `repeat`, and `matchTextDirection` arguments must not /// be null either, but they have default values. @@ -155,6 +156,7 @@ class Ink extends StatefulWidget { Key key, this.padding, @required ImageProvider image, + ImageErrorListener onImageError, ColorFilter colorFilter, BoxFit fit, AlignmentGeometry alignment = Alignment.center, @@ -172,6 +174,7 @@ class Ink extends StatefulWidget { decoration = BoxDecoration( image: DecorationImage( image: image, + onError: onImageError, colorFilter: colorFilter, fit: fit, alignment: alignment, diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index 094ed1eff5..c21aaa7fcb 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -71,7 +71,9 @@ class Switch extends StatefulWidget { this.inactiveThumbColor, this.inactiveTrackColor, this.activeThumbImage, + this.onActiveThumbImageError, this.inactiveThumbImage, + this.onInactiveThumbImageError, this.materialTapTargetSize, this.dragStartBehavior = DragStartBehavior.start, this.focusColor, @@ -80,6 +82,8 @@ class Switch extends StatefulWidget { this.autofocus = false, }) : _switchType = _SwitchType.material, assert(dragStartBehavior != null), + assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null), super(key: key); /// Creates a [CupertinoSwitch] if the target platform is iOS, creates a @@ -87,7 +91,8 @@ class Switch extends StatefulWidget { /// /// If a [CupertinoSwitch] is created, the following parameters are /// ignored: [activeTrackColor], [inactiveThumbColor], [inactiveTrackColor], - /// [activeThumbImage], [inactiveThumbImage], [materialTapTargetSize]. + /// [activeThumbImage], [onActiveThumbImageError], [inactiveThumbImage], + /// [onInactiveImageThumbError], [materialTapTargetSize]. /// /// The target platform is based on the current [Theme]: [ThemeData.platform]. const Switch.adaptive({ @@ -99,7 +104,9 @@ class Switch extends StatefulWidget { this.inactiveThumbColor, this.inactiveTrackColor, this.activeThumbImage, + this.onActiveThumbImageError, this.inactiveThumbImage, + this.onInactiveThumbImageError, this.materialTapTargetSize, this.dragStartBehavior = DragStartBehavior.start, this.focusColor, @@ -107,6 +114,8 @@ class Switch extends StatefulWidget { this.focusNode, this.autofocus = false, }) : assert(autofocus != null), + assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null), _switchType = _SwitchType.adaptive, super(key: key); @@ -170,11 +179,19 @@ class Switch extends StatefulWidget { /// Ignored if this switch is created with [Switch.adaptive]. final ImageProvider activeThumbImage; + /// An optional error callback for errors emitted when loading + /// [activeThumbImage]. + final ImageErrorListener onActiveThumbImageError; + /// An image to use on the thumb of this switch when the switch is off. /// /// Ignored if this switch is created with [Switch.adaptive]. final ImageProvider inactiveThumbImage; + /// An optional error callback for errors emitted when loading + /// [inactiveThumbImage]. + final ImageErrorListener onInactiveThumbImageError; + /// Configures the minimum size of the tap target. /// /// Defaults to [ThemeData.materialTapTargetSize]. @@ -311,7 +328,9 @@ class _SwitchState extends State with TickerProviderStateMixin { hoverColor: hoverColor, focusColor: focusColor, activeThumbImage: widget.activeThumbImage, + onActiveThumbImageError: widget.onActiveThumbImageError, inactiveThumbImage: widget.inactiveThumbImage, + onInactiveThumbImageError: widget.onInactiveThumbImageError, activeTrackColor: activeTrackColor, inactiveTrackColor: inactiveTrackColor, configuration: createLocalImageConfiguration(context), @@ -381,7 +400,9 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { this.hoverColor, this.focusColor, this.activeThumbImage, + this.onActiveThumbImageError, this.inactiveThumbImage, + this.onInactiveThumbImageError, this.activeTrackColor, this.inactiveTrackColor, this.configuration, @@ -399,7 +420,9 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { final Color hoverColor; final Color focusColor; final ImageProvider activeThumbImage; + final ImageErrorListener onActiveThumbImageError; final ImageProvider inactiveThumbImage; + final ImageErrorListener onInactiveThumbImageError; final Color activeTrackColor; final Color inactiveTrackColor; final ImageConfiguration configuration; @@ -420,7 +443,9 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { hoverColor: hoverColor, focusColor: focusColor, activeThumbImage: activeThumbImage, + onActiveThumbImageError: onActiveThumbImageError, inactiveThumbImage: inactiveThumbImage, + onInactiveThumbImageError: onInactiveThumbImageError, activeTrackColor: activeTrackColor, inactiveTrackColor: inactiveTrackColor, configuration: configuration, @@ -442,7 +467,9 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { ..hoverColor = hoverColor ..focusColor = focusColor ..activeThumbImage = activeThumbImage + ..onActiveThumbImageError = onActiveThumbImageError ..inactiveThumbImage = inactiveThumbImage + ..onInactiveThumbImageError = onInactiveThumbImageError ..activeTrackColor = activeTrackColor ..inactiveTrackColor = inactiveTrackColor ..configuration = configuration @@ -464,7 +491,9 @@ class _RenderSwitch extends RenderToggleable { Color hoverColor, Color focusColor, ImageProvider activeThumbImage, + ImageErrorListener onActiveThumbImageError, ImageProvider inactiveThumbImage, + ImageErrorListener onInactiveThumbImageError, Color activeTrackColor, Color inactiveTrackColor, ImageConfiguration configuration, @@ -477,7 +506,9 @@ class _RenderSwitch extends RenderToggleable { @required this.state, }) : assert(textDirection != null), _activeThumbImage = activeThumbImage, + _onActiveThumbImageError = onActiveThumbImageError, _inactiveThumbImage = inactiveThumbImage, + _onInactiveThumbImageError = onInactiveThumbImageError, _activeTrackColor = activeTrackColor, _inactiveTrackColor = inactiveTrackColor, _configuration = configuration, @@ -511,6 +542,16 @@ class _RenderSwitch extends RenderToggleable { markNeedsPaint(); } + ImageErrorListener get onActiveThumbImageError => _onActiveThumbImageError; + ImageErrorListener _onActiveThumbImageError; + set onActiveThumbImageError(ImageErrorListener value) { + if (value == _onActiveThumbImageError) { + return; + } + _onActiveThumbImageError = value; + markNeedsPaint(); + } + ImageProvider get inactiveThumbImage => _inactiveThumbImage; ImageProvider _inactiveThumbImage; set inactiveThumbImage(ImageProvider value) { @@ -520,6 +561,16 @@ class _RenderSwitch extends RenderToggleable { markNeedsPaint(); } + ImageErrorListener get onInactiveThumbImageError => _onInactiveThumbImageError; + ImageErrorListener _onInactiveThumbImageError; + set onInactiveThumbImageError(ImageErrorListener value) { + if (value == _onInactiveThumbImageError) { + return; + } + _onInactiveThumbImageError = value; + markNeedsPaint(); + } + Color get activeTrackColor => _activeTrackColor; Color _activeTrackColor; set activeTrackColor(Color value) { @@ -642,12 +693,13 @@ class _RenderSwitch extends RenderToggleable { Color _cachedThumbColor; ImageProvider _cachedThumbImage; + ImageErrorListener _cachedThumbErrorListener; BoxPainter _cachedThumbPainter; - BoxDecoration _createDefaultThumbDecoration(Color color, ImageProvider image) { + BoxDecoration _createDefaultThumbDecoration(Color color, ImageProvider image, ImageErrorListener errorListener) { return BoxDecoration( color: color, - image: image == null ? null : DecorationImage(image: image), + image: image == null ? null : DecorationImage(image: image, onError: errorListener), shape: BoxShape.circle, boxShadow: kElevationToShadow[1], ); @@ -698,6 +750,10 @@ class _RenderSwitch extends RenderToggleable { ? (currentValue < 0.5 ? inactiveThumbImage : activeThumbImage) : inactiveThumbImage; + final ImageErrorListener thumbErrorListener = isEnabled + ? (currentValue < 0.5 ? onInactiveThumbImageError : onActiveThumbImageError) + : onInactiveThumbImageError; + // Paint the track final Paint paint = Paint() ..color = trackColor; @@ -721,10 +777,11 @@ class _RenderSwitch extends RenderToggleable { try { _isPainting = true; BoxPainter thumbPainter; - if (_cachedThumbPainter == null || thumbColor != _cachedThumbColor || thumbImage != _cachedThumbImage) { + if (_cachedThumbPainter == null || thumbColor != _cachedThumbColor || thumbImage != _cachedThumbImage || thumbErrorListener != _cachedThumbErrorListener) { _cachedThumbColor = thumbColor; _cachedThumbImage = thumbImage; - _cachedThumbPainter = _createDefaultThumbDecoration(thumbColor, thumbImage).createBoxPainter(_handleDecorationChanged); + _cachedThumbErrorListener = thumbErrorListener; + _cachedThumbPainter = _createDefaultThumbDecoration(thumbColor, thumbImage, thumbErrorListener).createBoxPainter(_handleDecorationChanged); } thumbPainter = _cachedThumbPainter; diff --git a/packages/flutter/lib/src/painting/decoration_image.dart b/packages/flutter/lib/src/painting/decoration_image.dart index 3bc0a78a95..e07ae23474 100644 --- a/packages/flutter/lib/src/painting/decoration_image.dart +++ b/packages/flutter/lib/src/painting/decoration_image.dart @@ -40,6 +40,7 @@ class DecorationImage { /// must not be null. const DecorationImage({ @required this.image, + this.onError, this.colorFilter, this.fit, this.alignment = Alignment.center, @@ -57,6 +58,9 @@ class DecorationImage { /// application) or a [NetworkImage] (for an image obtained from the network). final ImageProvider image; + /// An optional error callback for errors emitted when loading [image]. + final ImageErrorListener onError; + /// A color filter to apply to the image before painting it. final ColorFilter colorFilter; @@ -239,7 +243,10 @@ class DecorationImagePainter { final ImageStream newImageStream = _details.image.resolve(configuration); if (newImageStream.key != _imageStream?.key) { - final ImageStreamListener listener = ImageStreamListener(_handleImage); + final ImageStreamListener listener = ImageStreamListener( + _handleImage, + onError: _details.onError, + ); _imageStream?.removeListener(listener); _imageStream = newImageStream; _imageStream.addListener(listener); @@ -286,7 +293,10 @@ class DecorationImagePainter { /// After this method has been called, the object is no longer usable. @mustCallSuper void dispose() { - _imageStream?.removeListener(ImageStreamListener(_handleImage)); + _imageStream?.removeListener(ImageStreamListener( + _handleImage, + onError: _details.onError, + )); } @override diff --git a/packages/flutter/lib/src/widgets/fade_in_image.dart b/packages/flutter/lib/src/widgets/fade_in_image.dart index feae786d04..40eaaf390d 100644 --- a/packages/flutter/lib/src/widgets/fade_in_image.dart +++ b/packages/flutter/lib/src/widgets/fade_in_image.dart @@ -77,7 +77,9 @@ class FadeInImage extends StatelessWidget { const FadeInImage({ Key key, @required this.placeholder, + this.placeholderErrorBuilder, @required this.image, + this.imageErrorBuilder, this.excludeFromSemantics = false, this.imageSemanticLabel, this.fadeOutDuration = const Duration(milliseconds: 300), @@ -132,7 +134,9 @@ class FadeInImage extends StatelessWidget { FadeInImage.memoryNetwork({ Key key, @required Uint8List placeholder, + this.placeholderErrorBuilder, @required String image, + this.imageErrorBuilder, double placeholderScale = 1.0, double imageScale = 1.0, this.excludeFromSemantics = false, @@ -200,7 +204,9 @@ class FadeInImage extends StatelessWidget { FadeInImage.assetNetwork({ Key key, @required String placeholder, + this.placeholderErrorBuilder, @required String image, + this.imageErrorBuilder, AssetBundle bundle, double placeholderScale, double imageScale = 1.0, @@ -239,9 +245,24 @@ class FadeInImage extends StatelessWidget { /// Image displayed while the target [image] is loading. final ImageProvider placeholder; + /// A builder function that is called if an error occurs during placeholder + /// image loading. + /// + /// If this builder is not provided, any exceptions will be reported to + /// [FlutterError.onError]. If it is provided, the caller should either handle + /// the exception by providing a replacement widget, or rethrow the exception. + final ImageErrorWidgetBuilder placeholderErrorBuilder; + /// The target image that is displayed once it has loaded. final ImageProvider image; + /// A builder function that is called if an error occurs during image loading. + /// + /// If this builder is not provided, any exceptions will be reported to + /// [FlutterError.onError]. If it is provided, the caller should either handle + /// the exception by providing a replacement widget, or rethrow the exception. + final ImageErrorWidgetBuilder imageErrorBuilder; + /// The duration of the fade-out animation for the [placeholder]. final Duration fadeOutDuration; @@ -337,11 +358,13 @@ class FadeInImage extends StatelessWidget { Image _image({ @required ImageProvider image, + ImageErrorWidgetBuilder errorBuilder, ImageFrameBuilder frameBuilder, }) { assert(image != null); return Image( image: image, + errorBuilder: errorBuilder, frameBuilder: frameBuilder, width: width, height: height, @@ -358,12 +381,13 @@ class FadeInImage extends StatelessWidget { Widget build(BuildContext context) { Widget result = _image( image: image, + errorBuilder: imageErrorBuilder, frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) return child; return _AnimatedFadeOutFadeIn( target: child, - placeholder: _image(image: placeholder), + placeholder: _image(image: placeholder, errorBuilder: placeholderErrorBuilder), isTargetLoaded: frame != null, fadeInDuration: fadeInDuration, fadeOutDuration: fadeOutDuration, diff --git a/packages/flutter/test/painting/decoration_test.dart b/packages/flutter/test/painting/decoration_test.dart index dfa2ed388e..2ebadaa59f 100644 --- a/packages/flutter/test/painting/decoration_test.dart +++ b/packages/flutter/test/painting/decoration_test.dart @@ -40,6 +40,22 @@ class SynchronousTestImageProvider extends ImageProvider { } } +class SynchronousErrorTestImageProvider extends ImageProvider { + const SynchronousErrorTestImageProvider(this.throwable); + + final Object throwable; + + @override + Future obtainKey(ImageConfiguration configuration) { + throw throwable; + } + + @override + ImageStreamCompleter load(int key, DecoderCallback decode) { + throw throwable; + } +} + class AsyncTestImageProvider extends ImageProvider { @override Future obtainKey(ImageConfiguration configuration) { @@ -269,6 +285,26 @@ void main() { ); }); + test('DecorationImage - error listener', () async { + String exception; + final DecorationImage backgroundImage = DecorationImage( + image: const SynchronousErrorTestImageProvider('threw'), + onError: (dynamic error, StackTrace stackTrace) { + exception = error as String; + } + ); + + backgroundImage.createPainter(() { }).paint( + TestCanvas(), + Rect.largest, + Path(), + ImageConfiguration.empty, + ); + // Yield so that the exception callback gets called before we check it. + await null; + expect(exception, 'threw'); + }); + test('BoxDecoration.lerp - shapes', () { // We don't lerp the shape, we just switch from one to the other at t=0.5. // (Use a ShapeDecoration and ShapeBorder if you want to lerp the shapes...)