How to get scrollbar to automatically scroll to bottom?

Godot 4.2 Stable

The game I’m working on is heavily menu-based, and I’m trying to find a way to get the scroll container to scroll to the bottom of its contents via script whenever a new menu pops up. Looking at the other answers on here talks about using functions that don’t appear to exist anymore. Anything associated with .value and max_value don’t work, although max value shows up as an integer when printed. What exactly am I missing?

Modify it via ScrollContainer.scroll_vertical instead of changing the ScrollBar would be fine.

I’m afraid this doesn’t work either. I tried multiple ways of using ScrollContainer, including set_deferred and across multiple scripts. I either get error messages or nothing happens.

Strangely, when I attach the function to a button it works perfectly, but I can’t seem to get it to automatically go. I’ve even tried to use signals to relay the command to a custom script attached to the container, but nothing seems to do the trick.

Update: I just added a print to the signal function and it appears to be calling correctly. It’s almost as if something is overwriting the scroll value after the function is called. Considering I have no other functions attached to the scroll value, any ideas on possible culprits?

well um, just set it to 9999 or really get the max scroll value and set_deffered the scroll_vertical of the scrollcontainer to that value

how to get the max scroll value?

the_scrollcontainer.get_v_scroll_bar().max_value

how to set the scroll_vertical to max value?

the_scrollcontainer.set_deferred("scroll_vertical", the_scrollcontainer.get_v_scroll_bar().max_value)

As I said, I tried set_deferred already. My function is calling properly, but something else appears to be overwriting the value after the function sets it.

I’ve come across a workaround by using a timer set for .01 seconds into runtime.

have you tried tweening the value to that?

This solution works as well, and without the need for an extra node. Much appreciated

You just remind me of the scene tree timer:

await get_tree().create_timer(.01).timeout
the_scrollcontainer.set_deferred("scroll_vertical", the_scrollcontainer.get_v_scroll_bar().max_value)

or

get_tree().create_timer(.01).timeout.connect(
    func () -> void:
        the_scrollcontainer.set_deferred("scroll_vertical", the_scrollcontainer.get_v_scroll_bar().max_value)
)

Just put this in your ScrollContainer. Assuming you are adding some labels to the VBoxContainer when a button is pressed.

extends ScrollContainer

var start_text=100

func _enter_tree():
	set_deferred("scroll_vertical", 10000)

func _on_button_pressed():
	var label=Label.new()
	label.horizontal_alignment=HORIZONTAL_ALIGNMENT_CENTER
	start_text += 10
	label.text = str(start_text)
	$VBoxContainer.add_child(label)
	call_deferred("update_scroll")
	pass # Replace with function body.

func update_scroll():
	set_deferred("scroll_vertical", 10000)

I’m losing my mind. I’m using 4.3, using ChatGPT, I cannot get the scrollbar to go to the end.

I’m using

	await get_tree().create_timer(1).timeout
	scroll_container.set_deferred("scroll_vertical", 10000)

does not work. why is this so hard.

Here’s the function I used in my script to solve this problem. I’m taking a LineEdit’s submitted signal, making a new Label (of a custom extended class, CommandEntryLabel), assigning the submitted text to it, and appending it into a VBoxContainer within a ScrollContainer.

My best guess about what’s going on is that you tell it to scroll to what the maximum value is, but then a split second later it updates what that max value ACTUALLY is, which makes it appear like it didn’t really scroll to the bottom.

I hope this helps!

func _on_command_entry_text_submitted(command_text: String) -> void:
	var new_entry: CommandEntryLabel = CommandEntryLabel.new()
	new_entry.text = command_text
	%HistoryEntries.add_child(new_entry)
	%CommandEntry.text = ''
	
	# Waiting a split second after adding the text allows the program to catch up and properly
	# scroll all the way to the bottom. It's frustrating that I have to even do this hack,
	# but... here we are.
	get_tree().create_timer(.01).timeout.connect(
		func () -> void:
			%CommandHistory.scroll_vertical = %CommandHistory.get_v_scroll_bar().max_value
	)
texts_container.add_child(message_scene)
await to_signal(RenderingServer.singleton, RenderingServer.FRAME_POST_DRAW)
texts_container.get_parent().get_parent().scroll_vertical = 10000

This is how I did it. Waiting for the next frame works well

Another option is to do two call_deferred – in other words, wait two process frames.

func post_message(msg: String):
  # ... post your message
  call_deferred("scroll_chat_to_bottom_1")

func scroll_chat_to_bottom_1():
  call_deferred("scroll_chat_to_bottom_2")

func scroll_chat_to_bottom_2():
  scroll_container.scroll_vertical = scroll_container.get_v_scroll_bar().max_value

This is also really low-cost, as it’s just a normal (efficient) call_deferred, twice, rather than spawning a timer or awaiting a signal.

This is a nice, simple solution. I’ve nested the functions to avoid the confusing _1 and _2 funcs:

func scroll_to_bottom() -> void:
	var stb := func():
		scroll_vertical = get_v_scroll_bar().max_value
	stb.call_deferred()

func post_message(str: String):
	# ... post your message
	scroll_to_bottom.call_deferred()

Depending on the compiler optimisations, this may be slower if it’s creating a new function each time it’s called.

Further riffing on the ideas shared by @jamonholmgren & @flyte:

You could recursively defer call the same function here:

func scroll_to_bottom_in_n_frames(n: int):
	if n <= 0:
		@warning_ignore("narrowing_conversion")
		scroll_vertical = get_v_scroll_bar().max_value
	else:
		scroll_to_bottom_in_n_frames.call_deferred(n - 1)

I ran into a similar problem described in this thread, except I had to wait for 3 frames for the sizing to propagate to my scroll container, due to how I’m using a few nested containers between the list I’m appending to and the scrolling container.

scroll_to_bottom_in_n_frames(3)

So by playing around with a few of the suggestions here (thank you all, this thread is a life-saver lol) I ended up with this for making a DMs-style chatbox:

func new_message(message) :
    var message_instance = individualChats.instantiate()
    message_instance.set_data(message)
    $ScrollContainer/VBoxContainer.add_child(message_instance)

func update_scroll() :
    var max_scroll = $ScrollContainer.get_v_scroll_bar().max_value
    $ScrollContainer.set_deferred("scroll_vertical", max_scroll)

func _on_action_to_send_message() :
    var msg = "Hello World"
    new_message(msg)
    update_scroll.call_deferred()

Not sure if this only works because it’s instantiating another scene for the individual messages, but it might be worth testing without that and seeing if something like this:

func update_scroll() :
    var max_scroll = $ScrollContainer.get_v_scroll_bar().max_value
    $ScrollContainer.set_deferred("scroll_vertical", max_scroll)

func menu_popup() : 
    # insert code here
    update_scroll.call_deferred()

would still have the same desired effect!

Is this a bug? If yes, has anyone reported it yet? :sweat_smile: This feels like a really weird and hacky solution for something as basic as “scrolling to bottom in a node specifically designed for scrolling”

For future reference… here’s one of the solutions from above but in C#:

public void Update()
{
    // Add nodes, etc...
    CallDeferred("UpdateScroll");
}

private void UpdateScroll() {
    ScrollContainer.SetDeferred("scroll_vertical", 99999);
}

Well, that’s when AI really helps. I asked Chat-GPT to improve your code, and here’s the result:

func scroll_to_bottom() -> void:
	await get_tree().process_frame
	scroll_container.scroll_vertical = int(scroll_container.get_v_scroll_bar().max_value)

In my case, I’m using it after updating the text.

if result != "":
		history_label.text += "\n" + result
		scroll_to_bottom()

The key here is this: await get_tree().process_frame; which makes the script wait for 1 full frame.

Thank you, jamonholmgren, and all!

Years later, spent all night trying to solve this problem with some help from this thread. This is a custom ScrollContainer which keeps the scrollbar pinned to the bottom as it’s content is changed, but only when the scrollbar was touching the bottom before the content was changed. Those who come later may find it useful. Made in Godot 4.6.2.

extends ScrollContainer

## Scroll container that stays at bottom if new element is added and scroll was at bottom
class_name PinDownScrollContainer

## [code]true[/code] if scrollbar is at bottom
var _pinning : bool = false 

func _init():
    get_v_scroll_bar().changed.connect(_on_scroll_bar_changed)
    get_v_scroll_bar().value_changed.connect(_on_scroll_bar_value_changed)

## When scroll bar size changes (element count changed), keep scroll at bottom is pinning [code]true[/code]
func _on_scroll_bar_changed():
    if _pinning:
        _scroll_to_bottom.call_deferred()

## When scroll bar is scrolled, set [code]pinned[/code] to [code]true[/code]
func _on_scroll_bar_value_changed(x):
    _pinning = (scroll_vertical >= get_v_scroll_bar().max_value-get_v_scroll_bar().page-1.0)

## Scroll to the bottom
func _scroll_to_bottom():
    scroll_vertical = get_v_scroll_bar().max_value