Monday, October 16, 2023

MarkupExtension on a DataTrigger

I have a MarkupExtension called XBinding that enhances the concept of RelativeSource by implementing an XPath-like syntax to find the Source of the Binding. It works well when used on a control but fails when used on a DataTrigger. That's because the TargetObject provided in the ProvideValue method is the DataTrigger which is not part of the visual tree so I can't support relative XPaths.

I need to find the control that has the style that contains the data trigger. But you can't get from the data trigger to the host control. You can only get from the data trigger to the root control (window, user control, etc.) and you have to use reflection to do even that.

I have to wait until the root control has initialized and then search its children for one that uses a DataTrigger that is my DataTrigger. Once I have the target control I can find the source control and modify the binding on my DataTrigger. Except I can't because it is sealed at that point. So the trick is to create a new style based on the target controls current style, build a new data trigger based on the old data trigger, remove the old data trigger, add the new data trigger, and finally replace the control's style with the new style.

  • Can you give me an example?
  • Yes, I think I should.

Start a new Visual Studio project using C# and .Net Core. Call it SealedTrigger.

Change MainWindow.xaml to look like this. You don't need to change the code-behind.

<Window x:Class="SealedTrigger.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:SealedTrigger"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <TextBox Width="100" HorizontalAlignment="Left"/>
        <TextBlock Text="Red when TEST">
            <TextBlock.Style>
                <Style TargetType="TextBlock">
                    <Setter Property="Foreground" Value="Black"/>
                    <Style.Triggers>
                        <DataTrigger Binding="{local:MyMarkup Path=Text, Source=PreviousTextBox}" Value="TEST">
                            <Setter Property="Foreground" Value="Red"/>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </TextBlock.Style>
        </TextBlock>
    </StackPanel>
</Window>

The interesting part is the MyMarkup markup extension. In this highly contrived example it will find the previous text box and create a binding to its Text property.

Add a class called MyMarkup and change it to look like this.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Markup;
 
namespace SealedTrigger
{
    internal class MyMarkup : MarkupExtension
    {
        public String? Source { get; set; }
        public String? Path { get; set; }
 
        TextBox? previousTextBox = null;
        DependencyProperty? dp;
        DependencyObject? dep;
 
        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            IProvideValueTarget? pvt = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
            dp = pvt?.TargetProperty as DependencyProperty;
            dep = pvt?.TargetObject as DependencyObject;
            if (dep is DataTrigger)
            {
                // We only need to do this if the target object is a data trigger.
                // Normally target object is the control itself and rootObject is its ultimate parent. This example does not cover that condition
                // Either way we need to provide an event handler for the root object's loaded event.
                PropertyInfo? PI = pvt?.GetType().GetProperty("System.Xaml.IRootObjectProvider.RootObject", BindingFlags.Instance | BindingFlags.NonPublic);
                Control? rootObject = PI?.GetValue(pvt) as Control;
                if (rootObject != null) rootObject.Loaded += RootObject_Loaded;
            }
            return new Binding();
        }
 
        private void RootObject_Loaded(object? sender, RoutedEventArgs e)
        {
            if (Source == "PreviousTextBox")
            {
                ContentControl? rootObject = sender as ContentControl;
                List<FrameworkElement> frameworkElements = FindChildren(rootObject, IsRecursive: true).OfType<FrameworkElement>().ToList();
                foreach (FrameworkElement fe in frameworkElements)
                {
                    // previousTextBox will be the last text box before the control that contains this Markup Extension
                    if (fe is TextBox) previousTextBox = fe as TextBox;
 
                    Style? style = fe.Style;
                    if (style != null)
                    {
                        foreach (DataTrigger dt in style.Triggers.OfType<DataTrigger>().Where(dt => dt.Equals(dep)))
                        {
                            // style contains the data trigger that uses this markup extension and fe contains that style.
                            // Create a new style based on that style and apply it to fe
 
                            Style newStyle = new Style(fe.GetType(), style);
 
                            Binding b = new Binding(Path) { Source = previousTextBox };
                            DataTrigger newdt = new DataTrigger() { Binding = b, Value = dt.Value };
                            foreach (Setter setter in dt.Setters)
                                newdt.Setters.Add(setter);
 
                            newStyle.Triggers.Remove(dt);
                            newStyle.Triggers.Add(newdt);
                            fe.Style = newStyle;
                        }
                    }
                }
            }
        }
 
        public static System.Collections.Generic.List<DependencyObject> FindChildren(DependencyObject? depObj, bool IsRecursive = false)
        {
            PropertyInfo? PI;
            List<DependencyObject?> children = new List<DependencyObject?>();
            List<DependencyObject> result = new List<DependencyObject>();
 
            if (depObj != null)
            {
                PI = depObj.GetType().GetProperty("Content");
                if (PI != null) children.Add(PI.GetValue(depObj) as DependencyObject);
 
                PI = depObj.GetType().GetProperty("Children");
                if (PI != null)
                    children.AddRange((PI.GetValue(depObj) as UIElementCollection).Cast<DependencyObject>());
 
                foreach (DependencyObject? child in children)
                {
                    if (child != null)
                    {
                        result.Add(child);
                        if (IsRecursive)
                            result.AddRange(FindChildren(child, IsRecursive));
                    }
                }
            }
            return result;
        }
 
 
    }
}

This code creates a new binding to the previous text box's Text property. It builds a new DataTrigger using that binding, Attaches the DataTrigger to a new Style and finally replaces the style of the target control (the textblock).

If you enter TEST into the text box, the text of the TextBlock will turn red.


Now I need to enhance the XBinding markup extension to support this feature.


No comments:

Post a Comment