UI/Components/MixedVirtualGrid.cs
using Microsoft.AspNetCore.Components;
namespace Sandbox.UI;
/// <summary>
/// Implemented by items that occupy a complete row in a <see cref="MixedVirtualGrid"/>.
/// </summary>
public interface IMixedVirtualGridFullRow
{
bool IsFullRow { get; }
float Height { get; }
}
/// <summary>
/// A virtual grid that mixes fixed-size tiles with full-width rows.
/// </summary>
public class MixedVirtualGrid : BaseVirtualPanel
{
private readonly record struct Row( int FirstIndex, int Count, float Top, float Height, bool FullWidth );
private readonly List<Row> _rows = new();
private int[] _rowForItem = [];
private Rect _innerRect;
private float _viewportHeight;
private float _totalHeight;
private float _tileWidth;
private float _tileHeight;
private Vector2 _spacing;
private int _columns = 1;
private int _geometryHash;
private int _updateHash;
private int _dropIndex = -1;
[Parameter] public Vector2 ItemSize { get; set; } = new( 100, 100 );
[Parameter] public bool ScaleUp { get; set; } = true;
[Parameter] public Func<PanelEvent, bool> CanAcceptDrop { get; set; }
[Parameter] public Action<int, PanelEvent> OnDropIndex { get; set; }
private bool _dragHover;
protected override void UpdateLayoutSpacing( Vector2 spacing )
{
_spacing = spacing;
}
protected override bool UpdateLayout()
{
var geometryHash = HashCode.Combine( Box.RectInner, Box.Rect, ScaleFromScreen, ItemSize, ScaleUp, _spacing, _items.Count );
if ( NeedsRebuild || geometryHash != _geometryHash )
{
_geometryHash = geometryHash;
RebuildRows();
}
var updateHash = HashCode.Combine( geometryHash, ScrollOffset.y );
if ( updateHash == _updateHash ) return false;
_updateHash = updateHash;
return true;
}
private void RebuildRows()
{
_rows.Clear();
_rowForItem = new int[_items.Count];
_totalHeight = 0f;
var inner = Box.RectInner;
inner.Position -= Box.Rect.Position;
_innerRect = inner * ScaleFromScreen;
_viewportHeight = Box.Rect.Height * ScaleFromScreen;
var nominalWidth = MathF.Max( 1f, ItemSize.x );
var nominalHeight = MathF.Max( 1f, ItemSize.y );
var stepX = nominalWidth + _spacing.x;
_columns = Math.Max( 1, ((_innerRect.Width + _spacing.x) / stepX).FloorToInt() );
_tileWidth = nominalWidth;
_tileHeight = nominalHeight;
if ( ScaleUp )
{
_tileWidth = MathF.Max( 1f, (_innerRect.Width - (_columns - 1) * _spacing.x) / _columns );
_tileHeight = MathF.Max( 1f, _tileWidth * nominalHeight / nominalWidth );
}
var top = _innerRect.Top;
var index = 0;
while ( index < _items.Count )
{
var fullHeight = FullRowHeight( _items[index] );
if ( fullHeight is not null )
{
AddRow( index, 1, top, fullHeight.Value, true );
top += fullHeight.Value + _spacing.y;
index++;
continue;
}
var count = 1;
while ( count < _columns && index + count < _items.Count && FullRowHeight( _items[index + count] ) is null )
count++;
AddRow( index, count, top, _tileHeight, false );
top += _tileHeight + _spacing.y;
index += count;
}
var bottomPadding = MathF.Max( 0f, _viewportHeight - _innerRect.Bottom );
_totalHeight = MathF.Max( 0f, top + bottomPadding );
}
private static float? FullRowHeight( object item )
{
if ( item is not IMixedVirtualGridFullRow { IsFullRow: true } fullRow ) return null;
if ( !float.IsFinite( fullRow.Height ) ) return 1f;
return MathF.Max( 1f, fullRow.Height );
}
private void AddRow( int firstIndex, int count, float top, float height, bool fullWidth )
{
var rowIndex = _rows.Count;
_rows.Add( new Row( firstIndex, count, top, height, fullWidth ) );
for ( var i = 0; i < count; i++ )
_rowForItem[firstIndex + i] = rowIndex;
}
protected override void GetVisibleRange( out int first, out int pastEnd )
{
if ( _rows.Count == 0 )
{
first = 0;
pastEnd = 0;
return;
}
var visibleTop = ScrollOffset.y * ScaleFromScreen;
var visibleBottom = visibleTop + _viewportHeight;
var firstRow = _rows.FindIndex( row => row.Top + row.Height > visibleTop );
if ( firstRow < 0 ) firstRow = _rows.Count - 1;
firstRow = Math.Max( 0, firstRow - 1 );
var pastRow = _rows.FindIndex( firstRow, row => row.Top >= visibleBottom );
if ( pastRow < 0 ) pastRow = _rows.Count;
pastRow = Math.Min( _rows.Count, pastRow + 1 );
first = _rows[firstRow].FirstIndex;
var last = _rows[pastRow - 1];
pastEnd = last.FirstIndex + last.Count;
}
protected override void PositionPanel( int index, Panel panel )
{
if ( index < 0 || index >= _rowForItem.Length ) return;
var row = _rows[_rowForItem[index]];
var column = index - row.FirstIndex;
var rect = row.FullWidth
? new Rect( _innerRect.Left, row.Top, _innerRect.Width, row.Height )
: new Rect( _innerRect.Left + column * (_tileWidth + _spacing.x), row.Top, _tileWidth, _tileHeight );
panel.Style.Left = rect.Left;
panel.Style.Top = rect.Top;
panel.Style.Width = rect.Width;
panel.Style.Height = rect.Height;
panel.SetClass( "full-row", row.FullWidth );
panel.Style.Dirty();
}
protected override float GetTotalHeight( int itemCount ) => _totalHeight;
public int GetDropIndex( Vector2 viewportPosition )
{
if ( _rows.Count == 0 ) return 0;
var point = (viewportPosition + ScrollOffset) * ScaleFromScreen;
if ( point.y < _rows[0].Top ) return 0;
var rowIndex = _rows.FindLastIndex( row => row.Top <= point.y );
if ( rowIndex < 0 ) return 0;
var row = _rows[rowIndex];
if ( point.y > row.Top + row.Height )
return row.FirstIndex + row.Count;
if ( row.FullWidth )
return row.FirstIndex + (point.y >= row.Top + row.Height * 0.5f ? 1 : 0);
var firstCenter = _innerRect.Left + _tileWidth * 0.5f;
var slot = ((point.x - firstCenter) / (_tileWidth + _spacing.x)).FloorToInt() + 1;
return row.FirstIndex + slot.Clamp( 0, row.Count );
}
protected override void OnDragEnter( PanelEvent e )
{
base.OnDragEnter( e );
if ( OnDropIndex is null || CanAcceptDrop?.Invoke( e ) != true ) return;
_dragHover = true;
SetDropIndex( GetDropIndex( MousePosition ) );
e.StopPropagation();
}
protected override void OnDragLeave( PanelEvent e )
{
base.OnDragLeave( e );
_dragHover = false;
ClearDropIndex();
}
protected override void OnDrop( PanelEvent e )
{
base.OnDrop( e );
if ( OnDropIndex is null || CanAcceptDrop?.Invoke( e ) != true ) return;
var index = GetDropIndex( MousePosition );
_dragHover = false;
ClearDropIndex();
OnDropIndex( index, e );
e.StopPropagation();
}
public override void Tick()
{
base.Tick();
if ( !_dragHover ) return;
if ( CanAcceptDrop?.Invoke( null ) != true )
{
_dragHover = false;
ClearDropIndex();
return;
}
var edge = 36f;
if ( MousePosition.y < edge ) ScrollOffset -= new Vector2( 0, 12 );
if ( MousePosition.y > Box.Rect.Height - edge ) ScrollOffset += new Vector2( 0, 12 );
SetDropIndex( GetDropIndex( MousePosition ) );
}
private void SetDropIndex( int index )
{
if ( _dropIndex == index ) return;
ClearDropIndex();
_dropIndex = index;
if ( index < _items.Count && _created.TryGetValue( index, out var before ) )
before.AddClass( "drop-before" );
else if ( index > 0 && _created.TryGetValue( index - 1, out var after ) )
after.AddClass( "drop-after" );
}
private void ClearDropIndex()
{
if ( _dropIndex < 0 ) return;
foreach ( var panel in _created.Values )
{
panel.RemoveClass( "drop-before" );
panel.RemoveClass( "drop-after" );
}
_dropIndex = -1;
}
}