Skip to content

Commit 95049ed

Browse files
committed
Run loop animations and fire cursor enter, exit and hover triggers
Nodes with a loopAnimation build inside a LoopAnimationBox, which repeats a pulse, fade, scale or spin on its own ticker and disposes it with the node. A `loopAnimationEnabled` condition or variable turns it on and off without remounting the node, and under a disabled TickerMode (the editor's design canvas) it holds still at its start. Nodes with cursorEnter, cursorExit or hover reactions get a translucent MouseRegion that runs them, so hover state can be kept with set variable actions. Taps and regions behind still get through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3b0bd83 commit 95049ed

5 files changed

Lines changed: 521 additions & 1 deletion

File tree

‎lib/src/transformers/passive_transformer_manager.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager {
123123
if (settings.withOpacity) {
124124
widget = applyWidgetOpacity(styled, widget);
125125
}
126+
widget = applyLoopAnimation(context, styled, widget);
126127
if (settings.withReactions) {
127128
widget = wrapWithReaction(context, styled, widget);
128129
widget = wrapWithSemantics(styled, widget);
130+
widget = wrapWithCursorReactions(context, styled, widget);
129131
widget = applyTooltipAndZoom(context, styled, widget);
130132
}
131133
if (settings.withRotation) {

‎lib/src/transformers/transformers.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export 'utils/condition_visitors.dart';
1010
export 'utils/dash_path.dart';
1111
export 'utils/fit_layout.dart';
1212
export 'utils/indexed_item_provider.dart';
13+
export 'utils/loop_animation_box.dart';
1314
export 'utils/node_geometry.dart';
1415
export 'utils/property_overrides.dart';
1516
export 'utils/property_value_delegate.dart';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import 'dart:async';
2+
3+
import 'package:codelessly_api/codelessly_api.dart';
4+
import 'package:flutter/widgets.dart';
5+
6+
/// Runs a node's [LoopAnimation] on [child] over and over: it scales and
7+
/// fades [child] for [LoopAnimationType.pulse], fades it for
8+
/// [LoopAnimationType.fade], scales it for [LoopAnimationType.scale] and
9+
/// rotates it for [LoopAnimationType.spin], about its center.
10+
///
11+
/// The animation runs on this widget's ticker, so it holds still at its
12+
/// `from` values under a disabled [TickerMode], as on the editor's design
13+
/// canvas. When [enabled] is false, [child] shows as designed; the widgets
14+
/// around it stay the same, so turning the animation on and off keeps the
15+
/// state of [child].
16+
class LoopAnimationBox extends StatefulWidget {
17+
/// The animation to run.
18+
final LoopAnimation animation;
19+
20+
/// Whether the animation runs. Defaults to [LoopAnimation.enabled].
21+
final bool? enabled;
22+
23+
/// The widget to animate.
24+
final Widget child;
25+
26+
/// Creates a [LoopAnimationBox].
27+
const LoopAnimationBox({
28+
super.key,
29+
required this.animation,
30+
this.enabled,
31+
required this.child,
32+
});
33+
34+
/// The Flutter curve of [curve].
35+
static Curve curveOf(LoopAnimationCurve curve) => switch (curve) {
36+
LoopAnimationCurve.linear => Curves.linear,
37+
LoopAnimationCurve.easeIn => Curves.easeIn,
38+
LoopAnimationCurve.easeOut => Curves.easeOut,
39+
LoopAnimationCurve.easeInOut => Curves.easeInOut,
40+
LoopAnimationCurve.fastOutSlowIn => Curves.fastOutSlowIn,
41+
LoopAnimationCurve.decelerate => Curves.decelerate,
42+
};
43+
44+
@override
45+
State<LoopAnimationBox> createState() => _LoopAnimationBoxState();
46+
}
47+
48+
class _LoopAnimationBoxState extends State<LoopAnimationBox>
49+
with SingleTickerProviderStateMixin {
50+
late final AnimationController controller = AnimationController(vsync: this);
51+
52+
/// Waits for [LoopAnimation.delayMs] before the first cycle.
53+
Timer? delay;
54+
55+
late Animation<double> scale;
56+
late Animation<double> opacity;
57+
late Animation<double> turns;
58+
59+
bool get enabled => widget.enabled ?? widget.animation.enabled;
60+
61+
@override
62+
void initState() {
63+
super.initState();
64+
update();
65+
}
66+
67+
@override
68+
void didUpdateWidget(covariant LoopAnimationBox oldWidget) {
69+
super.didUpdateWidget(oldWidget);
70+
final bool wasEnabled = oldWidget.enabled ?? oldWidget.animation.enabled;
71+
if (oldWidget.animation != widget.animation || wasEnabled != enabled) {
72+
update();
73+
}
74+
}
75+
76+
/// Builds the animations of [LoopAnimationBox.animation] and starts it
77+
/// over, or stops it when it is disabled.
78+
void update() {
79+
delay?.cancel();
80+
delay = null;
81+
controller.reset();
82+
83+
final LoopAnimation animation = widget.animation;
84+
if (!enabled) {
85+
scale = opacity = const AlwaysStoppedAnimation<double>(1);
86+
turns = const AlwaysStoppedAnimation<double>(0);
87+
return;
88+
}
89+
90+
final Animation<double> curved = CurvedAnimation(
91+
parent: controller,
92+
curve: LoopAnimationBox.curveOf(animation.curve),
93+
);
94+
final LoopAnimationType type = animation.type;
95+
scale = type.scales
96+
? Tween<double>(
97+
begin: animation.scaleFrom,
98+
end: animation.scaleTo,
99+
).animate(curved)
100+
: const AlwaysStoppedAnimation<double>(1);
101+
opacity = type.fades
102+
? Tween<double>(
103+
begin: animation.opacityFrom.clamp(0, 1),
104+
end: animation.opacityTo.clamp(0, 1),
105+
).animate(curved)
106+
: const AlwaysStoppedAnimation<double>(1);
107+
turns = type == LoopAnimationType.spin
108+
? Tween<double>(begin: 0, end: animation.turns).animate(curved)
109+
: const AlwaysStoppedAnimation<double>(0);
110+
111+
controller.duration = Duration(
112+
milliseconds: animation.durationMs.clamp(1, 1 << 31),
113+
);
114+
if (animation.delayMs > 0) {
115+
delay = Timer(Duration(milliseconds: animation.delayMs), start);
116+
} else {
117+
start();
118+
}
119+
}
120+
121+
void start() {
122+
if (!mounted) return;
123+
controller.repeat(reverse: widget.animation.reverse);
124+
}
125+
126+
@override
127+
void dispose() {
128+
delay?.cancel();
129+
controller.dispose();
130+
super.dispose();
131+
}
132+
133+
@override
134+
Widget build(BuildContext context) {
135+
// Only the transitions of the animation's type wrap the child, so a
136+
// node that just spins doesn't pay for an opacity layer.
137+
Widget child = widget.child;
138+
final LoopAnimationType type = widget.animation.type;
139+
if (type == LoopAnimationType.spin) {
140+
child = RotationTransition(turns: turns, child: child);
141+
}
142+
if (type.scales) {
143+
child = ScaleTransition(scale: scale, child: child);
144+
}
145+
if (type.fades) {
146+
child = FadeTransition(opacity: opacity, child: child);
147+
}
148+
return child;
149+
}
150+
}

‎lib/src/transformers/widget_node_transformer_manager.dart‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ abstract class WidgetNodeTransformerManager
5959
return Opacity(opacity: opacity, child: widget);
6060
}
6161

62+
/// Runs the [BaseNode.loopAnimation] of [node] on [widget], if it has one.
63+
///
64+
/// A `loopAnimationEnabled` condition or variable turns it on and off at
65+
/// runtime; otherwise [LoopAnimation.enabled] does. The animation holds
66+
/// still under a disabled [TickerMode], as on the editor's design canvas.
67+
Widget applyLoopAnimation(
68+
BuildContext context,
69+
BaseNode node,
70+
Widget widget, {
71+
ScopedValues? scopedValues,
72+
}) {
73+
final LoopAnimation? animation = node.loopAnimation;
74+
if (animation == null) return widget;
75+
final bool? enabled = PropertyValueDelegate.getPropertyValue<bool>(
76+
node,
77+
'loopAnimationEnabled',
78+
scopedValues: scopedValues ?? ScopedValues.of(context),
79+
);
80+
return LoopAnimationBox(
81+
key: ValueKey('loop animation ${node.id}'),
82+
animation: animation,
83+
enabled: enabled,
84+
child: widget,
85+
);
86+
}
87+
6288
/// Returns the visibility a condition or variable gives [node] at runtime,
6389
/// or null when nothing overrides the node's own visibility.
6490
///
@@ -293,7 +319,7 @@ abstract class WidgetNodeTransformerManager
293319
if (onClickReactions.isEmpty && onLongPressReactions.isEmpty) {
294320
return widget;
295321
}
296-
// TODO: should handle TriggerType.hover and TriggerType.unhover too.
322+
// Cursor triggers are handled by [wrapWithCursorReactions].
297323
return MouseRegion(
298324
cursor: SystemMouseCursors.click,
299325
hitTestBehavior: HitTestBehavior.opaque,
@@ -317,6 +343,62 @@ abstract class WidgetNodeTransformerManager
317343
}
318344
}
319345

346+
/// Runs the [TriggerType.cursorEnter], [TriggerType.cursorExit] and
347+
/// [TriggerType.hover] reactions of [node] when a mouse cursor enters,
348+
/// leaves or moves over it.
349+
///
350+
/// The region covers the node's whole box, transparent parts included, and
351+
/// lets the nodes behind it get the cursor and taps too. Touch input fires
352+
/// none of these triggers.
353+
Widget wrapWithCursorReactions(
354+
BuildContext context,
355+
BaseNode node,
356+
Widget widget,
357+
) {
358+
if (node is! ReactionMixin) return widget;
359+
if (node case CanvasNode() || SpacerNode()) return widget;
360+
361+
List<Reaction> reactionsOf(TriggerType type) => (node as ReactionMixin)
362+
.reactions
363+
.where(
364+
(reaction) =>
365+
reaction.trigger.type == type && reaction.action.enabled,
366+
)
367+
.toList();
368+
369+
final List<Reaction> onEnter = reactionsOf(TriggerType.cursorEnter);
370+
final List<Reaction> onExit = reactionsOf(TriggerType.cursorExit);
371+
final List<Reaction> onHover = reactionsOf(TriggerType.hover);
372+
if (onEnter.isEmpty && onExit.isEmpty && onHover.isEmpty) return widget;
373+
374+
return MouseRegion(
375+
opaque: false,
376+
hitTestBehavior: HitTestBehavior.translucent,
377+
onEnter: onEnter.isEmpty
378+
? null
379+
: (_) => FunctionsRepository.triggerAction(
380+
context,
381+
TriggerType.cursorEnter,
382+
reactions: onEnter,
383+
),
384+
onExit: onExit.isEmpty
385+
? null
386+
: (_) => FunctionsRepository.triggerAction(
387+
context,
388+
TriggerType.cursorExit,
389+
reactions: onExit,
390+
),
391+
onHover: onHover.isEmpty
392+
? null
393+
: (_) => FunctionsRepository.triggerAction(
394+
context,
395+
TriggerType.hover,
396+
reactions: onHover,
397+
),
398+
child: widget,
399+
);
400+
}
401+
320402
BorderRadius? getBorderRadius(BaseNode node) {
321403
if (node is CornerMixin) {
322404
if (node.cornerRadius.linked &&

0 commit comments

Comments
 (0)