Last time on, on Dotnetbyexample…
In the first post I described how to write the business logic to find a location by searching for an address by text, and in the second post I described how to do some actual routing – still, only business logic and viewmodels. And we kept in mind all the results should be tombstonable, and how to make sure this all worked by using simple unit/integration tests.
Today, it’s time for the actual app. Bear in mind this post will actually refer to a lot of earlier other posts – from even outside this series. In this post I am also going to show you that working with you designer sometimes means you need to make little tweaks to your viewmodels to make it work the way you want, or make life easier on the designer. Remember, you are the technician, the person who needs to make it finally work.
GUI and interaction design
So, after conferring with the designer we have agreed the app should work like this:
The route is displayed as a line, every maneuver as a star, and when you tap that star it should show a panel showing the maneuver description. For interested parties – this is a car route I could take to my home to my work at Vicrea ;-)
Well – the moving panels are easy to solve with some View States and DataTriggers – I’ve been down that road before. Showing a line and points on a map from the view model – been there done that, wrote behaviors for that and those are snugly in the wp7nl library which is, not quite coincidental, part of this solution already.
So, we need to do the following:
- Define the app user interface, that is:
- Two panels enabling the user to input text and select a route, e.g. a user interface for both the GeocodeViewModels in RoutingViewmodel. We’ll make a user control from that
- A panel with From/To info making showing the address from an to, and making it possible to let the From and To panel into view. This will be a user control as well
- A button firing off the RoutingViewmodel’s DoRoutingCommand
- A panel that’s shown as the user taps a maneuver. This, too, will be a user control.
- A Map
- A MainViewModel that’s acting as a kind of locator and a serialization root point, like I always do.
- Something to handle the view state.
Adding ViewState management
Just like I did before in this post, I added a DisplayState enumeration in de NavigationDemo.Logic project, like this:
namespace NavigationDemo.Logic.States { public enum DisplayState { Normal = 0, SearchFrom = 1, SearchTo = 2, ShowManeuver = 3 } }one state for every panel, and a “Normal” state for every other panel. And then I decided to take the easy way out and add the state control to the RoutingViewmodel, basically to make data binding easier. Of course I could have made a separate view model for this, but what the heck, it’s only a few lines of code anyway:
[DoNotSerialize] public ICommand DisplayPopupCommand { get { return new RelayCommand<string>( p => { DisplayState = (DisplayState)Enum.Parse(typeof(DisplayState), p); }); } } private DisplayState displayState; public DisplayState DisplayState { get { return displayState; } set { if (displayState != value) { displayState = value; RaisePropertyChanged(() => DisplayState); } } }
It makes life easier on the designer, he does not have mess around with data context so much. The property is the actual storage for the DisplayState, and the command sets the display state to it's parameter. All described before. The final piece is a little adaption in the SelectedManeuver property, there we need to add in the setter:
DisplayState = SelectedManeuver != null ? DisplayState.ShowManeuver : DisplayState.Normal;directly behind the RaisePropertyChanged. This will have the panel showed automatically when a non-null value is detected after the setter has been accessed.
I tend to put user controls into a separate folder UserControls (never been one for original naming conventions anyway). The design of the GeocodeControl looks like this:
And I’ve put it in XAML like this:
<UserControl.Resources> <DataTemplate x:Key="AddressTemplate"> <Grid> <TextBlock HorizontalAlignment="Left" TextWrapping="Wrap" Text="{Binding Address}"
VerticalAlignment="Top"/> </Grid> </DataTemplate> </UserControl.Resources> <Grid x:Name="LayoutRoot" Background="{StaticResource PhoneChromeBrush}"> <Grid Margin="12,0" > <!-- Definitions omitted --> <Button Grid.Column="2" Style="{StaticResource RoundButton}" Command="{Binding SearchLocationCommand, Mode=OneWay}" VerticalAlignment="Bottom" HorizontalAlignment="Left" Height="72" Width="72"
Margin="0,0,-5,0" > <Rectangle Fill="{StaticResource PhoneForegroundBrush}" Width="44" Height="44" > <Rectangle.OpacityMask> <ImageBrush ImageSource="/images/feature.search.png" Stretch="Fill"/> </Rectangle.OpacityMask> </Rectangle> </Button> <TextBlock TextWrapping="Wrap" Text="Search" VerticalAlignment="Center"
Height="27" Margin="0,23,0,22" /> <TextBlock TextWrapping="Wrap" Text="Found" Grid.Row="1"
VerticalAlignment="Center" Height="27"/> <TextBox Grid.Column="1" TextWrapping="NoWrap"
Text="{Binding SearchText, Mode=TwoWay}"> <i:Interaction.Behaviors> <Behaviors:TextBoxChangeModelUpdateBehavior/> </i:Interaction.Behaviors> </TextBox> <ListBox Grid.Column="1" Grid.Row="1" Margin="12" ItemsSource="{Binding MapLocations}" SelectedItem="{Binding SelectedLocation,Mode=TwoWay}" ItemTemplate="{StaticResource AddressTemplate}"/> <Button Content="Done" Grid.Row="2" Grid.Column="1" VerticalAlignment="Center" Margin="0,23,0,22" Height="71" Command="{Binding DoneCommand}"/> </Grid> </Grid>
There will be two instances of this user control – one to determine the “From” address, and one for the “”To” address.
There are some things to note here, all underlined in red:
- I am using a RoundButton style to get a round button, to show an standard image feature_search.png. I pulled this ages ago from this post by Alex Yakhnin if I am not mistaken.
- There is no data context set, therefore, the data context – a GeocodeViewModel – should be set in the parent element. No problem. But the DoneCommand, which should dismiss the panel, is therefore also in the GeocodeViewModel, and there is no code for that. Worse, the actual state control logic is in a totally different view model – in this case the RoutingViewmodel. Two view models, having no real knowledge of each other, yet needing to get some data across – that spells m-e-s-s-e-n-g-e-r.
Adding some Messenger magic
We define a simple message “DoneMessage” that, when received by the RoutingViewmodel (that also keeps the view state) will dismiss all popups. It’s pretty easy to implement:
namespace NavigationDemo.Logic.Messages { public class DoneMessage{ } }
And in the RoutingViewmodel, in the constructor, just one line:
Messenger.Default.Register<DoneMessage>(this,
msg => DisplayState = DisplayState.Normal);
Well, okay, one line split in two ;). But anyway, this allows to add a DoneCommand in GeocodeViewModel simply like this:
[DoNotSerialize] public ICommand DoneCommand { get { return new RelayCommand(() => Messenger.Default.Send(new DoneMessage())); } }
And boom – executing the DoneCommand in RoutingViewmodel will reset the DisplayState to Normal, dismissing all popups. That is, as soon as we have implemented the DataTriggers and the ViewStates ;-)
This is the thing used to scroll the GeocodeControls into view, looking like this
<Grid Height="144" VerticalAlignment="Top" Background="#7F000000"> <Grid Margin="12,0"> <Grid > <!-- Definitions omitted --> <TextBlock TextWrapping="Wrap" Text="From" VerticalAlignment="Top"
Margin="0,12,0,0" Height="27" /> <TextBlock TextWrapping="Wrap" Text="To" Grid.Row="1" VerticalAlignment="Top"
Margin="0,12,0,0" Height="27"/> <TextBlock TextWrapping="Wrap" Text="{Binding FromViewModel.SelectedLocation.Address, Mode=TwoWay}" Grid.Column="1" VerticalAlignment="Top" Margin="0,12,0,0" /> <TextBlock TextWrapping="Wrap" Text="{Binding ToViewModel.SelectedLocation.Address, Mode=TwoWay}" Grid.Row="1" Grid.Column="1" VerticalAlignment="Top" Margin="0,12,0,0" /> <Button Grid.Column="2" Style="{StaticResource RoundButton}" Command="{Binding DisplayPopupCommand, Mode=OneWay}" CommandParameter="SearchFrom" VerticalAlignment="Center" HorizontalAlignment="Left" Height="72"
Width="72" Margin="0,0,-10,0" > <!-- Rounded button stuff omitted --> </Button> <Button Grid.Column="2" Grid.Row="1" Style="{StaticResource RoundButton}" Command="{Binding DisplayPopupCommand, Mode=OneWay}" CommandParameter="SearchTo" VerticalAlignment="Center" HorizontalAlignment="Left" Height="72" Width="72"
Margin="0,0,-10,0"> <!-- Rounded button stuff omitted --> </Button> </Grid> </Grid> </Grid>
for the sake of brevity I cut some things out of the XAML. Most interesting to note here, once again red and underlined:
- The 3nd and the 4th TextBlock show the Address of the Selected location of the From and the To GeocodeViewModel
- The buttons both call the same command, but with a parameter – this will determine which popup appears. Or actually, the viewstate which will be selected, but that amounts to the same
This is the popup that appears from the side, as the user has tapped on a start.
It’s a pretty simple piece, both how it looks and in XAML:
<Grid x:Name="LayoutRoot" Background="{StaticResource PhoneChromeBrush}" Opacity="0.9"> <Grid Margin="12,0" > <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="76"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> </Grid.ColumnDefinitions> <TextBlock Grid.Row="0" TextWrapping="Wrap" Text="{Binding Description}" HorizontalAlignment="Center"/> <Button Content="Close" Grid.Row="1" VerticalAlignment="Center" Height="71" Width="313" Command="{Binding DoneCommand}" /> </Grid> </Grid>
With only one thing really to note – this popup needs to be able to be dismissed to – so we can implement the exact same DoneCommand as in GeocodeViewModel and put it into ManeuverViewModel. Copy – paste – done. Of course you can also make a base class for both viewmodels and making both GeocodeViewModel ManeuverViewModel child classes of it. This won’t save you much code in this case though.
MainViewModel
This is kinda not very interesting – see for an explanation on usage this post, that’s quite old already. MainViewModel is the root for all view model, used as a starting point for data binding and tomb stoning But this is what we are going to use a binding root. How the initialization and tombstoning from App.xaml.cs is working is described in the same post, some I am not going to repeat that. This particular MainViewModel looks like this:
using GalaSoft.MvvmLight; using NavigationDemo.Logic.Models; namespace NavigationDemo.Logic.ViewModels { public class MainViewModel : ViewModelBase { public NavigationModel Model { get; set; } public MainViewModel() { } public MainViewModel(NavigationModel model) { Model = model; } private RoutingViewmodel routingViewModel; public RoutingViewmodel RoutingViewModel { get { if (routingViewModel == null) { routingViewModel = new RoutingViewmodel(Model); } return routingViewModel; } set { if (routingViewModel != value) { routingViewModel = value; RaisePropertyChanged(() => RoutingViewModel); } } } private static MainViewModel instance; public static MainViewModel Instance { get { return instance; } set { instance = value; } } public static MainViewModel CreateNew() { if (instance == null) { instance = new MainViewModel(new NavigationModel()); } return instance; } } }
We define it as a data source in App.Xaml
<ViewModels:MainViewModel x:Key="MainViewModelDataSource"/>Which requires the following definition in your App.xaml header:
xmlns:ViewModels="clr-namespace:NavigationDemo.Logic.ViewModels;assembly=NavigationDemo.Logic"
MainPage.xaml – the main gui
Initially, this has four major parts:
- The header
- The Map
- The three routing panels (2x GeocodeControl + 1x LocationPanel)
- The ManeuverPopup
- The search button.
Now the header is simple enough:
<Grid x:Name="LayoutRoot" Background="Transparent" DataContext="{Binding Instance, Source={StaticResource MainViewModelDataSource}}"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28"> <TextBlock Text="NAVIGATE BY MVVMLIGHT" Style="{StaticResource PhoneTextNormalStyle}" Margin="12,0"/> </StackPanel>
The only more or less interesting part is the data binding. Then comes the map. This uses my MapShapeDrawBehavior to bind the shapes to map:
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0" DataContext="{Binding RoutingViewModel}"> <maps:Map Wp8nl_MapBinding:MapBindingHelpers.MapArea="{Binding ViewArea}" Center="{Binding MapCenter, Mode=TwoWay}" ZoomLevel="{Binding ZoomLevel, Mode=TwoWay}"> <i:Interaction.Behaviors> <MapBinding:MapShapeDrawBehavior LayerName="Route" ItemsSource="{Binding RouteCoordinates}" PathPropertyName="Geometry"> <MapBinding:MapShapeDrawBehavior.ShapeDrawer> <MapBinding:MapPolylineDrawer Color="Green" Width="10"/> </MapBinding:MapShapeDrawBehavior.ShapeDrawer> </MapBinding:MapShapeDrawBehavior> <MapBinding:MapShapeDrawBehavior LayerName="Maneuvers" ItemsSource="{Binding Maneuvers}" PathPropertyName="Location"> <MapBinding:MapShapeDrawBehavior.ShapeDrawer> <MapBinding:MapStarDrawer Color="Red" Arms="8" InnerRadius="25" OuterRadius="50"/> </MapBinding:MapShapeDrawBehavior.ShapeDrawer> <MapBinding:MapShapeDrawBehavior.EventToCommandMappers> <MapBinding:EventToCommandMapper EventName="Tap" CommandName="SelectCommand"/> </MapBinding:MapShapeDrawBehavior.EventToCommandMappers> </MapBinding:MapShapeDrawBehavior> </maps:Map>
Things of notice here:
- The grid where the map is in (and incidentally, almost the whole user interface, has the RoutingViewModel as it’s data context.
- Map view area is not bindable so that’s bound using a simple attached dependency property, I will include this in the coming version for the wp7nl library on codeplex but skip it for now. Center and ZoomLevel are simple direct property bindings
- The actual route is a green line, bound to RouteCoordinates. TheMapShapeDrawBehavior actually expects a list of objects, so we gave it a list, remember from the previous post?
- The Maneuvers are display as red stars, by binding a MapShapeDrawBehavior to the Maneuvers list. If one is tapped, the SelectCommand on the ManeuverViewModel is fired, causing the the selected maneuver to be sent over the Messenger
Next, are the three panels:
<UserControls:LocationsPanel VerticalAlignment="Top"/> <UserControls:GeocodeControl x:Name="GeocodeFrom" VerticalAlignment="Top" RenderTransformOrigin="0.5,0.5" DataContext="{Binding FromViewModel}"> <UserControls:GeocodeControl.RenderTransform> <CompositeTransform TranslateY="-326"/> </UserControls:GeocodeControl.RenderTransform> </UserControls:GeocodeControl> <UserControls:GeocodeControl x:Name="GeocodeTo" VerticalAlignment="Top" RenderTransformOrigin="0.5,0.5" DataContext="{Binding ToViewModel}"> <UserControls:GeocodeControl.RenderTransform> <CompositeTransform TranslateY="-326"/> </UserControls:GeocodeControl.RenderTransform> </UserControls:GeocodeControl> <UserControls:ManeuverPopup x:Name="maneuverPopup" VerticalAlignment="Bottom" Height="151" RenderTransformOrigin="0.5,0.5" Margin="0,0,0,72" DataContext="{Binding SelectedManeuver}"> <UserControls:ManeuverPopup.RenderTransform> <CompositeTransform TranslateX="470"/> </UserControls:ManeuverPopup.RenderTransform> </UserControls:ManeuverPopup>
Actually rather straightforward. From panel binds to FromViewModel, To panel to ToViewModel, and the maneuverPopup to SelectedManeuver.
The last part ain’t rocket sciece: just a simple BindableApplicationBar with one button executing the actual routing command:
<phone7Fx:BindableApplicationBar BarOpacity="0.9" > <phone7Fx:BindableApplicationBarIconButton Command="{Binding RoutingViewModel.DoRoutingCommand}" IconUri="/images/feature.search.png" Text="Route" /> </phone7Fx:BindableApplicationBar>
The only thing to notice is that since it’s sitting outside the content panel, in the main grid, it’s data content is het MainViewModel to I have to prepend the RoutingViewModel again.
DataTrigger Animation Magic
I am going to keep this simple: if you want to know how to create this, I suggest you read my article on ViewModel driven multi-state animations using DataTriggers and Blend on Windows Phone. I have created the following visual states:
<VisualStateManager.CustomVisualStateManager> <ei:ExtendedVisualStateManager/> </VisualStateManager.CustomVisualStateManager> <VisualStateManager.VisualStateGroups> <VisualState x:Name="Normal"/> <VisualStateGroup x:Name="Search"> <VisualStateGroup.Transitions> <VisualTransition GeneratedDuration="0:0:0.5"/> </VisualStateGroup.Transitions> <VisualState x:Name="SearchFrom"> <Storyboard> <DoubleAnimation To="0" Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateY)" Storyboard.TargetName="GeocodeFrom"/> </Storyboard> </VisualState> <VisualState x:Name="SearchTo"> <Storyboard> <DoubleAnimation To="0" Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateY)" Storyboard.TargetName="GeocodeTo"/> </Storyboard> </VisualState> <VisualState x:Name="ShowManeuver"> <Storyboard> <DoubleAnimation Duration="0" To="0" Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="maneuverPopup" /> </Storyboard> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups>You can see “Search” moves “GeocodeFrom” into view, “SearchTo” moves GeocodeTo into view, and “ShowManeuver” shows the maneuverPopup. And Normal is the base state, basically moving everything back to it’s base place, i.e. out of the screen. I also created these data triggers.
<i:Interaction.Triggers> <ei:DataTrigger Binding="{Binding DisplayState}" Value="0"> <ei:GoToStateAction StateName="Normal" /> </ei:DataTrigger> <ei:DataTrigger Binding="{Binding DisplayState}" Value="1"> <ei:GoToStateAction StateName="SearchFrom" /> </ei:DataTrigger> <ei:DataTrigger Binding="{Binding DisplayState}" Value="2"> <ei:GoToStateAction StateName="SearchTo" /> </ei:DataTrigger> <ei:DataTrigger Binding="{Binding DisplayState}" Value="3"> <ei:GoToStateAction StateName="ShowManeuver" /> </ei:DataTrigger> </i:Interaction.Triggers>
These are all sitting inside the contentpanel, right above the map.
And finally… well… not really
If you run the app now – or download the sample solution, lo and behold – the app works as designed. That is … until you press the start button and start the app anew. You will notice the fact that although the right location is displayed, the route is not, the LocationPanel is empty again, but the search text is not. How the hell is this possible? We have written test till the cows came home!
Well, I can tell you we have run in some very subtle serialization bugs. Fixing those bugs is what we are going to do in the next and last episode. Like I said in the previous episode, no amount of unit and/or integration testing is going to free you from manually testing your app – is just a tool to increase, maintain and guarantee the quality of some parts of your app.
No comments:
Post a Comment