godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.1k stars 69 forks source link

Add a SectionContainer with sticky header that signals section visibility to allow queing and freeing of resources #5599

Open Koyper opened 1 year ago

Koyper commented 1 year ago

Describe the project you are working on

A GUI with image and document browsing with thousands of items;

A GUI with forms with multiple sections.

Describe the problem or limitation you are having in your project

Browsing through thousands of images, that may require replacing small thumbnail views with highres versions depending on a local zoom setting.

Long forms with multiple sections such that scrolling to the bottom of a section hides the descriptive header of the form region.

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

A SectionContainer manages any number of independent sections made up of any Control, plus an optional header as any Control. The header is optionally sticky at the top of the scroll panel until the next section header displaces it vertically off the screen.

This is a highly useful API type for iOS, and allows breaking up large collections of items into sections that can be freed when off screen, or loaded only when entering visibility.

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

Here's the SectionContainer as GDscript:

class_name SectionContainer
extends ScrollContainer

onready var sections: VBoxContainer = get_node("%Sections")

func clear():
    for child in sections.get_children():
        child.queue_free()

func get_sections() -> Array:
    var sections_info := []
    for child in sections.get_children():
        sections_info.append(child.get_section())
    return sections_info

func add_section(section: Control, header: Control = null) -> Control: # returns ContainerSection
    var new_section := ContainerSection.new(self)
    if header != null:
        new_section.add_child(header)
    section.show_behind_parent = true
    new_section.add_child(section)
    section.owner = new_section
    sections.add_child(new_section)
    return new_section

func set_vseparation(separation: int):
    sections.add_constant_override("separation", separation)

class ContainerSection extends VBoxContainer:
    var parent: SectionContainer
    var vscroll: VScrollBar
    var header: Control = null
    var intersects_state := false

    signal section_visibility_changed(container_section, is_visible)

    func _init(p_parent: SectionContainer):
        parent = p_parent
        vscroll = parent.get_v_scrollbar()
        add_constant_override("separation", 0)
        size_flags_vertical = SIZE_EXPAND_FILL

    func _ready():
        if get_child_count() == 2:
            header = get_child(0)
            vscroll.connect("changed", self, "position_header")

    func get_section() -> Node:
        return get_child(1 if get_child_count() == 2 else 0)

    func position_header():
        var parent_global_rect: Rect2 = parent.get_global_rect()
        var global_rect: Rect2 = get_global_rect()
        var overlap_rect := Rect2(parent_global_rect.position, Vector2(1.0, rect_size.y))
        var test_point := Vector2(global_rect.position.x, global_rect.position.y + rect_size.y)
        if header != null and overlap_rect.has_point(test_point):
            var overlap: float = (parent_global_rect.position.y + rect_position.y) - global_rect.position.y
            var remaining_gap: float = rect_size.y - overlap + rect_position.y
            var header_offset: float = overlap - rect_position.y
            if remaining_gap < header.rect_size.y:
                var pushup_amount: float = header.rect_size.y - remaining_gap
                header_offset -= pushup_amount
            header.rect_position.y = header_offset
        else:
            header.rect_position.y = 0.0
        if parent_global_rect.intersects(global_rect):
            if intersects_state == false:
                emit_signal("section_visibility_changed", self, true)
                intersects_state = true
        else:
            if intersects_state == true:
                emit_signal("section_visibility_changed", self, false)
                intersects_state = false

And here is the example header script:

extends Control

func _ready():
    pass

func set_color(p_color: Color):
    $Panel.get_stylebox("panel").bg_color = p_color

func set_section_name(p_name: String):
    $Label.text = p_name

func set_stylebox(stylebox: StyleBox):
    $Panel.add_stylebox_override("panel", stylebox)

Here's how to script a section node to receive the visibility changed signal from the ContainerSection class. This is where you would free or load resources as required for example on a background thread when first visible:

extends MarginContainer

func _ready():
    owner.connect("section_visibility_changed", self, "_signaled_section_visibility_changed")

func _signaled_section_visibility_changed(_container_section: Control, is_visible: bool):
    prints("Flowsection is visible", is_visible)

https://user-images.githubusercontent.com/33969780/196006621-37f71e29-bcdd-4ef8-a6e2-878408c49698.mov

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

I'm using it as script, but wish it was a built-in feature, especially as I add the dynamic loading/freeing of resources going forward.

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

There is some expense in computing the sticky header that would benefit from core integration. The script code is 78-lines long, so it should be a compact addition to the core.

Koyper commented 1 year ago

Here's the Godot 4 compatible script that also finds a solution for obtaining the ScrollContainer change at the right point in time, i.e., after the SC has finished updating it's children:

class_name SectionContainer
extends ScrollContainer

@onready var sections: VBoxContainer = get_node("%Sections")

func clear():
    for child in sections.get_children():
        child.queue_free()

func get_sections() -> Array:
    var sections_info := []
    for child in sections.get_children():
        sections_info.append(child.get_section())
    return sections_info

func add_section(section: Control, header: Control = null) -> Control: # returns ContainerSection
    var new_section := ContainerSection.new(self)
    if header != null:
        new_section.add_child(header)
    section.show_behind_parent = true
    new_section.add_child(section)
    section.owner = new_section
    sections.add_child(new_section)
    return new_section

func set_vseparation(separation: int):
    sections.add_theme_constant_override("separation", separation)

class ContainerSection extends VBoxContainer:
    var parent: SectionContainer
    var header: Control = null
    var intersects_state := false

    signal section_visibility_changed(container_section, is_visible)

    func _init(p_parent: SectionContainer):
        parent = p_parent
        add_theme_constant_override("separation", 0)
        size_flags_vertical = SIZE_EXPAND_FILL

    func _ready():
        if get_child_count() == 2:
            header = get_child(0)
            parent.get_child(0).connect("item_rect_changed", Callable(self, "position_header"), CONNECT_DEFERRED)

    func get_section() -> Node:
        return get_child(1 if get_child_count() == 2 else 0)

    func position_header():
        var overlap_rect := Rect2(parent.global_position, Vector2(1.0, size.y))
        var test_point := Vector2(global_position.x, global_position.y + size.y)
        if header != null and overlap_rect.has_point(test_point):
            var overlap: float = (parent.global_position.y + position.y) - global_position.y
            var remaining_gap: float = size.y - overlap + position.y
            var header_offset: float = overlap - position.y
            if remaining_gap < header.size.y:
                var pushup_amount: float = header.size.y - remaining_gap
                header_offset -= pushup_amount
            header.position.y = header_offset
        else:
            header.position.y = 0.0
        if Rect2(parent.global_position, parent.size).intersects(Rect2(global_position, size)):
            if intersects_state == false:
                emit_signal("section_visibility_changed", self, true)
                intersects_state = true
        else:
            if intersects_state == true:
                emit_signal("section_visibility_changed", self, false)
                intersects_state = false

https://user-images.githubusercontent.com/33969780/196970816-e5b6dc88-0a09-4b0d-9870-2eba104ad365.mov