Skip to content

Use the scroll API directly

Most projects use the ScrollView helper from Extra/DebugUI/ covered in Add a scroll view. The helper calls a lower-level scroll API on UI that is also public, and a caller can use it directly to draw custom scrollbars, apply a different clip region, or read scroll metrics before laying out content.

When to bypass the helper

Use the lower-level API when one of the following applies:

  • The default scrollbar visual does not match your theme. The helper draws a fixed-style rounded handle; the lower-level API hands you the handle rect to draw yourself.
  • You need to clip to a non-rectangular region (rounded corners, multiple panes), so you want to apply your own clip rect.
  • You need the calculated content area before deciding what to lay out. CalculateScrollViewWidth and CalculateScrollViewHeight answer that question without opening a scope.

If none of those apply, use the helper. The lower-level API is more verbose and takes more care to use correctly.

To open a scroll scope

  1. Build a ScrollConfig. The same struct the helper uses:
    var config = new ScrollConfig
    {
        Direction = ScrollDirection.Vertical,
        BackgroundPadding = 5,
        ScrollSize = 10,
    };
    
  2. Optionally calculate the content area before laying out:
    int contentW = ui.CalculateScrollViewWidth(config);
    int contentH = ui.CalculateScrollViewHeight(config);
    
    These return the inner area available to content after subtracting padding and scrollbar thickness. Useful when the total content size depends on the available width (text wrapping, for instance).
  3. Open the scope with BeginScroll. The call returns a ScrollLayout with the rects you need to draw the visual:
    ui.BeginScroll("log", new Vector2Int(contentW, totalHeight),
        config, out ScrollLayout layout);
    
  4. Draw the chrome you want using the layout rects:
    ui.FillRounded(layout.BackgroundRect, backgroundColor, 10);
    ui.FillRounded(layout.VerticalHandleRect, handleColor, 5);
    ui.FillRounded(layout.HorizontalHandleRect, handleColor, 5);
    
  5. Push the content rect, optionally clip to the view rect, then lay out content:
    ui.PushRect(layout.ContentRect);
    ui.PushClipRect(layout.ViewRect);
        // content layout calls go here
    ui.PopClip();
    ui.Pop();
    
    The ui.Pop() balances the ui.PushRect(layout.ContentRect) above; EndScroll does not pop it for you.
  6. Close the scope with EndScroll:
    ui.EndScroll();
    
    EndScroll pops the rects pushed by BeginScroll, consumes mouse-wheel input if the cursor is over the view rect, and matches the PushID issued by BeginScroll.

ScrollLayout fields

BeginScroll fills a ScrollLayout with seven rects in layout space:

Field What it covers
BackgroundRect The full scroll area, including padding and scrollbar gutter. Use it to draw the surface behind everything.
ViewRect The visible content area after padding. Use it as the clip rect.
ContentRect The content rect for the current frame, offset by the scroll position and sized to the content. Pushed by BeginScroll.
VerticalHandleContainerRect The track the vertical handle slides in.
VerticalHandleRect The vertical scrollbar handle.
HorizontalHandleContainerRect The track the horizontal handle slides in.
HorizontalHandleRect The horizontal scrollbar handle.

When Direction is Vertical, the horizontal rects are zero-sized; when Horizontal, the vertical rects are zero-sized. Both populates everything; None leaves both pairs zero-sized.

ScrollConfig fields

Field Purpose
Direction One of None, Vertical, Horizontal, Both.
BackgroundPadding Pixels of padding around the view rect.
ScrollSize Scrollbar thickness in pixels.

Snapping to bottom

ui.ScrollToBottom("log") flags the scroll area for a snap on the next BeginScroll call. It is safe to call from anywhere, even while another scroll scope is open:

if (newLogLineAppended)
    ui.ScrollToBottom("log");

The snap clamps the vertical offset to max(0, contentHeight - viewHeight). The flag is consumed when the matching BeginScroll runs, so the call only takes effect once.

The full pattern

var config = new ScrollConfig
{
    Direction = ScrollDirection.Vertical,
    BackgroundPadding = 5,
    ScrollSize = 10,
};

int contentW = ui.CalculateScrollViewWidth(config);
int totalHeight = items.Count * itemHeight;

ui.BeginScroll("items", new Vector2Int(contentW, totalHeight),
    config, out ScrollLayout layout);

ui.FillRounded(layout.BackgroundRect, backgroundColor, 10);
ui.FillRounded(layout.VerticalHandleRect, handleColor, 5);

ui.PushRect(layout.ContentRect);
ui.PushClipRect(layout.ViewRect);
{
    ui.PushSeriesVertical(itemHeight);
    {
        for (int i = 0; i < items.Count; i++)
        {
            DrawItem(ui, items[i]);
            if (i < items.Count - 1)
                ui.Next();
        }
    }
    ui.Pop();
}
ui.PopClip();
ui.Pop();

ui.EndScroll();

Pitfalls

Forgetting EndScroll. : BeginScroll pushes rects and an ID; EndScroll pops them. A missing EndScroll corrupts the rect and ID stacks and triggers ValidateStacks at end of frame.

Drawing handles before BeginScroll. : The handle rects come from the out ScrollLayout, which BeginScroll populates. Read the layout after the call.

Mismatched clip-rect pop. : If you PushClipRect inside the scroll scope, pair it with PopClip before EndScroll. EndScroll does not pop the clip stack.

The handle stays in place even though content has changed. : The content size passed to BeginScroll is stale. Recompute it from the underlying data each frame.

Custom scrollbar interactions don't work. : The lower-level API draws no handle interaction itself; the helper does. To support handle-drag and click-on-track, follow the pattern in Source/Extra/DebugUI/ScrollView.cs or call the helper instead.

Additional resources

  • Add a scroll view: the high-level helper most projects use.
  • Stacks: why every push needs a matching pop.
  • Input and focus: the rules for mouse-wheel routing inside a scroll area.
  • State: scroll position is owned by Tick UI, keyed by the scroll ID.