Статьи

Использование MVVMLight, ItemsControl, Blend и Behaviors для создания «компасов»

Когда я говорю о шаблоне 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

Сохраните и скомпилируйте приложение. Не запускай пока.

DragBehaviorДобавление / настройка поведения с использованием Expression Blend

Вернитесь к Expression Blend и используйте следующий рабочий процесс:

  • На панели объектов и временной шкалы слева выберите ItemsControl «CompassItems».
  • Щелкните правой кнопкой мыши в, выберите «Редактировать дополнительные шаблоны», затем «Редактировать сгенерированные элементы (ItemsTemplate)», и, наконец, «Редактировать текущий»
  • Вверху слева выберите вкладку «Активы». На левой панели под ним выберите «Поведения». На правой панели вы должны увидеть «CompassDirectionDisplayBehavior».
  • Перетащите поведение поверх сетки

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

Настройка поведения привязки

behaviorpropertiesПривязка данных к поведению, да, сэр (или мэм)! Добро пожаловать в Mango: это Silverlight 4, так что нет более сложной шумихи с прикрепленными свойствами зависимостей, если вы хотите, чтобы поведение соответствовало привязке данных. Теперь вы можете напрямую связать свойства зависимостей в самом поведении! После того, как вы перетащили CompassDirectionDisplayBehavior в Grid, вы получите небольшую вкладку свойств, как показано слева, показывающую два свойства поведения. Чтобы связать их, используйте следующий рабочий процесс:

  • Нажмите на маленький квадрат справа от «DisplayCompassDirection» (отмечен красным кружком) и выберите «Привязка данных» во всплывающем меню.
  • Выберите вкладку «Контекст данных» в появившемся диалоговом окне (обычно это по умолчанию)
  • Выберите свойство «Направление: (Int32), которое находится непосредственно под CompassDirections: (CompassDirectionModel)

Теперь вы выбрали свойство DisplayCompassDirection поведения, которое будет напрямую связано со свойством «Направление» для CompassDirectionModel. Можно спорить, если технически DataBindingDisplayDirectionправильно напрямую связывать модель, вместо того, чтобы использовать ViewModel между ними. Так как эта модель показывает только данные и не имеет никакого интеллекта, я чувствую себя комфортно с ним — если вы этого не сделаете, продолжайте и определите ViewModel вокруг него ?

Вторая и последняя часть связывания выглядит так:

  • Нажмите на квадрат позади «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 — делайте удивительные будни;

Полное примерное решение можно найти здесь .