Friday, February 24, 2012

Out Of Control InteractionRequestTriggers

Recently at $DAYJOB, I ran into an interesting problem with Prism's interaction framework, which made our InteractionRequest actions start firing themselves off more than once. It turns out that the problem was in the InteractionRequestTriggers, specifically, the ones found on a particular view that was nested inside two levels of ContentControl. Tracking down the problem took the better part of a day; fortunately, fixing the problem took less than 10 minutes. (In fairness: the problem wasn't Prism's fault; it's a problem in the Blend classes that Prism derives from. We just happened to be using Prism when we found it.)

The application in question was using a fairly typical MVVM-style data templating setup for navigation. Each major area of the application has a view model associated with it, and a XAML view designed to be bound to that view model. The main application view itself is just a menu and a single ContentControl, bound to a property on the main application view model. Graphically, it looks something like this:

The Prism library includes some very useful interaction support that allows you to define and display dialog-style windows using the same MVVM pattern as the rest of the application. This is based on the System.Windows.Interactivity classes that are part of the Blend SDK, such as the EventTrigger and TriggerAction classes. In your XAML, you include an InteractionRequestTrigger, which contains one or more TriggerActions that define the user interface behavio(s)r that should occur when that request is triggered. (See the Prism documentation on MSDN for a much longer explanation). For example, your XAML might include a fragment such as:
<i:Interaction.Triggers>
  <prism:InteractionRequestTrigger SourceObject="{Binding ExitInteractionRequest}">
    <prism:PopupChildWindowAction ContentTemplate="{StaticResource ExitConfirmTemplate}"/>
  </prism:InteractionRequestTrigger>
</i:Interaction.Triggers>
In the view model, you include an object which implements IInteractionRequest, which your trigger then binds to its SourceObject property. This interface contains just a single member, a Raised event, which the triggers are expected to attach an event handler for. When you cause the Raised event to be raised in your view model, any attached triggers run their defined UI behaviors. The stock implementation of IInteractionRequest, for example, is the InteractionRequest<> object, that includes a generic type parameter to provide context for the action, and a callback to run when the action has completed. When you create a view model, then loaded and bound a XAML view to it, the following conceptual sequence of operations occurs (the actual sequence of operations is many, many times more complex, but the effect is the same):
model.ExitInteractionRequest = new InteractionRequest();
view.AnonymousTrigger = new InteractionRequestTrigger();
view.AnonymousTrigger.SourceObject = model.ExitInteractionRequest();
    view.AnonymousTrigger.Attach()
      model.ExitInteractionRequest.Raised += view.AnonymousTrigger.InternalEventHandler();
view.AnonymousPopupChildWindowAction = new PopupChildWindowAction();
view.AnonymousPopupChildWindowAction.ContentTemplate = view.Resources["ExitConfirmTemplate"];
view.AnonymousTrigger.Actions.Add(view.AnonymousPopupChildWindowAction);
Later on, when we change the current page on our main window, WPF triggers the ContentControl to update it's content template, which in turn triggers a long series of unloading and reloading actions. During that process, the following (again, highly conceptual) series of actions happens:
contentControl.TemplateChild = someOtherView;
  view.OnVisualParentChanged()
    view.AnonymousTrigger.DataContext = null;
      view.AnonymousTrigger.SourceObject = null;
        view.AnonymousTrigger.Detach();
          model.ExitInteractionRequest.Raised -= view.AnonymousTrigger.InternalEventHandler()
That last line in crucial: when the visual element that contains our trigger is removed from its containing control, its DataContext it set to null. This change is propagated by WPF down the entire logical tree, until it reaches our trigger object. When the trigger's DataContext changes, the SourceObject becomes null, and the trigger detaches itself from the request objects and unloads. When our view is brought back into the visual tree, the same sequence of events runs all over again, and we get a whole new set of triggers bound to our requests, and the process repeats.

Nested Views

That's how things are supposed to work. The problem were seeing, however, was that each time we navigated away from a specific page and came back, we got a new set of triggers listening to our requests, but the old set was still there. So, if we did this four times, we had four identical sets of triggers, and all four would invoke their defined action when the Raised event was fired. This page was different from the rest of the application in that the view had its own ContentControl and performed its own, nested level of view navigation, similar to this diagram:
In this case, everything was fine as we moved around within the child view (from Option to Setting to Param, for example). But if we tried to move from Settings to Help and back to Settings, wham!, duplicate interaction triggers.

Tracing through the process eventually led to the source of the problem: when we changed the template applied to the top-level ContentControl, it unloaded the XAML from the inner ContentControl from the visual tree, but it did not change the inner ContentControl's TemplatedChild. This means that the inner view did not get a VisualParentChanged event, and never had its DataContext reset. The EventTrigger base class never got the event it uses to know when to detach its Raised handler from the view model. But when we navigate back to that view, a new inner data template is created and added to the visual tree, so we do get our second set of triggers created, while the first set is still around. (Bonus bug: because these objects are still reachable from a root object -- the main application window, by way of the view model containing the InteractionRequest -- they never got GC'd either!)

Detach Ourselves On Unload

Once I followed the white rabbit far enough to find the problem, the solution was surprisingly easy. I simply derived a custom class from InteractionRequestTrigger that is aware of its parent unloading itself, and removes the event handler from its source object, as in the following:
public class UnloadableInteractionRequestTrigger : InteractionRequestTrigger
{
    private FrameworkElement parent;

    protected override void OnAttached()
    {
        base.OnAttached();

        this.parent = this.AssociatedObject as FrameworkElement;
        this.parent.Unloaded += this.ParentUnloaded;
    }

    private void ParentUnloaded(object sender, RoutedEventArgs e)
    {
        this.parent.Unloaded -= this.ParentUnloaded;
        this.parent.Loaded += this.ParentReloaded;
        this.Detach();
    }

    private void ParentReloaded(object sender, RoutedEventArgs e)
    {
        this.parent.Loaded -= this.ParentReloaded;
        this.Attach(this.parent);
    }
}
With this new behavior, whenever our view is removed from the visual tree the triggers detach themselves, and everything works again.

One thing to be aware of, is that views can be unloaded for reasons other than data templating changes. If this is the case, its possible for a view containing a trigger to unload and reload the same instance of the view, as opposed to creating a new instance when applying a template. To guard against that happening, we also hook into our parent control's Loaded event before detaching. If the same instance of our parent is reloaded intact, our Loaded handler will fire and we re-attach ourselves. In the data templating case, this never happens: the old instance of the view will just sit around until it gets garbage collected, along with our trigger, and a new instance will be created on demand.

2 comments:

Ilya Tretyakov said...

It doesn't work for Silverlight. And I think this is not the best workaround at WPF.
But thanks for your thoughts! It helped me make my own :)
http://prisminteractionlack.codeplex.com/

Rodrigo said...

It actually worked very well for me in Silverlight 5. Haven't tried WPF yet.

By the way, in Prism 5 they addressed the multiple popup issue in a way that solves the problem but introduces a new side-effect, when you have nested popups and you open, close and reopen both. In that case the nested popup won't show again.

This implementation here doesn't exhibit that behavior.

Thanx!