A UI Razor component for a scrollable box. It renders a viewport and optional vertical/horizontal scrollbars, computes thumb sizes/positions each Tick, supports stick-to-bottom behavior, and exposes ScrollToBottom.
@namespace Sunless.Libraries.UI
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@attribute [StyleSheet]
<root class="scroll-box @(Vertical ? "is-vertical" : "") @(Horizontal ? "is-horizontal" : "") @(StickToBottom ? "is-stick-bottom" : "")">
<div class="scroll-box-viewport" @ref="Viewport">
@ChildContent
</div>
@if ( _showVerticalBar )
{
<div class="scroll-box-bar scroll-box-bar--vertical">
<div class="scroll-box-thumb" style="@VerticalThumbStyle"></div>
</div>
}
@if ( _showHorizontalBar )
{
<div class="scroll-box-bar scroll-box-bar--horizontal">
<div class="scroll-box-thumb" style="@HorizontalThumbStyle"></div>
</div>
}
</root>
@code
{
[Parameter] public new RenderFragment ChildContent { get; set; }
[Parameter] public bool Vertical { get; set; } = true;
[Parameter] public bool Horizontal { get; set; }
[Parameter] public bool StickToBottom { get; set; }
public Panel Viewport { get; set; }
bool _showVerticalBar;
bool _showHorizontalBar;
float _vThumbPct;
float _vThumbPos;
float _hThumbPct;
float _hThumbPos;
string VerticalThumbStyle => $"height: {_vThumbPct * 100:0.##}%; top: {_vThumbPos * 100:0.##}%;";
string HorizontalThumbStyle => $"width: {_hThumbPct * 100:0.##}%; left: {_hThumbPos * 100:0.##}%;";
public override void Tick()
{
base.Tick();
if ( !Viewport.IsValid() )
return;
if ( StickToBottom )
Viewport.PreferScrollToBottom = true;
var size = Viewport.Box.Rect.Size;
var scrollSize = Viewport.ScrollSize;
var offset = Viewport.ScrollOffset;
bool newV = Vertical && scrollSize.y > 0.5f;
bool newH = Horizontal && scrollSize.x > 0.5f;
if ( newV )
{
var total = size.y + scrollSize.y;
_vThumbPct = total > 0 ? Math.Clamp( size.y / total, 0.05f, 1f ) : 1f;
var maxScroll = Math.Max( scrollSize.y, 0.0001f );
var travel = 1f - _vThumbPct;
_vThumbPos = Math.Clamp( offset.y / maxScroll, 0f, 1f ) * travel;
}
if ( newH )
{
var total = size.x + scrollSize.x;
_hThumbPct = total > 0 ? Math.Clamp( size.x / total, 0.05f, 1f ) : 1f;
var maxScroll = Math.Max( scrollSize.x, 0.0001f );
var travel = 1f - _hThumbPct;
_hThumbPos = Math.Clamp( offset.x / maxScroll, 0f, 1f ) * travel;
}
if ( newV != _showVerticalBar || newH != _showHorizontalBar )
{
_showVerticalBar = newV;
_showHorizontalBar = newH;
StateHasChanged();
}
else if ( newV || newH )
{
StateHasChanged();
}
}
public void ScrollToBottom()
{
if ( Viewport.IsValid() )
Viewport.TryScrollToBottom();
}
protected override int BuildHash() => HashCode.Combine( Vertical, Horizontal, StickToBottom, _showVerticalBar, _showHorizontalBar );
}