Элемент управления XYSlider в WinRT

70

Элемент XYSIider похож на Slider, но он предназначен для выбора точки двумерного пространства, а вместо ползунка используется перекрестие-«прицел». На первый взгляд кажется, что события Pointer хорошо подходят для его реализации, но потом становится очевидно, что этому элементу в действительности не нужно отслеживать действия нескольких пальцев. Использование событий Manipulation позволит избежать многих хлопот. По крайней мере я так думал... Но давайте попробуем.

Я определил класс XYSIider производным от ContentControl, чтобы на фоне можно было вывести произвольное изображение (свойство Content). Поверх изображения перемещается указатель, который можно перемещать пальцем, мышью или пером. Элемент управления содержит свойство Value типа Point и поддерживает событие ValueChanged. Координаты X и Y свойства Point нормализуются в диапазоне от 0 до 1 относительно содержимого, что избавляет элемент управления от необходимости определять значения Minimum и Maximum, как у RangeBase, или свойство IsDirectionReversed, как у Slider (тем более, что ему понадобится пара свойств IsDirectionReversed для осей X и Y).

Само определение элемента управления не является шаблонным, но в шаблоне следует оформить две части: нестандартный объект ContentPresenter из шаблона ContentControl и объект, визуально напоминающий перекрестие прицела. Для перемещения последнего в коде используются вложенные свойства Canvas.Left и Canvas.Top, что наводит на мысль, что шаблон должен определять перекрестие в Canvas:

using System;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;

namespace WinRTTestApp
{
    public class XYSlider : ContentControl
    {
        FrameworkElement crossHairPart;
        ContentPresenter contentPresenter;

        static readonly DependencyProperty valueProperty =
                DependencyProperty.Register("Value",
                        typeof(Point), typeof(XYSlider),
                        new PropertyMetadata(new Point(0.5, 0.5), OnValueChanged));

        public event EventHandler<Point> ValueChanged;

        public XYSlider()
        {
            this.DefaultStyleKey = typeof(XYSlider);
        }

        public static DependencyProperty ValueProperty
        {
            get { return valueProperty; }
        }

        public Point Value
        {
            set { SetValue(ValueProperty, value); }
            get { return (Point)GetValue(ValueProperty); }
        }

        protected override void OnApplyTemplate()
        {
            // Отмена обработчиков событий
            if (contentPresenter != null)
            {
                contentPresenter.ManipulationStarted -= OnContentPresenterManipulationStarted;
                contentPresenter.ManipulationDelta -= OnContentPresenterManipulationDelta;
                contentPresenter.SizeChanged -= OnContentPresenterSizeChanged;
            }

            // Получение новых частей
            crossHairPart = GetTemplateChild("CrossHairPart") as FrameworkElement;
            contentPresenter = GetTemplateChild("ContentPresenterPart") as ContentPresenter;

            // Назначение обработчиков событий
            if (contentPresenter != null)
            {
                contentPresenter.ManipulationMode = ManipulationModes.TranslateX |
                                                    ManipulationModes.TranslateY;
                contentPresenter.ManipulationStarted += OnContentPresenterManipulationStarted;
                contentPresenter.ManipulationDelta += OnContentPresenterManipulationDelta;
                contentPresenter.SizeChanged += OnContentPresenterSizeChanged;
            }

            // Перекрестие должно быть прозрачным для касаний
            if (crossHairPart != null)
            {
                crossHairPart.IsHitTestVisible = false;
            }

            base.OnApplyTemplate();
        }

        private void OnContentPresenterManipulationStarted(object sender, ManipulationStartedRoutedEventArgs e)
        {
            RecalculateValue(e.Position);
        }

        private void OnContentPresenterManipulationDelta(object sender, ManipulationDeltaRoutedEventArgs e)
        {
            RecalculateValue(e.Position);
        }

        private void OnContentPresenterSizeChanged(object sender, SizeChangedEventArgs e)
        {
            SetCrossHair();
        }

        private void RecalculateValue(Point absolutePoint)
        {
            double x = Math.Max(0, Math.Min(1, absolutePoint.X / contentPresenter.ActualWidth));
            double y = Math.Max(0, Math.Min(1, absolutePoint.Y / contentPresenter.ActualHeight));
            this.Value = new Point(x, y);
        }

        private void SetCrossHair()
        {
            if (contentPresenter != null && crossHairPart != null)
            {
                Canvas.SetLeft(crossHairPart, this.Value.X * contentPresenter.ActualWidth);
                Canvas.SetTop(crossHairPart, this.Value.Y * contentPresenter.ActualHeight);
            }
        }

        static void OnValueChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            ((XYSlider)obj).SetCrossHair();
            ((XYSlider)obj).OnValueChanged((Point)e.NewValue);
        }

        protected void OnValueChanged(Point value)
        {
            if (ValueChanged != null)
                ValueChanged(this, value);
        }
    }
}

Когда свойство Value задается в программном коде, класс должен установить перекрестие в правильную позицию, умножив относительные координаты на ширину и высоту ContentPresenter. Это происходит в методе SetCrossHair. Для объекта ContentPresenter назначаются обработчики событий ManipulationStarted и ManipulationDelta. Оба обработчика вызывают метод RecalculateValue для преобразования абсолютных координат указателя в относительные координаты для свойства Value.

Оба обработчика ManipulationStarted и ManipulationDelta обращаются к свойству Position аргументов события, о котором я еще не упоминал. Для мыши или пера свойство Position содержит позицию указателя мыши или пера относительно элемента управления, генерирующего события Manipulation — в данном случае ContentPresenter. Для прикосновений свойство Position содержит усредненное местонахождение всех пальцев, задействованных в манипуляции. Оно предоставляет удобный способ обработки позиций нескольких пальцев, когда в действительности вас интересует позиция только одного пальца.

Файл MainPage.xaml создает экземпляр XYSlider и использует географическую карту Земли, которую я загрузил с веб-сайта NASA. Большая часть файла XAML определяет шаблон для XYSlider и особенно перекрестия. Обратите внимание: я поместил ContentPresenter и Canvas на панель Grid и задал Grid некоторые свойства, которые обычно задаются для ContentPresenter. Это означает, что левые верхние углы ContentPresenter и Canvas выровнены, что упрощает преобразование между координатами ContentPresenter и относительными координатами:

<Page ...>

    <Page.Resources>
        <ControlTemplate x:Key="xySliderTmpl" TargetType="local:XYSlider">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}">

                <Grid Margin="{TemplateBinding Padding}"
                      HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                      VerticalAlignment="{TemplateBinding VerticalContentAlignment}">

                    <ContentPresenter Name="ContentPresenterPart"
                                      Content="{TemplateBinding Content}"
                                      ContentTemplate="{TemplateBinding ContentTemplate}" />
                    <Canvas>
                        <Path Name="CrossHairPart" Fill="Transparent"
                              Stroke="{TemplateBinding Foreground}" StrokeThickness="3" >
                            <Path.Data>
                                <GeometryGroup FillRule="Nonzero">
                                    <LineGeometry StartPoint="-48 0" EndPoint="-6 0" />
                                    <LineGeometry StartPoint="48 0" EndPoint="6 0" />
                                    <LineGeometry StartPoint="0 -48" EndPoint="0 -6" />
                                    <LineGeometry StartPoint="0 48" EndPoint="0 6" />
                                    <EllipseGeometry RadiusX="48" RadiusY="48" />
                                    <EllipseGeometry RadiusX="6" RadiusY="6" />
                                </GeometryGroup>
                            </Path.Data>
                        </Path>
                    </Canvas>
                </Grid>
            </Border>
        </ControlTemplate>

        <Style TargetType="local:XYSlider">
            <Setter Property="Template" Value="{StaticResource xySliderTmpl}" />
        </Style>
    </Page.Resources>

    <Grid Background="#FF1D1D1D">
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <local:XYSlider x:Name="xySlider"
                        Grid.Row="0"
                        Margin="48"
                        ValueChanged="OnXYSliderValueChanged">
            <!-- Позаимствовано на сайте NASN/JPL-Caltech (http://maps.jpl.nasa.gov) -->
            <Image Source="Images/map.jpg" />
        </local:XYSlider>

        <TextBlock Name="label" Grid.Row="1"
                   Foreground="White" Padding="10"
                   FontSize="20"
                   HorizontalAlignment="Center" />
    </Grid>
</Page>

В файле фонового кода определяется обработчик ValueChanged класса XYSlider, который используется для вывода широты и долготы. Просто для дополнительной проверки работоспособности кода приложение также использует класс Geolocator для получения текущей географической позиции на компьютере, на котором выполняется программа:

using System;
using Windows.Devices.Geolocation;
using Windows.Foundation;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        bool manualChange = false;

        public MainPage()
        {
            this.InitializeComponent();

            // Инициализация позиции перекрестия в элементе XYSlider
            Loaded += async (sender, args) =>
            {
                Geolocator geolocator = new Geolocator();

                // Проверка возможного запрета на определение местоположения
                try
                {
                    Geoposition position = await geolocator.GetGeopositionAsync();

                    if (!manualChange)
                    {
                        double x = (position.Coordinate.Longitude + 180) / 360;
                        double y = (90 - position.Coordinate.Latitude) / 180;
                        xySlider.Value = new Point(x, y);
                    }
                }
                catch
                {
                }
            };
        }

        private void OnXYSliderValueChanged(object sender, Point point)
        {
            double longitude = 360 * point.X - 180;
            double latitude = 90 - 180 * point.Y;
            label.Text = String.Format("Долгота: {0:F0} Широта: {1:F0}",
                                       longitude, latitude);
            manualChange = true;
        }
    }
}

Чтобы использовать класс Geolocator, необходимо отредактировать класс Package.appxmanifest, явно запросив сервис геопозиционирования. В Visual Studio выберите файл Package.appxmanifest, перейдите на вкладку Capabilities и установите флажок Location:

Разрешение геолокации для приложения Windws Runtime

Во время выполнения Windows 8 спрашивает у пользователя разрешение на определение текущего местонахождения компьютера. Если пользователь не даст разрешения, вызов GetGeopositionAsync() порождает исключение. Вот как это выглядит:

Получение местоположения пользователя с помощью геолокации в приложении Windows Runtime

В предыдущей версии этой программы, написанной мной для Windows Phone 7, я использовал для перекрестия шаблонную версию Thumb. Я не был доволен этой версией, потому что пользователю приходилось переводить перекрестие в новую позицию перетаскиванием. Я хотел, чтобы в новой версии перекрестие перемещалось в новую позицию простым касанием. Впрочем, я не могу сказать, что в новой версии проблема была полностью решена. Как я упоминал ранее (и как вы можете сами убедиться), простое касание не перемещает перекрестие в выбранную точку, потому что для выдачи события ManipulationStarted необходимо смещение.

Сначала я подумал, что реакцию программы можно ускорить, заменив событие ManipulationStarted событием PointerPressed. Тем не менее, очевидно, что простой вызов GetCurrentPoint() для объекта PointerRoutedEventArgs подавляет события Manipulation.

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

Пройди тесты
Лучший чат для C# программистов