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:
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:
- Extend the
Containerclass - Handle the
NOTIFICATION_SORT_CHILDRENnotification - You get access to a layout function suggestively named
fit_child_in_rect, which takes aControland aRect2 - 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:
- Children provide a minimum size. That size is obtained by the
Control.get_combined_minimum_size()method, which returns aVector2with thexcomponent representing the minimum width, and theycomponent representing the minimum height. - 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.
- 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
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
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
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
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
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
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
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
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
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()
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)
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()
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
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)