mnemo_cards/lib/widgets/image_fade.dart

357 lines
11 KiB
Dart
Raw Normal View History

2024-05-22 20:48:44 +00:00
import 'package:flutter/widgets.dart';
import 'dart:ui' as ui;
/// Signature used by [ImageFade.errorBuilder] to build the widget that will be displayed
/// if an error occurs while loading an image.
typedef ImageFadeErrorBuilder = Widget Function(
BuildContext context,
Object exception,
);
/// Signature used by [ImageFade.loadingBuilder] to build the widget that will be displayed
/// while an image is loading. `progress` returns a value between 0 and 1 indicating load progress.
typedef ImageFadeLoadingBuilder = Widget Function(
BuildContext context,
double progress,
ImageChunkEvent? chunkEvent,
);
/// 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 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: Image.asset('assets/myPlaceholder.png'),
/// image: NetworkImage('https://backend.example.com/image.png'),
/// )
/// ```
class ImageFade extends StatefulWidget {
/// Creates a widget that displays a [placeholder] widget while a specified [image] loads,
/// then cross-fades to the loaded image.
2024-05-28 21:25:51 +00:00
const
ImageFade({
2024-05-22 20:48:44 +00:00
Key? key,
this.placeholder,
this.image,
this.curve = Curves.linear,
this.duration = const Duration(milliseconds: 300),
this.syncDuration,
this.width,
this.height,
this.scale = 1,
this.fit = BoxFit.scaleDown,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.matchTextDirection = false,
this.excludeFromSemantics = false,
this.semanticLabel,
this.loadingBuilder,
this.errorBuilder,
}) : super(key: key);
/// Widget layered behind the loaded images. Displayed when [image] is null or is loading initially.
final Widget? placeholder;
/// The image to display. Subsequently changing the image will fade the new image over the previous one.
final ImageProvider? image;
/// The curve of the fade-in animation.
final Curve curve;
/// The duration of the fade-in animation.
final Duration duration;
/// An optional duration for fading in a synchronously loaded image (ex. from memory), error, or placeholder.
/// For example, you could set this to `Duration.zero` to immediately display images that are already loaded.
/// If omitted, [duration] will be used.
final Duration? syncDuration;
/// The width to display at. See [Image.width] for more information.
final double? width;
/// The height to display at. See [Image.height] for more information.
final double? height;
/// The scale factor for drawing this image at its intended size. See [RawImage.scale] for more information.
final double scale;
/// How to draw the image within its bounds. Defaults to [BoxFit.scaleDown]. See [Image.fit] for more information.
final BoxFit fit;
/// How to align the image within its bounds. See [Image.alignment] for more information.
final Alignment alignment;
/// How to paint any portions of the layout bounds not covered by the image. See [Image.repeat] for more information.
final ImageRepeat repeat;
/// Whether to paint the image in the direction of the [TextDirection]. See [Image.matchTextDirection] for more information.
final bool matchTextDirection;
/// Whether to exclude this image from semantics. See [Image.excludeFromSemantics] for more information.
final bool excludeFromSemantics;
/// A Semantic description of the image. See [Image.semanticLabel] for more information.
final String? semanticLabel;
/// A builder that specifies the widget to display while an image is loading.
/// See [ImageFadeLoadingBuilder] for more information.
final ImageFadeLoadingBuilder? 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<StatefulWidget> createState() => _ImageFadeState();
}
class _ImageFadeState extends State<ImageFade> with TickerProviderStateMixin {
_ImageResolver? _resolver;
Widget? _front;
Widget? _back;
late final AnimationController _controller;
Widget? _fadeFront;
Widget? _fadeBack;
bool? _sync; // could use onImage synchronousCall, but this is more forgiving
bool _shouldBuildFront = false;
@override
void initState() {
_controller = AnimationController(vsync: this);
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Can't call this in initState because createLocalImageConfiguration throws errors:
_update(context);
}
@override
void didUpdateWidget(ImageFade old) {
// not called on init
super.didUpdateWidget(old);
_update(context, old);
}
void _update(BuildContext context, [ImageFade? old]) {
final ImageProvider? image = widget.image;
final ImageProvider? oldImage = old?.image;
if (image == oldImage) return;
_back = null;
_shouldBuildFront = false;
if (_resolver != null) {
// move previous loaded image to back & cancel any active loads.
if (_resolver!.complete) _back = _fadeBack = _front;
_resolver!.dispose();
}
// load the new image:
_front = _sync = null;
_resolver = image == null
? null
: _ImageResolver(
image,
context,
onError: _handleComplete,
onComplete: _handleComplete,
width: widget.width,
height: widget.height,
);
// start transition to placeholder if there's no new image:
if (_back != null && _resolver == null) _buildTransition();
}
void _handleComplete(_ImageResolver resolver) {
if (_sync == null) _sync = true;
// defer building the front content until build so we have an active context.
setState(() => _shouldBuildFront = true);
}
void _buildFront(BuildContext context) {
_shouldBuildFront = false;
_ImageResolver resolver = _resolver!;
_front = resolver.error
? widget.errorBuilder?.call(context, resolver.exception!)
: _getImage(resolver.image);
_buildTransition();
}
void _buildTransition() {
final bool out = _front == null; // no new image
// use the "fast" duration if sync load, error, or placeholder:
bool fast = (_sync != false || _resolver?.error == true || out);
Duration duration = (fast ? widget.syncDuration : null) ?? widget.duration;
// Fade in for duration, out for 1/2 as long:
_controller.duration = duration * (out ? 1 : 3 / 2);
_fadeFront = _buildFade(
child: _front,
opacity: CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 2 / 3, curve: widget.curve),
),
);
_fadeBack = _buildFade(
child: _back,
opacity: Tween<double>(begin: 1.0, end: 0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(out ? 0.0 : 2 / 3, 1.0),
),
),
);
if (_front != null || _back != null) _controller.forward(from: 0);
}
Widget? _buildFade({Widget? child, required Animation<double> opacity}) {
if (child == null) return null;
// if the child is a loaded image, we can fade its opacity directly for better performance:
return (child is RawImage)
? _getImage(child.image, opacity: opacity)
: FadeTransition(child: child, opacity: opacity);
}
RawImage _getImage(ui.Image? image, {Animation<double>? opacity}) {
return RawImage(
image: image,
width: widget.width,
height: widget.height,
scale: widget.scale,
fit: widget.fit,
alignment: widget.alignment,
repeat: widget.repeat,
matchTextDirection: widget.matchTextDirection,
opacity: opacity,
);
}
@override
Widget build(BuildContext context) {
if (_sync == null) _sync = false;
if (_shouldBuildFront) _buildFront(context);
Widget? front = _fadeFront, back = _fadeBack;
bool inLoad = _resolver != null && !_resolver!.complete;
if (inLoad && widget.loadingBuilder != null) {
_ImageResolver resolver = _resolver!;
front = AnimatedBuilder(
animation: resolver.notifier,
builder: (_, __) => widget.loadingBuilder!(
context,
resolver.notifier.value,
resolver.chunkEvent,
),
);
}
List<Widget> kids = [];
if (widget.placeholder != null) kids.add(widget.placeholder!);
if (back != null) kids.add(back);
if (front != null) kids.add(front);
Widget content = Container(
width: widget.width,
height: widget.height,
child: kids.isEmpty
? null
: Stack(fit: StackFit.passthrough, children: kids),
);
if (widget.excludeFromSemantics) return content;
String? label = widget.semanticLabel;
return Semantics(
container: label != null,
image: true,
label: label ?? "",
child: content,
);
}
@override
void dispose() {
_resolver?.dispose();
_controller.dispose();
super.dispose();
}
}
// Simplifies working with image loading events and states.
class _ImageResolver {
_ImageResolver(
ImageProvider provider,
BuildContext context, {
required this.onComplete,
required this.onError,
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.
notifier = ValueNotifier(0);
}
Object? exception;
ImageChunkEvent? chunkEvent;
late final ValueNotifier<double> notifier;
final Function(_ImageResolver resolver) onComplete;
final Function(_ImageResolver resolver) onError;
late final ImageStream _stream;
late final ImageStreamListener _listener;
ImageInfo? _imageInfo;
bool _complete = false;
ui.Image? get image => _imageInfo?.image;
bool get complete => _complete;
bool get error => exception != null;
void _handleComplete(ImageInfo imageInfo, bool sync) {
_imageInfo = imageInfo;
_complete = true;
onComplete(this);
}
void _handleProgress(ImageChunkEvent event) {
chunkEvent = event;
notifier.value = event.expectedTotalBytes != null
? event.cumulativeBytesLoaded / event.expectedTotalBytes!
: 0.0;
}
void _handleError(Object exc, StackTrace? _) {
exception = exc;
_complete = true;
onError(this);
}
void dispose() {
_stream.removeListener(_listener);
}
}