Home > Navigation > Navigating from Object to Object

Navigating from Object to Object


WPF offers a whole application paradigm based on navigation: The Navigation Application, hosted in a NavigationWindow, Frame or WebBrowser; consisting of XAML pages which are independent of each other; keeping track of the navigation history and restoring pages and their values while going forwards and backwards are supported out of the box.

However, some things are lacking. Suppose you have a data object model. In your application, you want to be able to select an object from a list, then click on a link to open a new page and edit the details of the object. When you are done, you want to select another object, and  when you have repeated this a couple of times, you want to be able to go backwards using the back button, returning to the objects you edited on each page.

What is the problem here? WPF offers no built-in way to pass an object from one page to the other using XAML only. This has consequences. If you use the approach suggested in the WPF documentation, you will have to instantiate a new page object, pass the object you want to edit to its constructor, and navigate to it using the NavigationService.Navigate(Object) overload. However, pages which are navigated to this way are not disposed when they are no longer displayed, but retained in memory as a whole, and this has some side effects which I already talked about in this post.

So, what can we do about it?

The GoToPage Sticky Command

A sticky command is a command binding that is attachable to any XAML element and adds a certain functionality to this element without changing its code.

The GoToPage navigation command was one of my first experiments with the WPFGlue programming style. Since then, I have learned a few things and changed it considerably. One of the changes being that now the command which is the navigation command is no longer hardcoded, but could be any routed command which is configured to use the Navigation command implementation. This is how you use it:

<NavigationWindow x:Class="Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation&quot;
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml&quot;
    xmlns:n="https://wpfglue.wordpress.com/navigation&quot;       
    Title="NavigationExample" Height="300" Width="300"
    n:Navigation.Command="GoToPage" Source="ListPage.xaml"/>

 

By setting the Navigation.Command attached property on the NavigationWindow to the WPF GoToPage command, you enable all pages that the NavigationWindow hosts to use this command in order to navigate to other pages and pass along objects as DataContext for the new page.

In the page, you would use the command like this:

<Button x:Name="EditButton"
   DataContext="{Binding ElementName=ItemListView, Path=SelectedItem}"
   Command="GoToPage" CommandParameter="{Binding}"
   n:Navigation.Uri="DetailPage.xaml">

This button uses the selected item of a ListView as its DataContext. If clicked, it invokes the GoToPage command (set to its Command property) The selected item is bound to the CommandParameter property, which means that it will be passed as parameter to the GoToPage command. Finally, the URL of the page that should be navigated to is configured through the Navigation.Uri attached property.

How Does It Work?

The Navigation.Command property is a sticky property: in its change handler, it attaches a CommandBinding with the code that calls the Navigation command to the element it is set on:

Public Shared ReadOnly CommandProperty As DependencyProperty = DependencyProperty.RegisterAttached("Command", GetType(RoutedCommand), GetType(Navigation), New PropertyMetadata(AddressOf OnCommandChanged))
Public Shared Function GetCommand(ByVal d As DependencyObject) As RoutedCommand
    Return d.GetValue(CommandProperty)
End Function
Public Shared Sub SetCommand(ByVal d As DependencyObject, ByVal value As RoutedCommand)
    d.SetValue(CommandProperty, value)
End Sub
Private Shared Sub OnCommandChanged(ByVal d As DependencyObject, ByVal e As DependencyPropertyChangedEventArgs)
    If e.OldValue IsNot Nothing Then
        Detach(d, e.OldValue)
    End If
    If e.NewValue IsNot Nothing Then
        Attach(d, e.NewValue)
    End If
End Sub
Private Shared Sub OnUnloaded(ByVal sender As Object, ByVal e As RoutedEventArgs)
    SetCommand(sender, Nothing)
End Sub

This code follows a pattern that is typical for Sticky Components; I want to introduce them in more detail later, but we are going to encounter their patterns continuously here, so I want to point it out:

A Sticky Component needs to be able to clean up after itself. So, there are two procedures, Attach and Detach. The Attach method connects the sticky component by setting up CommandBindings or event handlers, the Detach method removes all these references between the Sticky Component and its hosting element. So, the Detach procedure needs to be called in two cases: either if the Sticky Component is removed from the element, or if the element is unloaded. So, the common pattern for Sticky Components is to call Detach when a Sticky Component is replaced on an element, and to reset the attached property that controls the Sticky Component in the element’s Unloaded event.

Navigation Flow Control

This is the method that finally gets called when the GoToPage command is invoked:

Public Shared Function Navigate(ByVal sender As Object, ByVal target As Uri, ByVal data As Object) As Boolean
    Dim result As Boolean = False
    Dim service As NavigationService = GetNavigationService(sender)
    If service IsNot Nothing Then
        AddHandler service.LoadCompleted, AddressOf SetDataContextHandler
        result = service.Navigate(target, data)
    End If
    Return result
End Function

It uses the NavigationService.Navigate(Uri,Object) overloaded method, which allows to pass additional data into the navigation process. This additional data is the object that the user selected, and that was passed to the GoToPage command as parameter. The NavigationService will hold on to this object while the pages are changed. When the new page is loaded, we want to set the pages DataContext to the object, so we register a handler for the NavigationService’s LoadCompleted event.

The handler looks like this:

Private Shared Sub SetDataContextHandler(ByVal sender As Object, ByVal e As System.Windows.Navigation.NavigationEventArgs)
    Dim service As NavigationService = GetNavigationService(e.Navigator)
    If service IsNot Nothing Then
        RemoveHandler service.LoadCompleted, AddressOf SetDataContextHandler
        Dim data As Object = e.ExtraData
        If data IsNot Nothing Then
            If TypeOf e.Content Is DependencyObject Then
                SetDataContext(e.Content, data)
                AddHandler service.Navigating, AddressOf SaveDataContextHandler
            End If
        End If
    End If
End Sub

 

Notice that the first thing this procedure does is to unregister itself from the LoadCompleted event. This is because there is no guarantee that all navigation in the application will use our command. This handler makes sense only if the ExtraData of the NavigationEventArgs really contains an object which should be set to the DataContext of the new page. Thus, we register it specifically for this case, and unregister it immediately after use. I call this design pattern a “One Shot Event”.

Then, we set the DataContext of the new page. By the time the event occurs, this page can be found in the Content property of the NavigationEventArgs. However, we cannot set its DataContext directly: WPF is quite particular about when exactly during the lifetime of a page the DataContext is set; some validation features will not work properly if it is set too early, so we define a special attached behaviour that waits until the new page’s Loaded event occurs and then sets the DataContext:

Public Shared ReadOnly DataContextProperty As DependencyProperty = _
    DependencyProperty.RegisterAttached("DataContext", GetType(Object), _
    GetType(Navigation), New PropertyMetadata(Nothing, AddressOf OnDataContextChanged))
Public Shared Function GetDataContext(ByVal d As DependencyObject) As Object
    Return d.GetValue(DataContextProperty)
End Function
Public Shared Sub SetDataContext(ByVal d As DependencyObject, ByVal value As Object)
    d.SetValue(DataContextProperty, value)
End Sub
Private Shared Sub OnDataContextChanged(ByVal d As DependencyObject, ByVal e As DependencyPropertyChangedEventArgs)
    If e.NewValue IsNot Nothing Then
        If StickyComponentManager.GetIsLoaded(d) Then
            d.Dispatcher.Invoke(System.Delegate.CreateDelegate(GetType(Navigation), Nothing, "OnDataContextLoaded"), d, Nothing)
        Else
            StickyComponentManager.AttachEvent(d, FrameworkElement.LoadedEvent, AddressOf OnDataContextLoaded)
        End If
    End If
End Sub
Private Shared Sub OnDataContextLoaded(ByVal sender As Object, ByVal e As RoutedEventArgs)
    Dim d As DependencyObject = TryCast(sender, DependencyObject)
    If d IsNot Nothing Then
        StickyComponentManager.DetachEvent(d, FrameworkElement.LoadedEvent, AddressOf OnDataContextLoaded)
        Dim data As Object = GetDataContext(d)
        d.SetValue(FrameworkElement.DataContextProperty, data)
        d.ClearValue(DataContextProperty)
    End If
End Sub

Notice that the handler for the Loaded event not only removes itself from the page, but also resets the attached property that held on to the DataContext so as to leave no references to this object in places where they are not expected.

We want to be able to return to the page and still see the same object as DataContext. So, we need to save the DataContext to the Journal when the page is about to be left. This is where it gets tricky (again). The method to add custom information to the Journal is to implement a class that inherits from CustomContentState and set it to the CustomContentState property of the NavigatingCancelEventArgs that are passed into the NavigationService.Navigating event when the user tries to navigate away from the current page. However, CustomContentState objects are not stored as object references, but in serialized format, being deserialized as they are needed. This means that our DataContext would be serialized as well, making it impossible to return to the same instance. In order to work around this, we save the DataContext to a shared Session object in the background, and retain only an integer index, which can be serialized easily, while allowing us to retrieve the object later:

Private Shared Sub SaveDataContextHandler(ByVal sender As Object, ByVal e As System.Windows.Navigation.NavigatingCancelEventArgs)
    Dim service As NavigationService = GetNavigationService(e.Navigator)
    If service IsNot Nothing Then
        RemoveHandler service.Navigating, AddressOf SaveDataContextHandler
        If Not e.Cancel AndAlso TypeOf service.Content Is DependencyObject Then
            Dim content As DependencyObject = service.Content
            Dim data As Object = content.GetValue(FrameworkElement.DataContextProperty)
            If data IsNot Nothing Then
                Dim id As Integer = GetDataContextId(content)
                id = Session.StoreReference(id, data)
                Dim state As NavigationContentState = New NavigationContentState(id, e.ContentStateToSave)
                e.ContentStateToSave = state
            End If
        End If
    End If
End Sub

 

Since we don’t want to keep the DataContext object alive longer than its object model supposes it is alive, the Session object uses WeakReferences for storing the DataContext.

When the user navigates back to the page, the CustomContentState’s Replay method is called. In this method, the DataContext is retrieved from the Session object and put into the new page’s DataContext property, using the same method as before. Since we cannot use a CustomContentState more than once, we also have to set up the handling of the Navigating event again, so that the DataContext is saved again when the page is left.

Public Overrides Sub Replay(ByVal navigationService As System.Windows.Navigation.NavigationService, ByVal mode As System.Windows.Navigation.NavigationMode)
    If _OriginalState IsNot Nothing Then
        _OriginalState.Replay(navigationService, mode)
    End If
    If _DataContextId <> -1 Then
        Dim content As DependencyObject = TryCast(navigationService.Content, DependencyObject)
        If content IsNot Nothing Then
            Dim data As Object = Session.RetrieveReference(_DataContextId)
            If data Is Nothing Then
                SetIsExpired(content, True)
                WPFGlue.Validation.Validation.SetSuppressErrorTemplate(content, True)
            Else
                SetDataContext(content, data)
                SetDataContextId(content, _DataContextId)
                AddHandler navigationService.Navigating, AddressOf SaveDataContextHandler
            End If
        End If
    End If
End Sub

 

Handling Expired Content

But what if the object that was the DataContext has be freed since the user last visited the page?

Handling this case gracefully almost drove me crazy. What I wanted to do was to navigate backwards one step and to clear the forward navigation stack so that the user couldn’t navigate to the page again. But I found no way of doing this that would work with all cases I wanted to cover: the API of the Journal is just too narrow. So, what I ended up doing was simply disabling the page and displaying a big fat Adorner on top of it, telling the user to go away and not come back… The Adorner can be styled using a ControlTemplate, a little bit like the Validation.ErrorTemplate, so you can make it less obnoxious; if anyone can tell me how to achieve what I originally wanted to do, I’ll be forever grateful.

Disabling the GoToPage Command

In the example, it makes no sense to try to go to the details page if no object is selected in the list. In cases like this, it is possible to disable the GoToPage command by setting the Navigation.CanNavigate property on the element that invokes the command. This can be done through a trigger, like in the example:

<Style TargetType="Button">
    <Style.Triggers>
        <Trigger Property="DataContext" Value="{x:Null}">
            <Setter Property="n:Navigation.CanNavigate" Value="False"/>
        </Trigger>
    </Style.Triggers>
</Style>

Or it could be done by binding this property to a property in a ViewModel.

Blocking Navigation Completely

Sometimes, it might be necessary to block navigation completely. On the details page, there is a ValidationRule that demands that the Name property contains a value. By binding the Navigation.BlockNavigation property to Validation.HasError on the Page element, one can disable all navigation away from the page until this property is filled. In order to support this behaviour, the following event handler is attached to the NavigationService.Navigating event:

Private Shared Sub BlockNavigationHandler(ByVal sender As Object, ByVal e As System.Windows.Navigation.NavigatingCancelEventArgs)
    Dim service As NavigationService = GetNavigationService(sender)
    If service IsNot Nothing Then
        If service.Content IsNot Nothing Then
            e.Cancel = GetBlockNavigation(service.Content)
            'Allow Fragment navigation
            If e.Cancel And Uri.Compare(service.CurrentSource, service.Source, UriComponents.PathAndQuery, UriFormat.UriEscaped, StringComparison.InvariantCulture) = 0 Then
                e.Cancel = False
            End If
        End If
    End If
End Sub

 

Testing the Component

You will find the example application NavigationExample in the WPFGluePublished solution on the Downloads page. In this example, each page contains a “GC” button which forces the .Net garbage collection. By running the example in the debugger and following the debug output, you can see the lifetime events of the pages, and by forcing the garbage collection you can verify that the page objects get finalized when they are no longer needed, thus proving that the Navigation.Command didn’t leave behind any dangling references and doesn’t cause any memory leaks.

By adding some customers, editing their details, removing them, and going back using the NavigationWindow’s Back button, you can test the behaviour for pages whose DataContext has expired.

Conclusion

This example shows how it is possible to pass objects from page to page using XAML only, and how one can handle forwards and backwards navigation using object references. However, there are still some limitations: I don’t really like displaying expired pages, and since the Session object uses weak references, it might not be suitable for partial trust scenarios where there is no permission to execute unmanaged code.

In posts to come, I want to explore the possibilities of defining a navigation topology as a central resource. However, for this I will need the complete Sticky Component Framework, about which I will write as soon as possible…

Advertisements
  1. No comments yet.
  1. December 16, 2009 at 07:50

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: