Маршрутизируемые события в WinRT

147

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

Как вы уже видели, присваивание атрибута Name или x:Name элементу XAML приводит к тому, что в классе страницы определяется поле, которое упрощает доступ к этому элементу из файла отделенного кода. Это один из двух основных способов взаимодействия кода и XAML. Второй способ базируется на событиях - механизме общего назначения, позволяющем одному объекту передавать другим объектам информацию, которая представляет для них интерес. Говорят, что первый объект «выдает» или «инициирует» событие, а второй «обрабатывает» (handle) его. В Windows Runtime события применяются для оповещения приложений о вводе с сенсорного экрана, мыши, пера или клавиатуры.

После инициализации программа Windows Runtime обычно «дремлет» в памяти, ожидая, пока не произойдет что-нибудь интересное. Практически все, что делает программа, происходит в ответ на какое-нибудь событие.

Событие Tapped

Класс UIElement определяет все основные события пользовательского ввода. К ним относятся:

События сенсорного ввода, мыши и пера будут подробно рассмотрены позже. Остальные события, определяемые UIElement, также относятся к вводу данных пользователем:

Пока остановимся на Tapped - это простое и вполне типичное событие. Элемент, производный от UIElement, генерирует событие Tapped, чтобы сообщить о том, что пользователь прикоснулся к элементу пальцем, щелкнул на нем мышью или пометил пером. Чтобы действие было воспринято как событие Tapped, палец (мышь, перо) не должен перемещаться, а нажатие должно занимать короткий период времени.

Все события пользовательского ввода определяются по похожей схеме. В синтаксисе C#, UIElement определяет событие Tapped следующим образом:

public delegate void TappedEventHandler Tapped;

Делегат TappedEventHandler определяется в пространстве имен Windows.UI.Xaml.Input. Это тип-делегат, который определяет сигнатуру обработчика события следующим образом:

public delegate void TappedEventHandler(
    object sender, 
    TappedRoutedEventArgs e
)

В обработчике события первый аргумент определяет источник события (которым всегда является экземпляр класса, производного от UIElement), а второй предоставляет свойства и методы, специфические для события Tapped.

Ниже показан файл XAML тестовой программы, который определяет элемент TextBlock с атрибутом Name, а также обработчик события Tapped:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock x:Name="txb"
            Text="Привет, Windows 8!"
            FontSize="80"
            Foreground="LimeGreen"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Tapped="txb_Tapped">
        </TextBlock>
</Grid>

Во время ввода атрибутов TextBlock в XAML функция IntelliSense предлагает не только свойства, но и события. Их можно различить по значкам: гаечный ключ для свойств, молния для событий. (Также встречаются подсказки с парой фигурных скобок - это присоединенные свойства.) Если разрешить, IntelliSense также предложит имя обработчика события - в данном случае я его подтвердил. На основании одного лишь синтаксиса XAML невозможно сказать, какие атрибуты представляют события, а какие - свойства.

Обработчик события реализуется в файле отделенного кода. Если вы разрешите Visual Studio выбрать имя обработчика за вас, то Visual Studio также создаст заготовку обработчика в файле MainPage.xaml.cs:

private void txb_Tapped(object sender, TappedRoutedEventArgs e)
{

}

Ниже показана простая программа, в которой при касании TextBlock, будет назначаться случайный цвет для цвета текста. Для этого мы определим поля для объекта Random и массив byte для красной, зеленой и синей составляющих цвета (RGB):

public sealed partial class MainPage : Page
{
        Random rand = new Random();
        byte[] rgb = new byte[3];

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

        private void txb_Tapped(object sender, TappedRoutedEventArgs e)
        {
            rand.NextBytes(rgb);
            Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
            txb.Foreground = new SolidColorBrush(color);
        }
}

В обработчике события Tapped метод NextBytes() объекта Random получает три случайных байта, которые затем используются для конструирования Color при помощи статического метода Color.FromArgb(). Выполнение обработчика завершается заданием свойству Foreground элемента TextBlock значения SolidColorBrush, основанного на этом значении Color.

Запустите программу и прикоснитесь к TextBlock пальцем, мышью или пером; элемент окрасится в случайный цвет. Если прикоснуться к области экрана за пределами TextBlock, не произойдет ничего. Обратите внимание: если вы используете мышь или перо, вам не обязательно прикасаться к самим штрихам, образующим буквы. Точка касания может быть между штрихами - TextBlock все равно отреагирует. Все выглядит так, словно у TextBlock существует невидимый фон, простирающийся на всю высоту шрифта с учетом диакритических знаков и подстрочных выносных элементов - и это действительно так.

Заглянув в файл MainPage.g.cs, сгенерированный Visual Studio, вы увидите метод Connect() с кодом, связывающим обработчик события с событием Tapped элемента TextBlock. То же самое можно сделать в программном коде; попробуйте удалить обработчик Tapped, назначенный в файле MainPage.xaml, и вместо этого присоедините обработчик события в конструкторе из файла отделенного кода:

public MainPage()
{
    this.InitializeComponent();
    txb.Tapped += txb_Tapped;
}

Программа работает так же, как прежде.

Чтобы событие Tapped работало, необходимо правильно задать значения нескольких свойств TextBlock. Свойства IsHitTestVisible и IsTapEnabled должны содержать свои значения по умолчанию true. Свойство Visibility также должно содержать значение Visibility.Visible. Если задать ему значение Visibility.Collapsed, элемент TextBlock станет невидимым и не будет реагировать на действия пользователя.

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

Маршрутизируемые события

Так как в первом аргументе обработчика события Tapped передается элемент, сгенерировавший событие, не обязательно назначать элементу TextBlock имя для обращения к нему из обработчика события. Можно просто преобразовать аргумент sender в объект типа TextBlock. Этот прием особенно полезен для совместного использования обработчика события несколькими элементами, что мы и сделаем далее.

Следующий код является первым примером из серии последующих примеров, демонстрирующих концепцию обработки маршрутизируемых событий - важной возможности Windows Runtime. Впрочем, в этой конкретной программе не задействованы никакие возможности, относящиеся непосредственно к маршрутизированным событиям. Сначала мы создаем обработчик Tapped с правильной сигнатурой и выбранным именем:

public sealed partial class MainPage : Page
{
        Random rand = new Random();
        byte[] rgb = new byte[3];

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

        private void OnTextBlockTapped(object sender, TappedRoutedEventArgs e)
        {
            TextBlock txb = sender as TextBlock;
            rand.NextBytes(rgb);
            Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
            txb.Foreground = new SolidColorBrush(color);
        }
}

Обратите внимание на преобразование типа аргумента sender к TextBlock в первой строке обработчика события.

Так как обработчик события уже существует в файле отделенного кода, Visual Studio предлагает это имя при вводе имени события в файле XAML. И это было удобно, потому что я добавил в Grid девять элементов TextBlock:

<Page ...
    FontSize="48">

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock Text="Слева сверху"
                   HorizontalAlignment="Left"
                   VerticalAlignment="Top"
                   Tapped="OnTextBlockTapped" />

        ...

        <TextBlock Text="Справа с низу"
                   HorizontalAlignment="Right"
                   VerticalAlignment="Bottom"
                   Tapped="OnTextBlockTapped" />
    </Grid>
</Page>

Конечно, мне не нужно приводить всю разметку, чтобы вы поняли общую идею. Обратите внимание: атрибут FontSize задается для элемента Page, чтобы он наследовался всеми элементами TextBlock. Запустите программу и попробуйте прикасаться к разным элементам; вы увидите, что каждый элемент изменяет цвет независимо от других.

Обработка одного маршрутизируемого события для нескольких элементов

Если точка касания находится между элементами, ничего не происходит.

Возможно, необходимость задания одного обработчика события для девяти разных элементов в файле XAML вас не обрадует. В таком случае вы, вероятно, оцените следующую версию приложения. Следующая программа использует перенаправляемую обработку ввода - под этим термином понимается то, как события ввода (например, Tapped) инициируются элементом, с которым произошло событие, а затем перенаправляются по визуальному дереву. Вместо того чтобы назначать обработчик Tapped для отдельных элементов TextBlock, можно назначить его для родителя одного из этих элементов (например, для Grid). Ниже приведен фрагмент файла XAML программы:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}"
      Tapped="OnGridTapped">
        <TextBlock Text="Слева сверху"
                   HorizontalAlignment="Left"
                   VerticalAlignment="Top" />

        ...

        <TextBlock Text="Справа с низу"
                   HorizontalAlignment="Right"
                   VerticalAlignment="Bottom" />
</Grid>

Переместив обработчик Tapped из отдельных элементов TextBlock в Grid, я также переименовал его, чтобы он более точно описывал источник события.

В обработчик события необходимо внести некоторые изменения. Предыдущий обработчик Tapped преобразовывал аргумент sender в TextBlock. В той версии такое преобразование было безопасным, потому что обработчик события назначался только элементам типа TextBlock. Однако когда обработчик события назначается элементу Grid, как в нашем случае, в аргументе sender будет передаваться Grid. Как определить, к какому элементу TextBlock относится касание?

Очень просто: класс TappedRoutedEventArgs (экземпляр которого передается во втором аргументе обработчика события) содержит свойство с именем OriginalSource, определяющее источник события. В нашем примере OriginalSource может содержать либо TextBlock (если вы прикоснулись к тексту), либо Grid (в случае касания между текстом), поэтому перед преобразованием типа новый обработчик события должен выполнить проверку:

private void OnGridTapped(object sender, TappedRoutedEventArgs e)
{
    if (e.OriginalSource is TextBlock)
    {
        TextBlock txb = e.OriginalSource as TextBlock;
        rand.NextBytes(rgb);
        Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
        txb.Foreground = new SolidColorBrush(color);
    }
}

Чтобы немного повысить эффективность проверки, можно сначала выполнить преобразование типа, а затем проверить, что результат отличен от null.

Класс TappedRoutedEventArgs является производным от класса RoutedEventArgs, определяющего только OriginalSource без каких-либо других свойств. Разумеется, свойство OriginalSource занимает центральное место в концепции обработки маршрутизируемых событий. Оно позволяет элементам обрабатывать события, происходящие в их потомках в визуальном дереве, и определять источник этих событий. Обработка маршрутизируемых событий предоставляет родителю информацию о том, что происходит с его потомками, а свойство OriginalSource идентифицирует конкретного потомка, породившего событие.

Обработчик Tapped также можно назначить элементу MainPage вместо Grid. Впрочем, для MainPage существует более удобное решение. Ранее я уже упоминал, что класс UIElement определяет все события пользовательского ввода. Эти события наследуются всеми производными классами, но класс Control добавляет собственный интерфейс событий из целого набора виртуальных методов, соответствующих этим событиям. Например, для события Tapped, определяемого UIElement, класс Control определяет виртуальный метод с именем OnTapped.

Эти виртуальные методы всегда начинаются со слова On, за которым следует имя события, поэтому их иногда называют «On-методами». Класс Page является производным от Control через UserControl, поэтому эти методы наследуются классами Page и MainPage.

Из следующего фрагмента файла XAML мы видим, что файл XAML не определяет обработчики событий:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
     ...
</Grid>

Вместо этого в файле отделенного кода переопределяется метод OnTapped:

protected override void OnTapped(TappedRoutedEventArgs e)
{
    if (e.OriginalSource is TextBlock)
    {
        TextBlock txb = e.OriginalSource as TextBlock;
        rand.NextBytes(rgb);
        Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
        txb.Foreground = new SolidColorBrush(color);
    }

    base.OnTapped(e);
}

Если вы набираете код в Visual Studio, то для переопределения виртуального метода (такого, как OnTapped) достаточно ввести ключевое слово override и нажать пробел, Visual Studio выдаст список всех виртуальных методов, определенных для класса. Если выбрать один из методов в списке, Visual Studio создаст заготовку с вызовом базового метода. В данном случае вызов базового метода не обязателен, однако вам стоит привыкнуть включать его при переопределении виртуальных методов. В зависимости от переопределяемого метода вызов базового метода может располагаться в начале, середине или в конце - или вообще нигде.

On-методы почти не отличаются от обработчиков событий, но у них нет аргумента sender, поскольку он был бы лишним: sender в данном случае всегда совпадает с this (экземпляр Page, обрабатывающий событие).

В следующем примере элементу Grid при касании назначается случайный цвет фона. Файл XAML остался неизменным, а новая версия метода OnTapped выглядит так:

protected override void OnTapped(TappedRoutedEventArgs e)
{
    rand.NextBytes(rgb);
    Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
    SolidColorBrush brush = new SolidColorBrush(color);

    if (e.OriginalSource is Grid)
        (e.OriginalSource as Grid).Background = brush;
    else
        if (e.OriginalSource is TextBlock)
            (e.OriginalSource as TextBlock).Foreground = brush;

    base.OnTapped(e);
}

Если теперь прикоснуться к элементу TextBlock, он изменит цвет. Но если прикоснуться к любой другой точке экрана, цвет изменит Grid.

Допустим, по той или иной причине вы решили вернуться к исходной схеме с отдельным назначением обработчика события каждому элементу TextBlock, но при этом также хотите сохранить переопределение OnTapped для изменения цвета фона Grid. Допустим, в файле XAML в элементах TextBlock восстановлены события Tapped, а элементу Grid присвоено имя:

<Grid x:Name="layoutGrid" 
      Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock Text="Слева сверху"
                   HorizontalAlignment="Left"
                   VerticalAlignment="Top"
                   Tapped="OnTextBlockTapped" />

        ...

        <TextBlock Text="Справа с низу"
                   HorizontalAlignment="Right"
                   VerticalAlignment="Bottom"
                   Tapped="OnTextBlockTapped" />
</Grid>

Одно преимущество такого решения заключается в том, что цвета TextBlock и Grid теперь отделены друг от друга, поэтому отпадает необходимость в блоках if-else. Обработчик Tapped элементов TextBlock может безопасно преобразовывать аргумент sender, а переопределение OnTapped может просто обращаться к Grid по имени:

public sealed partial class MainPage : Page
{
        Random rand = new Random();
        byte[] rgb = new byte[3];

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

        private void OnTextBlockTapped(object sender, TappedRoutedEventArgs e)
        {
            TextBlock txb = sender as TextBlock;
            txb.Foreground = GetRandomBrush();
        }

        protected override void OnTapped(TappedRoutedEventArgs e)
        {
            layoutGrid.Background = GetRandomBrush();
            base.OnTapped(e);
        }

        private Brush GetRandomBrush()
        {
            rand.NextBytes(rgb);
            Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
            return new SolidColorBrush(color);
        }
}

Возможно, этот код работает не совсем так, как вы ожидали. Касание TextBlock не ограничивается изменением цвета TextBlock; событие перемещается вверх по визуальному дереву, где оно обрабатывается переопределением OnTapped - поэтому цвет Grid тоже изменяется! Если так и было задумано - вам повезло. А если нет - в TappedRoutedEventArgs имеется свойство, предназначенное специально для предотвращения таких ситуаций. Если обработчик OnTextBlockTapped устанавливает свойство Handled равным true, дальнейшая передача события вверх по визуальному дереву блокируется.

Эта возможность продемонстрирована в следующем примере, который почти не отличается от предыдущего, кроме одной команды в методе OnTextBlockTapped:

public sealed partial class MainPage : Page
{
        Random rand = new Random();
        byte[] rgb = new byte[3];

        public MainPage()
        {
            this.InitializeComponent();

            this.AddHandler(UIElement.TappedEvent,
                new TappedEventHandler(OnPageTapped),
                true);
        }

        private void OnTextBlockTapped(object sender, TappedRoutedEventArgs e)
        {
            TextBlock txb = sender as TextBlock;
            txb.Foreground = GetRandomBrush();
            e.Handled = true;
        }

        private void OnPageTapped(object sender, TappedRoutedEventArgs e)
        {
            layoutGrid.Background = GetRandomBrush();
        }

        private Brush GetRandomBrush()
        {
            rand.NextBytes(rgb);
            Color color = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]);
            return new SolidColorBrush(color);
        }
}

Ввод, выравнивание и фон

Давайте рассмотрим еще одну короткую программу, которая продемонстрирует пару важных аспектов событий ввода. Файл XAML содержит всего один элемент TextBlock без определенных обработчиков событий:

<Page ... FontSize="48">

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock Text="Привет, Windows 8!"
                   Foreground="LimeGreen"/>
    </Grid>
</Page>

Так как для элемента TextBlock не заданы свойства HorizontalAlignment и VerticalAlignment, он отображается в левом верхнем углу Grid. Файл отделенного кода обеспечивает раздельную обработку событий, поступающих от TextBlock и от Grid:

public sealed partial class MainPage : Page
{
        Random rand = new Random();
        byte[] rgb = new byte[3];

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

        protected override void OnTapped(TappedRoutedEventArgs e)
        {
            rand.NextBytes(rgb);
            Color clr = Color.FromArgb(255, rgb[0], rgb[1], rgb[2]); 
            SolidColorBrush brush = new SolidColorBrush(clr);

            if (e.OriginalSource is TextBlock)
                (e.OriginalSource as TextBlock).Foreground = brush;
            else if (e.OriginalSource is Grid)
                (e.OriginalSource as Grid).Background = brush;

            base.OnTapped(e);
        }
}

Вот как это выглядит:

Выравнивание текста в элементе

При касании на TextBlock элемент окрашивается в случайный цвет, как обычно, но при касании за пределами TextBlock элемент Grid не меняет цвет, как это происходило прежде. Вместо этого изменяется цвет TextBlock! Все выглядит так, словно... да, словно TextBlock теперь занимает всю страницу и «забирает» все события Tapped.

Собственно, так оно и есть. У TextBlock имеются значения по умолчанию для HorizontalAlignment и VerticalAlignment, но это не значения Left и Top, как можно было бы предположить по внешнему виду. Значения по умолчанию называются Stretch, и они означают, что элемент TextBlock растягивается по размеру своего родителя (то есть Grid). Текст по-прежнему выводится шрифтом с размером 48 пикселов, но TextBlock теперь имеет прозрачный фон, который заполняет всю страницу.

В Windows Runtime у всех элементов свойства HorizontalAlignment и VerticalAlignment по умолчанию равны Stretch. Это обстоятельство играет важную роль в системе формирования макета Windows Runtime. Попробуем задать значения HorizontalAlignment и VerticalAlignment для элемента TextBlock:

<TextBlock Text="Привет, Windows 8!"
    Foreground="LimeGreen"
    HorizontalAlignment="Left"
    VerticalAlignment="Top"/>

Теперь TextBlock занимает небольшую область в левом верхнем углу страницы, а при касании за пределами TextBlock изменяется цвет Grid. Заменим атрибут HorizontalAlignment на TextAlignment:

<TextBlock Text="Привет, Windows 8!"
    Foreground="LimeGreen"
    TextAlignment="Left"
    VerticalAlignment="Top"/>

Программа внешне не изменилась, текст по-прежнему находится в левом верхнем углу. Но теперь при касании справа от TextBlock изменяется цвет TextBlock, а не Grid. Свойство Stretch элемента TextBlock сохранило значение по умолчанию Stretch, поэтому теперь элемент занимает всю ширину экрана, но в пределах широкой области, занимаемой TextBlock, текст выровнен по левому краю.

Мораль: свойства HorizontalAlignment и TextAlignment не эквивалентны, что бы ни казалось, если судить исключительно по внешнему виду приложения.

Проведем другой эксперимент - восстановите значение HorizontalAlignment и удалите свойство Background элемента Grid:

<Grid>
    <TextBlock Text="Привет, Windows 8!"
        Foreground="LimeGreen"
        HorizontalAlignment="Left"
        VerticalAlignment="Top"/>
</Grid>

Co светлой темой элемент Grid имеет белый фон. При удалении свойства Background фон страницы становится черным. Но в поведении программы также происходят изменения: TextBlock по-прежнему изменяет цвет при касании, но если точка касания находится за пределами TextBlock, цвет Grid остается неизменным.

У свойства Background, определяемого классом Panel (и наследуемого Grid), значение по умолчанию равно null, а при отсутствии фона элемент Grid не отслеживает события касания - они просто «проходят насквозь».

Одно из возможных решений без изменения визуального оформления - назначение Grid свойства Background со значением Transparent:

<Grid Background="Transparent">
    <TextBlock Text="Привет, Windows 8!"
        Foreground="LimeGreen"
        HorizontalAlignment="Left"
        VerticalAlignment="Top"/>
</Grid>

Приложение выглядит так же, как с null, но теперь вы будете получать события Tapped, у которых свойство OriginalSource равно Grid.

Из этого следует важный урок: внешность бывает обманчивой. Элемент со значениями по умолчанию HorizontalAlignment и VerticalAlignment может внешне не отличаться от элемента со значениями Left и Тор, но в действительности он занимает всю область контейнера и может блокировать события, не позволяя им добраться до нижележащих элементов. Потомок Panel, у которого свойство Background имеет значение по умолчанию null, может внешне не отличаться от элемента со значением Transparent, но он не реагирует на события касания.

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

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