Создание стрелочных часов в WinRT

59

Циферблат стрелочных часов имеет круглую форму. Это означает, что прорисовка часов проще всего реализуется при использовании произвольных координат, измеряемых не в пикселах, а в более удобных единицах, с началом координат в центре. Из размещения начала координат в центре также следует, что вам не придется возиться со свойствами CenterX или CenterY объектов RotateTransform, позиционирующих стрелки часов, потому что начало координат также совпадает с центром вращения.

Традиционные стрелочные часы в графической среде подстраиваются под выделенное им пространство. Возникает искушение использовать для этой цели Viewbox, но со стрелочными часами такой подход создает проблемы. Система формирования макета (и Viewbox) считает, что размер объекта векторной графики равен максимальным значениям X и Y его координат. Отрицательные координаты игнорируются, включая координаты в трех квадратах часов, у которых начало координат располагается в центре.

Система формирования макета (и Viewbox) неправильно определяет размер графических объектов с отрицательными координатами. Но преобразования распространяются от родителей к потомкам. Если назначить преобразование для панели Grid, оно будет применено ко всему содержимому Grid. Для содержимого Grid могут определяться и собственные преобразования.

Именно так я поступил в программе AnalogClock. Вся графика размещается на панели Grid, имеющей фиксированный размер с 200-пиксельными значениями Width и Height; таким образом, радиус внутреннего циферблата составит 100 пикселов:

<Grid Width="200" Height="200">
    ... графическая реализация часов
</Grid>

Внутри Grid размещаются пять элементов Path, которые рисуют деления по окружности часов, а также часовую, минутную и секундную стрелки. Все они базируются на координатной системе со значениями координат X и Y в диапазоне от -100 до 100. Если бы в окне приложения отображалась панель Grid (обведенная синим контуром) и циферблат, это выглядело бы так:

Стартовая позиция элемента Grid в приложении AnalogClock

Панель Grid по умолчанию расположена в центре страницы, но центр часов располагается в левом верхнем углу Grid, где находится точка (0,0). Теперь мы поместим панель Grid в Viewbox:

<Viewbox>
    <Grid Width="200" Height="200">
        ... графическая реализация часов
    </Grid>
</ViewBox>

Элемент Viewbox правильно работает с элементами, у которых начало координат расположено в левом верхнем углу, но не с графикой с отрицательными координатами:

Позиционирование Viewbox в приложении AnalogClock

К счастью, проблема решается относительно просто - простым сдвигом Grid и циферблата. Преобразование выполняется до того, как Viewbox найдет элемент, поэтому величина смещения составляет всего 100 пикселов:

<Viewbox>
    <Grid Width="200" Height="200">
        <Grid.RenderTransform>
            <TranslateTransform X="100" Y="100" />
        </Grid.RenderTransform>
        
        ... графическая реализация часов
    </Grid>
</ViewBox>

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

Окончательное позиционирование элементов в приложении AnalogClock

Часы состоят из пяти элементов Path. Каждая из трех стрелок определяется синтаксисом разметки, состоящим из прямых линий и кривых Безье. Ниже приведено определение часовой стрелки, указывающей на 12:00. Так как большая часть стрелки располагается в верхней половине часов, координаты Y в основном имеют отрицательные значения:

<!-- Часовая стрелка, указывающая вверх -->
<Path Data="M 0 -60 C 0 -30, 20 -30, 5 -20 L 5 0
            C 5 7.5, -5 7.5, -5 0 L -5 -20
            C -20 -30, 0 -30, 0 -60">
    <Path.RenderTransform>
        <RotateTransform x:Name="rotateHour" Angle="135" />
    </Path.RenderTransform>
</Path>

Для вывода делений используются пунктирные линии. Элемент Path для малых делений выглядит так:

<!-- Малые деления часов -->
<Path Fill="{x:Null}"
      StrokeThickness="3"
      StrokeDashArray="0 3.14159">
    <Path.Data>
        <EllipseGeometry RadiusX="90" RadiusY="90" />
    </Path.Data>
</Path>

Разметка создает окружность с радиусом 90; соответственно длина окружности составит 2π90. Таким образом, 60 делений разделяются расстоянием 3π - произведением StrokeThickness и числа в StrokeDashArray, обозначающего расстояние между точками в единицах StrokeThickness.

После этого вы без труда поймете определение Path для больших делений:

<!-- Большие деления часов -->
<Path Fill="{x:Null}"
      StrokeThickness="6"
      StrokeDashArray="0 7.854">
    <Path.Data>
        <EllipseGeometry RadiusX="90" RadiusY="90" />
    </Path.Data>
</Path>

И снова длина окружности составляет 2π90, однако делений на этот раз всего 12. Они разделяются расстоянием 15π, достаточно близким к произведению 6 и 7,854. Итак, соберем все вместе:

<Page
    x:Class="WinRTTestApp.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinRTTestApp">

    <Page.Resources>
        <Style TargetType="Path">
            <Setter Property="Stroke" Value="{StaticResource ApplicationForegroundThemeBrush}" />
            <Setter Property="StrokeThickness" Value="2" />
            <Setter Property="StrokeStartLineCap" Value="Round" />
            <Setter Property="StrokeEndLineCap" Value="Round" />
            <Setter Property="StrokeLineJoin" Value="Round" />
            <Setter Property="StrokeDashCap" Value="Round" />
            <Setter Property="Fill" Value="LimeGreen" />
        </Style>
    </Page.Resources>

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">

        <Viewbox>
            <!-- Панель Grid содержит графику с началом координат (0,0) -->
            <Grid Width="200" Height="200">

                <!-- Преобразование для всего изображения часов -->
                <Grid.RenderTransform>
                    <TranslateTransform X="100" Y="100" />
                </Grid.RenderTransform>

                <!-- Малые деления часов -->
                <Path Fill="{x:Null}"
                      StrokeThickness="3"
                      StrokeDashArray="0 3.14159">
                    <Path.Data>
                        <EllipseGeometry RadiusX="90" RadiusY="90" />
                    </Path.Data>
                </Path>

                <!-- Большие деления часов -->
                <Path Fill="{x:Null}"
                      StrokeThickness="6"
                      StrokeDashArray="0 7.854">
                    <Path.Data>
                        <EllipseGeometry RadiusX="90" RadiusY="90" />
                    </Path.Data>
                </Path>

                <!-- Часовая стрелка, указывающая вверх -->
                <Path Data="M 0 -60 C 0 -30, 20 -30, 5 -20 L 5 0
                                    C 5 7.5, -5 7.5, -5 0 L -5 -20
                                    C -20 -30, 0 -30, 0 -60">
                    <Path.RenderTransform>
                        <RotateTransform x:Name="rotateHour" Angle="135" />
                    </Path.RenderTransform>
                </Path>

                <!-- Минутная стрелка, указывающая вверх -->
                <Path Data="M 0 -80 C 0 -75, 0 -70, 2.5 -60 L 2.5 0
                                    C 2.5 5, -2.5 5, -2.5 0 L -2.55 -60
                                    C 0 -70, 0 -75, 0 -80">
                    <Path.RenderTransform>
                        <RotateTransform x:Name="rotateMinute" Angle="290" />
                    </Path.RenderTransform>
                </Path>

                <!-- Секундная стрелка, указывающая вверх -->
                <Path Data="M 0 10 L 0 -80">
                    <Path.RenderTransform>
                        <RotateTransform x:Name="rotateSecond" Angle="195" />
                    </Path.RenderTransform>
                </Path>
            </Grid>
        </Viewbox>
    </Grid>
</Page>

Файл фонового кода отвечает за вычисление углов, измеряемых по часовой стрелке от положений 12:00 для трех объектов RotateTransform:

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

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();
            CompositionTarget.Rendering += CompositionTarget_Rendering;
        }

        private void CompositionTarget_Rendering(object sender, object args)
        {
            DateTime dt = DateTime.Now;
            rotateSecond.Angle = 6 * (dt.Second + dt.Millisecond / 1000.0);
            rotateMinute.Angle = 6 * dt.Minute + rotateSecond.Angle / 60;
            rotateHour.Angle = 30 * (dt.Hour % 12) + rotateMinute.Angle / 12;
        }
    }
}

Секундная стрелка часов движется непрерывно. Если вы предпочитаете секундную стрелку, которая каждую секунду «перепрыгивает» к новому делению, просто исключите миллисекунды из вычислений. Впрочем, лучше использовать DispatcherTimer с 1-секундным интервалом вместо события CompositionTarget.Rendering, которое всегда происходит на частоте смены кадров.

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