Monday, November 30, 2020

Make Markup Extensions update like Bindings

 If you've ever written a Markup Extension you know that ProvideValue is only called once. So how do you update target values when the source changes later? The {Binding} markup extension can do it and so can we. The trick is to subscribe to the page's PropertyChanged event, detect if the change is relevant, and update the dependency property's value.

In this walkthrough we will write a Markup Extension that will provide a UI for an "account structure" which is a series of text boxes and labels that change depending on whether we are dealing with a "domestic" or "international" account. We could have achieved the same result with simple bindings (with a LOT more XAML) or with some custom controls (which is the correct solution).

Here are a couple of screen prints to show you how it is supposed to work.



The intent is that by merely changing the selected account structure type, we can get labels and text boxes to change properties including visibility.

We will start by implementing a simple Markup Extension that does not respond to changes to the source values.

Start a new C#, Core 3.1, WPF Visual Studio project called AccountStructureExtension. The XAML for the MainWindow contains a combo box and a grid of labels and text boxes.

<Window x:Class="AccountStructureExtension.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:AccountStructureExtension"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="Account Structure" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <ComboBox ItemsSource="{Binding AccountStructures}" SelectedItem="{Binding AccountStructure}" DisplayMemberPath="AccountStructureType"/>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="auto"/>
                <RowDefinition Height="auto"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
            </Grid.ColumnDefinitions>
 
            <TextBlock Grid.Row="0" Grid.Column="0" Text="{local:AccountSection 1}" Visibility="{local:AccountSection 1}"/>
            <TextBox Grid.Row="1" Grid.Column="0" MaxLength="{local:AccountSection 1}" Width="{local:AccountSection 1}" Visibility="{local:AccountSection 1}"/>
 
            <TextBlock Grid.Row="0" Grid.Column="1" Text="{local:AccountSection 2}" Visibility="{local:AccountSection 2}"/>
            <TextBox Grid.Row="1" Grid.Column="1" MaxLength="{local:AccountSection 2}" Width="{local:AccountSection 2}" Visibility="{local:AccountSection 2}"/>
 
            <TextBlock Grid.Row="0" Grid.Column="2" Text="{local:AccountSection 3}" Visibility="{local:AccountSection 3}"/>
            <TextBox Grid.Row="1" Grid.Column="2" MaxLength="{local:AccountSection 3}" Width="{local:AccountSection 3}" Visibility="{local:AccountSection 3}"/>
 
            <TextBlock Grid.Row="0" Grid.Column="3" Text="{local:AccountSection 4}" Visibility="{local:AccountSection 4}"/>
            <TextBox Grid.Row="1" Grid.Column="3" MaxLength="{local:AccountSection 4}" Width="{local:AccountSection 4}" Visibility="{local:AccountSection 4}"/>
 
            <TextBlock Grid.Row="0" Grid.Column="4" Text="{local:AccountSection 5}" Visibility="{local:AccountSection 5}"/>
            <TextBox Grid.Row="1" Grid.Column="4" MaxLength="{local:AccountSection 5}" Width="{local:AccountSection 5}" Visibility="{local:AccountSection 5}"/>
 
            <TextBlock Grid.Row="0" Grid.Column="5" Text="{local:AccountSection 6}" Visibility="{local:AccountSection 6}"/>
            <TextBox Grid.Row="1" Grid.Column="5" MaxLength="{local:AccountSection 6}" Width="{local:AccountSection 6}" Visibility="{local:AccountSection 6}"/>
        </Grid>
    </StackPanel>
</Window>

The code behind defines some properties and populates them. It also implements INotifyPropertyChanged.

using AccountSectionExtension;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
 
namespace AccountStructureExtension
{
    public partial class MainWindow : WindowINotifyPropertyChanged
    {
        public List<cAccountStructure> AccountStructures { get; } = new List<cAccountStructure>()
                {
                    new cAccountStructure()
                    {
                        AccountStructureType = "Domestic",
                        Sections = new List<cAccountSection>()
                        {
                            new cAccountSection() { Title = "Div", MaxLength = 4 },
                            new cAccountSection() { Title = "FY", MaxLength = 4 },
                            new cAccountSection() { Title = "Dept", MaxLength = 8 },
                            new cAccountSection() { Title = "Dist", MaxLength = 2 }
                        }
                    }
                    ,
                    new cAccountStructure()
                    {
                        AccountStructureType = "International",
                        Sections = new List<cAccountSection>()
                        {
                            new cAccountSection() { Title = "Country", MaxLength = 8 },
                            new cAccountSection() { Title = "FY", MaxLength = 4 },
                            new cAccountSection() { Title = "Region", MaxLength = 6 },
                            new cAccountSection() { Title = "Unit", MaxLength = 3 },
                            new cAccountSection() { Title = "Channel", MaxLength = 6 },
                            new cAccountSection() { Title = "Sub", MaxLength = 2 }
                        }
                    }
                };
 
        private cAccountStructure _AccountStructure;
        public cAccountStructure AccountStructure
        {
            get { return _AccountStructure; }
            set { SetProperty(ref _AccountStructure, value); }
        }
 
        public MainWindow()
        {
            AccountStructure = AccountStructures[0];
            InitializeComponent();
        }
 
        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storageT value, [CallerMemberNamestring name = "")
        {
            if (!Object.Equals(storagevalue))
            {
                storage = value;
                PropertyChanged?.Invoke(thisnew PropertyChangedEventArgs(name));
            }
        }
    }
}

Add a new class called AccountStructure.

using System;
using System.Collections.Generic;
using System.Text;
 
namespace AccountSectionExtension
{
    public class cAccountSection
    {
        public string Title;
        public int MaxLength;
    }
 
    public class cAccountStructure
    {
        public String AccountStructureType { getset; }
        public int SectionCount { get { return Sections.Count; } }
        public List<cAccountSection> Sections = new List<cAccountSection>();
    }
}

Now we get to the meat of the project. Add a class called AccountStructureExtension. Initially it will implement a simple markup extension that populates the controls but does not respond to changes in the source values.

using AccountSectionExtension;
using System;
using System.ComponentModel;
using System.Reflection;
using System.Windows;
using System.Windows.Markup;
 
namespace AccountStructureExtension
{
    class AccountSectionExtension : System.Windows.Markup.MarkupExtension
    {
        public String AccountStructurePropertyPath { getset; }
        public int SectionIndex { getset; }
 
        public DependencyObject dependencyObject;
        public DependencyProperty dependencyProperty;
 
        public AccountSectionExtension()
        {
            this.AccountStructurePropertyPath = "AccountStructure";
            this.SectionIndex = 1;
        }
 
        public AccountSectionExtension(int SectionIndex)
        {
            this.AccountStructurePropertyPath = "AccountStructure";
            this.SectionIndex = SectionIndex;
        }
 
        public AccountSectionExtension(String AccountStructurePropertyPathint SectionIndex)
        {
            this.AccountStructurePropertyPath = AccountStructurePropertyPath;
            this.SectionIndex = SectionIndex;
        }
 
        public override object ProvideValue(System.IServiceProvider serviceProvider)
        {
            IProvideValueTarget pvt = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
            dependencyObject = pvt.TargetObject as DependencyObject;
            dependencyProperty  = pvt.TargetProperty as DependencyProperty;
 
            return GetValue();
        }
 
        public object GetValue()
        {
            object dc = (dependencyObject as FrameworkElement).DataContext;
            cAccountStructure accountStructure = dc.GetType().GetProperty(AccountStructurePropertyPath).GetValue(dcnullas cAccountStructure;
            if (accountStructure == nullreturn DependencyProperty.UnsetValue;
            if (SectionIndex < 1) return DependencyProperty.UnsetValue;
 
            switch (dependencyProperty.Name.ToLower())
            {
                case "maxlength"return (SectionIndex > accountStructure.SectionCount) ? DependencyProperty.UnsetValue : accountStructure.Sections[SectionIndex - 1].MaxLength;
                case "text"return (SectionIndex > accountStructure.SectionCount) ? DependencyProperty.UnsetValue : accountStructure.Sections[SectionIndex - 1].Title;
                case "visibility"return (SectionIndex > accountStructure.SectionCount) ? Visibility.Collapsed : Visibility.Visible;
                case "width"return (SectionIndex > accountStructure.SectionCount) ? DependencyProperty.UnsetValue : (double)(4 + accountStructure.Sections[SectionIndex - 1].MaxLength * 8);
                defaultreturn DependencyProperty.UnsetValue;
            }
        } 
    }
}

If you run the project now, you will see the labels and text boxes work correctly at first, but if you select the International account structure, nothing changes.


Well that's not right!
We need to subscribe to the PropertyChanged event of the Data Context (and unsubscribe when we are destroyed). Then we need to check the name of the property that was changed and, if it is the Account Structure we are bound to, force an update. This all happens in AccountSectionExtension.

Add the following code after the constructors.
~AccountSectionExtension()
{
    RemoveEventHandler();
}
Modify ProvideValue to call AddEventHandler.
public override object ProvideValue(System.IServiceProvider serviceProvider)
{
    IProvideValueTarget pvt = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
    dependencyObject = pvt.TargetObject as DependencyObject;
    dependencyProperty  = pvt.TargetProperty as DependencyProperty;
    AddEventHandler();
 
    return GetValue();
}

Add the two new methods we just referenced.

public void AddEventHandler()
{
    object dc = (dependencyObject as FrameworkElement).DataContext;
    EventInfo npc = dc.GetType().GetEvent("PropertyChanged");
    MethodInfo mi = this.GetType().GetMethod("NotifyAccountStructureChanged");
    Delegate d = Delegate.CreateDelegate(npc.EventHandlerType, thismi);
    npc.AddEventHandler(dcd);
}
 
public void RemoveEventHandler()
{
    object dc = (dependencyObject as FrameworkElement).DataContext;
    EventInfo npc = dc.GetType().GetEvent("PropertyChanged");
    MethodInfo mi = this.GetType().GetMethod("NotifyAccountStructureChanged");
    Delegate d = Delegate.CreateDelegate(npc.EventHandlerType, thismi);
    npc.RemoveEventHandler(dcd);
}

And lastly, add the method that forces the update.

public void NotifyAccountStructureChanged(Object senderPropertyChangedEventArgs e)
{
    if (e.PropertyName == AccountStructurePropertyPath)
    {
        Action updateAction = () => dependencyObject.SetValue(dependencyProperty, GetValue());
        if (dependencyObject.CheckAccess())
            updateAction();
        else
            dependencyObject.Dispatcher.Invoke(updateAction);
    }
}
Yay, it works!

No comments:

Post a Comment