Когда я говорю о шаблоне MVVM, люди обычно думают о бизнес-объектах, обернутых ViewModels, которые связывают данные с пользовательским интерфейсом. Обычно это что-то вроде списка людей, новостей, предметов, которые можно купить, что угодно — и обычно эти данные отображаются в виде списка, с небольшим количеством шаблонов, если это не слишком проблематично. Это хорошо само по себе и хороший способ использовать мой любимый шаблон, но с привязкой данных MVVM возможно больше вещей, чем думает большинство людей. Самый забавный способ, который мне удалось обнаружить, — это комбинирование ItemsControl и поведения. Это то, что движет моей игрой Catch’em Birds, И эта статья показывает, как использовать эту технику для создания своего рода компаса. Я также расскажу о том, как «делать вещи в Blend» (например, добавление и настройка поведения) в этой статье.
Для поспешных читателей: «Установка сцены», «Построение моделей» и «Построение модели представления» — это основополагающая работа. Реальные вещи начинаются с « Начального пользовательского интерфейса ».
Установка сцены
- Создайте новое приложение для Windows Phone 7 . Давайте назовем это «HeadsUpCompass». Выберите Windows Phone 7.1 — Дух;).
- Установите мою библиотеку wp7nl из codeplex через NuGet. Это даст вам некоторые из моих вещей, MVVMLight и еще кое-что за один раз.
- Добавьте ссылки на Microsoft.Device.Sensors и Microsoft.Xna.Framework.
Построение моделей
Приложение имеет две модели: CompassDirectionModel — содержит материал, который нужно отобразить, и CompassModel, который проверяет направление компаса с помощью API движения. CompassModel реализован ниже. Я объяснил, как использовать Motion API, чтобы проверить, куда камера смотрит в предыдущем посте, поэтому я пропущу детали здесь. Это в основном та же функциональность, заключенная в модель, с событием, запускаемым в конце:
using System;
using System.Windows;
using Microsoft.Devices.Sensors;
using Microsoft.Xna.Framework;
namespace HeadsUpCompass.Models
{
  public class CompassModel
  {
    Motion motion;
    /// <summary>
    /// Inits this instance.
    /// </summary>
    public void Init()
    {
      // Check to see if the Motion API is supported on the device.
      if (!Motion.IsSupported)
      {
        MessageBox.Show("the Motion API is not supported on this device.");
        return;
      }
      // If the Motion object is null, initialize it and add a CurrentValueChanged
      // event handler.
      if (motion == null)
      {
        motion = new Motion {TimeBetweenUpdates = TimeSpan.FromMilliseconds(250)};
        motion.CurrentValueChanged += MotionCurrentValueChanged;
      }
      // Try to start the Motion API.
      try
      {
        motion.Start();
      }
      catch (Exception)
      {
        MessageBox.Show("unable to start the Motion API.");
      }
    }
        /// <summary>
    /// Stops this instance.
    /// </summary>
    public void Stop()
    {
      motion.Stop();
      motion.CurrentValueChanged -= MotionCurrentValueChanged;
    }
    /// <summary>
    /// Fired when a direction change is detected
    /// </summary>
    void MotionCurrentValueChanged(object sender, 
                                   SensorReadingEventArgs<MotionReading> e)
    {
      var yaw = MathHelper.ToDegrees(e.SensorReading.Attitude.Yaw);
      var roll = MathHelper.ToDegrees(e.SensorReading.Attitude.Roll);
      var pitch = MathHelper.ToDegrees(e.SensorReading.Attitude.Pitch);
      if (roll < -20 && roll > -160)
      {
        SetNewCompassDirection(360 - yaw + 90);
      }
      else if (roll > 20 && roll < 160)
      {
        SetNewCompassDirection(360 - yaw - 90);
      }
      else if (pitch > 20 && pitch < 160)
      {
        SetNewCompassDirection(-yaw );
      }
      else if (pitch < -20 && pitch > -160)
      {
        SetNewCompassDirection(360 - yaw + 180);
      }
    }
    private void SetNewCompassDirection(double compassDirection)
    {
      if (compassDirection > 360)
      {
        compassDirection -= 360;
      }
      if (compassDirection < 0)
      {
        compassDirection += 360;
      }
      if (CompassDirectionChanged != null)
      {
        CompassDirectionChanged(Convert.ToInt32(Math.Round(compassDirection)));
      }
    }
    // Event communicating compass direction change to outside world
    public event CompassDirectionChangedHandler CompassDirectionChanged;
    
    public delegate void CompassDirectionChangedHandler(int newDirection);
  }
}
Материал, который отображается, имеет свою собственную модель и очень прост:
using System.Collections.Generic;
namespace HeadsUpCompass.Models
{
  public class CompassDirectionModel
  {
    public int Direction { get; set; }
    public string Text { get; set; }
    public static IEnumerable<CompassDirectionModel> GetCompassDirections()
    {
      return new List<CompassDirectionModel>
      {
        new CompassDirectionModel {Direction = 0, Text = "N"},
        new CompassDirectionModel {Direction = 45, Text = "NE"},
        new CompassDirectionModel {Direction = 90, Text = "E"},
        new CompassDirectionModel {Direction = 135, Text = "SE"},
        new CompassDirectionModel {Direction = 180, Text = "S"},
        new CompassDirectionModel {Direction = 225, Text = "SW"},
        new CompassDirectionModel {Direction = 270, Text = "W"},
        new CompassDirectionModel {Direction = 315, Text = "NW"}
      };
    }
  }
}
И большинство из них — это статический фабричный метод, который я, будучи ленивым программистом, просто погрузил в класс. Эта модель принимает текст и направление компаса, где она хочет отображаться. Вы можете ограничить или добавить что угодно.
Построение ViewModel
Пока что это не совсем ракетостроение, и не единственный ViewModel, который используется в этом решении:
using System.Collections.ObjectModel;
using System.Windows;
using GalaSoft.MvvmLight;
using HeadsUpCompass.Models;
namespace HeadsUpCompass.ViewModels
{
  public class CompassViewModel : ViewModelBase
  {
    private readonly CompassModel model;
    public CompassViewModel()
    {
      model = new CompassModel();
      model.CompassDirectionChanged += ModelCompassDirectionChanged;
      CompassDirections = 
        new ObservableCollection<CompassDirectionModel>(
          CompassDirectionModel.GetCompassDirections());
      if( !IsInDesignMode) model.Init();
    }
    void ModelCompassDirectionChanged(int newDirection)
    {
      Deployment.Current.Dispatcher.BeginInvoke(
        () => { CompassDirection = newDirection; });
    }
    private int compassDirection;
    public int CompassDirection
    {
      get { return compassDirection; }
      set
      {
        if (compassDirection != value)
        {
          compassDirection = value;
          RaisePropertyChanged(() => CompassDirection);
        }
      }
    }
    private ObservableCollection<CompassDirectionModel> compassDirections;
    public ObservableCollection<CompassDirectionModel> CompassDirections
    {
      get { return compassDirections; }
      set
      {
        if (compassDirections != value)
        {
          compassDirections = value;
          RaisePropertyChanged(() => CompassDirections);
        }
      }
    }
  }
}
ViewModel создает CompassDirectionModel и подписывается на его события, и заполняет наблюдаемую коллекцию CompassDirectionModels — так что в основном это список текстов и направление, в котором они хотят отображаться.
Начальный интерфейс пользователя
Прежде всего, откройте MainPage.xaml, установите shell: SystemTray.IsVisible = «false», SupportedOrientations = «PortraitOrLandscape», а затем удалите сетку «LayoutRoot» и все, что внутри нее (и избавьтесь от примера кода панели приложений, который закомментирован а также, что очищает сцену). Замените это на это:
<Grid x:Name="LayoutRoot" >
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
  </Grid.RowDefinitions >
  <!--TitlePanel contains the name of the application and page title-->
  <StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28">
    <TextBlock x:Name="ApplicationTitle" Text="HeadsUp Compass" 
      Style="{StaticResource PhoneTextNormalStyle}"/>
  </StackPanel>
  <!--ContentPanel - place additional content here-->
  <Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
    <Grid>
      <Grid.RowDefinitions>
        <RowDefinition Height="0.7*"/>
        <RowDefinition Height="0.3*"/>
      </Grid.RowDefinitions >
    <ItemsControl x:Name="CompassItems" ItemsSource="{Binding CompassDirections}"
           Grid.Row="0">
      <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
          <Canvas Background="Transparent" />
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <Grid>
            <TextBlock Text="{Binding Text}" FontSize="48" Foreground="Red"/>
          </Grid>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
    <TextBlock TextWrapping="Wrap" Text="{Binding CompassDirection}" 
       VerticalAlignment="Top" Margin="0,10,0,0" FontSize="48" Foreground="Red" 
       HorizontalAlignment="Center" Grid.Row="1"/>
    </Grid>
  </Grid>
</Grid>
Интересная часть я пометил красным. Это ItemsControl — не более чем простой повторитель. В объявлении, используемом в этой статье, используются два шаблона: ItemsPanelTemplate , который описывает то, на что выводятся все связанные элементы, — в данном случае прозрачный холст, — и ItemTemplate, который описывает, как каждый отдельный элемент в свойстве CompassDirections связывается с элементом управления. отображается само изображение — в этом случае сетка с простым текстом, связанным со свойством Text. Но на ItemTemplate вы можете поместить буквально все, что вы можете мечтать. Включая поведение.
Настройка привязки данных с помощью Expression Blend
Сначала давайте сделаем привязку данных. Это значительно проще, используя Expression Blend. Скомпилируйте приложение и откройте его в Expression Blend. Затем используйте следующий рабочий процесс:
- В правом верхнем углу выберите вкладку «Данные»
- Нажмите на иконку справа, которая дает подсказку «Создать источник данных»
- Выберите «Создать объектный источник данных»
- Выберите «CompassViewModel» в появившемся всплывающем окне.
- Перетащите «CompassViewModel» в «CompassViewModelSource» поверх сетки «LayoutRoot» на панели «Объекты и временная шкала» в левом нижнем углу.
Если вы все сделали правильно, вы должны сразу же увидеть красный ноль в горизонтальной середине экрана чуть ниже центра, и множество текстов, наложенных друг на друга в левом верхнем углу экрана.
Самое интересное, что это приложение уже работает более или менее. Если вы развернете это на устройстве и запустите его, вы уже увидите, что 0 начинает отображать направление компаса в градусах. Но тексты направления компаса по-прежнему накладываются друг на друга в верхнем левом углу. Теперь пришло время для купе де Грас: поведение, которое динамически меняет расположение текстов направления компаса.
Расчет местоположения
Поведение фактически состоит из двух частей: класса LocationCalculator и фактического CompassDirectionDisplayBehavior. Я вытащил фактическое вычисление местоположения из поведения, потому что мне пришлось объединять его методом проб и ошибок — и добавление его в тестовый проект по ссылке и тестирование его с помощью модульных тестов сделало это намного проще. Во всяком случае, сам код довольно маленький: большая часть это комментарии и свойства:
using System;
using System.Windows;
namespace ARCompass.Behaviors
{
  /// <summary>
  /// Calculates screen positions based upon compass locations
  /// </summary>
  public class LocationCalcutator
  {
    /// <summary>
    /// Initializes a new instance of the LocationCalcutator class.
    /// Sets some reasonable defaults
    /// </summary>
    public LocationCalcutator()
    {
      Resolution = (6 * 800 / 360);
      CanvasHeight = 800;
      CanvasWidth = 480;
      ObjectWidth = 10;
    }
    /// <summary>
    /// Gets or sets the resolution (i.e. the pixels per degree
    /// </summary>
    public int Resolution { get; set; }
    /// <summary>
    /// The compass direction where the object to calculate for is located
    /// </summary>
    public int DisplayCompassDirection { get; set; }
    /// <summary>
    /// Gets or sets the width of the canvas.
    /// </summary>
    public double CanvasWidth { get; set; }
    /// <summary>
    /// Gets or sets the height of the canvas.
    /// </summary>
    public double CanvasHeight { get; set; }
    /// <summary>
    /// Gets or sets the width of the object (in pixels)
    /// </summary>
    public double ObjectWidth { get; set; }
    /// <summary>
    /// Sets the horizontal pixels.
    /// </summary>
    /// <param name="pixels">The pixels.</param>
    public void SetHorizontalPixels(double pixels)
    {
      Resolution = Convert.ToInt32(Math.Round(pixels/360));
    }
    /// <summary>
    /// Calculates the screen position.
    /// </summary>
    /// <param name="compassDirection">The compass direction the screen is 
    /// currently looking at.</param>
    /// <returns></returns>
    public Point CalculateScreenPosition(int compassDirection)
    {
      if (!(double.IsNaN(CanvasHeight) || double.IsNaN(CanvasWidth)))
      {
        var y = CanvasHeight / 2;
        var deltaDegrees1 = compassDirection - DisplayCompassDirection;
        var deltaDegrees2 = compassDirection - DisplayCompassDirection - 360;
        var deltaDegrees = 
           Math.Abs(deltaDegrees1) < Math.Abs(deltaDegrees2) ? 
             deltaDegrees1 : deltaDegrees2;
        var dx = deltaDegrees * Resolution;
        var x = Convert.ToInt32(CanvasWidth / 2) - dx;
        return new Point(x, y);
      }
      return new Point(-1000, -1000);
    }
    /// <summary>
    /// Determines whether the specified point is visible in the current canvas
    /// </summary>
    public bool IsVisible(Point point)
    {
      var overshoot = Convert.ToInt32(Math.Round(ObjectWidth/2 + 5));
      return (point.X > -overshoot && point.X < CanvasWidth + overshoot);
    }
  }
}
Разрешение довольно странное свойство и является основой для всех других расчетов. Это в основном говорит — сколько пикселей составляет 1 градус? По умолчанию я установил значение 6 * 800/360 = 13,333, что в основном означает, что при перемещении камеры на 1 градус влево все, что отображается на экране, перемещается на 13 пикселей вправо.
Метод CalculateScreenPosition — это мой способ вычислить положение объекта на экране в направлении compassDirection, без использования тригонометрии, поскольку, как известно, я в этом плох. Я узнал об этом три раза, если мне действительно нужно его использовать, но по какой-то причине, как только я перестал его использовать, он довольно быстро выпал из моего разума. Я не сомневаюсь, что получу реакцию любителей математики, которые укажут на этот глупый способ сделать это ;-). Но это работает, и это нормально для меня. Наконец, свойство IsVisible можно использовать для определения того, находятся ли объекты на экране вообще.
Фактическое поведение
Это поведение в значительной степени опирается на вещи, о которых я писал ранее, а именно на методы расширения FrameWorkElement, которые я описал в своей статье « Простое поведение Windows Phone 7 / перетаскивание / пролистывание Silverlight », но которые, к счастью, теперь есть в библиотеке Wp7nl. Он также основан на «шаблоне» отделения поведения безопасных событий для поведения . В любом случае — базовая настройка выглядит так:
using System.Windows;
using System.Windows.Interactivity;
using System.Windows.Media;
using ARCompass.Behaviors;
using Phone7.Fx.Preview;
using Wp7nl.Utilities;
namespace HeadsUpCompass.Behaviors
{
  public class CompassDirectionDisplayBehavior : Behavior<FrameworkElement>
  {
    private FrameworkElement elementToAnimate;
    private FrameworkElement displayCanvas;
    private LocationCalcutator calculator;
    #region Setup
    protected override void OnAttached()
    {
      base.OnAttached();
      AssociatedObject.Loaded += AssociatedObjectLoaded;
      AssociatedObject.Unloaded += AssociatedObjectUnloaded;
    }
    void AssociatedObjectLoaded(object sender, RoutedEventArgs e)
    {
      calculator = new LocationCalcutator 
        {DisplayCompassDirection = DisplayCompassDirection};
      elementToAnimate = AssociatedObject.GetElementToAnimate();
      if (!(elementToAnimate.RenderTransform is CompositeTransform))
      {
        elementToAnimate.RenderTransform = new CompositeTransform();
      }
      displayCanvas = elementToAnimate.GetVisualParent();
      if (displayCanvas != null)
      {
        displayCanvas.SizeChanged += DisplayCanvasSizeChanged;
        UpdateCalculator();
      }
    }
    #endregion
    #region Cleanup
    private bool isCleanedUp;
    private void Cleanup()
    {
      if (!isCleanedUp)
      {
        isCleanedUp = true;
        AssociatedObject.Loaded -= AssociatedObjectLoaded;
        AssociatedObject.Unloaded -= AssociatedObjectUnloaded;
      }
    }
    protected override void OnDetaching()
    {
      Cleanup();
      base.OnDetaching();
    }
    void AssociatedObjectUnloaded(object sender, RoutedEventArgs e)
    {
      Cleanup();
    }
    #endregion
  }
}
Теперь, когда большая часть логики вычислений находится в LocationCalculator, логика вычислений и позиционирования сводится к этим трем маленьким методам:
void DisplayCanvasSizeChanged(object sender, SizeChangedEventArgs e)
{
  UpdateCalculator();
}
private void UpdateCalculator()
{
  calculator.CanvasHeight = displayCanvas.ActualHeight;
  calculator.CanvasWidth = displayCanvas.ActualWidth;
  calculator.SetHorizontalPixels(6 * calculator.CanvasWidth);
  UpdateScreenLocation();
}
void UpdateScreenLocation()
{
  var translationPoint = 
     calculator.CalculateScreenPosition(CurrentCompassDirection);
  if (calculator.IsVisible(translationPoint))
  {
    elementToAnimate.SetTranslatePoint(
      calculator.CalculateScreenPosition(CurrentCompassDirection));
    elementToAnimate.Visibility = Visibility.Visible;
  }
  else
  {
    elementToAnimate.Visibility = Visibility.Collapsed;
  }
}
Самое интересное, что разрешение калькулятора установлено в 6 раз больше ширины холста, так что каждый раз, когда вы поворачиваете телефон, он пересчитывает место, где отображаются объекты. В результате, когда вы поворачиваете телефон в горизонтальной плоскости, объекты располагаются шире. Таким образом, приложение обеспечивает оптимальное использование доступного пространства экрана.
И все, что осталось, — это два свойства зависимостей: DisplayCompassDirection, в котором хранится местоположение, в котором хочет отображаться текущий объект, и CurrentCompassDirection, который должен получить текущее направление, в котором смотрит камера. По природе они довольно многословны, к сожалению:
#region DisplayCompassDirection
public const string DisplayCompassDirectionPropertyName = 
   "DisplayCompassDirection";
public int DisplayCompassDirection
{
  get { return (int)GetValue(DisplayCompassDirectionProperty); }
  set { SetValue(DisplayCompassDirectionProperty, value); }
}
public static readonly DependencyProperty DisplayCompassDirectionProperty = 
  DependencyProperty.Register(
    DisplayCompassDirectionPropertyName,
    typeof(int),
    typeof(CompassDirectionDisplayBehavior),
    new PropertyMetadata(0, null));
#endregion
#region CurrentCompassDirection
public const string CurrentCompassDirectionPropertyName = 
  "CurrentCompassDirection";
public int CurrentCompassDirection
{
  get { return (int)GetValue(CurrentCompassDirectionProperty); }
  set { SetValue(CurrentCompassDirectionProperty, value); }
}
public static readonly DependencyProperty CurrentCompassDirectionProperty =
  DependencyProperty.Register(
    CurrentCompassDirectionPropertyName,
    typeof(int),
    typeof(CompassDirectionDisplayBehavior),
    new PropertyMetadata(0, CurrentCompassDirectionChanged));
public static void CurrentCompassDirectionChanged(
  DependencyObject d, DependencyPropertyChangedEventArgs e)
{
  var behavior = d as CompassDirectionDisplayBehavior;
  if (behavior != null)
  {
    behavior.UpdateScreenLocation();
  }
}
#endregion
Сохраните и скомпилируйте приложение. Не запускай пока.
 Добавление / настройка поведения с использованием Expression Blend
Добавление / настройка поведения с использованием Expression Blend
Вернитесь к Expression Blend и используйте следующий рабочий процесс:
- На панели объектов и временной шкалы слева выберите ItemsControl «CompassItems».
- Щелкните правой кнопкой мыши в, выберите «Редактировать дополнительные шаблоны», затем «Редактировать сгенерированные элементы (ItemsTemplate)», и, наконец, «Редактировать текущий»
- Вверху слева выберите вкладку «Активы». На левой панели под ним выберите «Поведения». На правой панели вы должны увидеть «CompassDirectionDisplayBehavior».
- Перетащите поведение поверх сетки
Как только вы это сделаете, в правой части экрана вы увидите вкладку свойств, в которой аккуратно показаны два свойства зависимостей. Конечно, вы можете делать это в коде, но Blend заботится о создании пространств имен и ссылок на пространства имен — это делает жизнь намного проще, и это должно апеллировать к естественной лени программиста. Следующая задача — это свойства привязки данных поведения к ViewModel, а также использование Blend, что тоже очень просто.
Настройка поведения привязки

- Нажмите на маленький квадрат справа от «DisplayCompassDirection» (отмечен красным кружком) и выберите «Привязка данных» во всплывающем меню.
- Выберите вкладку «Контекст данных» в появившемся диалоговом окне (обычно это по умолчанию)
- Выберите свойство «Направление: (Int32), которое находится непосредственно под CompassDirections: (CompassDirectionModel)
Теперь вы выбрали свойство DisplayCompassDirection поведения, которое будет напрямую связано со свойством «Направление» для CompassDirectionModel. Можно спорить, если технически 
Вторая и последняя часть связывания выглядит так:
- Нажмите на квадрат позади «CurrentCompassDirection» и снова выберите «Привязка данных» во всплывающем меню.
- Выберите вкладку «Поле данных»
- На вкладке «Поле данных» выберите «CompassViewModelDataSource» в правой панели.
- На левой панели выберите «CompassDirection: (Int32)
- Нажмите стрелку вниз в нижней части диалогового окна — появятся дополнительные параметры
- Выберите «TwoWay» для «Binding Direction»
Теперь вы выбрали свойство CurrentCompassDirection для поведения, которое будет связано с CompassDirection от CompassViewModel. А это значит — вы сделали! Нажмите «Файл» / «Сохранить все» и вернитесь в Visual Studio, чтобы запустить приложение на телефоне, и вы увидите, как по экрану движутся компасы, если вы перемещаете телефон.
Посошок
Наличие простого черного экрана, чтобы видеть, как компас движется, довольно скучно. Итак, давайте добавим поведение, чтобы отображать камеру Windows Phone 7 в качестве фона, который я также написал некоторое время назад. Добавьте его в проект, перетащите его поверх сетки LayoutRoot и нажмите «bang» — теперь у вас есть не только хедз-ап, но и прозрачный компас!
Несколько заключительных слов
Все это довольно грубо с точки зрения того, как все движется, и есть некоторые вещи, которые можно улучшить, но, тем не менее, то, что мы имеем здесь, почти похоже на любое другое старое приложение на основе LOB MVVM. Бизнес-объекты привязаны к GUI. Только этот GUI является ItemsControl с поведением в своем шаблоне. Что превращает все приложение в динамичный опыт. Я надеюсь, что пощекотал ваше воображение, и показал некоторые полезные инструкции Blend.
MVVM не ограничивается только LOB-приложениями. Сделай что-нибудь веселое. Процитирую Nokia — делайте удивительные будни;
Полное примерное решение можно найти здесь .


