Статьи

Навигация по Windows Phone 8, часть 3. Сборка приложения MVVMLight

В прошлый раз, на Dotnet, например …

В  первом посте  я описал, как написать бизнес-логику для поиска местоположения путем поиска адреса по тексту, а во  втором посте  я описал, как сделать некоторую фактическую маршрутизацию — все же, только бизнес-логику и модели представления. И мы помнили, что все результаты должны быть впечатляющими, и как убедиться, что все это работает, используя простые модульные / интеграционные тесты.

Сегодня пришло время для самого приложения. Имейте в виду, что этот пост на самом деле будет ссылаться на многие другие посты, даже ранее, даже вне этой серии. В этом посте я также собираюсь показать вам, что работа с вашим дизайнером иногда означает, что вам нужно внести небольшие изменения в ваши модели представления, чтобы заставить его работать так, как вы хотите, или облегчить жизнь дизайнера. Помните, что вы — техник, человек, которому нужно, чтобы он наконец заработал.

GUI и дизайн взаимодействия

Маршрут отображается в виде линии, каждый маневр — в виде звезды, и когда вы нажимаете на эту звезду, на ней должна отображаться панель с описанием маневра. Для заинтересованных сторон — это автомобильный маршрут, по которому я могу добраться до своего дома на работу в  Викреа  ?

Что ж, движущиеся панели легко решить  с помощью некоторых состояний просмотра и DataTriggers — я уже шел этим путем . Показывая  линию и точки на карте из модели представления — там уже было сделано , они написали поведения для этого, и они аккуратно находятся в  библиотеке wp7nl,  которая, не совсем случайно, уже является частью этого решения.

Итак, нам нужно сделать следующее: 

  • Определите пользовательский интерфейс приложения, а именно:
    • Две панели, позволяющие пользователю вводить текст и выбирать маршрут, например, пользовательский интерфейс для обеих GeocodeViewModels в RoutingViewmodel. Мы сделаем пользовательский контроль из этого
    • Панель с информацией «От / К», отображающая адрес от «до» и позволяющая отображать панель «От и К». Это будет также пользовательский элемент управления
    • Кнопка, запускающая команду DoRoutingCommand RoutingViewmodel
    • Панель, которая отображается, когда пользователь нажимает на маневр. Это тоже будет пользовательский элемент управления.
    • Карта
  • MainViewModel, который действует как своего рода локатор и корневая точка сериализации, как я всегда делаю.
  • Что-то для обработки состояния просмотра.

Добавление управления ViewState

Как и раньше в этом посте , я добавил перечисление DisplayState в проект de NavigationDemo.Logic, например:

namespace NavigationDemo.Logic.States
{
  public enum DisplayState
  {
    Normal = 0,
    SearchFrom = 1,
    SearchTo = 2,
    ShowManeuver = 3
  }
}

одно состояние для каждой панели и «нормальное» состояние для каждой другой панели. И тогда я решил пойти простым путем и добавить элемент управления состоянием в RoutingViewmodel, чтобы облегчить привязку данных. Конечно, я мог бы создать для этого отдельную модель представления, но, черт возьми, это всего лишь несколько строк кода:

[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);
    }
  }
}    

Это облегчает жизнь дизайнеру, он не слишком возится с контекстом данных. Свойство является фактическим хранилищем для DisplayState, и команда устанавливает состояние отображения для его параметра. Все описано ранее. Последняя часть — это небольшая адаптация в свойстве SelectedManeuver, которую мы должны добавить в установщик:

DisplayState = SelectedManeuver != null ? DisplayState.ShowManeuver : DisplayState.Normal;

непосредственно за RaisePropertyChanged. При этом панель будет автоматически отображаться при обнаружении ненулевого значения после обращения к установщику. 

образGeocodeViewModel пользовательский интерфейс

Я склонен помещать пользовательские элементы управления в отдельную папку UserControls (во всяком случае, для оригинальных соглашений об именах никогда не было). Дизайн GeocodeControl выглядит следующим образом:

И я поместил это в XAML так:

<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>

Будет два экземпляра этого пользовательского элемента управления — один для определения адреса «От» и один для адреса «До».

Здесь есть некоторые вещи, которые  подчеркнуты красным :

  • Я использую стиль RoundButton, чтобы получить круглую кнопку, чтобы показать стандартное изображение feature_search.png. Я вытащил это много лет назад из  этого поста  Алекса Яхнина, если я не ошибаюсь.
  • Не задан контекст данных, поэтому в родительском элементе должен быть задан контекст данных — GeocodeViewModel. Нет проблем. Но DoneCommand, который должен закрывать панель, также находится в GeocodeViewModel, и для этого нет кода. Хуже того, фактическая логика управления состоянием находится в совершенно другой модели представления — в данном случае RoutingViewmodel. Две модели представления, не имеющие реального знания друг о друге, но нуждающиеся в передаче некоторых данных, — это заклинание мессенджер.

Добавление магии Messenger

Мы определяем простое сообщение «DoneMessage», которое при получении RoutingViewmodel (которое также сохраняет состояние представления) будет отклонять все всплывающие окна. Это довольно легко реализовать:

namespace NavigationDemo.Logic.Messages
{
  public class DoneMessage{ }
}

А в RoutingViewmodel, в конструкторе, только одна строка:

Messenger.Default.Register<DoneMessage>(this, 
    msg => DisplayState = DisplayState.Normal);

Ну, ладно, одна строка разделена на две;). Но в любом случае это позволяет добавить DoneCommand в GeocodeViewModel просто так:

[DoNotSerialize]
public ICommand DoneCommand
{
  get
  {
    return new RelayCommand(() => Messenger.Default.Send(new DoneMessage()));
  }
}

И бум — выполнение DoneCommand в RoutingViewmodel приведет к сбросу DisplayState в Normal, отклоняя все всплывающие окна. То есть, как только мы реализовали DataTriggers и ViewStates ?

образLocationPanel

Это то, что используется для прокрутки GeocodeControls в виде, похожем на это

<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>

или ради краткости я вырезал некоторые вещи из XAML. Здесь интересно отметить еще раз  красный и подчеркнутый :

  • 3-й и 4-й текстовые блоки показывают адрес выбранного местоположения объектов From и To GeocodeViewModel
  • Обе кнопки вызывают одну и ту же команду, но с параметром — это определит, какое всплывающее окно появится. Или на самом деле, состояние просмотра, которое будет выбрано, но это равно

образManeuverPopup

Это всплывающее окно, которое появляется сбоку, когда пользователь нажал на старт.

Это довольно простая часть, как выглядит, так и в 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>

Нужно только отметить одну вещь — это всплывающее окно нужно уволить, чтобы мы могли реализовать тот же DoneCommand, что и в GeocodeViewModel, и поместить его в ManeuverViewModel. Копировать — вставить — сделано. Конечно, вы также можете создать базовый класс для обеих моделей представления и сделать оба GeocodeViewModel ManeuverViewModel его дочерними классами. Это не сэкономит вам много кода в этом случае.

MainViewModel

Это не очень интересно —  см. Объяснение использования этого поста , он уже довольно старый. MainViewModel является корнем для всей модели представления, используемой в качестве отправной точки для привязки данных и забивания камнями. Но это то, что мы собираемся использовать для привязки корня. Как работает инициализация и захоронение из App.xaml.cs, описано в том же посте, некоторые я не собираюсь повторять. Этот конкретный MainViewModel выглядит так:

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;
    }
  }
}

Мы определяем его как источник данных в App.Xaml

<ViewModels:MainViewModel x:Key="MainViewModelDataSource"/>

Для чего требуется следующее определение в заголовке вашего App.xaml:

xmlns:ViewModels="clr-namespace:NavigationDemo.Logic.ViewModels;assembly=NavigationDemo.Logic"

MainPage.xaml — основной интерфейс

Изначально это состоит из четырех основных частей:

  • Заголовок
  • Карта
  • Три панели маршрутизации (2x GeocodeControl + 1x LocationPanel)
  • ManeuverPopup
  • Кнопка поиска.

Теперь заголовок достаточно прост:

<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>

Единственная более или менее интересная часть — это привязка данных. Затем идет карта. Это использует мой  MapShapeDrawBehavior  для привязки фигур к карте:

<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>

Вещи заметить здесь:

  • Сетка, в которой находится карта (и, кстати, почти весь пользовательский интерфейс, имеет RoutingViewModel в качестве контекста данных).
  • Область просмотра карты не является привязываемой, поэтому она ограничена с помощью простого присоединенного свойства зависимости, я включу это в следующую версию для Библиотека wp7nl на codeplex,  но пока пропустите. Center и ZoomLevel — это простые прямые привязки свойств
  • Фактический маршрут — зеленая линия, привязанная к RouteCoordinates. TheMapShapeDrawBehavior фактически ожидает список объектов, поэтому мы  дали  ему список, помните из предыдущего поста?
  • Маневры отображаются в виде красных звезд, привязывая MapShapeDrawBehavior к списку Маневров. Если нажать одну из них, запускается команда SelectCommand в ManeuverViewModel, в результате чего выбранный маневр отправляется через Messenger.

Далее идут три панели:

<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>

На самом деле довольно просто. С панели привязывается к FromViewModel, к панели To toModel и от maneuverPopup к SelectedManeuver.

Последняя часть не является ракетой: просто BindableApplicationBar с одной кнопкой, выполняющей фактическую команду маршрутизации:

<phone7Fx:BindableApplicationBar BarOpacity="0.9" >
  <phone7Fx:BindableApplicationBarIconButton Command="{Binding RoutingViewModel.DoRoutingCommand}"
                                             IconUri="/images/feature.search.png"
                                             Text="Route" />
</phone7Fx:BindableApplicationBar>

Единственное, на что следует обратить внимание, так это то, что, поскольку он находится за пределами панели содержимого в основной сетке, его содержимое данных — это MainViewModel, так что мне нужно снова добавить RoutingViewModel.

DataTrigger Анимация Магия

Я собираюсь сохранить это простым: если вы хотите знать, как создать это, я предлагаю вам прочитать мою статью о многослойной анимации, управляемой ViewModel, с использованием DataTriggers и Blend на Windows Phone . Я создал следующие визуальные состояния:

<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>

Вы можете видеть, что «Поиск» перемещает «GeocodeFrom» в поле зрения, «SearchTo» перемещает GeocodeTo в представление, а «ShowManeuver» показывает maneuverPopup. А Normal — это базовое состояние, в основном все перемещается обратно на свое базовое место, т.е. за пределы экрана. Я также создал эти триггеры данных.

<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>

Все они находятся внутри контентной панели, прямо над картой.

И наконец … ну … не совсем

Если вы запустите приложение сейчас или загрузите пример решения, вот и все, — приложение работает как задумано. То есть … пока вы не нажмете кнопку запуска и не запустите приложение заново. Вы заметите тот факт, что, хотя отображается правильное местоположение, маршрут не отображается, LocationPanel снова пуст, но текст поиска не отображается. Как, черт возьми, это возможно? Мы написали тест, пока коровы не вернулись домой!

Что ж, я могу сказать вам, что мы столкнулись с некоторыми очень тонкими ошибками сериализации. Исправление этих ошибок — это то, что мы собираемся сделать в следующем и последнем эпизоде. Как я уже говорил в предыдущем выпуске, никакое количество модульных и / или интеграционных тестов не избавит вас от ручного тестирования вашего приложения — это всего лишь инструмент для повышения, поддержки и гарантии качества некоторых частей вашего приложения.

В любом случае —  решение пока можно скачать здесь .