添加一个表格组件,原理上贴近现代表格系统,理论上可以应对超大型表格以及离散数据集合。

This commit is contained in:
2026-06-26 19:14:48 +08:00
parent 55ace8d02b
commit 61bb0b012e
78 changed files with 4409 additions and 57 deletions
@@ -0,0 +1,55 @@
using System;
namespace XericUI.XTable.Rendering.Component
{
/// <summary>
/// 脏标记类型——标识表格需要刷新的变更类型。
/// </summary>
[Flags]
public enum TableDirtyType
{
None = 0,
/// <summary>数据内容变更</summary>
DataChanged = 1 << 0,
/// <summary>视图范围变更(滚动/缩放)</summary>
ViewChanged = 1 << 1,
/// <summary>样式表变更</summary>
StyleChanged = 1 << 2,
/// <summary>选中状态变更</summary>
SelectionChanged = 1 << 3,
/// <summary>行列尺寸变更</summary>
LayoutChanged = 1 << 4,
/// <summary>全部脏(强制完整刷新)</summary>
All = DataChanged | ViewChanged | StyleChanged | SelectionChanged | LayoutChanged
}
/// <summary>
/// 脏标记结构——追踪表格的刷新状态。
/// </summary>
public struct XTableDirtyFlag
{
/// <summary>当前脏标记集合</summary>
public TableDirtyType Flags;
/// <summary>是否有任何脏标记</summary>
public bool IsDirty => Flags != TableDirtyType.None;
/// <summary>标记一种脏类型</summary>
public void Mark(TableDirtyType type)
{
Flags |= type;
}
/// <summary>清除所有脏标记</summary>
public void Clear()
{
Flags = TableDirtyType.None;
}
/// <summary>检查是否包含特定脏类型</summary>
public bool Has(TableDirtyType type)
{
return (Flags & type) == type;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f87a16b53fa84c4a97b8ffd44aa2868
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.XTable.Rendering.Component
{
/// <summary>
/// 单元格对象池——复用表格单元格 GameObject,避免频繁 Instantiate/Destroy。
/// </summary>
public class XTableObjectPool
{
/// <summary>回收池</summary>
private readonly Stack<GameObject> m_Pool = new Stack<GameObject>();
/// <summary>当前活跃对象(已借出)</summary>
private readonly HashSet<GameObject> m_Active = new HashSet<GameObject>();
/// <summary>预制体模板</summary>
private readonly GameObject m_Prefab;
/// <summary>父级 Transform</summary>
private readonly Transform m_Parent;
/// <summary>初始池容量</summary>
private readonly int m_InitialCapacity;
public XTableObjectPool(GameObject prefab, Transform parent, int initialCapacity = 64)
{
m_Prefab = prefab;
m_Parent = parent;
m_InitialCapacity = initialCapacity;
// 预创建对象
for (int i = 0; i < initialCapacity; i++)
{
GameObject obj = CreateNew();
obj.SetActive(false);
m_Pool.Push(obj);
}
}
/// <summary>
/// 从池中获取一个对象
/// </summary>
public GameObject Get()
{
GameObject obj;
if (m_Pool.Count > 0)
{
obj = m_Pool.Pop();
}
else
{
obj = CreateNew();
}
obj.SetActive(true);
m_Active.Add(obj);
return obj;
}
/// <summary>
/// 将对象归还到池中
/// </summary>
public void Return(GameObject obj)
{
if (obj == null) return;
obj.SetActive(false);
m_Active.Remove(obj);
m_Pool.Push(obj);
}
/// <summary>
/// 归还所有活跃对象
/// </summary>
public void ReturnAll()
{
foreach (var obj in m_Active)
{
if (obj != null)
{
obj.SetActive(false);
m_Pool.Push(obj);
}
}
m_Active.Clear();
}
/// <summary>
/// 清理所有对象
/// </summary>
public void Clear()
{
ReturnAll();
while (m_Pool.Count > 0)
{
var obj = m_Pool.Pop();
if (obj != null)
Object.Destroy(obj);
}
}
/// <summary>当前活跃对象数</summary>
public int ActiveCount => m_Active.Count;
/// <summary>池中空闲对象数</summary>
public int PoolCount => m_Pool.Count;
private GameObject CreateNew()
{
GameObject obj = m_Prefab != null
? Object.Instantiate(m_Prefab, m_Parent)
: new GameObject("TableCell", typeof(RectTransform));
obj.transform.SetParent(m_Parent, false);
return obj;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ae4a5754316035e4489de05e40389c9e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,572 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using XericUI.Core.Base;
using XericUI.XTable.Core;
using XericUI.XTable.Rendering.Elements;
using XericLibrary.Runtime.SuperStyleSheet;
namespace XericUI.XTable.Rendering.Component
{
/// <summary>
/// 表格组件——纯数据渲染器。
/// 一张全幅背景 + 按需拼装单元格文本/图片。
/// 无行列标题、无样式差异分割线——所有内容由数据定义。
/// </summary>
[ExecuteAlways]
[AddComponentMenu("Xeric UI Vessel/Table/Xeric UI Action Table", 60)]
public class XericUIActionTable : XericUIBehaciour, IPointerClickHandler
{
#region 常量
private const string DEFAULT_STYLE_NS = "xeric_table_default";
#endregion
#region 序列化字段
[SerializeField] private XTableData m_TableData;
[SerializeField] private float[] m_RowHeights = new float[] { 40f, 30f };
[SerializeField] private float[] m_ColWidths = new float[] { 100f, 80f, 120f };
[SerializeField] private string m_DefaultStyleNamespace = DEFAULT_STYLE_NS;
[SerializeField] private ScrollRect m_ScrollRect;
#endregion
#region 私有字段
[System.NonSerialized] private CellAssembler m_Assembler;
[System.NonSerialized] private XTableDirtyFlag m_DirtyFlag;
private int m_SelectedRow = -1;
private int m_SelectedCol = -1;
[System.NonSerialized] private GameObject m_BackgroundGo;
private float m_TotalWidth;
private float m_TotalHeight;
// ScrollRect 可见范围
private Rect m_VisibleRect;
#endregion
#region 事件
public event Action<int, int> OnCellSelected;
public event Action OnTableRefreshed;
#endregion
#region 公开属性
public XTableData TableData
{
get => m_TableData;
set { m_TableData = value; MarkDirty(TableDirtyType.DataChanged); }
}
public float[] RowHeights => m_RowHeights;
public float[] ColWidths => m_ColWidths;
public string DefaultStyleNamespace => m_DefaultStyleNamespace;
public int SelectedRow => m_SelectedRow;
public int SelectedCol => m_SelectedCol;
#endregion
#region 生命周期
protected override void Awake()
{
base.Awake();
if (m_TableData == null)
m_TableData = new XTableData();
m_Assembler = new CellAssembler(rectTransform);
RecalculateTotalSize();
EnsureDefaultStyleNamespace();
CreateDefaultSampleData();
CreateBackground();
if (m_ScrollRect != null)
m_ScrollRect.onValueChanged.AddListener(OnScrollValueChanged);
if (!Application.isPlaying)
SubscribeEditorUpdate();
MarkDirty(TableDirtyType.All);
}
protected override void OnEnable()
{
base.OnEnable();
EnsureDefaultStyleNamespace();
MarkDirty(TableDirtyType.All);
}
protected override void OnDisable()
{
base.OnDisable();
}
protected override void OnDestroy()
{
if (m_ScrollRect != null)
m_ScrollRect.onValueChanged.RemoveListener(OnScrollValueChanged);
UnsubscribeEditorUpdate();
// 销毁所有动态生成的对象
if (m_Assembler != null)
m_Assembler.DestroyAll();
if (m_BackgroundGo != null)
{
if (Application.isPlaying) Destroy(m_BackgroundGo);
else DestroyImmediate(m_BackgroundGo);
m_BackgroundGo = null;
}
base.OnDestroy();
}
private void Update()
{
if (m_DirtyFlag.IsDirty) RefreshView();
}
#endregion
#region 默认示例数据
private bool m_DefaultDataCreated;
private void CreateDefaultSampleData()
{
if (m_DefaultDataCreated) return;
m_DefaultDataCreated = true;
string[,] sample = new string[2, 3]
{
{ "Name", "Age", "City" },
{ "Alice", "25", "NYC" }
};
for (int r = 0; r < 2; r++)
for (int c = 0; c < 3; c++)
SetCellTextInternal(r, c, sample[r, c], skipDirty: true);
}
private void SetCellTextInternal(int row, int col, string text, bool skipDirty)
{
var data = m_TableData.GetCell(row, col);
if (data == null)
{
data = new XTableCellData();
m_TableData.SetCell(row, col, data);
}
data.Text = text;
if (!skipDirty) MarkDirty(TableDirtyType.DataChanged);
}
#endregion
#region 编辑器模式支持
[System.NonSerialized] private bool m_EditorUpdateSubscribed;
private void SubscribeEditorUpdate()
{
if (m_EditorUpdateSubscribed) return;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update += EditorUpdate;
m_EditorUpdateSubscribed = true;
#endif
}
private void UnsubscribeEditorUpdate()
{
if (!m_EditorUpdateSubscribed) return;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update -= EditorUpdate;
m_EditorUpdateSubscribed = false;
#endif
}
#if UNITY_EDITOR
private void EditorUpdate()
{
if (this == null || !isActiveAndEnabled)
{
UnsubscribeEditorUpdate();
return;
}
if (m_DirtyFlag.IsDirty)
RefreshView();
}
#endif
#endregion
#region 公开方法
public void SetCellData(int row, int col, XTableCellData data)
{
m_TableData.SetCell(row, col, data);
MarkDirty(TableDirtyType.DataChanged);
}
public void SetCellText(int row, int col, string text)
{
SetCellTextInternal(row, col, text, skipDirty: false);
}
public XTableCellData GetCellData(int row, int col)
=> m_TableData?.GetCell(row, col);
public void MergeCells(int startRow, int startCol, int rowSpan, int colSpan)
{
m_TableData.MergeCells(startRow, startCol, rowSpan, colSpan);
MarkDirty(TableDirtyType.DataChanged);
}
public void UnmergeCells(int startRow, int startCol)
{
m_TableData.UnmergeCells(startRow, startCol);
MarkDirty(TableDirtyType.DataChanged);
}
public void SelectCell(int row, int col)
{
m_SelectedRow = row;
m_SelectedCol = col;
MarkDirty(TableDirtyType.DataChanged);
}
public void ClearSelection()
{
m_SelectedRow = -1;
m_SelectedCol = -1;
MarkDirty(TableDirtyType.DataChanged);
}
public void RefreshView()
{
if (m_TableData == null || m_Assembler == null) return;
if (m_RowHeights == null || m_ColWidths == null) return;
if (m_RowHeights.Length == 0 || m_ColWidths.Length == 0) return;
#if UNITY_EDITOR
if (!Application.isPlaying)
Canvas.ForceUpdateCanvases();
#endif
RecalculateTotalSize();
UpdateVisibleRect();
// 更新背景尺寸
if (m_BackgroundGo != null)
{
var bgRt = m_BackgroundGo.GetComponent<RectTransform>();
bgRt.sizeDelta = new Vector2(m_TotalWidth, m_TotalHeight);
}
// 回收池
m_Assembler.ReturnAll();
// 计算可见范围
int sr, er, sc, ec;
CalcVisibleRange(out sr, out er, out sc, out ec);
if (er <= sr || ec <= sc) return;
// 渲染每个可见单元格
RenderCells(sr, er, sc, ec);
m_DirtyFlag.Clear();
OnTableRefreshed?.Invoke();
}
#endregion
#region 背景
private void CreateBackground()
{
if (m_BackgroundGo != null) return;
m_BackgroundGo = new GameObject("_TableBackground",
typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
m_BackgroundGo.hideFlags = HideFlags.DontSave | HideFlags.HideInHierarchy;
m_BackgroundGo.transform.SetParent(rectTransform, false);
RectTransform bgRt = m_BackgroundGo.GetComponent<RectTransform>();
bgRt.anchorMin = new Vector2(0, 1);
bgRt.anchorMax = new Vector2(0, 1);
bgRt.pivot = new Vector2(0, 1);
bgRt.anchoredPosition = Vector2.zero;
bgRt.sizeDelta = new Vector2(m_TotalWidth, m_TotalHeight);
var bgImg = m_BackgroundGo.GetComponent<Image>();
bgImg.color = new Color(0.18f, 0.18f, 0.18f, 1f);
bgImg.raycastTarget = false;
}
/// <summary>完全重建表格——销毁所有动态生成的对象并重新初始化</summary>
[ContextMenu("Xeric UI/完全重建表格")]
public void FullRebuild()
{
m_Assembler?.DestroyAll();
m_DefaultDataCreated = false;
if (m_BackgroundGo != null)
{
if (Application.isPlaying) Destroy(m_BackgroundGo);
else DestroyImmediate(m_BackgroundGo);
m_BackgroundGo = null;
}
m_TableData = new XTableData();
m_Assembler = new CellAssembler(rectTransform);
RecalculateTotalSize();
CreateDefaultSampleData();
CreateBackground();
MarkDirty(TableDirtyType.All);
RefreshView();
}
#endregion
#region 单元格渲染
private void RenderCells(int sr, int er, int sc, int ec)
{
Color fgColor = GetStyleColor(m_DefaultStyleNamespace, "/cell/fg/color",
new Color(0.05f, 0.05f, 0.05f));
Color selectionColor = new Color(0.18f, 0.42f, 0.82f, 0.25f);
int fontSize = GetStyleInt(m_DefaultStyleNamespace, "/cell/font/size", 14);
for (int r = sr; r < er && r < m_RowHeights.Length; r++)
{
for (int c = sc; c < ec && c < m_ColWidths.Length; c++)
{
float cellX = GetColLeftX(c);
float cellY = -GetRowTopY(r);
float cellW = m_ColWidths[c];
float cellH = m_RowHeights[r];
XTableCellData data = m_TableData.GetCell(r, c);
// 选中高亮背景
if (r == m_SelectedRow && c == m_SelectedCol)
{
var selImg = m_Assembler.GetImage("cell_select");
selImg.Rect.anchoredPosition = new Vector2(cellX, cellY);
selImg.Rect.sizeDelta = new Vector2(cellW, cellH);
selImg.Image.color = selectionColor;
}
// 水平网格线
var hLine = m_Assembler.GetImage("cell_hline");
hLine.Rect.anchoredPosition = new Vector2(cellX, cellY - cellH);
hLine.Rect.sizeDelta = new Vector2(cellW, 1f);
hLine.Image.color = new Color(0.35f, 0.35f, 0.35f, 0.5f);
// 垂直网格线
var vLine = m_Assembler.GetImage("cell_vline");
vLine.Rect.anchoredPosition = new Vector2(cellX + cellW - 1, cellY);
vLine.Rect.sizeDelta = new Vector2(1f, cellH);
vLine.Image.color = new Color(0.35f, 0.35f, 0.35f, 0.5f);
if (data == null) continue;
// 文本:与 cell 同位置同尺寸,TMP 内部 Midline 对齐实现居中
if (!string.IsNullOrEmpty(data.Text))
{
var txt = m_Assembler.GetText("cell_text");
txt.Rect.anchoredPosition = new Vector2(cellX + 4, cellY);
txt.Rect.sizeDelta = new Vector2(cellW - 8, cellH);
txt.Text.text = data.Text;
txt.Text.color = fgColor;
txt.Text.fontSize = fontSize;
txt.Text.alignment = TextAlignmentOptions.Midline;
}
}
}
}
#endregion
#region 坐标计算
private float GetRowTopY(int row)
{
float y = 0;
for (int i = 0; i < row && i < m_RowHeights.Length; i++)
y += m_RowHeights[i];
return y;
}
private float GetColLeftX(int col)
{
float x = 0;
for (int i = 0; i < col && i < m_ColWidths.Length; i++)
x += m_ColWidths[i];
return x;
}
private void RecalculateTotalSize()
{
m_TotalWidth = 0;
if (m_ColWidths != null)
foreach (float w in m_ColWidths) m_TotalWidth += w;
m_TotalHeight = 0;
if (m_RowHeights != null)
foreach (float h in m_RowHeights) m_TotalHeight += h;
if (m_ScrollRect != null && m_ScrollRect.content != null)
m_ScrollRect.content.sizeDelta = new Vector2(m_TotalWidth, m_TotalHeight);
}
private void UpdateVisibleRect()
{
m_VisibleRect = new Rect(0, 0, m_TotalWidth, m_TotalHeight);
if (m_ScrollRect != null && m_ScrollRect.viewport != null)
{
RectTransform vt = m_ScrollRect.viewport;
RectTransform ct = m_ScrollRect.content;
if (vt != null && ct != null)
{
float sx = Mathf.Max(0, -ct.anchoredPosition.x);
float sy = Mathf.Max(0, ct.anchoredPosition.y);
m_VisibleRect = new Rect(sx, sy, vt.rect.width, vt.rect.height);
}
}
}
private void CalcVisibleRange(out int sr, out int er, out int sc, out int ec)
{
sr = 0; er = 0; sc = 0; ec = 0;
float accY = 0;
for (int r = 0; r < m_RowHeights.Length; r++)
{
float rowBottom = accY + m_RowHeights[r];
if (rowBottom > m_VisibleRect.y && accY < m_VisibleRect.yMax)
{
if (er == 0 && sr == 0) sr = r;
er = r + 1;
}
accY = rowBottom;
if (accY > m_VisibleRect.yMax) break;
}
float accX = 0;
for (int c = 0; c < m_ColWidths.Length; c++)
{
float colRight = accX + m_ColWidths[c];
if (colRight > m_VisibleRect.x && accX < m_VisibleRect.xMax)
{
if (ec == 0 && sc == 0) sc = c;
ec = c + 1;
}
accX = colRight;
if (accX > m_VisibleRect.xMax) break;
}
if (sr < 0) sr = 0;
if (sc < 0) sc = 0;
}
#endregion
#region 样式
private Color GetStyleColor(string ns, string path, Color fallback)
{
StyleValue val = StyleManager.GetValue(ns, path);
return val != null ? (Color)val : fallback;
}
private int GetStyleInt(string ns, string path, int fallback)
{
StyleValue val = StyleManager.GetValue(ns, path);
return val != null ? (int)val : fallback;
}
private void EnsureDefaultStyleNamespace()
{
if (!StyleManager.HasStyle(m_DefaultStyleNamespace, "/cell/bg/color"))
{
StyleManager.AddDynamicStyle(m_DefaultStyleNamespace, "/cell/bg/color",
new Color(0.18f, 0.18f, 0.18f));
StyleManager.AddDynamicStyle(m_DefaultStyleNamespace, "/cell/fg/color",
new Color(0.05f, 0.05f, 0.05f));
StyleManager.AddDynamicStyle(m_DefaultStyleNamespace, "/cell/font/size", 14);
StyleManager.ForceRefresh();
}
}
#endregion
#region 其他
private void MarkDirty(TableDirtyType type) => m_DirtyFlag.Mark(type);
private void OnScrollValueChanged(Vector2 _) => MarkDirty(TableDirtyType.ViewChanged);
#endregion
#region IPointerClickHandler
public void OnPointerClick(PointerEventData eventData)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform, eventData.position, eventData.pressEventCamera, out Vector2 localPoint);
// rectTransform 锚点在左上角,localPoint 以中心为原点
float tableX = localPoint.x - rectTransform.rect.xMin;
float tableY = rectTransform.rect.yMax - localPoint.y;
float scrollY = m_ScrollRect != null ? Mathf.Max(0, m_ScrollRect.content.anchoredPosition.y) : 0;
float scrollX = m_ScrollRect != null ? Mathf.Max(0, -m_ScrollRect.content.anchoredPosition.x) : 0;
tableX += scrollX;
tableY += scrollY;
int col = -1;
float ax = 0;
for (int c = 0; c < m_ColWidths.Length; c++)
{
if (tableX >= ax && tableX < ax + m_ColWidths[c]) { col = c; break; }
ax += m_ColWidths[c];
}
int row = -1;
float ay = 0;
for (int r = 0; r < m_RowHeights.Length; r++)
{
if (tableY >= ay && tableY < ay + m_RowHeights[r]) { row = r; break; }
ay += m_RowHeights[r];
}
if (row >= 0 && col >= 0)
{
SelectCell(row, col);
OnCellSelected?.Invoke(row, col);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13944afb118d5de499192b64114ec4f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: