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 : Window, INotifyPropertyChanged { 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 storage, T value, [CallerMemberName] string name = "") { if (!Object.Equals(storage, value)) { storage = value; PropertyChanged?.Invoke(this, new 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 { get; set; } 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 { get; set; } public int SectionIndex { get; set; } 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 AccountStructurePropertyPath, int 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(dc, null) as cAccountStructure; if (accountStructure == null) return 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); default: return 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! |
Add the following code after the constructors.
~AccountSectionExtension()
{
RemoveEventHandler();
}
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, this, mi); npc.AddEventHandler(dc, d); } 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, this, mi); npc.RemoveEventHandler(dc, d); }
And lastly, add the method that forces the update.
public void NotifyAccountStructureChanged(Object sender, PropertyChangedEventArgs e) { if (e.PropertyName == AccountStructurePropertyPath) { Action updateAction = () => dependencyObject.SetValue(dependencyProperty, GetValue()); if (dependencyObject.CheckAccess()) updateAction(); else dependencyObject.Dispatcher.Invoke(updateAction); } }
Yay, it works!