Below is my best attempt to do what you ask for, as written. I have done some testing in Godot 4.1 and 4.2.dev 2, and it works with some caveats I'll get into.
But before I get into that, I want to mention that using base Container
is not recommendable as it is a Container
that does nothing. Thus you might instead use a regular Control
. And yes, the solution I present can be modified to work with a regular Control
, I'll get to that at the end.
Addendum: I guess the Godot standard approach would have been to use "Editable Children" (which you find in the context menu of the scene instance in the Scene dock), I want to mention it, just in case you are not aware of that. Also, the feature you want could make for a good proposal: https://github.com/godotengine/godot-proposals/issues (at least consider opening a discussion: https://github.com/godotengine/godot-proposals/discussions)
Design considerations and caveats:
- Since you say this is like a
ContentPresenter
from XAML, I'm calling the new class ContentPresenter
.
- I do not want to remove the children
Control
s nor reparent them, as this might result in problems (e.g. a NodePath
no longer being correct, or the script on the control not handling being removed from the scene tree).
- I do not require to add code to the children
Control
s. This is all in the new ContentPresenter
class.
- Perhaps you intent to hardcode where the
Container
is, however I'm handling the case where the Container
might change, under the idea that it would be easier for you to remove that part of my code than to add it. And perhaps you learn something in the process.
- Since this must work in the editor, we will have a
@tool
script.
- Given that I want a
@tool
script to have a variable reference to another node... I will not expose a Container
property, but a NodePath
property (I got a crash trying to use the Container
property while developing this in Godot 4.2.dev5 - whatever it was I hope will be fixed for the stable release - but I'm falling back to the old way for this).
- Since Containers are designed to operate on their children (setting their position and size), and which kind of
Container
you will use is not clear, I want to have something actually as child of the Container
.
- Thus, I must create - what I'll call -
dual
s of the children on the Container
, and copy their position properties back and forth.
- You won't see the
dual
s in the scene tree. This is because they do not have an owner
, and consequently won't be stored with the scene either. Instead they will be regenerated when the scene loads (either in the editor or in runtime).
- I'm mapping
dual
s to the children Control
s using metadata, which I temporarily compile into a Dictionary
. Before I was storing the Dictionary
but ran into issues of it getting out sync. I also did consider using name
s, but that might have caused a conflict if you wanted to have children of the Container
already there (e.g. Cursor
).
- Speaking of children of the
Container
already there, the code will place the dual
s after them (if you want them before, replace the computation of children_to_skip
with 0
).
- I'm using the signals
child_entered_tree
, child_order_changed
and child_order_changed
to make sure the correct duals exists in the correct order. And the signals minimum_size_changed
and item_rect_changed
to trigger the copy of the position properties. Here item_rect_changed
seems to make resized
redundant, and I didn't find another way to handle the change of position (other than checking each frame, which would be much less efficient).
- Speaking of checking each frame, I didn't find a way to get a signal when the resize flags changed, so to detect if they changed I would have to check every frame (in
_process
) however, I'm not including that in this answer (The code will keep _process
disable most of the time to reduce performance impact). As a result changes in size flags won't be reflected right away, you can take advantage of the dual
s being regenerated to fix it in the editor, calling _invalidate_childdren
should fix it too.
- This does not solve Z-ordering. If you want something to be on top of the child
Control
s, placing it onto of top of the Container
s won't do.
- I had to figure out how to get the position properties in global coordinates to copy them around correctly.
This is the code:
@tool
class_name ContentPresenter
extends Control
# Reference to the container that children will behave as if they were in
var target_container:Container
# NodePath to the container that children will behave as if they were in
@export var target_container_path:NodePath:
set(mod_value):
# Update the NodePath
target_container_path = mod_value
# Update the reference et.al. only if this node is ready
# If this node is not ready, the reponsability falls to _ready
if is_node_ready():
_update_target_container()
var invalidated_children:bool:
set(mod_value):
if invalidated_children == mod_value:
return
invalidated_children = mod_value
var invalidated_container:bool:
set(mod_value):
if invalidated_container == mod_value:
return
invalidated_container = mod_value
func _invalidate_children() -> void:
invalidated_children = true
set_process(true)
func _invalidate_container() -> void:
invalidated_container = true
set_process(true)
# Runs when this node is ready
func _ready() -> void:
# Make sure child_entered_tree is connected
if not child_entered_tree.is_connected(_child_entered_tree):
child_entered_tree.connect(_child_entered_tree)
# Make sure child_exiting_tree is connected
if not child_exiting_tree.is_connected(_child_exiting_tree):
child_exiting_tree.connect(_child_exiting_tree)
# Make sure child_order_changed is connected
if not child_order_changed.is_connected(_child_order_changed):
child_order_changed.connect(_child_order_changed)
# Update the container reference if necessary
_update_target_container()
_invalidate_children()
# Runs when this node leaves the scene tree
func _exit_tree() -> void:
# Request to run _ready next time it enters the scene tree
# This is so it can update the reference to the container
request_ready()
# Called by the Godot edito to get warning
func _get_configuration_warnings() -> PackedStringArray:
# If we don't have a valid reference to the container put up a warning
if not is_instance_valid(target_container):
return ["Target Container Not Found"]
return []
func _process(_delta: float) -> void:
if is_instance_valid(target_container):
var control_by_dual := {}
var dual_by_control := {}
var duals_to_remove:Array[Control] = []
var children_to_skip := 0
for dual_candidate in target_container.get_children():
if dual_candidate.has_meta("__dual_of"):
var control := _validate_control(dual_candidate.get_meta("__dual_of", null))
if is_instance_valid(control):
control_by_dual[dual_candidate] = control
dual_by_control[control] = dual_candidate
else:
duals_to_remove.append(dual_candidate)
else:
children_to_skip += 1
if invalidated_container:
for dual in control_by_dual.keys():
var control:Control = control_by_dual[dual]
_copy_positioning(dual, control, false)
if invalidated_children:
# Make sure all the children Controls have a dual, and what should their order be
var order:Array[Control] = []
for control_candidate in get_children():
var control := _validate_control(control_candidate)
if not is_instance_valid(control):
continue
var dual:Control = dual_by_control.get(control, null)
if not is_instance_valid(dual):
dual = Control.new()
# When the child control changes its minimum size, update the dual
if not control.minimum_size_changed.is_connected(_invalidate_children):
control.minimum_size_changed.connect(_invalidate_children)
# When the child control moves or resizes, update the dual
if not control.item_rect_changed.is_connected(_invalidate_children):
control.item_rect_changed.connect(_invalidate_children)
# When the dual moves or resizes, update the child control
if not dual.item_rect_changed.is_connected(_invalidate_container):
dual.item_rect_changed.connect(_invalidate_container)
dual.set_meta("__dual_of", control)
control_by_dual[dual] = control
dual_by_control[control] = dual
target_container.add_child(dual)
#dual.owner = owner if owner != null else self
order.append(dual)
# Remove any duals whose child Control is no longer valid
for dual in duals_to_remove:
target_container.remove_child(dual)
dual.queue_free()
# Clear the list to remove so we don't remove them again
duals_to_remove = []
# Make sure the dual is in the correct order in the container children
for index in order.size():
target_container.move_child(order[index], children_to_skip + index)
# Update the duals position
for dual in control_by_dual.keys():
var control = control_by_dual[dual]
_copy_positioning(control, dual, true)
# Remove any duals whose child Control is no longer valid (if they weren't removed before)
for dual in duals_to_remove:
target_container.remove_child(dual)
dual.queue_free()
set_process(false)
# Called by _ready or target_container_path's setter
func _update_target_container():
# Figure out the new reference to the container
var new_target_container:Container = null
if not target_container_path.is_empty():
new_target_container = get_node_or_null(target_container_path)
# If it is the same reference do nothing
if new_target_container == target_container:
update_configuration_warnings()
return
# Since we are going to change container, remove duals from the old one
if is_instance_valid(target_container):
var children := target_container.get_children()
for child in children:
if child.has_meta("__dual_of"):
target_container.remove_child(child)
if target_container.item_rect_changed.is_connected(_invalidate_container):
target_container.item_rect_changed.disconnect(_invalidate_container)
# Update the container reference
target_container = new_target_container
if is_instance_valid(target_container):
if not target_container.item_rect_changed.is_connected(_invalidate_container):
target_container.item_rect_changed.connect(_invalidate_container)
_invalidate_container()
_invalidate_children()
# Tell Godot to update warning
update_configuration_warnings()
# Handler for child_entered_tree
func _child_entered_tree(node:Node) -> void:
var control := _validate_control(node)
if control == null:
return
_invalidate_children()
# Handler for child_exiting_tree
func _child_exiting_tree(node:Node) -> void:
var control := _validate_control(node)
if control == null:
return
_invalidate_children()
# Handler for child_order_changed
func _child_order_changed() -> void:
_invalidate_children()
# Called from _child_entered_tree and _child_exiting_tree
func _validate_control(node:Node) -> Control:
if node.owner == self:
# We got a node that is part of the scene
return null
var control = node as Control
if not is_instance_valid(control):
# We got a node that is not a Control
return null
if control.get_parent() != self:
return null
if (
is_instance_valid(target_container)
and (
control == target_container
or control.is_ancestor_of(target_container)
)
):
# We got a Control that contains the container
return null
# return the Control
return control
# Copies data between the children Controls and their duals
func _copy_positioning(from:Control, to:Control, is_push:bool) -> void:
# global transform of from
var from_global_transform := from.get_global_transform()
# global transform of the parent of to
var to_parent_global_transform := Transform2D.IDENTITY
var to_parent := to.get_parent_control()
if to_parent != null:
to_parent_global_transform = to_parent.get_global_transform()
# transform of from relative to the parent of to
var from_to_local_transform := to_parent_global_transform.affine_inverse() * from_global_transform
if is_push:
to.visible = from.visible
to.size_flags_horizontal = from.size_flags_horizontal
to.size_flags_vertical = from.size_flags_vertical
to.size_flags_stretch_ratio = from.size_flags_stretch_ratio
to.custom_minimum_size = from.get_combined_minimum_size()
to.size = from.size
to.global_position = from_global_transform.origin
to.rotation = from_to_local_transform.get_rotation()
to.scale = from_to_local_transform.get_scale()
As you can see I have added some comments, which I hope help understand what is going on.
However, I want to elaborate further on a few things:
- You will see that I check
is_node_ready
before updating the reference to the Container
that is because I want to make sure that this node is in the scene tree before trying to access it (to query the NodePath
). If the node is not ready, then _ready
will call the method to update the reference. If the node is removed from the scene tree (perhaps the NodePath
modified while it is not in the scene tree) and added again, I'd need to update the reference again, for that I use request_ready
to make sure _ready
runs again (otherwise _ready
would only run the first time).
- The method
_validate_control
checks if the ContentPresenter
is the owner, which would be the case for Node
s added in the editor in a scene where the ContentPresenter
is the root. So this makes it easy to skip those Node
s. It also checks if the Control
is actually a child of the ContentPresenter
, allowing to detect if a dual
is pointing to a removed Control
that is otherwise still a valid instance.
- The method
_copy_positioning
is really the heart of this (and what took more time to figure out). It might be useful to you if you even do not need all the extra setup. I'll get to that.
- The
_process
method will disable itself with set_process(false)
and calling _invalidate_children
or _invalidate_container
enables it again.
- The method
_get_configuration_warnings
will be called by Godot to
get, well, warnings. They show up as that yellow triangle next to the Node
in the scene tree.
- I'll reiterate that this does not fix Z-Order.
Now, if you do not need the target to ge a Container
, you only need to change it in a a couple places:
var target_container:Container
var new_target_container:Container = null
Plus it is also mentioned in the _get_configuration_warnings
, and names of variables and methods, but these are not functional.
You could also remove the line where dual.item_rect_changed
is connected if the target is not a Container
, under the assumption that only Container
s would be moving or resizing their children.
I also want to note that handling this as a transformation would have been problematic. It would be an infinite feedback loop:
- Get the position of the
Control
.
- Transform it.
- Update the position of the
Control
.
- The position of the
Control
changed, repeat.
Thus I would have needed to keep track of the original values, and in a way that would still let you edit them. Thus, I believe the dual
s is a good solution.
I had initially posted this answer without using _process
, but I ran into order of execution problems from the signals. Which are important: Say both the Container
and a child Control
moved, which one updates from the other first matters.
Now using _process
I'm giving preference to the Container
moving the child Control
, which minimizes the situations where they behave incorrectly.
Perhaps you actually do not need to handle multiple children. Instead you might want to just copy the positioning of one Control
(e.g. the Container
) to another Control
(e.g. what you are placing "inside" the Container
). In which case the method _copy_positioning
might still be useful to you... But you can get rid of all the dual
s et.al.
Thus, I submit to your consideration specifying what you will put inside the Container
using a NodePath
.
No, copying the Container
position to the ContentPresenter
won't work, if you want the ContentPresenter
to be a parent of the Container
. But there might be an alternative approach hiding in there if you are OK not having the Container
as a child of the ContentPresenter
. For example, it could be similar to RemoteTransform
(2D
/3D
) but for Control
s. But anyway, that is not the question as written.