DEV Community

Kristyan Yochev
Kristyan Yochev

Posted on

Custom Animatable Containers in Godot

The Problem

I was making a prototype for a very UI-heavy game of mine, and I wanted to have an expanding animation that extended a VBoxContainer to unhide one of its children. I ended up creating a custom container for that, as VBoxContainer just doesn't allow tweening its height. Here's what the final result looks like:

Final container

As of writing this, there doesn't appear to be much in the way of documentation on how one makes their own custom component, so here's that missing bit.

I'm going to explain how I made this and some common pitfalls one might fall into when making custom containers.

The Bare Minimum

If one opens the current Godot docs on custom containers, they'll be greeted with a simple example script demonstrating the bare minimum for making a custom container:

  1. Extend the Container class
  2. Handle the NOTIFICATION_SORT_CHILDREN notification
  3. You get access to a layout function suggestively named fit_child_in_rect, which takes a Control and a Rect2
  4. You can request a re-sorting using queue_sort()

That fit_child_in_rect function doesn't have much in the way of documentation beyond what I just told you. Quoting from the method description:

Fit a child control in a given rect. This is mainly a helper for creating custom container classes.

So we are going to have to do a little investigative work before we know exactly what to do in order to be able to use that function effectively.

Godot's Layout Algorithm: Some Details

Godot's UI system is, in my view, far superior to whatever Unity has going on. In large part, that is due to there being a single comprehensive UI system that can has most of the tools needed to deal with variable resolutions in the form of the various containers it provides. I'm not going to list them off here, there's already enough out there to learn all about them.

What we are interested in is the general protocol that containers use to work out where and how to place children. Here is how that works:

  1. Children provide a minimum size. That size is obtained by the Control.get_combined_minimum_size() method, which returns a Vector2 with the x component representing the minimum width, and the y component representing the minimum height.
  2. The parent takes those minima into account and partitions its local space into rects for each child. The child is placed inside the rect according to its size flags.
  3. The parent itself needs to declare a minimum size in order to be properly used in other containers.

Finally Building It

We now know enough to actually build the element shown off in the little demo in the intro. We're going to make it into a single script, so we can easily instantiate it wherever we need to. Let's set up the script skeleton:

@tool
class_name AnimatedVerticalAccordion
extends Container

func _notification(what: int) -> void:
    if what == NOTIFICATION_SORT_CHILDREN:
        _sort()

func _sort() -> void:
    pass
Enter fullscreen mode Exit fullscreen mode

I've pulled out the children sorting logic into a separate function just to make things easier on ourselves.

Now, how this is going to work is, we're going to use clip_conents and tween the container's height to have the revealing effect we want, as clip_contents will make sure that only children, or parts thereof, that are within the container's rect will be rendered. Just set that in _ready:

func _ready() -> void:
    clip_contents = true
Enter fullscreen mode Exit fullscreen mode

It's important to note that we still want to set visible = false on children that are completely clipped-off, as otherwise they still participate in input handling, which we do not want.

Let's start with the actual sorting algorithm, as it is relatively easy:

func _sort() -> void:
    var y: float = 0.0
    for i in range(get_child_count()):
        var child: Control = get_child(i) as Control
        if child == null:
            continue
        var child_min = child.get_combined_minimum_size()
        fit_child_in_rect(child, Rect2(0.0, y, size.x, child_min.y))
        y += child_min.y
Enter fullscreen mode Exit fullscreen mode

Problem with that is that it is going to bunch up the children right next to each other on the Y axis, so let's add an export variable to control the separation:

@export var separation: float = 4.0

func _sort() -> void:
    var y: float = 0.0
    for i in range(get_child_count()):
        #... previous code untouched
        y += child_min.y + separation
Enter fullscreen mode Exit fullscreen mode

Better. We basically recreated VBoxContainer without any of the flex-like handling. Speaking of which, for this container, it doesn't make sense to even allow stretchable children, as the parent will size itself to the children and not the other way around, so probably a good idea to add a warning to tell whoever ends up using our container to not bother with expand flags, as they will be ignored. For that, we override the _get_configuration_warnings() method:

func _get_configuration_warnings() -> PackedStringArray:
    var warnings = PackedStringArray()
    for i in range(get_child_count()):
        var child = get_child(i) as Control
        if child == null:
            continue
        if child.size_flags_vertical & SIZE_EXPAND:
            warnings.append("Child \"%s\" has the Expand flag set on the vertical axis, which is not supported in VerticalAccordion." % child.name)
    return warnings
Enter fullscreen mode Exit fullscreen mode

Now, let's add showing and hiding of child controls. For that, we are going to need a helper function to calculate the height the container needs to have in order to have the first count number of children visible:

func _calculate_height(count: int) -> float:
    var total: float = 0.0
    for i in range(count):
        var child = get_child(i) as Control
        if child == null:
            continue
        total += child.get_combined_minimum_size().y
        if i < count - 1:
            total += separation
    return total
Enter fullscreen mode Exit fullscreen mode

We simply tally all the minimum heights for the number of revealed children, and keep in mind the separation between them.

We will need a variable to store the height we have revealed:

var revealed_count: int = 0
var _revealed_height: float = 0.0
Enter fullscreen mode Exit fullscreen mode

We will also expose the number of children revealed through the revealed_count variable.

And now we can expose the star of the show - the function that actually animates us from one size to another:

var tween: Tween
func expand_to(count: int) -> void:
    count = clampi(count, 0, get_child_count())
    var target = _calculate_height(count)
    _set_child_visibility(maxi(count, revealed_count))
    if tween:
        tween.kill()
    tween = create_tween()
    tween.tween_method(_set_revealed_height, _revealed_height, target, 0.25)\
        .set_trans(Tween.TRANS_QUART).set_ease(Tween.EASE_OUT)
    await tween.finished
    revealed_count = count
Enter fullscreen mode Exit fullscreen mode

Here, the tween parameters could be exposed in the editor as export variables. As I was just prototyping that, I didn't, but you're welcome to fix that in your version, and generally it is good practice.

As mentioned earlier, we need to make sure that children that are wholly or partially clipped need to be seen, and that's what _set_child_visibility does:

func _set_child_visibility(count: int) -> void:
    for i in range(get_child_count()):
        var child = get_child(i) as Control
        if child == null:
            continue
        child.visible = i < count
Enter fullscreen mode Exit fullscreen mode

You might have seen that we tween with a method rather than a simple property. That is because _set_revealed_height does a bit more than just set the instance variable:

func _get_minimum_size() -> Vector2:
    var max_width: float = 0.0
    for i in get_child_count():
        var child = get_child(i) as Control
        if child == null:
            continue
        max_width = maxf(max_width, child.get_combined_minimum_size().x)
    return Vector2(max_width, _revealed_height)

func _set_revealed_height(h: float) -> void:
    _revealed_height = h
    update_minimum_size()
Enter fullscreen mode Exit fullscreen mode

When we call update_minimum_size(), we tell Godot that this container's minimum size has changed, which will trigger a re-layout of its parent, which might then need to re-layout as well, eventually making the whole UI shift in order to respond the size change. Essentially, we end up animating the minimum reported height of this container. This would mean that if this container were placed within some environment that sets its size to more than that minimum (e.g. anchors to fill the entire screen), it will not work at all.

We don't want the accordion to always start with 0 children visible, so we export the revealed count as well and add a setter to put all else in place:

@export var revealed_count: int = 0:
    set(v):
        revealed_count = v
        if not is_node_ready():
            return
        _revealed_height = _calculate_height(v)
        _set_child_visibility(v)
        update_minimum_size()
        queue_sort()
@onready var _revealed_height: float = _calculate_height(revealed_count)
Enter fullscreen mode Exit fullscreen mode

And finally, Godot wouldn't show our warnings unless we call update_configuration_warnings(), so let's put that in _ready() along an initial queue_sort():

func _ready() -> void:
        clip_contents = true
    update_configuration_warnings()
    queue_sort()
Enter fullscreen mode Exit fullscreen mode

And that's all. Here's how the final code looks like:

@tool
class_name VerticalAccordion
extends Container

@export var separation: float = 4.0
@export var revealed_count: int = 0:
    set(v):
        revealed_count = v
        if not is_node_ready():
            return
        _revealed_height = _calculate_height(v)
        _set_child_visibility(v)
        update_minimum_size()
        queue_sort()
@onready var _revealed_height: float = _calculate_height(revealed_count)
var tween: Tween

func _ready() -> void:
        clip_contents = true
    update_configuration_warnings()
    queue_sort()

func expand_to(count: int) -> void:
    count = clampi(count, 0, get_child_count())
    var target = _calculate_height(count)
    _set_child_visibility(maxi(count, revealed_count))
    if tween:
        tween.kill()
    tween = create_tween()
    tween.tween_method(_set_revealed_height, _revealed_height, target, 0.25)\
        .set_trans(Tween.TRANS_QUART).set_ease(Tween.EASE_OUT)
    await tween.finished
    revealed_count = count

func _notification(what: int) -> void:
    if what == NOTIFICATION_SORT_CHILDREN:
        _sort()

func _sort() -> void:
    var y = 0.0
    for i in range(get_child_count()):
        var child: Control = get_child(i) as Control
        if child == null:
            continue
        var child_min = child.get_combined_minimum_size()
        fit_child_in_rect(child, Rect2(0.0, y, size.x, child_min.y))
        y += child_min.y + separation

func _get_minimum_size() -> Vector2:
    var max_width: float = 0.0
    for i in range(get_child_count()):
        var child = get_child(i) as Control
        if child == null:
            continue
        max_width = maxf(max_width, child.get_combined_minimum_size().x)
    return Vector2(max_width, _revealed_height)

func _set_revealed_height(h: float) -> void:
    _revealed_height = h
    update_minimum_size()

func _calculate_height(count: int) -> float:
    var total: float = 0.0
    for i in range(count):
        var child = get_child(i) as Control
        if child == null:
            continue
        total += child.get_combined_minimum_size().y
        if i < count - 1:
            total += separation
    return total

func _set_child_visibility(count: int) -> void:
    for i in range(get_child_count()):
        var child = get_child(i) as Control
        if child == null:
            continue
        child.visible = i < count

func _get_configuration_warnings() -> PackedStringArray:
    var warnings = PackedStringArray()
    for i in range(get_child_count()):
        var child = get_child(i) as Control
        if child == null:
            continue
        if child.size_flags_vertical & SIZE_EXPAND:
            warnings.append("Child \"%s\" has the Expand flag set on the vertical axis, which is not supported in VerticalAccordion." % child.name)
    return warnings
Enter fullscreen mode Exit fullscreen mode

I intentionally didn't add any interaction model to this, so one can add that themselves by extending this script and calling expand_to when appropriate with whatever values suit your particular needs. Keep in mind that since we await the tween to finish, expand_to is a coroutine and the revealed_count does not get set until after the tween has finished. That makes the most sense to me, but you can redo expand_to to set revealed_count immediately or not wait for the tween to finish at all.

I hope you've found this helpful, and thanks for reading!

Top comments (0)