UI Animation Guidance

Godot Version

4.6.1

Question

I’ve been looking into UI animation recently and I’m not entirely sure how you’re meant to handle it nicely?

Most guides I see are in regards to very simple UI, such as a button hover effect. Though I’m confused on how to handle more complex elements.
For instance, if you have an element that’s essentially a button, but is composed of many different control elements, like a border, image, etc underneath a root control node. What is the proper way to animate it?

My first thought would be to use tweens, but having a reference to every node I want to animate seems a bit messy I suppose? Though this is probably the correct answer and I should just live with it.

An alternative thought I had was an animation player, but I’m not entirely certain how your meant to transition between something like a OnHover animation to a OnClick animation without it breaking, as it did when I tested it. If there’s a functional proof of concept of this I would be very interested, as this would drastically reduce the complexity of animations, especially in regards to values within Themes, which feel incredibly annoying to try and manipulate in code.

I’ve seen a video use composition, where they had an animation component, but I can’t imagine that being very scalable. An element’s animations feels as though it could be way too many things to reasonably stuff it under a single script. Not to mention attaching a script and having to click between nodes just to modify a property seems a bit exhausting if you have need for a lot of animations.

Have you thought about using dictionaries?

I don’t believe I’ve seen a method that uses dictionaries, what would that look like?

I’ve only just started using Godot but I’m in the middle of writing an app that uses them for button calls and texture changes. It’s fairly basic but might give you some inspiration.

extends Control

var enviroatlas = preload("res://EnviroAtlas.tres")
@export var enviro = preload("res://enviro.tres")
var envdict = {"Aeternus":[0, 0], "Diamond":[300, 0], "Freedom":[600, 0], "Insula":[900, 0],
"Magmaria":[0, 200], "Megalopolis":[300, 200], "Pike":[600, 200], "Rook":[900, 200],
"Silver":[0, 400], "Block":[300, 400], "Wasteland":[600, 400], "Discord":[900, 400],
"Atlantis":[0, 600], "ZhuLong":[300, 600], "Anubis":[600, 600], "Wagner":[900, 600]}

func _ready() -> void:
	for n in enviro.get_buttons():
		n.toggled.connect(_enviropressed)
	enviroatlas.region = Rect2(envdict[enviro.get_pressed_button().name][0], envdict[enviro.get_pressed_button().name][1], 300, 200)
	$choice.texture = enviroatlas

func _enviropressed(toggled_on: bool) -> void:
	if toggled_on:
		enviroatlas.region = Rect2(envdict[enviro.get_pressed_button().name][0], envdict[enviro.get_pressed_button().name][1], 300, 200)
		$choice.texture = enviroatlas



func _on_button_17_pressed() -> void:
	get_tree().change_scene_to_file.call_deferred("res://Mainmenu.tscn")


func _on_button_pressed() -> void:
	var envkey = envdict.keys()
	var renv = envkey.pick_random()
	for n in enviro.get_buttons():
		if n.name == renv:
			n.button_pressed = true

It uses the button names as keys with coordinates to the image start points on an atlas texture. I’m going to add another value in there soon for the difficulty. You could probably do something like store the various borders, styles, etc as values and call the keywords to change to specific setups or cycle through them in a function that reads them to animate.

AnimationPlayer is probably best for animations with many different nodes and properties, as you do not need to reference every node, but just adding tracks as you have seen.

In order to address transitioning, you can use an AnimationTree, which can control and manipulate between existing animations in AnimationPlayers: Using AnimationTree — Godot Engine (latest) documentation in English

This is the correct answer IMO.

You just need to loop through child nodes and apply the tween to it.

You can use an AnimationPlayer, but that can get clunky fast. As soon as you delete, move, or add a node, you have to update the animation. If you’re just iterating through child nodes at animation time, adding or removing a node is all you need to do to change the animation.

I didn’t even think about using an AnimationTree, that would make sense and potentially solve my issues, I’ll look into that.

I’m curious as to what you mean? When looping through child nodes how do you know which element you need to manipulate, or even single it out correctly?

Take a Card element for instance, where you want to have the name of the card drop down, the card image to grow bigger, and various flair elements animate upon hovering as well.

Great question. You use Class Names. Like this:

class_name CardName extends Label
class_name CardImage extends TextureRect
class_name FlairElement extends TextureRect
class_name Card extends Control


func _ready() -> void:
	mouse_entered.connect(_on_mouse_entered)
	mouse_exited.connect(_on_mouse_exited)
	gui_input.connect(_on_gui_input)


func _on_mouse_entered() -> void:
	scale = Vector2(1.1, 1.1)


func _on_mouse_exited() -> void:
	scale = Vector2(1.0, 1.0)

func _on_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		if event.pressed :
			for node in get_children():
				if node is CardName:
					node.position.y += 10 #Put a tween here
				if node is CardImage:
					scale = Vector2(1.1, 1.1) #Put a tween here
				if node is FlairElement:
					rotation = 90.0 #Put a tween here
		else:
			for node in get_children():
				if node is CardName:
					node.position.y -= 10 #Put a tween here
				if node is CardImage:
					scale = Vector2(1.0, 1.0) #Put a tween here
				if node is FlairElement:
					rotation = 0.0 #Put a tween here

Would this solution not create a bunch of rather meaningless scrips? It feels like the same as having the controls be exported, but rather than cluttering the inspector it clutters the filesystem.

I was toying around with an AnimationTree but I forgot how honestly confusing the thing is and I’m unsure if the type of blending UI animations need would even be possible with it.

Meaningless in what way? That they only have a name? I wouldn’t do it that way probably, but you asked how you could tell them apart. I’d probably make it more atomic.

class_name CardName extends Label


func run_pressed_animation() -> void:
	node.position.y += 10 #Put a tween here


func run_unpressed_animation() -> void:
	node.position.y += 10 #Put a tween here
class_name Card extends Control


func _ready() -> void:
	mouse_entered.connect(_on_mouse_entered)
	mouse_exited.connect(_on_mouse_exited)
	gui_input.connect(_on_gui_input)


func _on_mouse_entered() -> void:
	scale = Vector2(1.1, 1.1)


func _on_mouse_exited() -> void:
	scale = Vector2(1.0, 1.0)


func _on_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		if event.pressed :
			for node in get_children():
				if node.has_method("run_pressed_animation"):
					node.run_pressed_animation()
		else:
			for node in get_children():
				if node.has_method("run_unpressed_animation"):
					node.run_unpressed_animation()

In this way each item handles its own animation. I’d still give them a Class Name though, because then you can add them just like normal nodes.

Perhaps. Except that if you exported all these items as variables, you’d have to make sure they were connected. Doing it in code means never having an error because you forgot to link a card. Plus, it’s a lot less work in the long term.

It is only possible to blend animations with 2D and 3D objects that have skeletons and bones. However you could run multiple animations at once. Each node would just have to have its own AnimationPlayer. Which means then you’d have to add a custom scene each time instead of a custom node. But not a huge deal.

Went with tweening combined with unique names, which solved the problem of cluttering the inspector. I wasn’t even thinking about it in the beginning due to concerns regarding type safety, but after having the privilege of looking at Slay the Spire 2’s project, I feel that these concerns were rather unfounded. (They reeaallly love unique names in sts2)