import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/semantics.dart'; // adapted from Flutter `_ImageState` in `/widgets/image.dart` // and `DecorationImagePainter` in `/painting/decoration_image.dart` // to transition between 2 different fits during hero animation: // - BoxFit.cover at t=0 // - BoxFit.contain at t=1 class TransitionImage extends StatefulWidget { final ImageProvider image; final ValueListenable animation; final BoxFit thumbnailFit, viewerFit; final ImageFrameBuilder? frameBuilder; final ImageLoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; final String? semanticLabel; final bool excludeFromSemantics; final double? width, height; final bool gaplessPlayback = false; final Color? background; const TransitionImage({ super.key, required this.image, required this.animation, required this.thumbnailFit, required this.viewerFit, this.frameBuilder, this.loadingBuilder, this.errorBuilder, this.semanticLabel, this.excludeFromSemantics = false, this.width, this.height, this.background, }); @override State createState() => _TransitionImageState(); } class _TransitionImageState extends State with WidgetsBindingObserver { ImageStream? _imageStream; ImageInfo? _imageInfo; ImageChunkEvent? _loadingProgress; bool _isListeningToStream = false; int? _frameNumber; bool _wasSynchronouslyLoaded = false; late DisposableBuildContext> _scrollAwareContext; Object? _lastException; StackTrace? _lastStack; ImageStreamCompleterHandle? _completerHandle; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _scrollAwareContext = DisposableBuildContext>(this); } @override void dispose() { assert(_imageStream != null); WidgetsBinding.instance.removeObserver(this); _stopListeningToStream(); _completerHandle?.dispose(); _scrollAwareContext.dispose(); _replaceImage(info: null); super.dispose(); } @override void didChangeDependencies() { _resolveImage(); if (TickerMode.of(context)) { _listenToStream(); } else { _stopListeningToStream(keepStreamAlive: true); } super.didChangeDependencies(); } @override void didUpdateWidget(TransitionImage oldWidget) { super.didUpdateWidget(oldWidget); if (_isListeningToStream && (widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) { final ImageStreamListener oldListener = _getListener(); _imageStream!.addListener(_getListener(recreateListener: true)); _imageStream!.removeListener(oldListener); } if (widget.image != oldWidget.image) { _resolveImage(); } } @override void reassemble() { _resolveImage(); // in case the image cache was flushed super.reassemble(); } void _resolveImage() { final ScrollAwareImageProvider provider = ScrollAwareImageProvider( context: _scrollAwareContext, imageProvider: widget.image, ); final ImageStream newStream = provider.resolve(createLocalImageConfiguration( context, size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null, )); _updateSourceStream(newStream); } ImageStreamListener? _imageStreamListener; ImageStreamListener _getListener({bool recreateListener = false}) { if (_imageStreamListener == null || recreateListener) { _lastException = null; _lastStack = null; _imageStreamListener = ImageStreamListener( _handleImageFrame, onChunk: widget.loadingBuilder == null ? null : _handleImageChunk, onError: widget.errorBuilder != null || kDebugMode ? (error, stackTrace) { setState(() { _lastException = error; _lastStack = stackTrace; }); assert(() { if (widget.errorBuilder == null) { // ignore: only_throw_errors, since we're just proxying the error. throw error; // Ensures the error message is printed to the console. } return true; }()); } : null, ); } return _imageStreamListener!; } void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) { setState(() { _replaceImage(info: imageInfo); _loadingProgress = null; _lastException = null; _lastStack = null; _frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1; _wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall; }); } void _handleImageChunk(ImageChunkEvent event) { assert(widget.loadingBuilder != null); setState(() { _loadingProgress = event; _lastException = null; _lastStack = null; }); } void _replaceImage({required ImageInfo? info}) { final ImageInfo? oldImageInfo = _imageInfo; SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose()); _imageInfo = info; } // Updates _imageStream to newStream, and moves the stream listener // registration from the old stream to the new stream (if a listener was // registered). void _updateSourceStream(ImageStream newStream) { if (_imageStream?.key == newStream.key) { return; } if (_isListeningToStream) { _imageStream!.removeListener(_getListener()); } if (!widget.gaplessPlayback) { setState(() { _replaceImage(info: null); }); } setState(() { _loadingProgress = null; _frameNumber = null; _wasSynchronouslyLoaded = false; }); _imageStream = newStream; if (_isListeningToStream) { _imageStream!.addListener(_getListener()); } } void _listenToStream() { if (_isListeningToStream) { return; } _imageStream!.addListener(_getListener()); _completerHandle?.dispose(); _completerHandle = null; _isListeningToStream = true; } /// Stops listening to the image stream, if this state object has attached a /// listener. /// /// If the listener from this state is the last listener on the stream, the /// stream will be disposed. To keep the stream alive, set `keepStreamAlive` /// to true, which create [ImageStreamCompleterHandle] to keep the completer /// alive and is compatible with the [TickerMode] being off. void _stopListeningToStream({bool keepStreamAlive = false}) { if (!_isListeningToStream) { return; } if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) { _completerHandle = _imageStream!.completer!.keepAlive(); } _imageStream!.removeListener(_getListener()); _isListeningToStream = false; } Widget _debugBuildErrorWidget(BuildContext context, Object error) { return Stack( alignment: Alignment.center, children: [ const Positioned.fill( child: Placeholder( color: Color(0xCF8D021F), ), ), Padding( padding: const EdgeInsets.all(4.0), child: FittedBox( child: Text( '$error', textAlign: TextAlign.center, textDirection: TextDirection.ltr, style: const TextStyle( shadows: [ Shadow(blurRadius: 1.0), ], ), ), ), ), ], ); } @override Widget build(BuildContext context) { if (_lastException != null) { if (widget.errorBuilder != null) { return widget.errorBuilder!(context, _lastException!, _lastStack); } if (kDebugMode) { return _debugBuildErrorWidget(context, _lastException!); } } Widget result = ValueListenableBuilder( valueListenable: widget.animation, builder: (context, t, child) => CustomPaint( painter: _TransitionImagePainter( image: _imageInfo?.image, scale: _imageInfo?.scale ?? 1.0, t: t, thumbnailFit: widget.thumbnailFit, viewerFit: widget.viewerFit, background: widget.background, ), ), ); if (!widget.excludeFromSemantics) { result = Semantics( container: widget.semanticLabel != null, image: true, label: widget.semanticLabel ?? '', child: result, ); } if (widget.frameBuilder != null) { result = widget.frameBuilder!(context, result, _frameNumber, _wasSynchronouslyLoaded); } if (widget.loadingBuilder != null) { result = widget.loadingBuilder!(context, result, _loadingProgress); } return result; } @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); description.add(DiagnosticsProperty('stream', _imageStream)); description.add(DiagnosticsProperty('pixels', _imageInfo)); description.add(DiagnosticsProperty('loadingProgress', _loadingProgress)); description.add(DiagnosticsProperty('frameNumber', _frameNumber)); description.add(DiagnosticsProperty('wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); } } class _TransitionImagePainter extends CustomPainter { final ui.Image? image; final double scale, t; final Color? background; final BoxFit thumbnailFit, viewerFit; const _TransitionImagePainter({ required this.image, required this.scale, required this.t, required this.thumbnailFit, required this.viewerFit, required this.background, }); @override void paint(Canvas canvas, Size size) { if (image == null) return; final paint = Paint() ..isAntiAlias = false ..filterQuality = FilterQuality.low; const alignment = Alignment.center; final rect = Offset.zero & size; if (rect.isEmpty) { return; } final outputSize = rect.size; final inputSize = Size(image!.width.toDouble(), image!.height.toDouble()); final thumbnailSizes = applyBoxFit(thumbnailFit, inputSize / scale, size); final viewerSizes = applyBoxFit(viewerFit, inputSize / scale, size); final sourceSize = Size.lerp(thumbnailSizes.source, viewerSizes.source, t)! * scale; final destinationSize = Size.lerp(thumbnailSizes.destination, viewerSizes.destination, t)!; final halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0; final halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0; final dx = halfWidthDelta + alignment.x * halfWidthDelta; final dy = halfHeightDelta + alignment.y * halfHeightDelta; final destinationPosition = rect.topLeft.translate(dx, dy); final destinationRect = destinationPosition & destinationSize; final sourceRect = alignment.inscribe( sourceSize, Offset.zero & inputSize, ); if (background != null) { // deflate to avoid background artifact around opaque image canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!); } canvas.drawImageRect(image!, sourceRect, destinationRect, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }