In Caliburn.Micro how to bind action to nested ViewModel method?
Asked Answered
E

1

4

I have a WPF application, powered by Caliburn.Micro, view-model first approach. There's a command-bar type of control with its CommandBarView.xaml and bound CommandBarViewModel. Command-bar VM holds a number of nested VMs, one for each button control, all showing a common interface and having common behaviour. Command-bar VM expose them so they can be bound from view:

public interface IWarningButtonViewModel
{
    bool IsVisible { get; }
    bool CanShowWarning { get; }
    void ShowWarning();
}

public class CommandBarViewModel : PropertyChangedBase
{
    public IWarningButtonViewModel UserNotFoundWarning { get; private set; }
    public IWarningButtonViewModel NetworkProblemWarning { get; private set; }
    // ... initialization omitted for simplicity
}

This is a tentative XAML for a bit of CommandBarView:

<Button x:Name="UserNotFoundWarning_ShowWarning"
        IsEnabled="{Binding UserNotFoundWarning.CanShowWarning}">
  ... 
  <DataTrigger Binding="{Binding UserNotFoundWarning.IsVisible}" Value="True">
  ...
</Button>

In this way I'm able to successfully bind the two properties (CanShowWarning, IsVisible) but I'm not able to bind the button command/action to ShowWarning method.

I tried with deep property binding and that works again for properties, but not for action.
I also tried with a mix of cal:Model.Bind and cal:Message.Attach:

<Button cal:Model.Bind="{Binding UserNotFoundWarning}" 
        cal:Message.Attach="[Event Click] = [Action ShowWarning]"
        IsEnabled="{Binding CanShowWarning}">
  ... 
  <DataTrigger Binding="{Binding IsVisible}" Value="True">
  ...
</Button>

That seems to work at runtime, but cal:Model.Bind makes the VS designer completely unusable, UI controls are not shown.

I've searched around quite a bit, but I could not find an actual solution that let me also work with designer. It seems strange to me that I could only find examples of deep binding for properties, not for actions.

Any idea how to solve this?

Electromagnet answered 13/8, 2015 at 14:33 Comment(3)
Do you really need UserNotFoundWarning to be an interface property? It seems odd to have a VM for a simple Button, an simple method would do.Cacia
Command-bar VM holds a number of those buttons. Each button subscribes to some business-level observable to detect if it should be visible. So each one would need its own IsXxxWarningButtonVisible with such logic, and the command-bar VM would need those business-level dependencies, just to pass them along to that logic. Then all buttons share a common logic related to the command and its guard, only the actual warning text changes among them. I started that way, but then I had a lot of ShowXxxWarning, CanShowXxxWarning, IsXxxWarningVisible ... not that greatElectromagnet
Apart from design choices, which I sure can reconsider & talk about, is there a way - in general - to bind events/actions to nested VM methods?Electromagnet
H
6

Here is my workaround:

private static void EnableNestedViewModelActionBinding()
{
    var baseGetTargetMethod = ActionMessage.GetTargetMethod;
    ActionMessage.GetTargetMethod = (message, target) =>
    {
        var methodName = GetRealMethodName(message.MethodName, ref target);
        if (methodName == null)
            return null;

        var fakeMessage = new ActionMessage { MethodName = methodName };
        foreach (var p in message.Parameters)
            fakeMessage.Parameters.Add(p);
        return baseGetTargetMethod(fakeMessage, target);
    };

    var baseSetMethodBinding = ActionMessage.SetMethodBinding;
    ActionMessage.SetMethodBinding = context =>
    {
        baseSetMethodBinding(context);
        var target = context.Target;
        if (target != null)
        {
            GetRealMethodName(context.Message.MethodName, ref target);
            context.Target = target;
        }
    };
}

private static string GetRealMethodName(string methodName, ref object target)
{
    var parts = methodName.Split('.');
    var model = target;
    foreach (var propName in parts.Take(parts.Length - 1))
    {
        if (model == null)
            return null;

        var prop = model.GetType().GetPropertyCaseInsensitive(propName);
        if (prop == null || !prop.CanRead)
            return null;

        model = prop.GetValue(model);
    }
    target = model;
    return parts.Last();
}

Call EnableNestedViewModelActionBinding() once from your bootstrapper and it will allow you to bind actions to nested model's methods using the usual dotted notation. E.g.

cal:Message.Attach="[Event Click] = [Action UserNotFoundWarning.ShowWarning]"

Edit: please note, that this wouldn't work if you change the nested ViewModel instance at runtime. E.g. if you assign your UserNotFoundWarning to something new after the binding happened - Caliburn would still call actions on previous instance.

Holstein answered 8/10, 2015 at 2:56 Comment(2)
Thanks. I was just trying your solution now and I read about limitations. We do assign the nested ViewModels at runtime, during parent construction, but then they never change during parent lifetime, so I guess it should workElectromagnet
that worked well, also with simplified notation cal:Message.Attach="UserNotFoundWarning.ShowWarning". Thanks!Electromagnet

© 2022 - 2024 — McMap. All rights reserved.