Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for a scrollable LocalHero #17

Merged
merged 5 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ class _LocalHeroPlayground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
length: 4,
child: Scaffold(
appBar: AppBar(
title: const TabBar(
tabs: <Widget>[
Text('Animate wrap reordering'),
Text('Move between containers'),
Text('Draggable content'),
Text('Animate scroll view'),
],
),
),
Expand All @@ -51,6 +52,7 @@ class _LocalHeroPlayground extends StatelessWidget {
_WrapReorderingAnimation(),
_AcrossContainersAnimation(),
_DraggableExample(),
_ScrollViewExample(),
],
),
),
Expand Down Expand Up @@ -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<String> 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<int>(index),
index: index,
items: items,
),
addRepaintBoundaries: false,
childCount: items.length,
findChildIndexCallback: (key) {
return (key as ValueKey<int>).value - offset;
},
),
),
],
),
),
],
),
),
);
}
}

class _ScrollViewItem extends StatelessWidget {
const _ScrollViewItem({
Key? key,
required this.index,
required this.items,
}) : super(key: key);

final int index;
final List<String> 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,
Expand Down
113 changes: 68 additions & 45 deletions lib/src/rendering/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class LocalHeroController {
required this.curve,
required this.createRectTween,
required this.tag,
required this.onlyAnimateRemount,
}) : link = LayerLink(),
_vsync = vsync,
_initialDuration = duration;
Expand All @@ -33,6 +34,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;
Expand All @@ -51,62 +56,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);
Expand Down
16 changes: 16 additions & 0 deletions lib/src/widgets/local_hero_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ class _LocalHeroLeaderElement extends SingleChildRenderObjectElement {
@override
LocalHeroLeader get widget => super.widget as LocalHeroLeader;

@override
void update(SingleChildRenderObjectWidget newWidget) {
super.update(newWidget);

// Mark remount due to widget change
widget.controller.markRemount();
}

@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);

// Mark remount due to reparenting
widget.controller.markRemount();
}

@override
void debugVisitOnstageChildren(ElementVisitor visitor) {
if (!widget.controller.isAnimating) {
Expand Down
16 changes: 16 additions & 0 deletions lib/src/widgets/local_hero_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class LocalHeroScope extends StatefulWidget {
this.curve = Curves.linear,
this.createRectTween = _defaultCreateTweenRect,
required this.child,
this.onlyAnimateRemount = true,
}) : super(key: key);

/// The duration of the animation.
Expand All @@ -31,6 +32,20 @@ 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 are not animated.
///
/// 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;

/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.child}
Expand Down Expand Up @@ -62,6 +77,7 @@ class _LocalHeroScopeState extends State<LocalHeroScope>
curve: widget.curve,
tag: localHero.tag,
vsync: this,
onlyAnimateRemount: widget.onlyAnimateRemount,
);
final Widget shuttle = localHero.flightShuttleBuilder?.call(
context,
Expand Down