06 October 2010

Using MVVMLight Messenger and behaviors to open files from local disk in Silverlight

The very first Silverlight application I had to make professionally (i.e. not as a hobby/research project at home) required the possibility to open a file from (local) disk. Of course I wanted to use Laurent Bugnion’s MVVMLight and the strict separation between logic that I talked and blogged about so much – ‘practice what thou preach’, eh?

This proved to be an interesting challenge. The most logical way forward seemed to be: make a button, attach a command to it via the usual EventToCommand route and then use OpenFileDialog from the model – which landed me a “Dialogs must be user-initiated” error. Googling Binging around I found out that Dennis Vroegop had run into this thingy quite recently as well.

Things looked ugly. Getting around it took me some code in the code behind. This kept nagging me, and only recently I found a better way to deal with this. Apart from the EventToCommand binding MVVMLight contains another gem, called the Messenger. This is an implementation of the Mediator pattern that - as far as I understand – is designed to broadcast property changes between different models in the application. But this mechanism can also be used as an alternative way to shuttle data from the GUI to the model in a designer-friendly – Blendable -way. The way to go for this particular problem is like this:

  • Define a message that holds the data from an OpenFileDialog
  • Make a behavior that can attach to a button that launches an OpenFileDialog on click, and then sends a message on the Messenger
  • Register a listener on the Messenger in the model for that particular message

True to my blog’s title, I will demonstrate this principle with an example. First, a message containing the result of an OpenFileDialog:

using System.Collections.Generic;
using System.IO;
using GalaSoft.MvvmLight.Messaging;

namespace LocalJoost.Behaviours
{
  public class FilesOpenedMessage : GenericMessage<IEnumerable<FileInfo>>
  {
    public FilesOpenedMessage(IEnumerable<FileInfo> parm)
      : base(parm)
    {
      Identifier = string.Empty;
    }

    public string Identifier{get;set;}
  }
}

Notice that the message also contains a property Identifier, acting like an optional additional message identifier. This is necessary, since the MVVMLight messenger seems to identify messages by type. Therefore, every method listening to a FilesOpenedMessage gets a message, and the various models would have no way to know if it was actually for them to act on it. Identifier makes coupling a particular listener to a particular sender possible. I’m sure there are other ways to get this done, I simply chose the simplest.

Getting on with the behavior:

using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;
using GalaSoft.MvvmLight.Messaging;

namespace LocalJoost.Behaviours
{
  /// <summary>
  /// A behavior attaching to a button
  /// </summary>
  public class FileOpenBehavior : Behavior<Button>
  {
    // Properties - can be set from XAML
    public string MessageIdentifier { get; set; }
    public string Filter { get; set; }
    public bool MultiSelect { get; set; }

    protected override void OnAttached()
    {
      base.OnAttached();
      Filter = "All files (*.*)|*.*";
      AssociatedObject.Click += AssociatedObject_Click;
    }

    void AssociatedObject_Click(object sender, RoutedEventArgs e)
    {
      // Open the dialog and send the message
      var dialog = 
        new OpenFileDialog {Filter = Filter, Multiselect = MultiSelect};
      if (dialog.ShowDialog() == true)
      {
        Messenger.Default.Send(
          new FilesOpenedMessage(dialog.Files) 
          { Identifier = MessageIdentifier });
      }      
    }

    protected override void OnDetaching()
    {
      AssociatedObject.Click -= AssociatedObject_Click;
      base.OnDetaching();
    }
  }
}

Which works pretty simple: it attaches itself to the click event, composes a message and fires it away on the Messenger. In your model you register a listener like this:

public class DemoModel : ViewModelBase
{
  public DemoModel()
  {
    if (IsInDesignMode)
    {
      // Code runs in Blend --> create design time data.
    }
    else
    {
      Messenger.Default.Register<FilesOpenedMessage>(
        this,
        DoOpenFileCallback);
    }
  }

  private void DoOpenFileCallback(FilesOpenedMessage msg)
  {
    if (msg.Identifier != "1234") return;
    // Store result in SelectedFiles
    SelectedFiles = msg.Content.Select(f => f.Name).ToList();
  }
  // Rest of model omitted - see demo solution
}

The “Content” property of the message always contains the payload, i.e. whatever you had to pass to the message constructor – in this case, an IEnumerable<FileInfo>. In XAML you then bind the whole thing together like this, like any ordinary behavior:

<UserControl x:Class="DemoMessageBehaviour.MainPage"
  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:model="clr-namespace:DemoMessageBehaviour.Model;assembly=DemoMessageBehaviour.Model"
  xmlns:LJBehaviours="clr-namespace:LocalJoost.Behaviours;assembly=LocalJoost.Behaviours"
  xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"        
  mc:Ignorable="d"
  d:DesignHeight="300" d:DesignWidth="400">
  <UserControl.Resources>
    <model:DemoModel x:Key="MyDemoModel"/>
  </UserControl.Resources>
  <Grid x:Name="LayoutRoot" Background="White" DataContext="{StaticResource MyDemoModel}">
    <Grid.RowDefinitions>
      <RowDefinition Height="0.9*"/>
      <RowDefinition Height="0.1*"/>
    </Grid.RowDefinitions>
    <Button Content="Open" Grid.Row="1" IsEnabled="{Binding CanOpen}" >
      <i:Interaction.Behaviors>
        <LJBehaviours:FileOpenBehavior MessageIdentifier="1234" MultiSelect="True"/>
      </i:Interaction.Behaviors>
    </Button>
    <ListBox Grid.Row="0" ItemsSource="{Binding SelectedFiles}"/>
  </Grid>
</UserControl>

The Listbox is used to display the names of the selected files. I did leave this out, there’s enough code in this sample already. For those who want to have the full picture: a complete solution demonstrating the behavior can be found here.

Notice there’s an important difference using this technique in stead of the EventToCommand: when the user presses the button, the model cannot control the actual display of the OpenFileDialog: the click is handled by the behavior, not the model itself (via EventToCommand) and the OpenFileDialog is always displayed once the button is clicked. Therefore, the model should control if the user can press the button at all, which is done by binding the IsEnabled property to a property of the model (CanOpen – sorry, lame pun). Which is good UX practice anyway – controls that cannot be used should be disabled in stead of giving a ‘sorry, you can’t do that’ message whenever possible, but in this case it’s simply necessary.

I only showed how to do open a file from disk, but a pattern like this can be used to save files to disk as well, or do other things that might seem to require coding in the code behind file. That is no sin in itself – nobody from the MVVM police will show up at your doorstep and take your family away if you need to do that. At least that is what Laurent himself promised at his MIX10 talk ;-). But by coding a behavior, you enable designers to add fairly easily relatively complex actions using Blend without having to dive too deep into XAML. Using behaviors like this FileOpenBehavior makes it even easier to add interactivity than the EventToCommand (which was closed off anyway during security restrictions).

It can’t hurt to be nice to your designers – after all, they make the pretty stuff up front that makes your application sell ;-)

Oh, and one final thing: in all my previous posts I was talking about ‘behaviour’ in stead of ‘behavior’. I hope you all will excuse me for being educated in the Queen’s English, even tough I am just Dutch. ;-).

2 comments:

peSHIr said...

Not strictly related to the content of the blog post, but I really liked the "P.S. Queens English" comment at the end. ;-)

mark said...

This doesn't seem to work with SaveFileDialog, i still get the Dialogs must be user-initiated error