Эффекты рисования в WinRT

143

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

эффект спирального рисования в приложении Windows Runtime

Программа Whirligig реализует захват указателя, но не завершение по нажатию Esc, так что переопределения OnPointerReleased и OnPointerCaptureLost ничем не отличаются от двух предыдущих проектов. Для каждой проведенной черты программа рисует один объект Polyline (как в ранних версиях), не считая того, что толщина этого объекта Polyline составляет всего один пиксел, и он вращается по кругу:

using System;
using System.Collections.Generic;
using Windows.Foundation;
using Windows.UI;
using Windows.UI.Input;
using Windows.UI.Popups;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Shapes;

namespace WinRTTestApp
{
    public class TouchInfo
    {
        public double Angle;
        public Point LastPoint;
        public Polyline Polyline;
    }

    public sealed partial class MainPage : Page
    {
        const double AngleIncrement = 0.5;  // радиан на пиксел
        const double Radius = 24;           // 1/4 дюйма

        Dictionary<uint, TouchInfo> pointerDic = new Dictionary<uint, TouchInfo>();

        public MainPage()
        {
            this.InitializeComponent();
        }

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            // Получение информации из аргументов события
            uint id = e.Pointer.PointerId;
            Point point = e.GetCurrentPoint(this).Position;

            // Создание объекта Polyline
            Polyline polyline = new Polyline
            {
                StrokeThickness = 1,
                Stroke = new SolidColorBrush(Color.FromArgb(222, 26, 216, 72))
            };

            // Добавить на панель Grid
            grid.Children.Add(polyline);

            // Создать объект TouchInfo
            TouchInfo touchInfo = new TouchInfo
            {
                Polyline = polyline,
                LastPoint = point
            };

            // Добавить в словарь
            pointerDic.Add(id, touchInfo);

            // Захват указателя
            CapturePointer(e.Pointer);
            base.OnPointerPressed(e);
        }

        protected override void OnPointerMoved(PointerRoutedEventArgs e)
        {
            // Получение информации из аргументов события
            uint id = e.Pointer.PointerId;
            Point point = e.GetCurrentPoint(this).Position;

            // Если идентификатора нет в словаре, ничего не делать
            if (!pointerDic.ContainsKey(id))
                return;

            // Получение объектов TouchInfo
            double angle = pointerDic[id].Angle;
            Polyline polyline = pointerDic[id].Polyline;
            Point lastPoint = pointerDic[id].LastPoint;

            // Расстояние от последней точки до этой
            double distance = Math.Sqrt(Math.Pow(point.X - lastPoint.X, 2) +
                                        Math.Pow(point.Y - lastPoint.Y, 2));

            int divisions = (int)distance;

            for (int i = 0; i < divisions; i++)
            {
                // Деление расстояния между последней и новой точкой
                double x = (i * point.X + (divisions - i) * lastPoint.X) / divisions;
                double y = (i * point.Y + (divisions - i) * lastPoint.Y) / divisions;
                Point pt = new Point(x, y);

                // Увеличить угол
                angle += distance * AngleIncrement / divisions;

                // Поворот точки
                pt.X += Radius * Math.Cos(angle);
                pt.Y += Radius * Math.Sin(angle);

                // Включить в объект Polyline
                polyline.Points.Add(pt);
            }

            // Сохранение новой информации
            pointerDic[id].LastPoint = point;
            pointerDic[id].Angle = angle;

            base.OnPointerMoved(e);
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;

            if (pointerDic.ContainsKey(id))
                pointerDic.Remove(id);

            base.OnPointerReleased(e);
        }

        protected override void OnPointerCaptureLost(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;

            if (pointerDic.ContainsKey(id))
            {
                grid.Children.Remove(pointerDic[id].Polyline);
                pointerDic.Remove(id);
            }

            base.OnPointerCaptureLost(e);
        }
    }
}

Вместе с самим объектом Polyline класс TouchInfo сохраняет значение LastPoint и значение Angle. Для каждого события PointerMoved программа делит расстояние от текущей точки до предыдущей на отрезки длиной в пиксел. На каждом из этих отрезков добавляются приблизительно 30° кругового узора (величина определяется константой AngleIncrement). Вместо отображения фактической точки эта точка поворачивается на накопленный угол и добавляется в Polyline.

Эффект пианино

Не все сенсорные приложения строятся по одному образцу. Для примера возьмем экранную клавиатуру пианино: разумеется, для нажатия клавиш лучше подходят события Pointer, а не Manipulation. Но любой пользователь экранного пианино также захочет проводить пальцем по клавишам, чтобы играть глиссандо. Если экранная клавиатура не предоставляет такой возможности, пользователь несомненно сочтет приложение неполноценным. Однако из этого следует, что при обработке пользовательского ввода вам не обойтись событиями PointerPressed и PointerReleased. Да, пользователь может нажать одну клавишу и отпустить палец на другой, но между ними простое проведение пальца может означать нажатие множества других клавиш.

Существует два основных способа построения такой клавиатуры. Можно использовать один элемент управления для представления всей клавиатуры или же использовать много элементов управления (по одному элементу управления для каждой клавиши).

Единый элемент управления должен прорисовывать все клавиши, а также обрабатывать события PointerMoved, сравнивая позиции указателя с границами клавиш. Приложение отслеживает каждый палец, определяя, когда событие PointerMoved обозначает перемещение в пределах границы клавиши, а когда оно выходит за эти границы. Это классический пример «проверки границ» (hit testing) — вы проверяете позицию указателя, чтобы узнать, лежат ли они в некоторой области.

Но если каждая клавиша реализуется отдельным элементом управления, ей не нужно выполнять проверку границ. Если она получает событие Pointer, значит, указатель находится в границах этого элемента управления (если только элемент управления не захватил указатель, но в данном приложении захват указателя не имеет смысла).

Какие события Pointer необходимы для реализации клавиши? Думать надо не о нажатых и отпущенных клавишах, а о глиссандах. Если клавиатура рассчитана исключительно на сенсорный ввод, то, скорее всего, действительно необходимы всего два события: PointerEntered и PointerExited.

Но скорее всего, вы захотите, чтобы клавиатура также разумно реагировала на мышь и перо. Клавиша пианино будет получать события PointerEntered и PointerExited при не нажатой кнопке мыши, и это создает проблемы. Для правильной обработки мыши и пера обработчик PointerEntered должен проверить свойство IsInContact. Для сенсорного ввода это свойство всегда равно true; для мыши оно равно true только в том случае, если нажата кнопка мыши, а для пера — если оно находится в контакте с экраном.

Более того, если рассматривать отдельный элемент, мышь и перо генерируют событие PointerEntered до PointerPressed, a PointerExited — после PointerReleased, поэтому события PointerPressed и PointerReleased тоже необходимо обрабатывать.

Построим клавиатуру на две октавы «снизу вверх», начиная с клавиш. Ниже приведен класс Key, производный от Control и не имеющий шаблона по умолчанию, вследствие чего он не имеет визуального оформления. Однако класс определяет свойство зависимости IsPressed и обработчик изменения свойства IsPressed, который переключается между двумя визуальными состояниями Normal и Pressed:

using System.Collections.Generic;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;

namespace WinRTTestApp
{
    public class Key : Control
    {
        static readonly DependencyProperty isPressedProperty =
                DependencyProperty.Register("IsPressed",
                        typeof(bool), typeof(Key),
                        new PropertyMetadata(false, pressed_Changed));

        List<uint> pointerList = new List<uint>();

        public static DependencyProperty IsPressedProperty
        {
            get { return isPressedProperty; }
        }

        public bool IsPressed
        {
            set { SetValue(IsPressedProperty, value); }
            get { return (bool)GetValue(IsPressedProperty); }
        }

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            AddToList(e.Pointer.PointerId);
            base.OnPointerPressed(e);
        }

        protected override void OnPointerEntered(PointerRoutedEventArgs e)
        {
            if (e.Pointer.IsInContact)
                AddToList(e.Pointer.PointerId);
            base.OnPointerEntered(e);
        }

        protected override void OnPointerExited(PointerRoutedEventArgs e)
        {
            RemoveFromList(e.Pointer.PointerId);
            base.OnPointerExited(e);
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            RemoveFromList(e.Pointer.PointerId);
            base.OnPointerReleased(e);
        }

        private void RemoveFromList(uint id)
        {
            if (pointerList.Contains(id))
                pointerList.Remove(id);

            CheckList();
        }

        private void AddToList(uint id)
        {
            if (!pointerList.Contains(id))
                pointerList.Add(id);

            CheckList();
        }

        private void CheckList()
        {
            this.IsPressed = pointerList.Count > 0;
        }

        static void pressed_Changed(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            VisualStateManager.GoToState(obj as Key,
                            (bool)e.NewValue ? "Pressed" : "Normal", false);
        }
    }
}

Так как пользователь может нажимать одну клавишу двумя пальцами, элементу управления все равно нужно отслеживать действия всех пальцев. Однако словарь для раздельного хранения информации не нужен - достаточно использовать List. Идентификаторы включаются в список в переопределениях OnPointerEntered (но только если свойство IsInContact содержит true) и OnPointerPressed и удаляются в OnPointerReleased и OnPointerExited, что приводит к изменению визуального состояния. Свойство IsPressed равно true, если List содержит хотя бы один объект. Обработчики событий PointPressed и PinterReleased предназначены только для работы с мышью и пером.

В файле Octave.xaml определяются два шаблона, для белых и черных клавиш. Эти шаблоны различаются только размером объекта Polygon, определяющего форму клавиши и цвет по умолчанию. (Все клавиши имеют прямоугольную форму. Сначала я хотел сделать белые клавиши разной формы, как на настоящем пианино, но решение с одинаковыми клавишами намного проще и обходится меньшим количеством шаблонов.) Оба шаблона в состоянии Pressed переключаются на красный цвет:

<UserControl ... xmlns:local="using:WinRTTestApp" ...>

    <UserControl.Resources>
        <ControlTemplate x:Key="whiteKey" TargetType="local:Key">
            <Grid Width="80">
                <Polygon Points="78 320, 2 320, 2 0, 78 0">
                    <Polygon.Fill>
                        <SolidColorBrush x:Name="brush" Color="#FFFFFF" />
                    </Polygon.Fill>
                </Polygon>

                <VisualStateManager.VisualStateGroups>
                    <VisualStateGroup x:Name="CommonStates">
                        <VisualState x:Name="Normal"/>
                        <VisualState x:Name="Pressed">
                            <Storyboard>
                                <ColorAnimationUsingKeyFrames Storyboard.TargetName="brush" 
                                                              Storyboard.TargetProperty="Color">
                                    <DiscreteColorKeyFrame KeyTime="0" Value="DarkOrange" />
                                </ColorAnimationUsingKeyFrames>
                            </Storyboard>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateManager.VisualStateGroups>
            </Grid>
        </ControlTemplate>

        <ControlTemplate x:Key="blackKey" TargetType="local:Key">
            <Grid>
                <Polygon Points="40 220, 0 220, 0 0, 40 0,">
                    <Polygon.Fill>
                        <SolidColorBrush x:Name="brush" Color="Black" />
                    </Polygon.Fill>
                </Polygon>

                <VisualStateManager.VisualStateGroups>
                    <VisualStateGroup x:Name="CommonStates">
                        <VisualState x:Name="Normal"/>
                        <VisualState x:Name="Pressed">
                            <Storyboard>
                                <ColorAnimationUsingKeyFrames Storyboard.TargetName="brush" 
                                                              Storyboard.TargetProperty="Color">
                                    <DiscreteColorKeyFrame KeyTime="0" Value="DarkOrange" />
                                </ColorAnimationUsingKeyFrames>
                            </Storyboard>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateManager.VisualStateGroups>
            </Grid>
        </ControlTemplate>
    </UserControl.Resources>

    <Grid>
        <StackPanel Orientation="Horizontal">
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key Template="{StaticResource whiteKey}" />
            <local:Key x:Name="lastKey"
                       Template="{StaticResource whiteKey}"
                       Visibility="Collapsed" />
        </StackPanel>
        <Canvas>
            <local:Key Template="{StaticResource blackKey}"
                       Canvas.Left="300" Canvas.Top="0" />
            <local:Key Template="{StaticResource blackKey}"
                       Canvas.Left="380" Canvas.Top="0" />
            <local:Key Template="{StaticResource blackKey}"
                       Canvas.Left="60" Canvas.Top="0" />
            <local:Key Template="{StaticResource blackKey}"
                       Canvas.Left="140" Canvas.Top="0" />
            <local:Key Template="{StaticResource blackKey}"
                       Canvas.Left="460" Canvas.Top="0" />
        </Canvas>
    </Grid>
</UserControl>

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

Восемь белых клавиш охватывают звукоряд от «до» до следующего «до». Очень часто малые клавиатуры начинаются и заканчиваются нотой «до», но появление двух смежных клавиш «до» в месте перехода октавы нежелательно. По этой причине свойство Visibility последней клавиши равно Collapsed. Свойству Visibility задается значение Visible или Collapsed на основании свойства зависимости LastKeyVisible:

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public sealed partial class Octave : UserControl
    {
        static readonly DependencyProperty lastKeyVisibleProperty =
                DependencyProperty.Register("LastKeyVisible",
                        typeof(bool), typeof(Octave),
                        new PropertyMetadata(false, OnLastKeyVisibleChanged));

        public Octave()
        {
            this.InitializeComponent();
        }


        public bool LastKeyVisible
        {
            get { return (bool)GetValue(LastKeyVisibleProperty); }
            set { SetValue(LastKeyVisibleProperty, value); }
        }

        public static DependencyProperty LastKeyVisibleProperty
        {
            get { return lastKeyVisibleProperty; }
        }

        static void OnLastKeyVisibleChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            (obj as Octave).lastKey.Visibility =
                (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed;
        }
    }
}

Остается лишь создать в файле MainPage.xaml два экземпляра Octave, причем у второго экземпляра свойство LastKeyVisible равно true:

<Page ...>

    <Grid Name="grid" Background="#999">
        <StackPanel Orientation="Horizontal"
                    HorizontalAlignment="Center"
                    VerticalAlignment="Center">
            <local:Octave />
            <local:Octave LastKeyVisible="True" />
        </StackPanel>
    </Grid>
</Page>

Результат выглядит так:

Пример создания эффекта пианино в приложении Windows Runtime
Пройди тесты
Лучший чат для C# программистов