diff --git a/src/BootstrapBlazor/Components/Table/Table.razor b/src/BootstrapBlazor/Components/Table/Table.razor index 5c2bf9ccdf0..6070d7cbd3b 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor +++ b/src/BootstrapBlazor/Components/Table/Table.razor @@ -253,7 +253,7 @@ { if (col.Visible) { - + } } @if (ShowExtendButtons && !IsExtendButtonsInRowHeader) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index ea6ca48e781..15785795cbe 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -358,7 +358,7 @@ string GetFixedHeaderStyleString() => IsFixedHeader private string? GetLeftStyle(ITableColumn col) { var columns = GetVisibleColumns(); - var defaultWidth = 200; + var defaultWidth = DefaultFixedColumnWidth; var width = 0; var start = 0; var index = columns.IndexOf(col); diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 388fede7a16..94936491a1d 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -206,6 +206,15 @@ private string GetSortTooltip(ITableColumn col) => SortName != col.GetFieldName( private static string? GetColWidthString(int? width) => (width.HasValue && width.Value > 0) ? $"width: {width.Value}px;" : null; + private int? GetColWidth(TableColumnState state) + { + // 列状态中的宽度在首次构建列时生成,运行时动态设置 Fixed 后状态中的宽度可能为空 + // 固定列未设置宽度时使用默认固定列宽,保证固定列偏移量与实际列宽一致 + var col = Columns.Find(i => i.GetFieldName() == state.Name); + var width = state.Width ?? col?.Width; + return width ?? (col is { Fixed: true } ? DefaultFixedColumnWidth : null); + } + /// /// 获得/设置 滚动条宽度 默认 null 未设置使用 配置类中的 /// Gets or sets Scroll Width. Default null (Use ) @@ -1155,6 +1164,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var measureColumnMinWidth = _measureColumnMinWidth; var updateSortTooltip = _updateSortTooltip; var scrollToTop = _shouldScrollTop; + var fixColumnOffset = _fixColumnOffset; _invoke = false; _resetColumnListPopover = false; @@ -1163,6 +1173,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _measureColumnMinWidth = false; _updateSortTooltip = false; _shouldScrollTop = false; + _fixColumnOffset = false; await InvokeVoidAsync("updateTableState", Id, new { @@ -1177,7 +1188,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) AutoScrollLastSelectedRowToView, AutoScrollVerticalAlign = AutoScrollVerticalAlign.ToDescriptionString(), ScrollIntoViewBehavior = ScrollIntoViewBehavior.ToDescriptionString(), - ScrollToTop = scrollToTop + ScrollToTop = scrollToTop, + FixColumnOffset = fixColumnOffset }); } @@ -1322,6 +1334,10 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { + // 缓存运行时动态设置的固定列状态 + // 自动生成列模式下列实例每次渲染均会重新创建,重建后回放固定状态防止丢失 + var fixedColumns = Columns.Where(i => i.Fixed).Select(i => i.GetFieldName()).ToHashSet(StringComparer.Ordinal); + // 构建列信息 var cols = GetTableColumns(); @@ -1335,10 +1351,35 @@ private async Task BuildTableColumnsAsync() Columns.AddRange(cols.OrderFunc()); EnsureTemplateColumnFieldNames(); + // 回放运行时动态设置的固定列状态 + if (fixedColumns.Count != 0) + { + foreach (var col in Columns) + { + if (fixedColumns.Contains(col.GetFieldName())) + { + col.Fixed = true; + } + } + } + // 加载客户端持久化列状态 ResetTableColumns(); + + // 固定列集合发生变化时通知客户端脚本按实际渲染宽度修正固定列偏移量 + var fixedColumnNames = Columns.Where(i => i.Fixed).Select(i => i.GetFieldName()).ToHashSet(StringComparer.Ordinal); + if (!fixedColumnNames.SetEquals(_lastFixedColumnNames)) + { + _lastFixedColumnNames = fixedColumnNames; + _fixColumnOffset = true; + _invoke = true; + } } + private HashSet _lastFixedColumnNames = new(StringComparer.Ordinal); + + private bool _fixColumnOffset; + private void EnsureTemplateColumnFieldNames() { const string prefix = "bb_template_column_"; @@ -1404,6 +1445,11 @@ private void ResetTableColumns() { _visibleColumnsCache.Clear(); + // 固定列状态可能在运行时发生改变且自动生成列模式下列实例每次渲染均会重新创建 + // 清空缓存重新计算固定列边界,同时防止缓存按引用累积过期列实例 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); + if (_tableColumnStates.Count == 0) { // 重建缓存 diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 31a6c9649a2..16c12b0b766 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -34,6 +34,14 @@ export async function reset(id) { cancelAnimationFrame(table.minWidthRaf); table.minWidthRaf = null; } + if (table.fixedOffsetObserver) { + table.fixedOffsetObserver.disconnect(); + table.fixedOffsetObserver = null; + } + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + table.fixedOffsetRaf = null; + } table.autoColumns = []; table.columns = []; @@ -118,6 +126,10 @@ export async function reset(id) { if (table.thead) { setColSize(table, table.options); } + + // 修正固定列 sticky 偏移量并监听表格尺寸变化 + fixColumnOffset(table); + setFixedOffsetObserver(table); } export async function switchCardView(id) { @@ -147,6 +159,14 @@ const destroyTable = table => { if (table.minWidthRaf) { cancelAnimationFrame(table.minWidthRaf); } + if (table.fixedOffsetObserver) { + table.fixedOffsetObserver.disconnect(); + table.fixedOffsetObserver = null; + } + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + table.fixedOffsetRaf = null; + } if (table.thead) { EventHandler.off(table.body, 'scroll') } @@ -1107,6 +1127,10 @@ export async function updateTableState(id, options) { setColSize(table, options); } + if (options.fixColumnOffset) { + fixColumnOffset(table); + } + if (options.resetColumnListPopover) { resetColumnListPopover(table); } @@ -1176,7 +1200,8 @@ const setColSize = (table, options) => { return; } const th = headerCollection.find(i => i.getAttribute('data-bb-field') === col.name); - if (th === void 0) { + if (th === void 0 || th.classList.contains('fixed')) { + // skip fixed column which width is controlled by colgroup return; } const colIndex = headerCollection.indexOf(th); @@ -1206,6 +1231,78 @@ const applyColumnMinWidth = table => { } } +const setFixedOffsetObserver = table => { + if (!window.ResizeObserver || !table.tables || table.tables.length === 0) { + return; + } + table.fixedOffsetObserver = new ResizeObserver(() => { + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + } + table.fixedOffsetRaf = requestAnimationFrame(() => { + table.fixedOffsetRaf = null; + fixColumnOffset(table); + }); + }); + table.tables.forEach(t => table.fixedOffsetObserver.observe(t)); +} + +const fixColumnOffset = table => { + if (!table.tables || table.tables.length === 0) { + return; + } + + table.tables.forEach(t => { + if (isVisible(t) === false || t.querySelector('.fixed') === null) { + return; + } + + // 以首个完整行单元格的实际渲染宽度为基准重新计算固定列 sticky 偏移量 + // 自动布局下列实际渲染宽度可能大于服务端声明宽度,导致偏移量与实际列宽错位 + const cells = getOffsetBaseCells(t); + if (cells.length === 0) { + return; + } + + const leftOffsets = []; + const rightOffsets = []; + let left = 0; + cells.forEach((cell, index) => { + leftOffsets[index] = left; + left += getWidth(cell); + }); + let right = 0; + for (let index = cells.length - 1; index >= 0; index--) { + rightOffsets[index] = right; + right += getWidth(cells[index]); + } + + [...t.querySelectorAll('tr')].forEach(row => { + [...row.children].forEach((cell, index) => { + if (cell.classList.contains('fixed')) { + if (cell.classList.contains('fixed-right')) { + if (rightOffsets[index] !== void 0) { + cell.style.right = `${rightOffsets[index]}px`; + } + } + else if (leftOffsets[index] !== void 0) { + cell.style.left = `${leftOffsets[index]}px`; + } + } + }); + }); + }); +} + +const getOffsetBaseCells = t => { + const headerRow = t.querySelector('thead > tr'); + if (headerRow) { + return [...headerRow.children]; + } + const bodyRow = [...t.querySelectorAll('tbody > tr')].find(r => !r.classList.contains('is-detail')); + return bodyRow ? [...bodyRow.children] : []; +} + const setAutoColWidths = (table, apply) => { const colgroups = table.tables.map(t => t.querySelectorAll('colgroup col')); table.autoColumns.forEach(({ colIndex, minWidth }) => { diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 19678620191..0c96a2dc0a0 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -1889,6 +1889,105 @@ public void ColumnFixed_Ok(bool showExtendButton, bool isFixedHeader) } } + [Fact] + public void ColumnFixed_Dynamic_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.DefaultFixedColumnWidth, 200); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(Foo.Name), typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(Foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 未固定列时 col 元素不输出宽度 + cut.DoesNotContain("width: 200px;"); + + // 运行时动态设置前两列为固定列 + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + cut.Render(); + + // 固定列未设置宽度时使用默认固定列宽渲染 col 元素 + var cols = cut.FindAll("colgroup col"); + Assert.Equal(2, cols.Count); + Assert.All(cols, col => Assert.Contains("width: 200px;", col.GetAttribute("style"))); + + // 固定列单元格输出固定样式与偏移量 + cut.Contains("left: 0px;"); + cut.Contains("left: 200px;"); + + // 取消固定后恢复 + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + cut.Render(); + + cut.DoesNotContain("width: 200px;"); + cut.DoesNotContain("left: 200px;"); + } + + [Fact] + public void ColumnFixed_DynamicAutoGenerateColumns_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.AutoGenerateColumns, true); + pb.Add(a => a.DefaultFixedColumnWidth, 200); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时动态设置前两列为固定列 + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + cut.Render(); + + // 自动生成列模式下列实例每次渲染均会重新创建,固定状态应保留 + Assert.True(table.Instance.Columns[0].Fixed); + Assert.True(table.Instance.Columns[1].Fixed); + + // 未设置宽度的固定列使用默认固定列宽渲染 col 元素,已设置宽度的固定列保持原宽度 + var cols = cut.FindAll("colgroup col"); + Assert.Single(cols.Where(c => (c.GetAttribute("style") ?? "").Contains("width: 200px;"))); + Assert.Single(cols.Where(c => (c.GetAttribute("style") ?? "").Contains("width: 180px;"))); + + // 固定列单元格输出固定样式与偏移量 + cut.Contains("left: 0px;"); + cut.Contains("left: 180px;"); + + // 取消固定后恢复 + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + cut.Render(); + + Assert.False(table.Instance.Columns[0].Fixed); + cut.DoesNotContain("width: 200px;"); + cut.DoesNotContain("left: 180px;"); + } + [Fact] public void ColumnFixed_TailColumn_Ok() {