diff --git a/CHANGELOG.md b/CHANGELOG.md index 045a772..667dc36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - none. -## [0.1.0] - 2019-07-01 +## [0.2.0] - 2019-07-29 +### Added +- Support for error handling via `errorBuilder` + +### Changed +- `placeholder` now accepts a `Widget` instead of an `ImageProvider`. +- previously loaded images are now faded out after the new image is faded in. Noticeable when a smaller image is loaded over a larger one. + +### Removed +- `backgroundColor` was removed. Use a `placeholder` with a color instead. + +## [0.1.0] - 2019-07-23 ### Added - First release. \ No newline at end of file diff --git a/README.md b/README.md index d6076e3..69e398a 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,16 @@ **Requires Flutter 1.6.7 or higher.** -A widget for Flutter that displays a `placeholder` image while a specified `image` loads, then cross-fades to the loaded image. +A widget for Flutter that displays a `placeholder` widget while a specified `image` loads, then cross-fades to the loaded image. Also handles progress and errors. If `image` is changed, it will cross-fade to the new image once it is finished loading. Setting `image` to `null` will cross-fade back to the placeholder. -![example image](https://gskinner.github.io/image_fade/example.gif) +![example image](https://gskinner.github.io/image_fade/example_v0_2_0.gif) -You can set `color` (background color), `fadeDuration` and `fadeCurve`, as well as most `Image` properties: -`width`, `height`, `fit`, `alignment`, `repeat`, `matchTextDirection`, `excludeFromSemantics` -and `semanticLabel`. +You can set `fadeDuration` and `fadeCurve`, as well as most `Image` properties: +`width`, `height`, `fit`, `alignment`, `repeat`, `matchTextDirection`, `excludeFromSemantics` and `semanticLabel`. -You can also specify a `loadingBuilder` that will display load progress any time a new image is loaded. +You can also specify a `loadingBuilder` that will display load progress any time a new image is loaded, and an `errorBuilder` that will display if an error occurs while loading an image. ## Example diff --git a/example/README.md b/example/README.md index 2022c67..675d494 100644 --- a/example/README.md +++ b/example/README.md @@ -2,6 +2,8 @@ Demonstrates the core features of the `ImageFade` widget. -Pressing the `(>)` button sets the `image` property to a new `NetworkImage` (loading from WikiMedia Commons). +Pressing the `>` button sets the `image` property to a new `NetworkImage` (loading from WikiMedia Commons). -Pressing the `(x)` button sets `image` to `null`. +Pressing the `x` button sets `image` to `null`. + +Pressing the `/!\` button sets `image` to a non-existent image url, demonstrating an error. diff --git a/example/lib/main.dart b/example/lib/main.dart index 4e29897..78e18e3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -37,10 +37,11 @@ class _MyHomePageState extends State { int _counter = 0; bool _clear = true; + bool _error = false; void _incrementCounter() { setState(() { - if (_clear) { _clear = false; } + if (_clear || _error) { _clear = _error = false; } else { _counter = (_counter+1)%_imgs.length; } }); } @@ -48,22 +49,35 @@ class _MyHomePageState extends State { void _clearImage() { setState(() { _clear = true; + _error = false; + }); + } + + void _testError() { + setState(() { + _error = true; }); } @override Widget build(BuildContext context) { + ImageProvider image; + if (_error) { image = NetworkImage('error.jpg'); } + else if (!_clear) { image = NetworkImage(_imgs[_counter]); } + return Scaffold( appBar: AppBar( - title: Text('Showing ' + (_clear ? 'placeholder image' : "image #$_counter from Wikimedia")), + title: Text('Showing ' + (_error ? 'error' : _clear ? 'placeholder' : 'image #$_counter from Wikimedia')), ), body: Stack(children: [ Positioned.fill(child: ImageFade( - image: _clear ? null : NetworkImage(_imgs[_counter]), - placeholder: AssetImage('assets/images/placeholder.png'), - backgroundColor: Colors.black, + image: image, + placeholder: Container( + color: Color(0xFFCFCDCA), + child: Center(child: Icon(Icons.photo, color: Colors.white30, size: 128.0,)), + ), alignment: Alignment.center, fit: BoxFit.cover, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent event) { @@ -74,6 +88,12 @@ class _MyHomePageState extends State { ), ); }, + errorBuilder: (BuildContext context, Widget child, dynamic exception) { + return Container( + color: Color(0xFF6F6D6A), + child: Center(child: Icon(Icons.warning, color: Colors.black26, size: 128.0)), + ); + }, ) ) ]), @@ -90,6 +110,12 @@ class _MyHomePageState extends State { tooltip: 'Clear', child: Icon(Icons.clear), ), + SizedBox(width:10.0), + FloatingActionButton( + onPressed: _testError, + tooltip: 'Error', + child: Icon(Icons.warning), + ), ]), ); } diff --git a/example/pubspec.lock b/example/pubspec.lock index f7a58b1..a379071 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -45,7 +45,7 @@ packages: path: ".." relative: true source: path - version: "0.1.0" + version: "0.2.0" matcher: dependency: transitive description: @@ -144,3 +144,4 @@ packages: version: "2.0.8" sdks: dart: ">=2.2.2 <3.0.0" + flutter: ">=1.6.7 <2.0.0" diff --git a/lib/image_fade.dart b/lib/image_fade.dart index 4d97b68..843dca0 100644 --- a/lib/image_fade.dart +++ b/lib/image_fade.dart @@ -1,32 +1,33 @@ library image_fade; +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'dart:ui' as ui; -/// A widget that displays a [placeholder] image while a specified [image] loads, -/// then cross-fades to the loaded image. +/// A widget that displays a [placeholder] widget while a specified [image] loads, +/// then cross-fades to the loaded image. Can optionally display loading progress +/// and errors. /// -/// If [image] is changed, it will cross-fade to the new image once it finishes -/// loading. +/// If [image] is subsequently changed, it will cross-fade to the new image once it +/// finishes loading. /// /// Setting [image] to null will cross-fade back to the [placeholder]. /// /// ```dart /// ImageFade( -/// placeholder: AssetImage('assets/myPlaceholder.png'), +/// placeholder: Image.asset('assets/myPlaceholder.png'), /// image: NetworkImage('https://backend.example.com/image.png'), /// ) /// ``` class ImageFade extends StatefulWidget { - /// Creates a widget that displays a [placeholder] image while a specified [image] loads, + /// Creates a widget that displays a [placeholder] widget while a specified [image] loads, /// then cross-fades to the loaded image. - /// - /// The [placeholder] argument must not be null. const ImageFade({ Key key, - @required this.placeholder, + this.placeholder, this.image, - this.backgroundColor = Colors.transparent, this.fadeCurve = Curves.linear, this.fadeDuration = const Duration(milliseconds: 500), @@ -41,19 +42,16 @@ class ImageFade extends StatefulWidget { this.semanticLabel, this.loadingBuilder, + this.errorBuilder, }) : - assert(placeholder != null), super(key: key); - /// Image displayed when [image] is null or is loading initially. - final ImageProvider placeholder; + /// Widget layered behind the loaded images. Displayed when [image] is null or is loading initially. + final Widget placeholder; - /// The image to display displayed. + /// The image to display. Subsequently changing the image will fade the new image over the previous one. final ImageProvider image; - /// The color that will display behind / around images. - final Color backgroundColor; - /// The curve of the fade-in animation. final Curve fadeCurve; @@ -87,35 +85,77 @@ class ImageFade extends StatefulWidget { /// A builder that specifies the widget to display while an image is loading. See [Image.loadingBuilder] for more information. final ImageLoadingBuilder loadingBuilder; + /// A builder that specifies the widget to display if an error occurs while an image is loading. + /// This will be faded in over previous content, so you may want to set an opaque background on it. + final ImageFadeErrorBuilder errorBuilder; + @override State createState() => _ImageFadeState(); } +/// Signature used by [ImageFader.errorBuilder] to build the widget that will +/// be displayed if an error occurs while loading an image. +typedef ImageFadeErrorBuilder = Widget Function( + BuildContext context, + Widget child, + dynamic exception, +); + class _ImageResolver { + bool success = false; + dynamic exception; + ImageChunkEvent chunkEvent; + + Function() onComplete; + Function() onError; + Function() onProgress; + ImageStream _stream; - Function(ImageInfo) onComplete; - Function(ImageChunkEvent) onProgress; ImageStreamListener _listener; + ImageInfo _imageInfo; + + _ImageResolver( + ImageProvider provider, + BuildContext context, { + this.onComplete, + this.onError, + this.onProgress, + double width, double height + }) { + Size size = width != null && height != null ? Size(width, height) : null; + ImageConfiguration config = createLocalImageConfiguration(context, size: size); + _listener = ImageStreamListener(_handleComplete, onChunk: _handleProgress, onError: _handleError); + _stream = provider.resolve(config); + _stream.addListener(_listener); // Called sync if already completed. + } - ImageChunkEvent chunkEvent; + ui.Image get image { + return _imageInfo?.image; + } - _ImageResolver(ImageProvider provider, _ImageFadeState state, this.onComplete, [this.onProgress]) { - double w = state.widget.width, h = state.widget.height; - ImageConfiguration config = createLocalImageConfiguration( - state.context, - size: w != null && h != null ? Size(w, h) : null, - ); - _listener = ImageStreamListener(_handleComplete, onChunk: _handleProgress); - _stream = provider.resolve(config, ); - _stream.addListener(_listener); // Called sync if already completed. + bool get inLoad { + return !success && !error; } - void _handleComplete(ImageInfo imageInfo, bool) { - onComplete(imageInfo); + bool get error { + return exception != null; + } + + void _handleComplete(ImageInfo imageInfo, bool _) { + _imageInfo = imageInfo; + chunkEvent = null; + success = true; + if (onComplete != null) { onComplete(); } } void _handleProgress(ImageChunkEvent event) { - if (onProgress != null) { onProgress(event); } + chunkEvent = event; + if (onProgress != null) { onProgress(); } + } + + void _handleError(dynamic exc, StackTrace _) { + exception = exc; + if (onError != null) { onError(); } } void dispose() { @@ -124,17 +164,16 @@ class _ImageResolver { } class _ImageFadeState extends State with TickerProviderStateMixin { - ImageInfo _backImageInfo; - ImageInfo _frontImageInfo; - _ImageResolver _backResolver; - _ImageResolver _frontResolver; + _ImageResolver _resolver; + Widget _front; + Widget _back; AnimationController _controller; - Animation _animation; - ImageChunkEvent _chunkEvent; + CurvedAnimation _animationIn; + CurvedAnimation _animationOut; @override void initState() { - _controller = AnimationController(vsync: this, value: 1.0); + _controller = AnimationController(vsync: this); _controller.addListener((){ setState(() {}); }); super.initState(); } @@ -153,36 +192,57 @@ class _ImageFadeState extends State with TickerProviderStateMixin { } void _update(BuildContext context, [ImageFade old]) { - final ImageProvider placeholder = widget.placeholder; - final ImageProvider image = widget.image ?? placeholder; - final ImageProvider oldPlaceholder = old?.placeholder; - final ImageProvider oldImage = old?.image ?? oldPlaceholder; - - if (_frontResolver == null && image != null && placeholder != null) { - // Initing, need to start with the placeholder in the back: - _backResolver = _ImageResolver(placeholder, this, (o) => _handleImageComplete(o, true)); - } + final ImageProvider image = widget.image; + final ImageProvider oldImage = old?.image; if (image == oldImage) { return; } - - if (_frontImageInfo == null) { - _frontResolver?.dispose(); // Active load. - } else if (_frontResolver != null) { - _backResolver?.dispose(); - _backResolver = _frontResolver; - _backImageInfo = _frontImageInfo; - _frontImageInfo = null; + + if (_resolver != null) { + _resolver.dispose(); + if (!_resolver.inLoad) { _back = _front; } + } else { + _back = null; + } + + _controller.value = 0.0; + if (image == null) { + _resolver = null; + _controller.forward(from: 0.5); + } else { + _resolver = _ImageResolver(image, context, + onError: _handleComplete, + onProgress: _handleProgress, + onComplete: _handleComplete, + width: widget.width, + height: widget.height + ); } + } - _frontResolver = _ImageResolver(image, this, _handleImageComplete, _handleImageProgress); + void _handleProgress() { + setState((){}); } - RawImage _getImage(ImageInfo imageInfo, {opacity:1.0}) { - return RawImage( - image: imageInfo?.image, - color: Color.fromRGBO(255, 255, 255, opacity), - colorBlendMode: BlendMode.modulate, + void _handleComplete() { + double m = 1 + 0.5; // defines the length of the fade out animation (ex. 1.5 = out is half as long as in) + setState((){ + _controller.duration = widget.fadeDuration * m; + _animationIn = CurvedAnimation( + + parent: _controller, + curve: Interval(0.0, 1/m, curve: widget.fadeCurve), + ); + _animationOut = CurvedAnimation( + parent: _controller, + curve: Interval(1/m, 1.0, curve: Curves.linear), + ); + _controller.forward(from: 0.0); + }); + } + RawImage _getImage(ui.Image image) { + return RawImage( + image: image, width: widget.width, height: widget.height, fit: widget.fit, @@ -192,43 +252,34 @@ class _ImageFadeState extends State with TickerProviderStateMixin { ); } - void _handleImageComplete(ImageInfo imageInfo, [back=false]) { - setState((){ - if (back) { - _backImageInfo = imageInfo; - } else { - _frontImageInfo = imageInfo; - _controller.duration = widget.fadeDuration; - _animation = CurvedAnimation( - parent: _controller, - curve: widget.fadeCurve - ); - _controller.forward(from: 0.0); - _chunkEvent = null; - } - }); - } - - void _handleImageProgress(ImageChunkEvent event) { - setState(() { - _chunkEvent = event; - }); - } - @override Widget build(BuildContext context) { List kids = []; - bool frontIsOpaque = _frontImageInfo != null && _animation.value == 1.0; - if (_backImageInfo != null && !frontIsOpaque) { kids.add(_getImage(_backImageInfo)); } + Widget back, front; - Widget front = _getImage(_frontImageInfo, opacity: _animation?.value ?? 1.0); - if (widget.loadingBuilder != null) { - front = widget.loadingBuilder(context, front, _chunkEvent); + if (_back != null && _animationOut.value < 1.0) { + back = Opacity(child: _back, opacity: 1.0 - _animationOut.value); } - kids.add(front); + if (_resolver != null) { + _front = _getImage(_resolver.image); + if (_resolver.inLoad && widget.loadingBuilder != null) { + front = widget.loadingBuilder(context, _front, _resolver.chunkEvent); + } else { + if (_resolver.error && widget.errorBuilder != null) { + _front = widget.errorBuilder(context, _front, _resolver.exception); + } + front = Opacity(child: _front, opacity: _animationIn?.value ?? 1.0); + } + } else { + _front = null; + } + + if (widget.placeholder != null) { kids.add(widget.placeholder); } + if (back != null) { kids.add(back); } + if (front != null) { kids.add(front); } + Widget content = Container( - color: widget.backgroundColor, width: widget.width, height: widget.height, child: Stack(children: kids, fit: StackFit.passthrough,) @@ -249,8 +300,8 @@ class _ImageFadeState extends State with TickerProviderStateMixin { @override void dispose() { - _backResolver?.dispose(); - _frontResolver?.dispose(); + _resolver?.dispose(); + _controller.dispose(); super.dispose(); } } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 7642229..1f66adf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,3 +137,4 @@ packages: version: "2.0.8" sdks: dart: ">=2.2.2 <3.0.0" + flutter: ">=1.6.7 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index dee7556..9203707 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,14 +4,15 @@ author: Grant Skinner repository: https://github.com/gskinner/image_fade homepage: https://github.com/gskinner/image_fade -version: 0.1.0 +version: 0.2.0 environment: - sdk: ">=2.1.0 <3.0.0" + sdk: '>=2.1.0 <3.0.0' + flutter: ^1.6.7 dependencies: flutter: - sdk: flutter + sdk: 'flutter' dev_dependencies: flutter_test: