Когда я говорю о шаблоне 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 и используйте следующий рабочий процесс:
- На панели объектов и временной шкалы слева выберите 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 — делайте удивительные будни;
Полное примерное решение можно найти здесь .


