diff --git a/src/EventArgs/TableViewStateChangedEventArgs.cs b/src/EventArgs/TableViewStateChangedEventArgs.cs new file mode 100644 index 00000000..7c442b1e --- /dev/null +++ b/src/EventArgs/TableViewStateChangedEventArgs.cs @@ -0,0 +1,23 @@ +using System; + +namespace WinUI.TableView; + +/// +/// Provides data for the event. +/// +public class TableViewStateChangedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The kind of change that triggered the event. + public TableViewStateChangedEventArgs(TableViewStateChangedKind kind) + { + Kind = kind; + } + + /// + /// Gets the kind of change that triggered the event. + /// + public TableViewStateChangedKind Kind { get; } +} diff --git a/src/EventArgs/TableViewStateChangedKind.cs b/src/EventArgs/TableViewStateChangedKind.cs new file mode 100644 index 00000000..7c9c5410 --- /dev/null +++ b/src/EventArgs/TableViewStateChangedKind.cs @@ -0,0 +1,22 @@ +namespace WinUI.TableView; + +/// +/// Identifies the kind of change that raised a event. +/// +public enum TableViewStateChangedKind +{ + /// + /// One or more sort descriptions were added, removed, or cleared. + /// + Sort, + + /// + /// One or more filter descriptions were added, removed, or cleared. + /// + Filter, + + /// + /// A column was reordered, or its width or visibility changed. + /// + Column, +} diff --git a/src/Helpers/TableViewStateHelper.cs b/src/Helpers/TableViewStateHelper.cs new file mode 100644 index 00000000..cb832dae --- /dev/null +++ b/src/Helpers/TableViewStateHelper.cs @@ -0,0 +1,317 @@ +using Microsoft.UI.Xaml; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace WinUI.TableView.Helpers; + +/// +/// Provides methods to capture and apply snapshots, +/// enabling persistence of a 's sort, filter, and column layout state. +/// +/// +/// This helper operates exclusively on state that can be round-tripped through serialization. +/// Runtime-only constructs (such as ) are intentionally +/// excluded — see the filter-related methods for details. Serialization itself (e.g. JSON) is +/// the responsibility of the consuming application. +/// +internal static class TableViewStateHelper +{ + /// + /// Captures the current sort, filter, and column layout state of + /// into a new instance. + /// + /// The table view whose state should be captured. + /// A representing the current state. + internal static TableViewState Capture(TableView tableView) + { + ArgumentNullException.ThrowIfNull(tableView); + + var state = new TableViewState(); + CaptureSort(tableView, state); + CaptureFilter(tableView, state); + CaptureColumns(tableView, state); + return state; + } + + /// + /// Applies a previously captured to , + /// restoring its sort, filter, and column layout state. + /// Columns are restored first so that ordering is correct before sort and filter are applied. + /// Unrecognised column keys are silently skipped; a + /// is a no-op. + /// + /// The target table view. + /// The state to apply, or to skip. + internal static void Apply(TableView tableView, TableViewState? state) + { + ArgumentNullException.ThrowIfNull(tableView); + + if (state is null) + { + return; + } + + ApplyColumns(tableView, state.Columns); + ApplySort(tableView, state.SortDescriptions); + ApplyFilter(tableView, state.FilterDescriptions); + } + + // ── Sort ────────────────────────────────────────────────────────────────── + + private static void CaptureSort(TableView tableView, TableViewState state) + { + foreach (var sd in tableView.SortDescriptions) + { + state.SortDescriptions.Add(new TableViewSortDescriptionState + { + PropertyName = sd.PropertyName, + Direction = sd.Direction, + }); + } + } + + private static void ApplySort(TableView tableView, IEnumerable sortDescriptions) + { + tableView.SortDescriptions.Clear(); + + foreach (var sd in sortDescriptions) + { + if (string.IsNullOrWhiteSpace(sd.PropertyName)) + { + continue; + } + + tableView.SortDescriptions.Add(new SortDescription(sd.PropertyName, sd.Direction)); + } + } + + // ── Filter ──────────────────────────────────────────────────────────────── + // + // FilterDescription.Predicate is a runtime-only lambda and cannot be serialized. + // Only the user-selected values from FilterHandler.SelectedValues are captured (as strings). + // On restore, the predicate is reconstructed by calling FilterHandler.ApplyFilter, + // which rebuilds it from the restored SelectedValues. + + private static void CaptureFilter(TableView tableView, TableViewState state) + { + foreach (var fd in tableView.FilterDescriptions) + { + // ColumnFilterDescription (internal) carries a direct column reference; use it + // when available to avoid the slower property-name lookup. + var column = fd is ColumnFilterDescription cfd + ? cfd.Column + : FindColumnByPropertyName(tableView, fd.PropertyName); + + if (column is null) + { + continue; + } + + var filterState = new TableViewFilterDescriptionState + { + ColumnKey = GetColumnKey(column), + }; + + if (tableView.FilterHandler.SelectedValues.TryGetValue(column, out var selectedValues)) + { + foreach (var value in selectedValues) + { + filterState.SelectedValues.Add(value?.ToString()); + } + } + + state.FilterDescriptions.Add(filterState); + } + } + + private static void ApplyFilter(TableView tableView, IEnumerable filterDescriptions) + { + // Clear all existing column filters before restoring. + tableView.FilterHandler?.ClearFilter(null); + + foreach (var filterState in filterDescriptions) + { + if (string.IsNullOrWhiteSpace(filterState.ColumnKey) || filterState.SelectedValues.Count == 0) + { + continue; + } + + var column = FindColumnByKey(tableView, filterState.ColumnKey); + if (column is null) + { + continue; + } + + var targetType = FindColumnValueType(tableView, column); + var selectedValues = filterState.SelectedValues + .Select(v => ConvertFromString(v, targetType)) + .ToList(); + + tableView.FilterHandler.SelectedValues[column] = selectedValues; + tableView.FilterHandler.ApplyFilter(column); + } + } + + // ── Columns ─────────────────────────────────────────────────────────────── + + private static void CaptureColumns(TableView tableView, TableViewState state) + { + for (var index = 0; index < tableView.Columns.Count; index++) + { + var column = tableView.Columns[index]; + state.Columns.Add(new TableViewColumnState + { + Key = GetColumnKey(column), + Header = column.Header?.ToString(), + Visibility = column.Visibility, + DisplayIndex = index, + WidthValue = column.Width.Value, + WidthUnitType = column.Width.GridUnitType, + }); + } + } + + private static void ApplyColumns(TableView tableView, IEnumerable columns) + { + foreach (var columnState in columns.OrderBy(c => c.DisplayIndex)) + { + var column = FindColumnByKey(tableView, columnState.Key); + if (column is null) + { + continue; + } + + // Only restore the header if a non-empty string was saved, to avoid overwriting + // complex header objects (e.g. DataTemplates set by code) with a plain string. + if (!string.IsNullOrWhiteSpace(columnState.Header)) + { + column.Header = columnState.Header; + } + + column.Visibility = columnState.Visibility; + column.Width = new GridLength(columnState.WidthValue, columnState.WidthUnitType); + + var currentIndex = tableView.Columns.IndexOf(column); + var targetIndex = Math.Clamp(columnState.DisplayIndex, 0, tableView.Columns.Count - 1); + if (currentIndex >= 0 && currentIndex != targetIndex) + { + tableView.Columns.Move(currentIndex, targetIndex); + } + } + } + + // ── Key resolution ───────────────────────────────────────────────────────── + + /// + /// Returns the stable key used to identify a column across sessions. + /// + private static string GetColumnKey(TableViewColumn column) + { + // For bound columns the binding property path is the primary key because it is + // semantically tied to the data model: if the binding changes, the column represents + // different data and a state miss is the correct outcome. + // Edge case: if two bound columns share the same property path, set Tag on both + // to disambiguate; Tag takes precedence in that case (see the fallback below). + if (column is TableViewBoundColumn boundColumn + && !string.IsNullOrWhiteSpace(boundColumn.PropertyPath)) + { + return boundColumn.PropertyPath!; + } + + // For template columns (which have no binding), Tag is the primary key — it is + // developer-assigned and intentional. Changing the Tag signals a deliberate identity + // change, so a state miss is the correct outcome. + // Header is a last resort and is fragile (may be localised or display-renamed + // without any change to the column's purpose). + return column.Tag?.ToString() + ?? column.Header?.ToString() + ?? string.Empty; + } + + private static TableViewColumn? FindColumnByKey(TableView tableView, string? key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + return tableView.Columns + .FirstOrDefault(c => string.Equals(GetColumnKey(c), key, StringComparison.OrdinalIgnoreCase)); + } + + private static TableViewColumn? FindColumnByPropertyName(TableView tableView, string? propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + return null; + } + + foreach (var column in tableView.Columns) + { + if (column is TableViewBoundColumn boundColumn + && string.Equals(boundColumn.PropertyPath, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return column; + } + + if (string.Equals(column.SortMemberPath, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return column; + } + } + + return null; + } + + // ── Filter value type resolution ─────────────────────────────────────────── + + private static Type? FindColumnValueType(TableView tableView, TableViewColumn column) + { + if (tableView.ItemsSource is not IEnumerable items) + { + return null; + } + + foreach (var item in items) + { + var value = column.GetCellContent(item); + if (value is not null) + { + return value.GetType(); + } + } + + return null; + } + + private static object? ConvertFromString(string? value, Type? targetType) + { + if (value is null) + { + return null; + } + + if (targetType is null || targetType == typeof(string)) + { + return value; + } + + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlying == typeof(bool) && bool.TryParse(value, out var boolVal)) return boolVal; + if (underlying == typeof(int) && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) return intVal; + if (underlying == typeof(long) && long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longVal)) return longVal; + if (underlying == typeof(short) && short.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var shortVal)) return shortVal; + if (underlying == typeof(float) && float.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var floatVal)) return floatVal; + if (underlying == typeof(double) && double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleVal)) return doubleVal; + if (underlying == typeof(decimal) && decimal.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var decimalVal)) return decimalVal; + if (underlying == typeof(Guid) && Guid.TryParse(value, out var guidVal)) return guidVal; + if (underlying == typeof(DateTime) && DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTimeVal)) return dateTimeVal; + if (underlying.IsEnum && Enum.TryParse(underlying, value, ignoreCase: true, out var enumVal)) return enumVal; + + return value; + } +} diff --git a/src/TableView.Events.cs b/src/TableView.Events.cs index 47868c32..23dbc5b9 100644 --- a/src/TableView.Events.cs +++ b/src/TableView.Events.cs @@ -1,6 +1,8 @@ using Microsoft.UI.Xaml; using System; +using System.Collections.Specialized; using System.ComponentModel; +using WinUI.TableView.Helpers; namespace WinUI.TableView; @@ -79,6 +81,39 @@ protected virtual void OnPasteFromClipboard(TableViewPasteFromClipboardEventArgs PasteFromClipboard?.Invoke(this, args); } + /// + /// Occurs when the sort order, active filters, or column layout (width, visibility, or order) + /// of the table view changes due to a user action. + /// Consumers can handle this event to persist and (later) restore the state via + /// + public event EventHandler? StateChanged; + + private void RaiseStateChanged(TableViewStateChangedKind kind) + { + StateChanged?.Invoke(this, new TableViewStateChangedEventArgs(kind)); + } + + private void OnColumnsPropertyChangedForState(object? sender, TableViewColumnPropertyChangedEventArgs e) + { + // Width and Visibility are user-driven layout choices that should be persisted. + // Other property changes (ActualWidth, CellStyle, IsFrozen, etc.) are rendering or + // code-controlled concerns and do not represent a user state change. + if (e.PropertyName is nameof(TableViewColumn.Width) or nameof(TableViewColumn.Visibility)) + { + RaiseStateChanged(TableViewStateChangedKind.Column); + } + } + + private void OnColumnsCollectionChangedForState(object? sender, NotifyCollectionChangedEventArgs e) + { + // Only column reorder (Move) is treated as a state change; Add and Remove are + // structural setup actions driven by code, not user-driven layout adjustments. + if (e.Action == NotifyCollectionChangedAction.Move) + { + RaiseStateChanged(TableViewStateChangedKind.Column); + } + } + /// /// Event triggered when the IsReadOnly property changes. /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 8b6fc070..dcf48231 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -338,6 +338,16 @@ public bool AllowLiveShaping set => _collectionView.AllowLiveShaping = value; } + /// + /// Gets or sets the current (run-time) state of the TableView, which includes sort, filter, and column layout + /// into a single serializable object of type . This can be used for persisting and restoring the state of the TableView. + /// + public TableViewState State + { + get => TableViewStateHelper.Capture(this); + set => TableViewStateHelper.Apply(this, value); + } + /// /// Gets or sets the last selection unit used. /// diff --git a/src/TableView.cs b/src/TableView.cs index e9ee5e50..54f550e3 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; @@ -71,6 +72,12 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; + + // Wire StateChanged to sort, filter and to column property / reorder changes + ((INotifyCollectionChanged)SortDescriptions).CollectionChanged += (_, _) => RaiseStateChanged(TableViewStateChangedKind.Sort); + ((INotifyCollectionChanged)FilterDescriptions).CollectionChanged += (_, _) => RaiseStateChanged(TableViewStateChangedKind.Filter); + Columns.ColumnPropertyChanged += OnColumnsPropertyChangedForState; + Columns.CollectionChanged += OnColumnsCollectionChangedForState; } /// diff --git a/src/TableViewState.cs b/src/TableViewState.cs new file mode 100644 index 00000000..e6a0e911 --- /dev/null +++ b/src/TableViewState.cs @@ -0,0 +1,100 @@ +using Microsoft.UI.Xaml; +using System.Collections.Generic; +using WinUI.TableView.Helpers; + +namespace WinUI.TableView; + +/// +/// Represents a captured snapshot of a 's sort, filter, and column +/// layout state that can be persisted and restored via . +/// +public sealed class TableViewState +{ + /// + /// Gets or sets the sort descriptions captured from the table view. + /// + public List SortDescriptions { get; set; } = []; + + /// + /// Gets or sets the filter descriptions captured from the table view. + /// + public List FilterDescriptions { get; set; } = []; + + /// + /// Gets or sets the column layout captured from the table view. + /// + public List Columns { get; set; } = []; +} + +/// +/// Represents the persisted state of a sort description. +/// +public sealed class TableViewSortDescriptionState +{ + /// + /// Gets or sets the name of the property to sort by. + /// + public string? PropertyName { get; set; } + + /// + /// Gets or sets the sort direction. + /// + public SortDirection Direction { get; set; } +} + +/// +/// Represents the persisted state of a column filter. +/// +/// +/// Only the user-selected values are persisted, not the predicate. The predicate is +/// reconstructed from the selected values when the state is applied. +/// +public sealed class TableViewFilterDescriptionState +{ + /// + /// Gets or sets the key that identifies the column this filter applies to. + /// + public string? ColumnKey { get; set; } + + /// + /// Gets or sets the user-selected filter values, serialized as strings. + /// A entry represents a blank (null or empty) data value. + /// + public List SelectedValues { get; set; } = []; +} + +/// +/// Represents the persisted layout state of a single column. +/// +public sealed class TableViewColumnState +{ + /// + /// Gets or sets the key that identifies the column. + /// + public string? Key { get; set; } + + /// + /// Gets or sets the header text of the column as it was when captured. + /// + public string? Header { get; set; } + + /// + /// Gets or sets the visibility of the column. + /// + public Visibility Visibility { get; set; } = Visibility.Visible; + + /// + /// Gets or sets the zero-based display index (order) of the column. + /// + public int DisplayIndex { get; set; } + + /// + /// Gets or sets the numeric value of the column width. + /// + public double WidthValue { get; set; } + + /// + /// Gets or sets the unit type of the column width. + /// + public GridUnitType WidthUnitType { get; set; } = GridUnitType.Auto; +}