Home > Software design >  How to pass commands across different XAML files/controls
How to pass commands across different XAML files/controls

Time:01-17

I come from a WPF background so I thought I'd experiment with building a to-do app in WinUI 3. The app structure is a little overdesigned as I'm trying build it out like a more complex app. For that reason I have a ToDoTaskView and ToDoTaskViewModel, along with a MainWindowView and MainWindowViewModel even though it'd be much easier to build the entire app in a single XAML file.

The ToDoTaskView has a delete button, but the delete command lives on the MainWindowViewModel, as that's where the list that it must be deleted from lives. I think this a pretty common pattern where a sub-view needs to send a command to a parent view model.

The (abridged) MainWindowView:

<UserControl>
    <ItemsRepeater ItemsSource="{Binding Tasks}">
        <DataTemplate>
            <local:ToDoTaskView />
        </DataTemplate>
    </ItemsRepeater>
</UserControl>

And the (heavily abridged) ToDoTaskView:

<UserControl>
    <Button Command="???">Delete</Button>
</UserControl>

In WPF there's many ways to deal with this.

RoutedCommand

My prefered method. The MainWindowView can listen for a custom ToDoTaskDeleted routed command and bind to the command on the view model. Then any UI element anywhere underneath MainWindowView can fire said event and rest easy knowing it'll be handled somewhere above it on the visual tree.

There's no RoutedCommand in WinUI 3, and even worse, routed events are locked down and you can't define custom ones. So even building a custom RoutedCommand implementation would be difficult.

DynamicResource

I can define a StandardUICommand in MainWindowView.Resources, bind it to the command in the view model, then in ToDoTaskView I can use {DynamicResource DeleteCommand} to have the resource system search up the visual tree for the command.

Except I can't. WinUI3 doesn't have DynamicResource, only StaticResource. And since the two views are in different XAML files, and ToDoTaskView in a templated context, StaticResource can't resolve the resource name between them.

I think this could work for resources in App.xaml, but I'd rather not shove every command into the top level scope instead of keeping them where they belong.

All the commanding examples in the Microsoft docs seem to assume that the button and handler are in the same file, or they directly pass a reference to the command through to the child view's DataContext.

RelativeAncestor

Peter below reminded me that I tried this too, and found it's missing in WinUI 3. RelativeSource doesn't support any kind of ancestor discovery.

Manual Kludge

Setting up a direct reference from ToDoTaskViewModel to MainWindowViewModel is certainly possible, but I hate it. After all, who's to guarantee that this particular to do item is part of a list at any one moment? Maybe it lives in a pop-up dialog as a reminder? Handling this kind of thing through the visual tree is the Correct(tm) way to do it.

I wouldn't accept a PR from a coworker on my WPF project with this solution. But I can't seem to find any better way in WinUI 3.

Have I missed something about WinUI 3? Is it just not mature enough yet to have a solution? It seems like this scenario isn't so uncommon that it would be completely unsupported.

CodePudding user response:

In this case, I'd create an ICommand dependency property, DeleteCommand and and bind a command in the view model. Here's a sample code using the CommunityToolkit.Mvvm NuGet package.

MainWindow.xaml

  • The MainWindow is named, "ThisWindow" in this case, so we can access its ViewModel from the ItemTemplate.
  • The DeleteCommandParameter is bound to the DataContext of the item, ToDoTaskViewModel in this case.
<Window
    x:Class="ToDoApp.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:local="using:ToDoApp"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    x:Name="ThisWindow"
    mc:Ignorable="d">

    <Grid RowDefinitions="Auto,*">
        <StackPanel
            Grid.Row="0"
            Orientation="Horizontal">
            <TextBox x:Name="NewToDo" />
            <Button
                Command="{x:Bind ViewModel.AddToDoCommand}"
                CommandParameter="{x:Bind NewToDo.Text, Mode=OneWay}"
                Content="Add" />
        </StackPanel>
        <ScrollViewer Grid.Row="1">
            <ItemsRepeater ItemsSource="{x:Bind ViewModel.ToDoTasks, Mode=OneWay}">
                <ItemsRepeater.ItemTemplate>
                    <DataTemplate x:DataType="local:ToDoTaskViewModel">
                        <local:ToDoTaskView
                            DeleteCommand="{Binding ElementName=ThisWindow, Path=ViewModel.DeleteToDoCommand}"
                            DeleteCommandParameter="{x:Bind}"
                            ToDo="{x:Bind ToDo, Mode=OneWay}" />
                    </DataTemplate>
                </ItemsRepeater.ItemTemplate>
            </ItemsRepeater>
        </ScrollViewer>
    </Grid>

</Window>

MainWindow.xaml.cs

using Microsoft.UI.Xaml;

namespace ToDoApp;

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        this.InitializeComponent();
    }

    public MainWindowViewModel ViewModel { get; } = new();
}

MainWindowViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

namespace ToDoApp;

[ObservableObject]
public partial class MainWindowViewModel
{
    [ObservableProperty]
    private ObservableCollection<ToDoTaskViewModel> toDoTasks = new();

    [RelayCommand]
    private void AddToDo(string todo)
    {
        ToDoTasks.Add(new ToDoTaskViewModel() { ToDo = todo });
    }

    [RelayCommand]
    private void DeleteToDo(ToDoTaskViewModel toDoTask)
    {
        ToDoTasks.Remove(toDoTask);
    }
}

ToDoTaskView.xaml

<UserControl
    x:Class="ToDoApp.ToDoTaskView"
    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:local="using:ToDoApp"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <Grid ColumnDefinitions="*,Auto">
        <TextBlock
            Grid.Column="0"
            Text="{x:Bind ToDo, Mode=OneWay}" />
        <Button
            Grid.Column="1"
            Command="{x:Bind DeleteCommand, Mode=OneWay}"
            CommandParameter="{x:Bind DeleteCommandParameter, Mode=OneWay}"
            Content="Delete" />
    </Grid>
</UserControl>

ToDoTaskView.xaml.cs

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Windows.Input;

namespace ToDoApp;

public sealed partial class ToDoTaskView : UserControl
{
    public static readonly DependencyProperty ToDoProperty = DependencyProperty.Register(
        nameof(ToDo),
        typeof(string),
        typeof(ToDoTaskView),
        new PropertyMetadata(default));

    public static readonly DependencyProperty DeleteCommandProperty = DependencyProperty.Register(
        nameof(DeleteCommand),
        typeof(ICommand),
        typeof(ToDoTaskView),
        new PropertyMetadata(default));

    public static readonly DependencyProperty DeleteCommandParameterProperty = DependencyProperty.Register(
        nameof(DeleteCommandParameter),
        typeof(object),
        typeof(ToDoTaskView),
        new PropertyMetadata(default));

    public ToDoTaskView()
    {
        this.InitializeComponent();
    }

    public string ToDo
    {
        get => (string)GetValue(ToDoProperty);
        set => SetValue(ToDoProperty, value);
    }

    public ICommand DeleteCommand
    {
        get => (ICommand)GetValue(DeleteCommandProperty);
        set => SetValue(DeleteCommandProperty, value);
    }

    public object DeleteCommandParameter
    {
        get => (object)GetValue(DeleteCommandParameterProperty);
        set => SetValue(DeleteCommandParameterProperty, value);
    }
}

ToDoTaskViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel;

namespace ToDoApp;

[ObservableObject]
public partial class ToDoTaskViewModel
{
    [ObservableProperty]
    private string toDo = string.Empty;
}

CodePudding user response:

Ok I have a solution. I cannot emphasize enough how much of a disgusting hack this is. Normally I'd be embarrassed to post this, but the only ones who should be embarrassed are Microsoft for publishing Win UI 3 in its current state and claiming it's capable of making real applications.

The gist of this is to mimic Ancestor-type RelativeSource binding in WPF. We create two attached properties - ParentContextViewType to specify the type of the ancestor we're looking for - and ParentContextView which is automatically assigned a reference to the desired parent view instance when the child loads. (I'd have made ParentContextView a readonly property, but of course, Win UI doesn't support that...) Then for the child button, we do a RelativeSource Self binding to the attached ParentContextView property, then adding the rest of the path, just like we would with a legit ancestor type bind.

Here goes (and may god have mercy on my soul):

using System;

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;

namespace ParentBinding
{
    public static class Hacks
    {
        public static DependencyProperty ParentContextView =
            DependencyProperty.RegisterAttached(
                "ParentContextView",
                typeof(FrameworkElement),
                typeof(Hacks),
                new PropertyMetadata(null));
        public static FrameworkElement GetParentContextView(DependencyObject d)
        {
            return d.GetValue(ParentContextView) as FrameworkElement;
        }
        public static void SetParentContextView(DependencyObject d, FrameworkElement view)
        {
            d.SetValue(ParentContextView, view);
        }

        public static DependencyProperty ParentContextViewTypeProperty =
            DependencyProperty.RegisterAttached(
                "ParentContextViewType",
                typeof(Type),
                typeof(Hacks),
                new PropertyMetadata(null, (d, e) =>
                {
                    if (!(d is FrameworkElement fe))
                        return;
                    if (e.OldValue != null)
                        fe.Loaded -= OnParentContextFeLoaded;
                    if (e.NewValue != null)
                        fe.Loaded  = OnParentContextFeLoaded;
                }));

        private static void OnParentContextFeLoaded(object sender, RoutedEventArgs e)
        {
            if (!(sender is FrameworkElement fe))
                return;

            var type = GetParentContextViewType(fe);
            if (type == null)
                return;

            while (!type.IsAssignableFrom(fe.GetType()) &&
                (fe = VisualTreeHelper.GetParent(fe) as FrameworkElement) != null)
            {
            }

            SetParentContextView(sender as DependencyObject, fe);
        }

        public static Type GetParentContextViewType(DependencyObject d)
        {
            return d.GetValue(ParentContextViewTypeProperty) as Type;
        }
        public static void SetParentContextViewType(DependencyObject d, Type val)
        {
            d.SetValue(ParentContextViewTypeProperty, val);
        }
    }
}

A use-case:

Model stuff:

using Microsoft.UI.Xaml.Input;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;

namespace ParentBinding
{
    public class Command : ICommand
    {
        Action<object> _action;

        public Command(Action<object> action)
        {
            _action = action;
        }

        public event EventHandler? CanExecuteChanged;

        public bool CanExecute(object? parameter) => true;

        public void Execute(object? parameter)
        {
            _action?.Invoke(parameter);
        }
    }

    public class Parent
    {
        public ObservableCollection<Child> Children { get; set; }

        private Command _deleteChildCommand;
        public ICommand DeleteChildCommand =>
            _deleteChildCommand ?? (_deleteChildCommand = new Command((p) =>
            {
                if (!(p is Child ch))
                    return;
                this.Children.Remove(ch);
            }));
    }

    public class Child
    {
        public string Name { get; set; }

        public override string ToString() => this.Name;
    }
}

Main Window:

<Window x:Class="ParentBinding.MainWindow"
        x:Name="_main"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="using:ParentBinding"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d">
    <ListView DataContext="{Binding ElementName=_main, Path=Parent}"
              ItemsSource="{Binding Children}">
        <ListView.ItemTemplate>
            <DataTemplate x:DataType="local:Child">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*" />
                        <ColumnDefinition Width="Auto" />
                    </Grid.ColumnDefinitions>
                    <TextBlock Text="{Binding Name}" />
                    <Button local:Hacks.ParentContextViewType="ListView"
                            Grid.Column="1"
                            CommandParameter="{Binding}"
                            Content="Delete"
                            Command="{Binding 
                                    Path=(local:Hacks.ParentContextView).DataContext.DeleteChildCommand,
                                    RelativeSource={RelativeSource Self}}" />
                </Grid>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</Window>
using Microsoft.UI.Xaml;

namespace ParentBinding
{
    public sealed partial class MainWindow : Window
    {
        public MainWindow()
        {
            this.InitializeComponent();
        }

        public Parent Parent { get; } = new Parent
        {
            Children = new System.Collections.ObjectModel.ObservableCollection<Child>
            {
                new Child
                {
                    Name = "Larry"
                },
                new Child
                {
                    Name = "Curly"
                },
                new Child
                {
                    Name = "Moe"
                }
            }
        };
    }
}

Amazingly, it works, and one of the reasons I was so curious to try it and post it is that it is, more or less, a general purpose substitute for ancestor type binding in WinUI 3. Hope someone finds it useful.

  • Related