364 lines
9.7 KiB
Dart
364 lines
9.7 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
typedef PhotoHeroOrigin = Rect? Function();
|
|
|
|
Rect? photoHeroRect(GlobalKey? key) =>
|
|
_globalRect(key?.currentContext?.findRenderObject());
|
|
|
|
Rect? photoHeroRectOf(BuildContext context) =>
|
|
context.mounted ? _globalRect(context.findRenderObject()) : null;
|
|
|
|
Rect? _globalRect(RenderObject? object) {
|
|
if (object is! RenderBox || !object.attached || !object.hasSize) return null;
|
|
final size = object.size;
|
|
if (size.isEmpty) return null;
|
|
return MatrixUtils.transformRect(
|
|
object.getTransformTo(null),
|
|
Offset.zero & size,
|
|
);
|
|
}
|
|
|
|
Rect inscribeRect(Size source, Rect box, {bool cover = false}) =>
|
|
_inscribe(source, box, cover: cover);
|
|
|
|
Rect _inscribe(Size source, Rect box, {required bool cover}) {
|
|
if (source.isEmpty || box.isEmpty) return box;
|
|
final scaleX = box.width / source.width;
|
|
final scaleY = box.height / source.height;
|
|
final scale = cover ? math.max(scaleX, scaleY) : math.min(scaleX, scaleY);
|
|
return Alignment.center.inscribe(
|
|
Size(source.width * scale, source.height * scale),
|
|
box,
|
|
);
|
|
}
|
|
|
|
class PhotoHeroController {
|
|
PhotoHeroController({
|
|
required this.origin,
|
|
ImageProvider? image,
|
|
this.size,
|
|
this.radius = BorderRadius.zero,
|
|
}) : image = ValueNotifier(image);
|
|
|
|
final PhotoHeroOrigin origin;
|
|
final ValueNotifier<ImageProvider?> image;
|
|
final ValueNotifier<bool> flying = ValueNotifier(false);
|
|
final Size? size;
|
|
final BorderRadius radius;
|
|
final GlobalKey areaKey = GlobalKey();
|
|
|
|
bool enabled = true;
|
|
|
|
Rect? get areaRect => photoHeroRect(areaKey);
|
|
|
|
Rect? get originRect => enabled ? origin() : null;
|
|
|
|
bool get canFly => image.value != null && originRect != null;
|
|
|
|
void dispose() {
|
|
image.dispose();
|
|
flying.dispose();
|
|
}
|
|
}
|
|
|
|
class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
|
|
PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
|
|
: super(
|
|
transitionDuration: const Duration(milliseconds: 320),
|
|
reverseTransitionDuration: const Duration(milliseconds: 280),
|
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
PhotoHeroScope(controller: hero, child: builder(context)),
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
_PhotoHeroTransition(
|
|
controller: hero,
|
|
animation: animation,
|
|
child: child,
|
|
),
|
|
);
|
|
|
|
final PhotoHeroController hero;
|
|
|
|
@override
|
|
void dispose() {
|
|
hero.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class PhotoHeroScope extends InheritedWidget {
|
|
const PhotoHeroScope({
|
|
super.key,
|
|
required this.controller,
|
|
required super.child,
|
|
});
|
|
|
|
final PhotoHeroController controller;
|
|
|
|
static PhotoHeroController? maybeOf(BuildContext context) =>
|
|
context.dependOnInheritedWidgetOfExactType<PhotoHeroScope>()?.controller;
|
|
|
|
@override
|
|
bool updateShouldNotify(PhotoHeroScope oldWidget) =>
|
|
controller != oldWidget.controller;
|
|
}
|
|
|
|
class PhotoHeroTarget extends StatelessWidget {
|
|
const PhotoHeroTarget({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
PhotoHeroAnchor(child: PhotoHeroFade(child: child));
|
|
}
|
|
|
|
class PhotoHeroAnchor extends StatelessWidget {
|
|
const PhotoHeroAnchor({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = PhotoHeroScope.maybeOf(context);
|
|
if (controller == null) return child;
|
|
return KeyedSubtree(key: controller.areaKey, child: child);
|
|
}
|
|
}
|
|
|
|
class PhotoHeroFade extends StatelessWidget {
|
|
const PhotoHeroFade({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = PhotoHeroScope.maybeOf(context);
|
|
if (controller == null) return child;
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: controller.flying,
|
|
child: child,
|
|
builder: (context, flying, child) =>
|
|
flying ? Opacity(opacity: 0, child: child) : child!,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RawImageProvider extends ImageProvider<RawImageProvider> {
|
|
const RawImageProvider(this.image);
|
|
|
|
final ui.Image image;
|
|
|
|
@override
|
|
Future<RawImageProvider> obtainKey(ImageConfiguration configuration) =>
|
|
SynchronousFuture<RawImageProvider>(this);
|
|
|
|
@override
|
|
ImageStreamCompleter loadImage(
|
|
RawImageProvider key,
|
|
ImageDecoderCallback decode,
|
|
) => OneFrameImageStreamCompleter(
|
|
SynchronousFuture<ImageInfo>(ImageInfo(image: image.clone())),
|
|
);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is RawImageProvider && identical(other.image, image);
|
|
|
|
@override
|
|
int get hashCode => identityHashCode(image);
|
|
}
|
|
|
|
class _PhotoHeroTransition extends StatefulWidget {
|
|
const _PhotoHeroTransition({
|
|
required this.controller,
|
|
required this.animation,
|
|
required this.child,
|
|
});
|
|
|
|
final PhotoHeroController controller;
|
|
final Animation<double> animation;
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_PhotoHeroTransition> createState() => _PhotoHeroTransitionState();
|
|
}
|
|
|
|
class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
|
|
ImageStream? _stream;
|
|
ImageStreamListener? _listener;
|
|
Size? _resolvedSize;
|
|
Rect? _from;
|
|
Rect? _area;
|
|
ImageProvider? _flightProvider;
|
|
Widget? _flightImage;
|
|
|
|
Size? get _imageSize => widget.controller.size ?? _resolvedSize;
|
|
|
|
bool get _flying => widget.controller.flying.value;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.controller.image.addListener(_resolveImage);
|
|
widget.animation.addStatusListener(_onStatusChanged);
|
|
if (widget.animation.status != AnimationStatus.completed) {
|
|
widget.controller.flying.value = widget.controller.canFly;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_resolveImage();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.animation.removeStatusListener(_onStatusChanged);
|
|
widget.controller.image.removeListener(_resolveImage);
|
|
_detachStream();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onStatusChanged(AnimationStatus status) {
|
|
switch (status) {
|
|
case AnimationStatus.forward:
|
|
case AnimationStatus.reverse:
|
|
_startFlight();
|
|
case AnimationStatus.completed:
|
|
case AnimationStatus.dismissed:
|
|
_stopFlight();
|
|
}
|
|
}
|
|
|
|
void _startFlight() {
|
|
_from = null;
|
|
_area = null;
|
|
_setFlying(widget.controller.canFly);
|
|
}
|
|
|
|
void _stopFlight() => _setFlying(false);
|
|
|
|
void _setFlying(bool value) {
|
|
if (_flying == value) return;
|
|
widget.controller.flying.value = value;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _detachStream() {
|
|
final listener = _listener;
|
|
if (listener != null) _stream?.removeListener(listener);
|
|
_stream = null;
|
|
_listener = null;
|
|
}
|
|
|
|
void _resolveImage() {
|
|
if (!mounted) return;
|
|
final provider = widget.controller.image.value;
|
|
if (provider == null) return;
|
|
final stream = provider.resolve(createLocalImageConfiguration(context));
|
|
if (stream.key == _stream?.key) return;
|
|
_detachStream();
|
|
final listener = ImageStreamListener((info, synchronous) {
|
|
final size = Size(
|
|
info.image.width.toDouble(),
|
|
info.image.height.toDouble(),
|
|
);
|
|
info.dispose();
|
|
if (_resolvedSize == size) return;
|
|
if (_resolvedSize != null && _flying) return;
|
|
if (synchronous) {
|
|
_resolvedSize = size;
|
|
} else if (mounted) {
|
|
setState(() => _resolvedSize = size);
|
|
}
|
|
});
|
|
_listener = listener;
|
|
_stream = stream..addListener(listener);
|
|
}
|
|
|
|
Widget _imageWidget(ImageProvider provider) {
|
|
if (!identical(_flightProvider, provider)) {
|
|
_flightProvider = provider;
|
|
_flightImage = Image(
|
|
image: provider,
|
|
fit: BoxFit.fill,
|
|
gaplessPlayback: true,
|
|
);
|
|
}
|
|
return _flightImage!;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = widget.controller.image.value;
|
|
if (!_flying || provider == null) {
|
|
return FadeTransition(opacity: widget.animation, child: widget.child);
|
|
}
|
|
return Stack(
|
|
children: [
|
|
widget.child,
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: AnimatedBuilder(
|
|
animation: widget.animation,
|
|
child: _imageWidget(provider),
|
|
builder: (context, child) => _layoutFlight(child!),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _layoutFlight(Widget image) {
|
|
final from = _from ??= widget.controller.originRect;
|
|
if (from == null) return const SizedBox.shrink();
|
|
final area = _area ??= widget.controller.areaRect;
|
|
final imageSize = _imageSize;
|
|
if (area == null || imageSize == null) {
|
|
return _position(image, from, from, 1);
|
|
}
|
|
final t = Curves.fastOutSlowIn.transform(
|
|
widget.animation.value.clamp(0.0, 1.0),
|
|
);
|
|
final target = _inscribe(imageSize, area, cover: false);
|
|
return _position(
|
|
image,
|
|
Rect.lerp(from, target, t)!,
|
|
Rect.lerp(_inscribe(imageSize, from, cover: true), target, t)!,
|
|
1 - t,
|
|
);
|
|
}
|
|
|
|
Widget _position(Widget image, Rect clip, Rect rect, double radiusT) {
|
|
final radius = widget.controller.radius * radiusT;
|
|
final content = Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
left: rect.left - clip.left,
|
|
top: rect.top - clip.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
child: image,
|
|
),
|
|
],
|
|
);
|
|
return Stack(
|
|
children: [
|
|
Positioned.fromRect(
|
|
rect: clip,
|
|
child: radius == BorderRadius.zero
|
|
? ClipRect(child: content)
|
|
: ClipRRect(borderRadius: radius, child: content),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|