godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.05k stars 65 forks source link

Allow overriding of "fitting" behavior in Containers to better animate Controls #9616

Open ArchieVillain opened 4 weeks ago

ArchieVillain commented 4 weeks ago

Describe the project you are working on

A game with a card-based minigame and an animation-heavy UI.

Describe the problem or limitation you are having in your project

In my project, I am implementing a simple Blackjack minigame. Cards are dealt from the deck to the player's hand, which is a HBoxContainer.

My goals are:

  1. Smoothly animate the card from the deck to the player's hand
  2. Smoothly animate the cards already in the player's hand so that they shift properly whenever a new card is added.
  3. Apply other animations to each card, for example, scaling up and down whenever the mouse cursor enters or leaves.

The issues with these goals are:

  1. As long as the control is inside a container, we have no control over it. Containers generally reset the position, scale and size of their children after a sort. This means that, while we can animate a Control, that is unwanted behavior by the engine and any changes made to it will be short-lived.
  2. The control must be inside a container for it to be laid out correctly. We can't know where the control will end up, short of calculating its final position by hand.
  3. We can change the way a container acts before and after it places its children (using NOTIFICATION_PRE_SORT and NOTIFICATION_SORT), but we have no way to override the placement mechanism itself, short of writing a new container from scratch.

These issues with containers and animation are a sticking point among the community. See #7053 and #7425 and a bunch of different threads on Reddit and the forums asking for help on this.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Allow the user to override/customize the rect-fitting logic by addition of a virtual method Container._fit_child_in_rect(Control, Rect).

The solution of both previous use-cases is trivial: a HBoxContainer subclass can be created, which implements _fit_child_in_rect to tween its children to their final position instead of setting it directly.

extends HBoxContainer

func _fit_child_in_rect(child: Control, rect: Rect2):
    var fitted_rect = get_fitted_rect(child, rect)
    var tween = create_tween()

    tween.set_parallel(true)
    tween.tween_property(child, "position", fitted_rect.position, 0.3)
    tween.tween_property(child, "size", fitted_rect.size, 0.3)

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

The way containers work currently can be summarized as follows:

  1. A sort is queued.
  2. A new NOTIFICATION_SORT_CHILDREN is fired.
  3. The container's _notification method iterates over children, calculates the rects making up its layout, and calls fit_child_in_rect(Control, Rect2) on each child.
  4. fit_child_in_rect calculates the final rect of the control in accordance with its size flags and applies it directly.

My proposal is to:

  1. Move the layouting logic from fit_child_in_rect to a new function called get_fitted_rect(Control, Rect2) which returns the calculated Rect2 without applying it.
  2. Modify fit_child_in_rect so that it checks for a _fit_child_in_rect(Control, Rect2) implementation. If it doesn't exist, it calls get_fitted_rect and applies it directly as it does now. If it exists, it forwards its arguments to it. It could even work like Object._set(), where the default behavior is applied even if the method has been implemented, if the implementation returns false.
  3. The user-defined _fit_child_in_rect can implement any custom behavior and even use get_fitted_rect as its starting point.
  4. It would probably be necessary to implement a warning to the user if they attempt to change size flags, minimum size or visibility of the Control in _fit_child_in_rect, as that could result in cascading sorts.

I imagine these changes would be retrocompatible and not break any existing code.

If this enhancement will not be used often, can it be worked around with a few lines of script?

Most of the behavior of this proposal can be simulated by storing the properties we're interested in during PRE_SORT, and restoring them afterwards. As an example, if I wanted to smoothly animate the position of cards in my HBoxContainer:

extends HBoxContainer
var pre_sort_positions := {}

func _notification(what):
    if what == NOTIFICATION_PRE_SORT_CHILDREN:
        pre_sort_positions.clear()
        for c in get_children():
            if !(c is Control): continue
            pre_sort_positions[c] = c.position
    elif what == NOTIFICATION_SORT_CHILDREN:
        for c in get_children():
            if !(c is Control): continue
            var final_position = c.position
            create_tween().tween_property(c, "position", final_position, 0.2).from(pre_sort_positions[c])

This setup is prone to user error and can fail for any number of random reasons. Other workarounds exist (such as using proxy nodes or wrapping controls in dummy containers so child nodes can animate freely within) but they're equally overcomplicated, janky, and don't work in all situations.

Is there a reason why this should be core and not an add-on in the asset library?

This is core Container functionality.