Skip to content

Conversation

@w-ahmad
Copy link
Owner

@w-ahmad w-ahmad commented Nov 22, 2025

Summary

This PR introduces a lightweight, XAML-friendly API to declare conditional cell styles for TableView. It enables declarative conditional formatting by allowing developers to attach one-or-more conditional styles to a table view (or a column) and have a matching Style applied to realized TableViewCell instances when the condition predicate returns true.

Key additions:

  • TableViewColumn.ConditionalCellStyles: Gets or sets the collection of conditional cell styles for the column.
  • TableView.ConditionalCellStyles: Gets or sets the collection of conditional cell styles.
  • TableViewConditionalCellStylesCollection: A collection of the conditional cell styles.
  • TableViewConditionalCellStyleContext: Context of the conditional cell style predicate.
  • TableViewConditionalCellStyle: Conditional cell style with a standard style and a predicate to determine the condition.

Example Usage

<tv:TableViewConditionalCellStylesCollection x:Key="ConditionalCellStyles">
    <tv:TableViewConditionalCellStyle Predicate="{Binding IsMultipleOfTen}">
        <Style TargetType="tv:TableViewCell">
            <Setter Property="Background" Value="#f5e6b5" />
            <Setter Property="Foreground" Value="Black" />
        </Style>
    </tv:TableViewConditionalCellStyle>
    <tv:TableViewConditionalCellStyle Predicate="{Binding IsMultipleOfFive}">
        <Style TargetType="tv:TableViewCell">
            <Setter Property="Background" Value="#6f0e82" />
            <Setter Property="Foreground" Value="White" />
        </Style>
    </tv:TableViewConditionalCellStyle>
</tv:TableViewConditionalCellStylesCollection>

Predicates for above conditional styles

public static Predicate<TableViewConditionalCellStyleContext> IsMultipleOfFive => 
    static context => context is { DataItem: ExampleModel { } model } && model.Id % 5 == 0;

public static Predicate<TableViewConditionalCellStyleContext> IsMultipleOfTen =>
    static context => context is { DataItem: ExampleModel { } model } && model.Id % 10 == 0;

conditional cell styling

@w-ahmad
Copy link
Owner Author

w-ahmad commented Nov 23, 2025

With this feature, the TableView can even achieve this look using some complex conditional styling.
image

@w-ahmad w-ahmad requested a review from Copilot November 27, 2025 19:28
Copilot finished reviewing on behalf of w-ahmad November 27, 2025 19:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a declarative conditional cell styling API for the TableView control, enabling developers to apply different styles to cells based on custom predicates. The feature allows column-level and table-level conditional styles with automatic style application during cell realization and updates on property changes.

Key Changes

  • Added TableViewConditionalCellStyle API with Predicate and Style properties for declarative conditional formatting
  • Implemented automatic style evaluation in TableViewCell.EnsureStyle() with priority: column conditional styles → table conditional styles → default styles
  • Modified XAML template to separate background rendering into dedicated BackgroundBorder for proper conditional style application

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/TableViewConditionalCellStyle.cs New API classes: TableViewConditionalCellStyle, TableViewConditionalCellStyleContext, and TableViewConditionalCellStylesCollection
src/Collections/DependencyObjectCollection.cs Generic wrapper for strongly-typed DependencyObjectCollection (Windows-only)
src/TableView.Properties.cs Added ConditionalCellStyles dependency property for table-level conditional styles
src/Columns/TableViewColumn.cs Added ConditionalCellStyles dependency property for column-level conditional styles
src/TableViewCell.cs Implemented EnsureStyle() method for evaluating and applying conditional styles; removed unused import
src/TableViewRow.cs Refactored to use EnsureStyle() instead of direct style assignment; simplified RowPresenter property
src/TableViewRowPresenter.cs Added EnsureStyle() call during cell insertion
src/TableView.cs Added OnItemPropertyChanged() handler to refresh cell styles when data properties change
src/ItemsSource/CollectionView.cs Exposed ItemPropertyChanged event for external subscribers
src/ItemsSource/CollectionView.Events.cs Added ItemPropertyChanged event declaration
src/Themes/TableViewCell.xaml Separated background into dedicated BackgroundBorder element for proper conditional style support

/// <summary>
/// Represents a collection of <see cref="TableViewConditionalCellStyle"/> for a TableView.
/// </summary>
public partial class TableViewConditionalCellStylesCollection : DependencyObjectCollection<TableViewConditionalCellStyle>;
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DependencyObjectCollection<T> base class is only available on Windows (wrapped in #if WINDOWS in DependencyObjectCollection.cs), but TableViewConditionalCellStylesCollection inherits from it without platform guards. This will cause compilation failures on Uno Platform targets (net8.0, net9.0).

The collection definition should either:

  1. Be wrapped in #if WINDOWS with an alternative implementation for non-Windows platforms, or
  2. The DependencyObjectCollection<T> base class should be made available on all platforms

Looking at the codebase, TableViewColumnsCollection directly inherits from the base DependencyObjectCollection (without generic), which is available on all platforms through WinUI/Uno. Consider following the same pattern or providing a non-Windows implementation.

Suggested change
public partial class TableViewConditionalCellStylesCollection : DependencyObjectCollection<TableViewConditionalCellStyle>;
public partial class TableViewConditionalCellStylesCollection : DependencyObjectCollection;

Copilot uses AI. Check for mistakes.
Comment on lines 261 to 263
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableView), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));

/// <summary>
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new collection instance in PropertyMetadata is problematic because this default value is shared across all instances of TableView. This means if no explicit collection is set, all TableView instances will share the same collection object, leading to unexpected behavior where conditional styles added to one TableView appear in all others.

The correct pattern is to initialize the collection in the getter if it's null, or create a new instance per object. See how WPF/WinUI typically handles collection properties - they should be initialized lazily per instance, not shared via PropertyMetadata.

Example fix:

public static readonly DependencyProperty ConditionalCellStylesProperty = 
    DependencyProperty.Register(nameof(ConditionalCellStyles), 
        typeof(IList<TableViewConditionalCellStyle>), 
        typeof(TableView), 
        new PropertyMetadata(null));

public IList<TableViewConditionalCellStyle> ConditionalCellStyles
{
    get 
    {
        var value = (IList<TableViewConditionalCellStyle>?)GetValue(ConditionalCellStylesProperty);
        if (value is null)
        {
            value = new TableViewConditionalCellStylesCollection();
            SetValue(ConditionalCellStylesProperty, value);
        }
        return value;
    }
    set => SetValue(ConditionalCellStylesProperty, value);
}
Suggested change
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableView), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
/// <summary>
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableView), new PropertyMetadata(null));
/// <summary>
/// Gets or sets the collection of conditional cell styles for this TableView instance.
/// </summary>
public IList<TableViewConditionalCellStyle> ConditionalCellStyles
{
get
{
var value = (IList<TableViewConditionalCellStyle>?)GetValue(ConditionalCellStylesProperty);
if (value is null)
{
value = new TableViewConditionalCellStylesCollection();
SetValue(ConditionalCellStylesProperty, value);
}
return value;
}
set => SetValue(ConditionalCellStylesProperty, value);
}
/// <summary>

Copilot uses AI. Check for mistakes.
/// <summary>
/// Identifies the <see cref="ConditionalCellStyles"/> dependency property.
/// </summary>
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableViewColumn), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new collection instance in PropertyMetadata is problematic because this default value is shared across all instances of TableViewColumn. This means if no explicit collection is set, all columns will share the same collection object, leading to unexpected behavior where conditional styles added to one column appear in all others.

The correct pattern is to initialize the collection in the getter if it's null, or create a new instance per object. Collection properties should be initialized lazily per instance, not shared via PropertyMetadata.

Example fix:

public static readonly DependencyProperty ConditionalCellStylesProperty = 
    DependencyProperty.Register(nameof(ConditionalCellStyles), 
        typeof(IList<TableViewConditionalCellStyle>), 
        typeof(TableViewColumn), 
        new PropertyMetadata(null));

public IList<TableViewConditionalCellStyle> ConditionalCellStyles
{
    get 
    {
        var value = (IList<TableViewConditionalCellStyle>?)GetValue(ConditionalCellStylesProperty);
        if (value is null)
        {
            value = new TableViewConditionalCellStylesCollection();
            SetValue(ConditionalCellStylesProperty, value);
        }
        return value;
    }
    set => SetValue(ConditionalCellStylesProperty, value);
}
Suggested change
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableViewColumn), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(
nameof(ConditionalCellStyles),
typeof(IList<TableViewConditionalCellStyle>),
typeof(TableViewColumn),
new PropertyMetadata(null));
/// <summary>
/// Gets or sets the collection of conditional cell styles for this column.
/// </summary>
public IList<TableViewConditionalCellStyle> ConditionalCellStyles
{
get
{
var value = (IList<TableViewConditionalCellStyle>?)GetValue(ConditionalCellStylesProperty);
if (value is null)
{
value = new TableViewConditionalCellStylesCollection();
SetValue(ConditionalCellStylesProperty, value);
}
return value;
}
set => SetValue(ConditionalCellStylesProperty, value);
}

Copilot uses AI. Check for mistakes.
Comment on lines 540 to 545
IList<TableViewConditionalCellStyle> cellStyles = [
.. Column?.ConditionalCellStyles ?? [], // Column styles have first priority
.. TableView?.ConditionalCellStyles ?? []]; // TableView styles have second priority

Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column!, item)) is true)?
.Style ?? Column?.CellStyle ?? TableView?.CellStyle;
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential NullReferenceException when Column is null. While the null-forgiving operator ! is used on line 544, if Column is null at this point, the predicate invocation will throw an exception.

Although the predicate can handle a null Column in the context parameter, using the null-forgiving operator without a prior null check is risky. Consider either:

  1. Checking if Column is null before invoking predicates that depend on it
  2. Short-circuiting when Column is null and falling back to default styles

For example:

if (Column is null)
{
    Style = TableView?.CellStyle;
    return;
}

IList<TableViewConditionalCellStyle> cellStyles = [
    .. Column.ConditionalCellStyles ?? [], 
    .. TableView?.ConditionalCellStyles ?? []];

Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column, item)) is true)?
                  .Style ?? Column.CellStyle ?? TableView?.CellStyle;
Suggested change
IList<TableViewConditionalCellStyle> cellStyles = [
.. Column?.ConditionalCellStyles ?? [], // Column styles have first priority
.. TableView?.ConditionalCellStyles ?? []]; // TableView styles have second priority
Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column!, item)) is true)?
.Style ?? Column?.CellStyle ?? TableView?.CellStyle;
if (Column is null)
{
Style = TableView?.CellStyle;
return;
}
IList<TableViewConditionalCellStyle> cellStyles = [
.. Column.ConditionalCellStyles ?? [], // Column styles have first priority
.. TableView?.ConditionalCellStyles ?? []]; // TableView styles have second priority
Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column, item)) is true)?
.Style ?? Column.CellStyle ?? TableView?.CellStyle;

Copilot uses AI. Check for mistakes.
/// <summary>
/// Identifies the <see cref="ConditionalCellStyles"/> dependency property.
/// </summary>
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableViewColumn), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing property changed callback for ConditionalCellStyles. When the collection is changed or replaced, existing cells in this column won't be updated with the new conditional styles until the next property change on the data item or row recreation.

Consider adding a property changed callback similar to other column properties that affect cell rendering:

public static readonly DependencyProperty ConditionalCellStylesProperty = 
    DependencyProperty.Register(nameof(ConditionalCellStyles), 
        typeof(IList<TableViewConditionalCellStyle>), 
        typeof(TableViewColumn), 
        new PropertyMetadata(null, OnConditionalCellStylesChanged));

private static void OnConditionalCellStylesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is TableViewColumn column && column.TableView is { } tableView)
    {
        // Update cells in this column for all visible rows
        foreach (var item in tableView.Items)
        {
            if (tableView.ContainerFromItem(item) is TableViewRow row)
            {
                row.EnsureCellsStyle(column, item);
            }
        }
    }
}
Suggested change
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableViewColumn), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
public static readonly DependencyProperty ConditionalCellStylesProperty =
DependencyProperty.Register(
nameof(ConditionalCellStyles),
typeof(IList<TableViewConditionalCellStyle>),
typeof(TableViewColumn),
new PropertyMetadata(new TableViewConditionalCellStylesCollection(), OnConditionalCellStylesChanged));
private static void OnConditionalCellStylesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TableViewColumn column && column.TableView is { } tableView)
{
foreach (var item in tableView.Items)
{
if (tableView.ContainerFromItem(item) is TableViewRow row)
{
row.EnsureCellsStyle(column, item);
}
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines 540 to 545
IList<TableViewConditionalCellStyle> cellStyles = [
.. Column?.ConditionalCellStyles ?? [], // Column styles have first priority
.. TableView?.ConditionalCellStyles ?? []]; // TableView styles have second priority

Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column!, item)) is true)?
.Style ?? Column?.CellStyle ?? TableView?.CellStyle;
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new IList with collection expressions and spread operators on every EnsureStyle call could impact performance, especially for large tables with many cells. This method is called frequently during cell virtualization and property changes.

Consider optimizing by:

  1. Caching the combined collection when conditional styles don't change frequently
  2. Using Concat with FirstOrDefault to avoid materializing the full list when an early match is found

Example optimization:

internal void EnsureStyle(object? item)
{
    var context = new TableViewConditionalCellStyleContext(Column!, item);
    
    // Check column styles first (higher priority)
    var matchingStyle = Column?.ConditionalCellStyles?
        .FirstOrDefault(c => c.Predicate?.Invoke(context) is true)?.Style;
    
    // If no match, check TableView styles
    matchingStyle ??= TableView?.ConditionalCellStyles?
        .FirstOrDefault(c => c.Predicate?.Invoke(context) is true)?.Style;
    
    // Fall back to default styles
    Style = matchingStyle ?? Column?.CellStyle ?? TableView?.CellStyle;
}
Suggested change
IList<TableViewConditionalCellStyle> cellStyles = [
.. Column?.ConditionalCellStyles ?? [], // Column styles have first priority
.. TableView?.ConditionalCellStyles ?? []]; // TableView styles have second priority
Style = cellStyles.FirstOrDefault(c => c.Predicate?.Invoke(new(Column!, item)) is true)?
.Style ?? Column?.CellStyle ?? TableView?.CellStyle;
var context = new TableViewConditionalCellStyleContext(Column!, item);
var matchingStyle =
(Column?.ConditionalCellStyles ?? Enumerable.Empty<TableViewConditionalCellStyle>())
.Concat(TableView?.ConditionalCellStyles ?? Enumerable.Empty<TableViewConditionalCellStyle>())
.FirstOrDefault(c => c.Predicate?.Invoke(context) is true)?.Style;
Style = matchingStyle ?? Column?.CellStyle ?? TableView?.CellStyle;

Copilot uses AI. Check for mistakes.
/// </summary>
/// <param name="Column">The column associated with the cell.</param>
/// <param name="DataItem">The data item represented by the cell.</param>
public partial record struct TableViewConditionalCellStyleContext(TableViewColumn Column, object? DataItem);
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Column parameter in TableViewConditionalCellStyleContext is defined as non-nullable (TableViewColumn Column), but in EnsureStyle it's being passed with a null-forgiving operator (Column!). This creates an inconsistency - the context struct signature promises a non-null column, but null values can still be passed.

This could mislead consumers of the API who might not expect to handle null columns in their predicates. Consider either:

  1. Making the Column parameter nullable in the context: TableViewColumn? Column
  2. Ensuring Column is never null when calling EnsureStyle and adding appropriate guards

Option 1 is safer and more explicit:

public partial record struct TableViewConditionalCellStyleContext(TableViewColumn? Column, object? DataItem);
Suggested change
public partial record struct TableViewConditionalCellStyleContext(TableViewColumn Column, object? DataItem);
public partial record struct TableViewConditionalCellStyleContext(TableViewColumn? Column, object? DataItem);

Copilot uses AI. Check for mistakes.
Comment on lines 261 to 263
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableView), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));

/// <summary>
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing property changed callback for ConditionalCellStyles. When the collection is changed or replaced, existing cells won't be updated with the new conditional styles until the next property change on the data item or row recreation.

Consider adding a property changed callback to update all visible cells when the collection changes:

public static readonly DependencyProperty ConditionalCellStylesProperty = 
    DependencyProperty.Register(nameof(ConditionalCellStyles), 
        typeof(IList<TableViewConditionalCellStyle>), 
        typeof(TableView), 
        new PropertyMetadata(null, OnConditionalCellStylesChanged));

private static void OnConditionalCellStylesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is TableView tableView)
    {
        // Update all visible rows
        foreach (var item in tableView.Items)
        {
            if (tableView.ContainerFromItem(item) is TableViewRow row)
            {
                row.EnsureCellsStyle(default, item);
            }
        }
    }
}

The same applies to TableViewColumn.ConditionalCellStyles - changes to the column's conditional styles should trigger updates for cells in that column.

Suggested change
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(nameof(ConditionalCellStyles), typeof(IList<TableViewConditionalCellStyle>), typeof(TableView), new PropertyMetadata(new TableViewConditionalCellStylesCollection()));
/// <summary>
public static readonly DependencyProperty ConditionalCellStylesProperty = DependencyProperty.Register(
nameof(ConditionalCellStyles),
typeof(IList<TableViewConditionalCellStyle>),
typeof(TableView),
new PropertyMetadata(new TableViewConditionalCellStylesCollection(), OnConditionalCellStylesChanged));
/// <summary>
/// Called when the ConditionalCellStyles property changes.
/// </summary>
private static void OnConditionalCellStylesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TableView tableView)
{
foreach (var item in tableView.Items)
{
if (tableView.ContainerFromItem(item) is TableViewRow row)
{
row.EnsureCellsStyle(default, item);
}
}
}
}
/// <summary>

Copilot uses AI. Check for mistakes.
@w-ahmad w-ahmad merged commit 84042b2 into main Nov 27, 2025
1 check passed
@w-ahmad w-ahmad deleted the feat/conditional_styling branch November 27, 2025 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants