Захват указателя в WinRT

196

Чтобы лучше понять последовательность событий Pointer, я написал программу PointerLog, которая регистрирует все события Pointer на экране. Центральное место в программе занимает производный от UserControl класс с именем LoggerControl. Панель Grid в файле LoggerControl.xaml получает имя, но изначально остается пустой:

<UserControl ...>
    
    <Grid x:Name="layoutGrid" Background="Transparent">

    </Grid>
</UserControl>

Файл фонового кода переопределяет все восемь методов Pointer, и в каждом переопределении вызывается метод Log с именем и аргументами события. Как и во всех программах, использующих Pointer, в ней определяется объект Dictionary, но в качестве значений этого словаря используются не простые объекты. Вместо этого я определил прямо в начале класса LoggerControl вложенный класс с именем PointerInfo для хранения в словаре информации, относящейся к каждому пальцу.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Windows.UI.Text;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Data;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;

namespace WinRTTestApp
{
    public sealed partial class LoggerControl : UserControl
    {
        class PointerInfo
        {
            public string repeatEvent;
            public StackPanel stackPanel;
            public TextBlock repeatTxb;
        };

        Dictionary<uint, PointerInfo> pointerDic = new Dictionary<uint, PointerInfo>();
        public bool CaptureOnPress { set; get; }

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

        protected override void OnPointerEntered(PointerRoutedEventArgs e)
        {
            Log("Entered", e);
            base.OnPointerEntered(e);
        }

        protected override void OnPointerMoved(PointerRoutedEventArgs e)
        {
            Log("Moved", e);
            base.OnPointerMoved(e);
        }

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            if (this.CaptureOnPress)
                CapturePointer(e.Pointer);

            Log("Pressed", e);
            base.OnPointerPressed(e);
        }

        protected override void OnPointerExited(PointerRoutedEventArgs e)
        {
            Log("Exited", e);
            base.OnPointerExited(e);
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            Log("Released", e);
            base.OnPointerReleased(e);
        }

        protected override void OnPointerCaptureLost(PointerRoutedEventArgs e)
        {
            Log("CaptureLost", e);
            base.OnPointerCaptureLost(e);
        }

        protected override void OnPointerCanceled(PointerRoutedEventArgs e)
        {
            Log("Canceled", e);
            base.OnPointerCanceled(e);
        }

        protected override void OnPointerWheelChanged(PointerRoutedEventArgs e)
        {
            Log("WheelChanged", e);
            base.OnPointerWheelChanged(e);
        }

        private void Log(string eventName, PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;
            PointerInfo pointerInfo;

            if (pointerDic.ContainsKey(id))
            {
                pointerInfo = pointerDic[id];
            }
            else
            {
                // Новый идентификатор, создаем новый заголовок
                // и объект StackPanel
                TextBlock header = new TextBlock
                {
                    Text = e.Pointer.PointerId + " - " + e.Pointer.PointerDeviceType,
                    FontWeight = FontWeights.Bold
                };

                StackPanel stp = new StackPanel();
                stp.Children.Add(header);

                // Новый объект PointerInfo для словаря
                pointerInfo = new PointerInfo
                {
                    stackPanel = stp
                };
                pointerDic.Add(id, pointerInfo);

                // Новый столбец в Grid для StackPanel
                ColumnDefinition coldef = new ColumnDefinition
                {
                    Width = new GridLength(1, GridUnitType.Star)
                };

                layoutGrid.ColumnDefinitions.Add(coldef);

                Grid.SetColumn(stp, layoutGrid.ColumnDefinitions.Count - 1);
                layoutGrid.Children.Add(stp);
            }

            // Не повторять события PointerMoved и PointerWheelChanged
            TextBlock txtblk = null;

            if (eventName == pointerInfo.repeatEvent)
            {
                txtblk = pointerInfo.repeatTxb;
            }
            else
            {
                txtblk = new TextBlock();
                pointerInfo.stackPanel.Children.Add(txtblk);
            }

            txtblk.Text = eventName + " ";

            if (eventName == "WheelChanged")
            {
                txtblk.Text += e.GetCurrentPoint(this).Properties.MouseWheelDelta;
            }
            else
            {
                txtblk.Text += e.GetCurrentPoint(this).Position;
            }

            txtblk.Text += e.Pointer.IsInContact ? " C" : "";
            txtblk.Text += e.Pointer.IsInRange ? " R" : "";

            if (eventName == "Moved" || eventName == "WheelChanged")
            {
                pointerInfo.repeatEvent = eventName;
                pointerInfo.repeatTxb = txtblk;
            }
            else
            {
                pointerInfo.repeatEvent = null;
                pointerInfo.repeatTxb = null;
            }
        }

        public void Clear()
        {
            layoutGrid.ColumnDefinitions.Clear();
            layoutGrid.Children.Clear();
            pointerDic.Clear();
        }
    }
}

Метод Log на первый взгляд кажется запутанным. Обнаруживая новое значение PointerId в аргументах события, он добавляет новый столбец в Grid, размещает наверху TextBlock с идентификатором и типом устройства и добавляет объект в словарь. Все последующие события с этим идентификатором попадают в этот столбец (кроме последовательных событий PointerMoved и PointerWheelChanged). Прокрутка не предусмотрена, и со временем столбцов станет слишком много, но открытый метод Clear восстанавливает все в исходном состоянии.

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

<Page x:Name="page" ...>

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

        <TextBlock Text="Лог событий указателя" 
                   HorizontalAlignment="Center"
                   Margin="16"
                   FontSize="32" Foreground="White"/>

        <local:LoggerControl x:Name="log" Grid.Row="1" FontSize="15" />

        <Grid Grid.Row="2">
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>

            <Button Content="Очистить" HorizontalAlignment="Center" Click="clearBtn_Click" />

            <ToggleButton Name="captureButton"
                          Content="Захватить указатель"
                          Grid.Column="1"
                          HorizontalAlignment="Center"
                          Checked="captureButton_Checked"
                          Unchecked="captureButton_Checked" />

            <Button Content="Захватить указатель на 5 секунд"
                    Name="releaseCaptureButton"
                    Grid.Column="2"
                    IsEnabled="{Binding ElementName=captureButton, Path=IsChecked}"
                    HorizontalAlignment="Center"
                    Click="releaseCaptureButton_Click" />
        </Grid>
    </Grid>
</Page>

Обратите внимание: последняя кнопка становится доступной только при установке ToggleButton. Файл фонового кода просто обеспечивает обработку кнопок (вскоре мы поговорим об этом подробнее):

using System;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        DispatcherTimer timer;

        public MainPage()
        {
            InitializeComponent();

            timer = new DispatcherTimer { 
                Interval = TimeSpan.FromSeconds(5) 
            };

            timer.Tick += timer_Tick;
        }

        private void clearBtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            log.Clear();
        }

        private void captureButton_Checked(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            ToggleButton toggle = (ToggleButton)sender;
            log.CaptureOnPress = toggle.IsChecked.Value;
        }

        private void releaseCaptureButton_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            timer.Start();
        }

        private void timer_Tick(object sender, object args)
        {
            log.ReleasePointerCaptures();
            timer.Stop();
        }
    }
}
Иерархия событий при манипуляции пальцами в приложении Windows Runtime

Из рисунка видно, что для каждого пальца назначается уникальный идентификатор и генерируются только пять событий. Каждой новой серии событий пера также назначается собственный идентификатор (с использованием такой же нумерации, как и с сенсорным вводом) с несколькими дополнительными событиями. Мыши всегда соответствует идентификатор 1.

Буквами C и R обозначаются истинные значения свойств IsInContact и IsInRange объекта Pointer. Как видите, для пера и мыши по свойству IsInRange можно различать события PointerMoved, происходящие при соприкосновении пера с экраном или нажатием кнопки мыши.

По умолчанию элемент получает события Pointer только тогда, когда указатель находится в границах элемента. Иногда это приводит к потере информации. Для демонстрации я намеренно спроектировал программу так, чтобы элемент управления LoggerControl не занимал всю высоту экрана. Выше находится область для заголовка программы, а ниже — область с кнопками. Эти области находятся в ведении MainPage. Такая конфигурация позволяет экспериментировать с вводом, перемещающимся между элементами.

Например, прикоснитесь к экрану PointerLog где-нибудь в середине, поводите пальцем по экрану, а затем переместите его в верхнюю или нижнюю область. Отведите палец от экрана. Программа не получает событие PointerReleased и понятия не имеет о том, что указатель был освобожден. Она никогда не получит следующего события с этим идентификатором и пребывает в неведении, а объект в словаре никогда не будет удален.

Или другой эксперимент: прикоснитесь к экрану в верхней или нижней области и переведите палец в центральную область. Программа регистрирует события PointerEntered и PointerMoved, но не событие PointerPressed.

Часто при отслеживании конкретного указателя программа должна получать ввод даже при выходе за границы элемента. Именно неполученным вводом от указателя объясняются недостатки программы FingerPaint1, которую мы создали в предыдущей статье.

Для достижения желаемого эффекта используется операция «захвата указателя» (capturing the pointer), которая осуществляется вызовом метода CapturePointer(), определенного классом UIElement. Метод получает аргумент типа Pointer и возвращает логический признак успешности захвата указателя. Когда попытка захвата может оказаться неудачной? При вызове CapturePointer во время события, предшествующего PointerPressed, а также во время PointerReleased или позднее.

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

При нажатии кнопки «Захватить указатель» в нижней части экрана PointerLog программа вызывает в переопределении OnPointerPressed метод

CapturePointer(e.Pointer);

Если вы нажмете в центральной области программы PointerLog, переместите палец в верхнюю или нижнюю область, а затем отпустите, программа зарегистрирует как событие PointerReleased, так и завершающее событие PointerCaptureLost, следующее за PointerExited.

Программа может получить список всех захваченных указателей вызовом PointerCaptures, а также освободить конкретный захват вызовом ReleasePointerCapture или освободить все захваченные указатели вызовом ReleasePointerCaptures.

В реальных приложениях возникает искушение игнорировать событие PointerCaptureLost, но это не лучшая мысль. Если системе Windows понадобится срочно сообщить пользователю что-то важное, это может привести к непреднамеренной потере захвата указателя. Я еще не видел, чтобы это происходило в Windows 8, но исторически такие проблемы возникали при отображении системного модального диалогового окна — настолько важного, что оно получает весь пользовательский ввод, пока не будет закрыто.

Чтобы продемонстрировать, что происходит в подобных случаях, я определил третью кнопку. Она устанавливает таймер DispatcherTimer на пять секунд, после чего вызывает ReleasePointerCaptures для LoggerControl. Когда это происходит, захваченный указатель инициирует событие PointerCaptureLost. Элемент продолжает получать другие события Pointer, если указатель все еще находится над элементом, но не при выходе его за границы элемента.

Что должно делать приложение при получении неожиданного события PointerCaptureLost, зависит от самого приложения. Например, в программе рисования можно переместить логику PointerReleased в PointerCaptureLost и одинаково обрабатывать как ожидаемую, так и непредвиденную потерю захвата.

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

Именно это и происходит в программе FingerPaint2. В ней используется такой же файл XAML, как в программе FingerPaint1, и почти такой же файл фонового кода со следующими исключениями:

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

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // ...

        public MainPage()
        {
            InitializeComponent();
            this.IsTabStop = true;
        }

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            // ...
            
            // Захват указателя
            CapturePointer(e.Pointer);

            // Назначение фокуса ввода
            Focus(Windows.UI.Xaml.FocusState.Programmatic);

            base.OnPointerPressed(e);
        }

        protected override void OnPointerMoved(PointerRoutedEventArgs e)
        {
            // ...
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            // ...
        }

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

            // Если идентификатор присутствует в словаре, отменить операцию рисования
            if (pointerDic.ContainsKey(id))
            {
                grid.Children.Remove(pointerDic[id]);
                pointerDic.Remove(id);
            }

            base.OnPointerCaptureLost(e);
        }

        protected override void OnKeyDown(KeyRoutedEventArgs e)
        {
            if (e.Key == Windows.System.VirtualKey.Escape)
                ReleasePointerCaptures();

            base.OnKeyDown(e);
        }
    }
}

В конструкторе свойству IsTabStop необходимо задать значение true, чтобы элемент получал ввод с клавиатуры. В любой момент времени только один элемент может получать ввод с клавиатуры. В терминологии Windows этот элемент имеет фокус ввода; некоторые элементы обозначают наличие фокуса специальным оформлением (например, пунктирной рамкой). Часто элемент может предоставить себе фокус ввода с клавиатуры, вызывая метод Focus при касании или (в нашем случае) во время события OnPointerPressed. Переопределение завершается вызовом методов Focus и CapturePointer.

Метод OnPointerCaptureLost удаляет текущий объект Polyline из Grid, а идентификатор — из словаря. Однако событие PoirrterCaptureLost может происходить и при отведении пальца от экрана; этот идентификатор останется в словаре только в том случае, если страница не получила вызова OnPointerReleased.

Метод OnKeyDown получает нажатия клавиш и вызывает ReleasePointerCaptures для клавиши Esc. При отсутствии захваченных указателей этот вызов ни на что не повлияет. Попробуйте повторить действия, вызывавшие проблемы в FingerPaint1. Вы увидите, что в этой версии проблемы исчезли. Более того, теперь можно во время рисования нажать Esc; текущий рисунок исчезнет, а движения пальца не будут ни на что влиять, пока пользователь не отведет палец от экрана и не прикоснется повторно (будем надеяться, что это именно то, что требуется).

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