From d0d22bb49ae847a2500964e42866a92b0dbb25c0 Mon Sep 17 00:00:00 2001 From: Simon Gerstmeier Date: Mon, 2 Oct 2023 13:15:32 +0200 Subject: [PATCH 1/4] Added the onlyAnimateRemount bool parameter to LocalHeroScope. It makes the LocalHero animation skip all position changes except when caused by a remount. A remount is considered a reparent or a slot change inside a multi-child parent. --- lib/src/rendering/controller.dart | 115 +++++++++++++++----------- lib/src/widgets/local_hero_layer.dart | 24 ++++++ lib/src/widgets/local_hero_scope.dart | 15 ++++ 3 files changed, 108 insertions(+), 46 deletions(-) diff --git a/lib/src/rendering/controller.dart b/lib/src/rendering/controller.dart index f06fb33..1c1392c 100644 --- a/lib/src/rendering/controller.dart +++ b/lib/src/rendering/controller.dart @@ -16,7 +16,8 @@ class LocalHeroController { required this.curve, required this.createRectTween, required this.tag, - }) : link = LayerLink(), + required this.onlyAnimateRemount, + }) : link = LayerLink(), _vsync = vsync, _initialDuration = duration; @@ -34,6 +35,10 @@ class LocalHeroController { Curve curve; RectTweenSupplier createRectTween; + final bool onlyAnimateRemount; + + bool _remountHappened = false; + Duration? get duration => _controller.duration; set duration(Duration? value) { _controller.duration = value; @@ -52,62 +57,80 @@ class LocalHeroController { if (status == AnimationStatus.completed) { _isAnimating = false; _animation = null; + _remountHappened = false; _controller.value = 0; } } void animateIfNeeded(Rect rect) { - if (_lastRect != null && _lastRect != rect) { - final bool inAnimation = isAnimating; - Rect from = Rect.fromLTWH( - _lastRect!.left - rect.left, - _lastRect!.top - rect.top, - _lastRect!.width, - _lastRect!.height, + final bool animationNeeded = _lastRect != null && + _lastRect != rect && + (!onlyAnimateRemount || _remountHappened); + + if (!animationNeeded) { + _remountHappened = false; + _lastRect = rect; + return; + } + + final bool inAnimation = isAnimating; + Rect from = Rect.fromLTWH( + _lastRect!.left - rect.left, + _lastRect!.top - rect.top, + _lastRect!.width, + _lastRect!.height, + ); + if (inAnimation) { + // We need to recompute the from. + final Rect currentRect = _animation!.value!; + from = Rect.fromLTWH( + currentRect.left + _lastRect!.left - rect.left, + currentRect.top + _lastRect!.top - rect.top, + currentRect.width, + currentRect.height, ); - if (inAnimation) { - // We need to recompute the from. - final Rect currentRect = _animation!.value!; - from = Rect.fromLTWH( - currentRect.left + _lastRect!.left - rect.left, - currentRect.top + _lastRect!.top - rect.top, - currentRect.width, - currentRect.height, - ); - } - _isAnimating = true; - - _animation = _controller.drive(CurveTween(curve: curve)).drive( - createRectTween( - from, - Rect.fromLTWH( - 0, - 0, - rect.width, - rect.height, - ), + } + _isAnimating = true; + + _animation = _controller.drive(CurveTween(curve: curve)).drive( + createRectTween( + from, + Rect.fromLTWH( + 0, + 0, + rect.width, + rect.height, ), - ); - - if (!inAnimation) { - SchedulerBinding.instance!.addPostFrameCallback((_) { - _controller.forward(); - }); - } else { - SchedulerBinding.instance!.addPostFrameCallback((_) { - final Duration duration = - _controller.duration! * (1 - _controller.value); - _controller.reset(); - _controller.animateTo( - 1, - duration: duration, - ); - }); - } + ), + ); + + if (!inAnimation) { + SchedulerBinding.instance!.addPostFrameCallback((_) { + _controller.forward(); + }); + } else { + SchedulerBinding.instance!.addPostFrameCallback((_) { + final Duration duration = + _controller.duration! * (1 - _controller.value); + _controller.reset(); + _controller.animateTo( + 1, + duration: duration, + ); + }); } + _lastRect = rect; } + /// Notify this controller that its local hero widget was remounted. + /// + /// This only affects the animation if [onlyAnimateRemount] is true. + /// It causes [animateIfNeeded] to only animate while the remount mark is set. + void markRemount() { + _remountHappened = true; + } + void dispose() { _controller.stop(); _controller.removeStatusListener(_onAnimationStatusChanged); diff --git a/lib/src/widgets/local_hero_layer.dart b/lib/src/widgets/local_hero_layer.dart index 20b3caa..c04a571 100644 --- a/lib/src/widgets/local_hero_layer.dart +++ b/lib/src/widgets/local_hero_layer.dart @@ -91,6 +91,30 @@ class _LocalHeroLeaderElement extends SingleChildRenderObjectElement { @override LocalHeroLeader get widget => super.widget as LocalHeroLeader; + /// Track the slot that this element is in to be able to call + /// [LocalHeroController.markRemount] when the slot changes + Object? _lastSlot; + + @override + void update(SingleChildRenderObjectWidget newWidget) { + super.update(newWidget); + + if (slot != _lastSlot) { + // Mark remount due to slot change + widget.controller.markRemount(); + _lastSlot = slot; + } + } + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + + // Mark remount due to reparenting + widget.controller.markRemount(); + _lastSlot = newSlot; + } + @override void debugVisitOnstageChildren(ElementVisitor visitor) { if (!widget.controller.isAnimating) { diff --git a/lib/src/widgets/local_hero_scope.dart b/lib/src/widgets/local_hero_scope.dart index a64d481..593c1f8 100644 --- a/lib/src/widgets/local_hero_scope.dart +++ b/lib/src/widgets/local_hero_scope.dart @@ -18,6 +18,7 @@ class LocalHeroScope extends StatefulWidget { this.curve = Curves.linear, this.createRectTween = _defaultCreateTweenRect, required this.child, + this.onlyAnimateRemount = false, }) : super(key: key); /// The duration of the animation. @@ -32,6 +33,19 @@ class LocalHeroScope extends StatefulWidget { /// The default value creates a [MaterialRectArcTween]. final CreateRectTween createRectTween; + /// When this is set to true, [LocalHero]s in this scope will only animate + /// when the widget is remounted on the widget tree. + /// + /// This means other position changes like scrolling or changes in padding + /// are not animated. + /// + /// Instead it only happens when the [LocalHero] e.g. changes its index in a + /// parent [Row] widget or gets reparented. + /// + /// Note: To reliably remount a widget it needs to have a unique [Key] in its + /// key property. + final bool onlyAnimateRemount; + /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.child} @@ -63,6 +77,7 @@ class _LocalHeroScopeState extends State curve: widget.curve, tag: localHero.tag, vsync: this, + onlyAnimateRemount: widget.onlyAnimateRemount, ); final Widget shuttle = localHero.flightShuttleBuilder?.call( context, From 20f2397d029fab6787e387f96a166a3f792c7f18 Mon Sep 17 00:00:00 2001 From: Simon Gerstmeier Date: Mon, 2 Oct 2023 16:02:13 +0200 Subject: [PATCH 2/4] Reverted the slot change detection. The LocalHero now animates due to any change in its Widget. This still filters out scrolling but enables e.g. resizing. --- lib/src/widgets/local_hero_layer.dart | 12 ++---------- lib/src/widgets/local_hero_scope.dart | 3 +-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/src/widgets/local_hero_layer.dart b/lib/src/widgets/local_hero_layer.dart index c04a571..18dfae1 100644 --- a/lib/src/widgets/local_hero_layer.dart +++ b/lib/src/widgets/local_hero_layer.dart @@ -91,19 +91,12 @@ class _LocalHeroLeaderElement extends SingleChildRenderObjectElement { @override LocalHeroLeader get widget => super.widget as LocalHeroLeader; - /// Track the slot that this element is in to be able to call - /// [LocalHeroController.markRemount] when the slot changes - Object? _lastSlot; - @override void update(SingleChildRenderObjectWidget newWidget) { super.update(newWidget); - if (slot != _lastSlot) { - // Mark remount due to slot change - widget.controller.markRemount(); - _lastSlot = slot; - } + // Mark remount due to widget change + widget.controller.markRemount(); } @override @@ -112,7 +105,6 @@ class _LocalHeroLeaderElement extends SingleChildRenderObjectElement { // Mark remount due to reparenting widget.controller.markRemount(); - _lastSlot = newSlot; } @override diff --git a/lib/src/widgets/local_hero_scope.dart b/lib/src/widgets/local_hero_scope.dart index 593c1f8..c29c3dd 100644 --- a/lib/src/widgets/local_hero_scope.dart +++ b/lib/src/widgets/local_hero_scope.dart @@ -36,8 +36,7 @@ class LocalHeroScope extends StatefulWidget { /// When this is set to true, [LocalHero]s in this scope will only animate /// when the widget is remounted on the widget tree. /// - /// This means other position changes like scrolling or changes in padding - /// are not animated. + /// This means other position changes like scrolling are not animated. /// /// Instead it only happens when the [LocalHero] e.g. changes its index in a /// parent [Row] widget or gets reparented. From 2d9130080955bb38b19bdeb01b73dac8272f4071 Mon Sep 17 00:00:00 2001 From: Simon Gerstmeier Date: Sun, 19 Nov 2023 00:34:15 +0100 Subject: [PATCH 3/4] Set onlyAnimateRemount to true by default. --- lib/src/widgets/local_hero_scope.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/widgets/local_hero_scope.dart b/lib/src/widgets/local_hero_scope.dart index 9bc936a..3546ba8 100644 --- a/lib/src/widgets/local_hero_scope.dart +++ b/lib/src/widgets/local_hero_scope.dart @@ -17,7 +17,7 @@ class LocalHeroScope extends StatefulWidget { this.curve = Curves.linear, this.createRectTween = _defaultCreateTweenRect, required this.child, - this.onlyAnimateRemount = false, + this.onlyAnimateRemount = true, }) : super(key: key); /// The duration of the animation. @@ -40,6 +40,8 @@ class LocalHeroScope extends StatefulWidget { /// Instead it only happens when the [LocalHero] e.g. changes its index in a /// parent [Row] widget or gets reparented. /// + /// Defaults to true. + /// /// Note: To reliably remount a widget it needs to have a unique [Key] in its /// key property. final bool onlyAnimateRemount; From fd0b69341e6f7e416fd47cd491c918e68761d038 Mon Sep 17 00:00:00 2001 From: Simon Gerstmeier Date: Sun, 19 Nov 2023 00:49:52 +0100 Subject: [PATCH 4/4] Added a new example with a scrolling sliver list --- example/lib/main.dart | 100 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index cc7b124..4f75403 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -34,7 +34,7 @@ class _LocalHeroPlayground extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 3, + length: 4, child: Scaffold( appBar: AppBar( title: const TabBar( @@ -42,6 +42,7 @@ class _LocalHeroPlayground extends StatelessWidget { Text('Animate wrap reordering'), Text('Move between containers'), Text('Draggable content'), + Text('Animate scroll view'), ], ), ), @@ -51,6 +52,7 @@ class _LocalHeroPlayground extends StatelessWidget { _WrapReorderingAnimation(), _AcrossContainersAnimation(), _DraggableExample(), + _ScrollViewExample(), ], ), ), @@ -376,6 +378,102 @@ class _DraggableTileState extends State<_DraggableTile> { } } +class _ScrollViewExample extends StatefulWidget { + const _ScrollViewExample(); + + @override + State<_ScrollViewExample> createState() => _ScrollViewExampleState(); +} + +class _ScrollViewExampleState extends State<_ScrollViewExample> { + int offset = 0; + + @override + Widget build(BuildContext context) { + List items = + List.generate(100, (index) => 'Item ${index + offset}'); + + return LocalHeroOverlay( + child: Align( + child: Row( + children: [ + ElevatedButton( + onPressed: () { + setState(() { + offset += 1; + }); + }, + child: Text('Remove Item'), + ), + const SizedBox(width: 20), + ElevatedButton( + onPressed: () { + setState(() { + offset -= 1; + }); + }, + child: Text('Add Item'), + ), + const SizedBox(width: 50), + Container( + width: 300, + color: Colors.lightBlue.withOpacity(.2), + child: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _ScrollViewItem( + key: ValueKey(index), + index: index, + items: items, + ), + addRepaintBoundaries: false, + childCount: items.length, + findChildIndexCallback: (key) { + return (key as ValueKey).value - offset; + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ScrollViewItem extends StatelessWidget { + const _ScrollViewItem({ + Key? key, + required this.index, + required this.items, + }) : super(key: key); + + final int index; + final List items; + + @override + Widget build(BuildContext context) { + return LocalHero( + tag: 'builder ${items[index]}', + child: Align( + child: Card( + color: Colors.blueGrey, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + items[index], + style: TextStyle(color: Colors.white70), + ), + ), + ), + ), + ); + } +} + class LocalHeroOverlay extends StatefulWidget { const LocalHeroOverlay({ Key? key,