Wednesday, March 9, 2011

Jounce Part 12: Providing History-Based Back Navigation

I purposefully kept the navigation engine in Jounce light because there are so many opinions about how navigation should work and what makes sense for a specific application. A foundation is provided to raise navigation events and wire view models to views, but the rest of navigation is a mix of region management and user interaction. One common interaction is to give the user the ability to "go back." This is a feature users are used to in their browser, but isn't always implemented well in applications (although the Windows Phone 7 forces this paradigm through the criteria around the hardware back button).

Jounce has everything needed to store the stack of navigated pages and provide the "go back" functionality. First thing is to create the stack of pages. For this short example, I'll provide a clock, a fibonacci sequence generator, and some text. The clock view model looks like this:

[ExportAsViewModel("Clock")]
public class ClockViewModel : ContentViewModel  
{
    public ClockViewModel()
    {
        if (InDesigner)
        {
            Time = DateTime.Now.ToLongTimeString();
        }
    }

    public string Time { get; set; }

    public override void _Initialize()
    {
        WorkflowController.Begin(ClockWorkflow());
        base._Initialize();
    }

    public IEnumerable<IWorkflow> ClockWorkflow()
    {
        var workflowDelay = new WorkflowDelay(TimeSpan.FromSeconds(1));

        while (true)
        {
            yield return workflowDelay;
            Time = DateTime.Now.ToLongTimeString();
            JounceHelper.ExecuteOnUI(()=>RaisePropertyChanged(()=>Time));
        }
    }
}

Instead of using the dispatcher timer, I'm using the workflow implementation to advance the clock every second. You'll notice the clock view model is derived from a content view model. The content view model provides a standard visual state experience:

public abstract class ContentViewModel : BaseViewModel 
{
    public override void _Activate(string viewName)
    {
        GoToVisualState("VisibleState", true);
        base._Activate(viewName);
    }

    public override void _Deactivate(string viewName)
    {
        GoToVisualState("InvisibleState", true);
        base._Deactivate(viewName);
    }
}

This keeps me from having to duplicate the visual state switching in every view model and just derive from the common one instead. The view has some transitions built-in so that you can see transition effects as navigation takes place:

<Grid x:Name="LayoutRoot" Background="Transparent" d:DataContext="{d:DesignInstance sampledata:ClockViewModel, IsDesignTimeCreatable=True}">
    <Grid.RenderTransform>
        <TranslateTransform/>
    </Grid.RenderTransform>
    <Grid.Projection>
        <PlaneProjection CenterOfRotationX="0"/>
    </Grid.Projection>
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="VisibilityStates">
            <VisualState x:Name="VisibleState">
                <Storyboard TargetName="LayoutRoot" TargetProperty="(UIElement.Visibility)">
                    <ObjectAnimationUsingKeyFrames>
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>
                                <Visibility>Visible</Visibility>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </VisualState>
            <VisualState x:Name="InvisibleState">
                <Storyboard TargetName="LayoutRoot" TargetProperty="(UIElement.Visibility)">
                    <ObjectAnimationUsingKeyFrames>
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>
                                <Visibility>Collapsed</Visibility>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </VisualState>
            <VisualStateGroup.Transitions>
                <VisualTransition To="VisibleState">
                    <Storyboard Duration="0:0:1">
                        <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Opacity)"
                                            From="0" To="1"/>
                        <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(FrameworkElement.RenderTransform).(TranslateTransform.X)"
                                            From="20" To="0"/>
                    </Storyboard>
                </VisualTransition>
                <VisualTransition To="InvisibleState">
                    <Storyboard Duration="0:0:1">
                        <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Opacity)"
                                            From="1" To="0"/>
                        <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(FrameworkElement.Projection).(PlaneProjection.RotationY)"
                                            From="0" To="-90"/>
                    </Storyboard>
                </VisualTransition>
            </VisualStateGroup.Transitions>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>
    <Viewbox HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="5">
        <TextBlock Text="{Binding Time}"/>
    </Viewbox>
</Grid>

What's most important is that the view exports itself with a category and a menu name, and targets the main content region:

[ExportAsView("Clock", Category="Content", MenuName = "Big Clock")]
[ExportViewToRegion("Clock", "ContentRegion")]
public partial class Clock
{
    public Clock()
    {
        InitializeComponent();
    }
}

Next I can create a navigation view model. The navigation view model will be responsible for a few tasks. First, it will query the view model router and get the list of views that are on the "content" category. It will sort and expose these in a list for the navigation control to bind to. It will also provide a "selected view" property that is bound to the selected item of the list box, so clicking on a menu name will automatically change the selection and fire the navigation to the separate view. Finally, it will listen for navigation events so if navigation occurs somewhere else, it can update the selected item so the menu stays in synch.

[ExportAsViewModel("Navigation")]
public class NavigationViewModel : BaseViewModel, IEventSink<ViewNavigationArgs>
{
    public NavigationViewModel()
    {
        Menu = new ObservableCollection<Tuple<string, string>>();

        if (!InDesigner) return;

        Menu.Add(Tuple.Create("Clock", "A Gigantic Clock"));
        Menu.Add(Tuple.Create("Fibonacci", "The Fibonacci Ratio"));
        Menu.Add(Tuple.Create("Shapes", "Random Shapes"));
        SelectedView = Menu[0];
    }

    private Tuple<string,string> _selectedView; 

    public Tuple<string, string> SelectedView
    {
        get { return _selectedView; }
        set 
        { 
            _selectedView = value;
            RaisePropertyChanged(()=>SelectedView);

            if (!InDesigner)
            {
                EventAggregator.Publish(value.Item1.AsViewNavigationArgs());
            }
        }
    }    

    public ObservableCollection<Tuple<string, string>> Menu { get; private set; }

    public override void _Initialize()
    {
        var query = (from vi in ((ViewModelRouter) Router).Views
                        where vi.Metadata.Category.Equals("Content")
                        orderby vi.Metadata.MenuName
                        select Tuple.Create(vi.Metadata.ExportedViewType, vi.Metadata.MenuName)).Distinct();            

        foreach(var item in query)
        {
            Menu.Add(item);
        }

        SelectedView = Menu[0];

        EventAggregator.Subscribe(this);

        base._Initialize();
    }

    public void HandleEvent(ViewNavigationArgs publishedEvent)
    {
        if (publishedEvent.Deactivate)
        {
            return; 
        }

        var item = (from menuItem in Menu
                    where menuItem.Item1.Equals(publishedEvent.ViewType)
                    select menuItem).FirstOrDefault();

        if (item != null)
        {
            _selectedView = item;
            RaisePropertyChanged(()=>SelectedView);
        }
    }
}

Notice that thet selected item is set once the menu list is parsed; this will automatically trigger a navigation to the first item in the list. The XAML to display the menu is straightforward:

<Grid x:Name="LayoutRoot" Background="White" d:DataContext="{d:DesignInstance sampledata:NavigationViewModel, IsDesignTimeCreatable=True}">
    <ListBox SelectedItem="{Binding SelectedView,Mode=TwoWay}" ItemsSource="{Binding Menu}">
        <ListBox.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Orientation="Horizontal"/>
            </ItemsPanelTemplate>
        </ListBox.ItemsPanel>
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Item2}"/>
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Grid>

Now there are several pages and the navigation to get to them. The last piece is to wire up the shell. The shell page has the navigation and content regions, and the ubiquitous back button:

<Grid x:Name="LayoutRoot" Background="White">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <ContentControl Grid.Row="0" 
                    Regions:ExportAsRegion.RegionName="NavigationRegion"
                    HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
                    />
    <ItemsControl 
        Regions:ExportAsRegion.RegionName="ContentRegion"
        Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch"
                    >
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Grid/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
    <Button Content="GoBack" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="5" Grid.Row="1" Command="{Binding BackCommand}"/>
</Grid>

The shell view model will do two things. First, it will keep track of the last page. When a new page is navigated to, it will raise a deactivate navigation event for the previous view. This will call the _Deactivate method on the view model for that view, and transition the state to the hidden state (using the transitions defined earlier). Jounce doesn't assume how you are going to use your views: you might want to have them stacked in the same items control, or overlaid, or automatically swapped out, so the view model must manage the step of deciding that the old views should be deactivated. Notice the main content is an items control but the panel is a grid. This allows the child views to stack on top of each other, and the transitions will simply collapse the pages that are not in the view.

The second thing the shell view model will do is listen to navigation events and store a stack of pages. New pages will be pushed to the stack. If the back button is clicked, it will raise a "go back" event. The handler for the event will pop the last view from the stack and navigate to it. I created it as an event in case there was some other mechanism to fire the back event. Here is the set up - notice if there is nothing in the stack, the back button will be disabled:

[ExportAsViewModel("Main")]
public class MainViewModel : BaseViewModel, IEventSink<ViewNavigationArgs>, IEventSink<GoBack>
{
    private string _currentView = string.Empty;

    private bool _goingBack; 

    private readonly Stack<string> _history = new Stack<string>();

    public MainViewModel()
    {
        BackCommand = new ActionCommand<object>(o => EventAggregator.Publish(new GoBack()),
            o => _history.Count > 0);
    }

    public override void _Initialize()
    {
        EventAggregator.Subscribe<ViewNavigationArgs>(this);
        EventAggregator.Subscribe<GoBack>(this);
        base._Initialize();
        EventAggregator.Publish("Navigation".AsViewNavigationArgs());
    }

    public IActionCommand BackCommand { get; private set; }
}

Notice when the view model is initialized, it subscribes to the actions to track navigations and process the "go back" request, and also publishes a navigation for the navigation bar so the menu will be rendered. The handler for the navigation event:

public void HandleEvent(ViewNavigationArgs publishedEvent)
{
    if (publishedEvent.Deactivate)
    {
        return;
    }

    var viewInfo = (from vi in ((ViewModelRouter) Router).Views
                    where vi.Metadata.ExportedViewType.Equals(publishedEvent.ViewType)
                    select vi.Metadata)
                    .FirstOrDefault();

    if (viewInfo == null || !viewInfo.Category.Equals("Content")) return;

    if (publishedEvent.ViewType.Equals(_currentView)) return;

    if (!string.IsNullOrEmpty(_currentView))
    {
        EventAggregator.Publish(new ViewNavigationArgs(_currentView) {Deactivate = true});

        if (_goingBack)
        {
            _goingBack = false;
        }
        else
        {
            _history.Push(_currentView);
        }
    }

    BackCommand.RaiseCanExecuteChanged();

    _currentView = publishedEvent.ViewType;
}

The handler ignores deactivations. The view information is parsed from the router, and if it's not a "content" navigation, it is also ignored. Otherwise, if it is a new view, the old view is deactivated, then pushed to the history stack. The back command is notified that the stack may have values so it can be enabled. The current view is stored.

When a back command is raised, it is handled like this:

public void HandleEvent(GoBack publishedEvent)
{
    if (_history.Count < 1)
    {
        return; 
    }

    var view = _history.Pop();

    _goingBack = true;

    EventAggregator.Publish(view.AsViewNavigationArgs());
}

The "going back" flag is used to prevent the item that was just popped from the stack from being pushed back on.

That's it ... when you run the program, you can navigate through the pages in any order, and clicking the back button will navigate back in order until the first page is reached, after which the back button will be disabled. You can see the app in action here:


And you can download the source from the Jounce site (it is one of the quick starts.)

Jeremy Likness

No comments:

Post a Comment