Таймеры и анимации в WinRT

121

Иногда приложение Windows 8 должно получать периодические события с фиксированными интервалами. Например, приложение-часы должно обновлять свое изображение каждую секунду. Идеальным классом для этой задачи является DispatcherTimer. Установите интервал, назначьте обработчик события Tick - и дело сделано.

Ниже приведен файл XAML для такого приложения. В сущности, это один большой элемент TextBlock:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock x:Name="txbClock"
                   HorizontalAlignment="Center"
                   VerticalAlignment="Top"
                   FontSize="40"
                   Foreground="LimeGreen" />
</Grid>

Файл отделенного кода создает объект DispatcherTimer с 1-секундным интервалом и устанавливает свойство Text элемента TextBlock в обработчике события:

public sealed partial class MainPage : Page
{
        public MainPage()
        {
            this.InitializeComponent();

            DispatcherTimer timer = new DispatcherTimer();
            timer.Interval = TimeSpan.FromSeconds(1);
            timer.Tick += OnTimerTick;
            timer.Start();
        }

        private void OnTimerTick(object sender, object e)
        {
            txbClock.Text = DateTime.Now.ToString("HH:mm:ss");
        }
}

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

Создание часов в Windows Runtime

Вызов обработчика Tick выполняется в одном программном потоке с остальным пользовательским интерфейсом. Если программа занята интенсивными вычислениями в этом потоке, вызовы не будут прерывать эту работу, периодичность поступления событий нарушится, и вы даже можете пропустить несколько событий. В многостраничном приложении таймер можно запустить в переопределении OnNavigatedTo и остановить его в OnNavigatedFrom, чтобы программа не тратила время попусту, когда страница не видна.

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

Интервал для DispatcherTimer можно установить сколь угодно низким, но частота срабатывания Tick не будет ниже частоты обновления экрана, которая обычно составляет 60 Гц (периодичность около 17 миллисекунд). Конечно, обновлять изображение с большей частотой бессмысленно. Наиболее плавная анимация достигается в том случае, если обновление экрана происходит точно на частоте смены кадров. Если вы стремитесь к этому, вместо DispatcherTimer лучше выбрать статическое событие CompositionTarget.Rendering, которое специально вызывается непосредственно перед обновлением экрана.

Еще удобнее воспользоваться анимационными классами, входящими в поставку Windows Runtime. Эти классы позволяют определять анимации в XAML или в коде, поддерживают разнообразные настройки, а некоторые из них выполняются в фоновых программных потоках.

Но до того, как мы познакомимся с анимационными классами позже, событие CompositionTarget.Rendering хорошо подходит для выполнения анимаций. Иногда такие анимации называются «ручными», потому что программа должна сама выполнить некоторые вычисления на основании затраченного времени.

Ниже показан небольшой пример, который изменяет свойство FontSize элемента TextBlock в обработчике события CompositionTarget.Rendering, увеличивая и уменьшая текст. Файл XAML просто создает экземпляр TextBlock:

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

В файле отделенного кода конструктор запускает событие CompositionTarget.Rendering простым назначением обработчика события. Второй аргумент обработчика определяется с типом object, но в действительности он относится к типу RenderingEventArgs; в его свойстве RenderingTime, относящемся к типу TimeSpan, хранится время с момента начала работы приложения:

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

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();

            CompositionTarget.Rendering += OnCompositionTargetRendering;
        }

        private void OnCompositionTargetRendering(object sender, object args)
        {
            RenderingEventArgs renderArgs = args as RenderingEventArgs;

            double t = (0.25 * renderArgs.RenderingTime.TotalSeconds) % 1;
            double scale = t < 0.5 ? 2 * t : 2 - 2 * t;
            txb.FontSize = 1 + scale * 143;
        }
    }
}

Я постарался немного обобщить этот код. Вычисление t заставляет переменную многократно увеличиваться от 0 до 1 с периодом 4 секунды. За те же 4 секунды значение scale изменяется от 0 до 1 и снова уменьшается до 0, так что свойство FontSize изменяется от 1 до 144 и снова уменьшается до 1. (Код следит за тем, чтобы значение FontSize не было равным 0, что привело бы к выдаче исключения.) При выполнении программы сначала наблюдается некоторая неравномерность анимации из-за необходимости растеризации шрифтов для разных размеров. Но когда анимация входит в ритм, она проходит достаточно гладко и без какого-либо мерцания.

Цвета также можно анимировать, я покажу два разных способа. Второй способ лучше, но я хочу, чтобы вы поняли суть происходящего:

<Grid x:Name="layoutGrid">
        <TextBlock x:Name="txb"
                   HorizontalAlignment="Center"
                   VerticalAlignment="Center"
                   Text="Привет, Windows 8!"
                   FontSize="80"
                   FontFamily="Arial"
                   FontWeight="Bold" />
</Grid>

Ни для Grid, ни для TextBlock явные кисти не определены. Созданием этих кистей на основе анимированных цветов занимается обработчик события CompositionTarget.Rendering:

public sealed partial class MainPage : Page
{
        public MainPage()
        {
            this.InitializeComponent();

            CompositionTarget.Rendering += OnCompositionTargetRendering;
        }

        private void OnCompositionTargetRendering(object sender, object args)
        {
            RenderingEventArgs renderingArgs = args as RenderingEventArgs;
            double t = (0.25 * renderingArgs.RenderingTime.TotalSeconds) % 1;
            t = t < 0.5 ? 2 * t : 2 - 2 * t;

            // Фоновый цвет
            byte gray = (byte)(255 * t);
            Color clr = Color.FromArgb(255, gray, gray, gray);
            layoutGrid.Background = new SolidColorBrush(clr);

            // Цвет текста
            gray = (byte)(255 - gray);
            clr = Color.FromArgb(255, gray, gray, gray);
            txb.Foreground = new SolidColorBrush(clr);
        }
}

Цвет фона Grid переходит от черного к белому и обратно; параллельно основной цвет TextBlock переходит от белого к черному и обратно. На середине они встречаются.

Эффект выглядит неплохо, но следует учесть, что он требует создания двух объектов SolidColorBrush на частоте обновления экрана (вероятно, около 60 раз в секунду), которые в дальнейшем не используются. Это слишком расточительно. Гораздо лучше создать два объекта SolidColorBrush в файле XAML:

<Grid>
    <Grid.Background>
         <SolidColorBrush x:Name="gridBrush" />
    </Grid.Background>
        
    <TextBlock HorizontalAlignment="Center"
               VerticalAlignment="Center"
               Text="Привет, Windows 8!"
               FontFamily="Arial"
               FontWeight="Bold"
               FontSize="80">
               
         <TextBlock.Foreground>
                <SolidColorBrush x:Name="txbBrush" />
         </TextBlock.Foreground>
         
    </TextBlock>
</Grid>

Объекты SolidColorBrush существуют на протяжении всего срока жизни программы. Чтобы упростить обращения из обработчика CompositionTarget.Rendering, им присваиваются имена:

private void OnCompositionTargetRendering(object sender, object args)
{
    RenderingEventArgs renderingArgs = args as RenderingEventArgs;
    double t = (0.25 * renderingArgs.RenderingTime.TotalSeconds) % 1;
    t = t < 0.5 ? 2 * t : 2 - 2 * t;

    // Фоновый цвет
    byte gray = (byte)(255 * t);
    gridBrush.Color = Color.FromArgb(255, gray, gray, gray);

    // Цвет текста
    gray = (byte)(255 - gray);
    txbBrush.Color = Color.FromArgb(255, gray, gray, gray);
}

На первый взгляд практически ничего не изменилось: теперь два объекта Color создаются для однократного использования с частотой обновления экрана. Однако в данном случае говорить об объектах было бы неправильно, потому что Color - структура, а не класс. Правильнее говорить о значениях Color. Эти значения хранятся в стеке и не требуют выделения памяти из кучи.

Если это возможно, частых выделений памяти из кучи следует избегать - особенно с частотой 60 раз в секунду. Но больше всего в этом примере мне нравится то, что объекты SolidColorBrush остаются в системе формирования изображения Windows Runtime. Фактически программа переходит на уровень формирования изображения и изменяет свойство кисти, чтобы изменить параметры выводимого изображения.

Программа также демонстрирует возможности свойств зависимости, обеспечивающих структурированную реакцию на изменения. Как вы вскоре убедитесь, встроенные средства анимации Windows Runtime работают только со свойствами зависимости, а «ручные» анимации с использованием CompositionTarget.Rendering обладают практически тем же ограничением. К счастью, свойство Foreground элемента TextBlock и свойство Background элемента Grid являются свойствами зависимости типа Brush, а свойство Color объекта SolidColorBrush тоже относится к свойствам зависимости.

Каждый раз, когда вы встречаете свойство зависимости, вы можете задать себе вопрос: а что будет, если применить к нему анимацию? Например, свойство Offset класса GradientStop является свойством зависимости, а его анимация позволяет реализовать некоторые интересные эффекты:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <TextBlock Name="txb"
                   Text="8"
                   FontFamily="Arial"
                   FontSize="1"
                   HorizontalAlignment="Center">
            <TextBlock.Foreground>
                <LinearGradientBrush x:Name="gradientBrush">
                    <GradientStop Offset="0.00" Color="Red" />
                    <GradientStop Offset="0.14" Color="Orange" />
                    <GradientStop Offset="0.28" Color="Yellow" />
                    <GradientStop Offset="0.43" Color="Green" />
                    <GradientStop Offset="0.57" Color="Blue" />
                    <GradientStop Offset="0.71" Color="Indigo" />
                    <GradientStop Offset="0.86" Color="Violet" />
                    <GradientStop Offset="1.00" Color="Red" />
                    <GradientStop Offset="1.14" Color="Orange" />
                    <GradientStop Offset="1.28" Color="Yellow" />
                    <GradientStop Offset="1.43" Color="Green" />
                    <GradientStop Offset="1.57" Color="Blue" />
                    <GradientStop Offset="1.71" Color="Indigo" />
                    <GradientStop Offset="1.86" Color="Violet" />
                    <GradientStop Offset="2.00" Color="Red" />
                </LinearGradientBrush>
            </TextBlock.Foreground>
        </TextBlock>
</Grid>

У некоторых объектов GradientStop значение Offset превышает 1, поэтому они не будут видимы. Более того, элемент TextBlock тоже не будет бросаться в глаза, потому что у него свойство FontSize равно 1. Однако во время обработки события Loaded класс Page получает значение ActualHeight крошечного элемента TextBlock и сохраняет его в поле, после чего подключает обработчик события CompositionTarget.Rendering:

public sealed partial class MainPage : Page
{
        // Поле для хранения FontSize
        private double baseSize;

        public MainPage()
        {
            this.InitializeComponent();
            Loaded += OnPageLoaded;
        }

        void OnPageLoaded(object sender, RoutedEventArgs args)
        {
            baseSize = txb.ActualHeight;
            CompositionTarget.Rendering += OnCompositionTargetRendering;
        }

        void OnCompositionTargetRendering(object sender, object args)
        {
            // Задание максимально возможного значения FontSize
            txb.FontSize = this.ActualHeight / baseSize;

            // Повторное вычисление t от 0 до 1
            RenderingEventArgs renderingArgs = args as RenderingEventArgs;
            double t = (0.25 * renderingArgs.RenderingTime.TotalSeconds) % 1;

            // Перечисление объектов GradientStop
            for (int index = 0; index < gradientBrush.GradientStops.Count; index++)
                gradientBrush.GradientStops[index].Offset = index / 7.0 - t;
        }
}

В обработчике CompositionTarget.Rendering значение FontSize элемента TextBlock увеличивается на основании свойства ActualHeight объекта Page (по аналогии с «ручной» реализацией). Текст не увеличивается до полной высоты страницы, потому что в значении ActualHeight элемента TextBlock учитывается место для подстрочных элементов и диакритических знаков, но символы будут достаточно крупными, а их размер будет изменяться при переключении ориентации экрана.

Кроме того, обработчик CompositionTarget.Rendering изменяет все свойства Offset объекта LinearGradientBrush для создания анимационного радужного эффекта, который, к сожалению, не удастся полноценно воспроизвести на странице сайта:

Анимация градиентной кисти

Возникает законный вопрос: а эффективно ли изменять свойство FontSize элемента TextBlock с частотой обновления экрана? Почему бы не назначить обработчик SizeChanged для Page и вносить изменения в нем?

Возможно, такое решение немного эффективнее. Но у свойств зависимости есть одна особенность: объект регистрирует изменение в том случае, если свойство действительно изменилось. Если свойству задается значение, которое он имеет в настоящий момент, ничего не происходит. Вы можете убедиться в этом, назначив обработчик события SizeChanged самому элементу TextBlock.

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