Создание редактора XAML в WinRT

56 Исходный код проекта

Даже после того, как вы освоите различные функциональные возможности Windows Runtime, собрать все воедино при построении приложения может быть очень непросто. Однако теперь, когда вы научились создавать строки приложений и диалоговые окна, стало возможным построить некое подобие настоящего приложения.

Приложение XamlCruncher позволяет ввести разметку XAML в поле TextBox и увидеть результат. «Волшебство» XamlCruncher основано на использовании метода XamlReader.Load(). Разметка XAML, обрабатываемая XamlReader.Load(), не может содержать ссылки на обработчики событий или внешние сборки, но такой инструмент, как XamlCruncher, будет очень полезным для интерактивных экспериментов с форматом XAML и его изучения. Не стану утверждать, что это продукт коммерческого уровня, но это настоящая программа с полноценным использованием возможностей Windows 8.

В области редактирования слева вводится разметка XAML, а в области отображения справа выводятся полученные объекты:

Редактор XAML

Редактор не предоставляет каких-либо удобств. Он не генерирует парный закрывающий тег при вводе начального тега; он не использует цветовое выделение элементов, атрибутов и строк; у него нет ничего даже отдаленно похожего на функциональность IntelliSense в Visual Studio. Однако конфигурацию страницы можно изменить: окно редактирования можно разместить наверху, справа или внизу.

В строке приложения находятся кнопки "Добавить", "Открыть файл", "Сохранить" и "Сохранить как", а также кнопка "Обновить" и кнопка настройки приложения:

Строка приложения редактора XAML

Вы можете выбрать, когда в XamlCruncher должна обрабатываться разметка XAML — при каждом нажатии клавиши или только при нажатии кнопки "Обновить". Этот и другие параметры доступны в диалоговом окне, вызываемом кнопкой "Настройки":

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

<UserControl ...>
    
    <Grid>
        <!-- По умолчанию используется горизонтальная ориентация -->
        <Grid.ColumnDefinitions>
            <ColumnDefinition x:Name="coldef1" Width="*" MinWidth="100" />
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition x:Name="coldef2" Width="*" MinWidth="100" />
        </Grid.ColumnDefinitions>

        <!-- Альтернативная вертикальная ориентация -->
        <Grid.RowDefinitions>
            <RowDefinition x:Name="rowdef1" Height="*" />
            <RowDefinition Height="Auto" />
            <RowDefinition x:Name="rowdef2" Height="0" />
        </Grid.RowDefinitions>
        
        <Grid Name="grid1"
              Grid.Row="0"
              Grid.Column="0" />
        
        <Thumb Name="thumb"
               Grid.Row="0"
               Grid.Column="1" 
               Width="12"
               DragStarted="OnThumbDragStarted"
               DragDelta="OnThumbDragDelta" />
        
        <Grid Name="grid2"
              Grid.Row="0"
              Grid.Column="2" />
    </Grid>
</UserControl>

Ранее похожая разметка использовалась в программе со списком цветов при изменении конфигурации Grid при переключении страницы между альбомным и книжным режимом.

В файле фонового кода определяются пять свойств, поддерживаемых свойствами зависимости. Обычно свойствам Child1 и Child2 задаются элементы, которые должны отображаться в левой и правой части элемента управления, но их фактическое местонахождение определяется свойствами Orientation и SwapChildren:

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

namespace XamlCruncher
{
    public sealed partial class SplitContainer : UserControl
    {
        // Статический конструктор и свойства
        static SplitContainer()
        {
            Child1Property = 
                DependencyProperty.Register("Child1",
                    typeof(UIElement), typeof(SplitContainer), 
                    new PropertyMetadata(null, OnChildChanged));

            Child2Property = 
                DependencyProperty.Register("Child2",
                    typeof(UIElement), typeof(SplitContainer),
                    new PropertyMetadata(null, OnChildChanged));

            OrientationProperty = 
                DependencyProperty.Register("Orientation",
                    typeof(Orientation), typeof(SplitContainer),
                    new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));

            SwapChildrenProperty =
                DependencyProperty.Register("SwapChildren",
                    typeof(bool), typeof(SplitContainer),
                    new PropertyMetadata(false, OnSwapChildrenChanged));

            MinimumSizeProperty =
                DependencyProperty.Register("MinimumSize",
                    typeof(double), typeof(SplitContainer),
                    new PropertyMetadata(100.0, OnMinSizeChanged));
        }

        public static DependencyProperty Child1Property { private set; get; }
        public static DependencyProperty Child2Property { private set; get; }
        public static DependencyProperty OrientationProperty { private set; get; }
        public static DependencyProperty SwapChildrenProperty { private set; get; }
        public static DependencyProperty MinimumSizeProperty { private set; get; }

        // Конструктор экземпляров и определение свойств
        public SplitContainer()
        {
            this.InitializeComponent();
        }

        public UIElement Child1
        {
            set { SetValue(Child1Property, value); }
            get { return (UIElement)GetValue(Child1Property); }
        }

        public UIElement Child2
        {
            set { SetValue(Child2Property, value); }
            get { return (UIElement)GetValue(Child2Property); }
        }

        public Orientation Orientation
        {
            set { SetValue(OrientationProperty, value); }
            get { return (Orientation)GetValue(OrientationProperty); }
        }

        public bool SwapChildren
        {
            set { SetValue(SwapChildrenProperty, value); }
            get { return (bool)GetValue(SwapChildrenProperty); }
        }

        public double MinimumSize
        {
            set { SetValue(MinimumSizeProperty, value); }
            get { return (double)GetValue(MinimumSizeProperty); }
        }
          
          // ...
     }
}

Свойство Orientation относится к типу Orientation — перечислению, также используемому для StackPanel и VariableSizedWrapGrid. Для свойств зависимости всегда удобнее использовать существующие типы вместо изобретения собственных. Обратите внимание: свойство MinimumSize относится к типу double и соответственно инициализируется значением 100.0 вместо 100 для предотвращения несоответствия типов во время выполнения.

В обработчиках изменения свойств продемонстрированы два разных способа, применяемых программистами при вызове экземплярных обработчиков изменения свойств из статических обработчиков. Я уже показывал способ, при котором статический обработчик просто вызывает экземплярный обработчик с тем же объектом DependencyPropertyChangedEventArgs. Иногда — как в случае с обработчиками Orientation, SwapChildren и MinimumSize — бывает удобнее вызвать из статического обработчика экземплярный обработчик со старым значением и новым значением, преобразованным к подходящему типу:

public sealed partial class SplitContainer : UserControl
{
        // ...

        // Обработчики изменения свойств
        static void OnChildChanged(DependencyObject obj, 
                                   DependencyPropertyChangedEventArgs args)
        {
            (obj as SplitContainer).OnChildChanged(args);
        }

        private void OnChildChanged(DependencyPropertyChangedEventArgs args)
        {
            Grid targetGrid = (args.Property == Child1Property ^ this.SwapChildren) ? grid1 : grid2;
            targetGrid.Children.Clear();

            if (args.NewValue != null)
                targetGrid.Children.Add(args.NewValue as UIElement);
        }

        static void OnOrientationChanged(DependencyObject obj, 
                                         DependencyPropertyChangedEventArgs args)
        {
            (obj as SplitContainer).OnOrientationChanged((Orientation)args.OldValue, 
                                                         (Orientation)args.NewValue);
        }

        private void OnOrientationChanged(Orientation oldOrientation, Orientation newOrientation)
        {
            // Вроде бы необязательно, но...
            if (newOrientation == oldOrientation)
                return;

            if (newOrientation == Orientation.Horizontal)
            {
                coldef1.Width = rowdef1.Height;
                coldef2.Width = rowdef2.Height;

                coldef1.MinWidth = this.MinimumSize;
                coldef2.MinWidth = this.MinimumSize;

                rowdef1.Height = new GridLength(1, GridUnitType.Star);
                rowdef2.Height = new GridLength(0);

                rowdef1.MinHeight = 0;
                rowdef2.MinHeight = 0;

                thumb.Width = 12;
                thumb.Height = Double.NaN;

                Grid.SetRow(thumb, 0);
                Grid.SetColumn(thumb, 1);

                Grid.SetRow(grid2, 0);
                Grid.SetColumn(grid2, 2);
            }
            else
            {
                rowdef1.Height = coldef1.Width;
                rowdef2.Height = coldef2.Width;

                rowdef1.MinHeight = this.MinimumSize;
                rowdef2.MinHeight = this.MinimumSize;

                coldef1.Width = new GridLength(1, GridUnitType.Star);
                coldef2.Width = new GridLength(0);

                coldef1.MinWidth = 0;
                coldef2.MinWidth = 0;

                thumb.Height = 12;
                thumb.Width = Double.NaN;

                Grid.SetRow(thumb, 1);
                Grid.SetColumn(thumb, 0);

                Grid.SetRow(grid2, 2);
                Grid.SetColumn(grid2, 0);
            }
        }

        static void OnSwapChildrenChanged(DependencyObject obj, 
                                          DependencyPropertyChangedEventArgs args)
        {
            (obj as SplitContainer).OnSwapChildrenChanged((bool)args.OldValue, 
                                                          (bool)args.NewValue);
        }

        private void OnSwapChildrenChanged(bool oldOrientation, bool newOrientation)
        {
            grid1.Children.Clear();
            grid2.Children.Clear();

            grid1.Children.Add(newOrientation ? this.Child2 : this.Child1);
            grid2.Children.Add(newOrientation ? this.Child1 : this.Child2);
        }

        static void OnMinSizeChanged(DependencyObject obj, 
                                     DependencyPropertyChangedEventArgs args)
        {
            (obj as SplitContainer).OnMinSizeChanged((double)args.OldValue, 
                                                     (double)args.NewValue);
        }

        private void OnMinSizeChanged(double oldValue, double newValue)
        {
            if (this.Orientation == Orientation.Horizontal)
            {
                coldef1.MinWidth = newValue;
                coldef2.MinWidth = newValue;
            }
            else
            {
                rowdef1.MinHeight = newValue;
                rowdef2.MinHeight = newValue;
            }
        }

        // ...
}

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

В реализации SplitContainer осталось рассмотреть лишь обработчики событий элемента Thumb. Идея заключается в том, что при выделении размеров двух столбцов (или строк) Grid используется «спецификация со звездочками», чтобы относительные размеры столбцов (или строк) оставались неизменными при изменении размера или пропорций Grid. Однако для того, чтобы логика обработки перетаскивания Thumb оставалась достаточно простой, числовые пропорции, связанные со спецификацией, представляли собой фактические размеры в пикселах. Они инициализируются в методе OnThumbDragStarted и изменяются в OnDragThumbDelta:

public sealed partial class SplitContainer : UserControl
{
        // ...
          
          // Обработчики событий Thumb
        void OnThumbDragStarted(object sender, DragStartedEventArgs args)
        {
            if (this.Orientation == Orientation.Horizontal)
            {
                coldef1.Width = new GridLength(coldef1.ActualWidth, GridUnitType.Star);
                coldef2.Width = new GridLength(coldef2.ActualWidth, GridUnitType.Star);
            }
            else
            {
                rowdef1.Height = new GridLength(rowdef1.ActualHeight, GridUnitType.Star);
                rowdef2.Height = new GridLength(rowdef2.ActualHeight, GridUnitType.Star);
            }
        }

        void OnThumbDragDelta(object sender, DragDeltaEventArgs args)
        {
            if (this.Orientation == Orientation.Horizontal)
            {
                double newWidth1 = Math.Max(0, coldef1.Width.Value + args.HorizontalChange);
                double newWidth2 = Math.Max(0, coldef2.Width.Value - args.HorizontalChange);

                coldef1.Width = new GridLength(newWidth1, GridUnitType.Star);
                coldef2.Width = new GridLength(newWidth2, GridUnitType.Star);
            }
            else
            {
                double newHeight1 = Math.Max(0, rowdef1.Height.Value + args.VerticalChange);
                double newHeight2 = Math.Max(0, rowdef2.Height.Value - args.VerticalChange);

                rowdef1.Height = new GridLength(newHeight1, GridUnitType.Star);
                rowdef2.Height = new GridLength(newHeight2, GridUnitType.Star);
            }
        }
}

На последней картинке выше выводится линейка и сетка в области отображения. Линейка размечена в дюймах; на дюйм приходится по 96 пикселов, поэтому линии сетки отстоят друг от друга на 24 пиксела. Линейка и сетка особенно удобны при интерактивном проектировании векторной графики или других графических объектов, требующих точного соблюдения размеров.

Линейка и линии сетки включаются независимо. Они отображаются классом, производным от UserControl, который называется RulerContainer. Как показано ниже, при конструировании страницы XamlCruncher экземпляр RulerContainer задается свойству Child2 объекта SplitContainer. Файл XAML для RulerContainer выглядит следующим образом:

<UserControl ...>

    <Grid SizeChanged="OnGridSizeChanged">
        <Canvas Name="rulerCanvas" />
        <Grid Name="innerGrid">
            <Grid Name="gridLinesGrid" />
            <Border Name="border" />
        </Grid>
    </Grid>
</UserControl>

Элемент управления RulerContainer содержит свойство Child, а его потомок задается свойству Child элемента Border. Визуально за Border располагается сетка из горизонтальных и вертикальных линий — потомков панели Grid с именем gridLinesGrid. Если линейка также присутствует, панели Grid с именем innerGrid задаются ненулевые поля (Margin) слева и сверху для отображения линейки. Деления и числа, образующие линейку, являются потомками панели Canvas с именем rulerCanvas.

Вот как выглядит весь вспомогательный код для определений свойств зависимости в файле фонового кода:

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

namespace XamlCruncher
{
    public sealed partial class RulerContainer : UserControl
    {
        // ...

        static RulerContainer()
        {
            ChildProperty =
                DependencyProperty.Register("Child",
                    typeof(UIElement), typeof(RulerContainer),
                    new PropertyMetadata(null, OnChildChanged));

            ShowRulerProperty =
                DependencyProperty.Register("ShowRuler",
                    typeof(bool), typeof(RulerContainer),
                    new PropertyMetadata(false, OnShowRulerChanged));

            ShowGridLinesProperty =
                DependencyProperty.Register("ShowGridLines",
                    typeof(bool), typeof(RulerContainer),
                    new PropertyMetadata(false, OnShowGridLinesChanged));
        }

        public static DependencyProperty ChildProperty { private set; get; }
        public static DependencyProperty ShowRulerProperty { private set; get; }
        public static DependencyProperty ShowGridLinesProperty { private set; get; }

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

        public UIElement Child
        {
            set { SetValue(ChildProperty, value); }
            get { return (UIElement)GetValue(ChildProperty); }
        }

        public bool ShowRuler
        {
            set { SetValue(ShowRulerProperty, value); }
            get { return (bool)GetValue(ShowRulerProperty); }
        }

        public bool ShowGridLines
        {
            set { SetValue(ShowGridLinesProperty, value); }
            get { return (bool)GetValue(ShowGridLinesProperty); }
        }

        // Обработчики изменения свойств
        static void OnChildChanged(DependencyObject obj,
                                   DependencyPropertyChangedEventArgs args)
        {
            (obj as RulerContainer).border.Child = (UIElement)args.NewValue;
        }

        static void OnShowRulerChanged(DependencyObject obj,
                                       DependencyPropertyChangedEventArgs args)
        {
            (obj as RulerContainer).RedrawRuler();
        }

        static void OnShowGridLinesChanged(DependencyObject obj,
                                           DependencyPropertyChangedEventArgs args)
        {
            (obj as RulerContainer).RedrawGridLines();
        }

        private void OnGridSizeChanged(object sender, SizeChangedEventArgs args)
        {
            RedrawRuler();
            RedrawGridLines();
        }

        // ...
    }
}

Также показаны обработчики свойств зависимости (достаточно простые, чтобы их можно было использовать в статических версиях) и обработчик SizeChanged для Grid. Два метода осуществляют всю перерисовку, которая сводится к созданию элементов Line и TextBlock и их упорядочению на двух панелях:

// ...

namespace XamlCruncher
{
    public sealed partial class RulerContainer : UserControl
    {
        const double RULER_WIDTH = 12;

        // ...

        private void RedrawGridLines()
        {
            gridLinesGrid.Children.Clear();

            if (!this.ShowGridLines)
                return;

            // Вертикальные линии сетки через каждые 1/4 дюйма
            for (double x = 24; x < gridLinesGrid.ActualWidth; x += 24)
            {
                Line line = new Line
                {
                    X1 = x,
                    Y1 = 0,
                    X2 = x,
                    Y2 = gridLinesGrid.ActualHeight,
                    Stroke = this.Foreground,
                    StrokeThickness = x % 96 == 0 ? 1 : 0.5
                };
                gridLinesGrid.Children.Add(line);
            }

            // Горизонтальные линии сетки через каждые 1/4 дюйма
            for (double y = 24; y < gridLinesGrid.ActualHeight; y += 24)
            {
                Line line = new Line
                {
                    X1 = 0,
                    Y1 = y,
                    X2 = gridLinesGrid.ActualWidth,
                    Y2 = y,
                    Stroke = this.Foreground,
                    StrokeThickness = y % 96 == 0 ? 1 : 0.5
                };
                gridLinesGrid.Children.Add(line);
            }
        }

        private void RedrawRuler()
        {
            rulerCanvas.Children.Clear();

            if (!this.ShowRuler)
            {
                innerGrid.Margin = new Thickness();
                return;
            }

            innerGrid.Margin = new Thickness(RULER_WIDTH, RULER_WIDTH, 0, 0);

            // Линейка у верхнего края
            for (double x = 0; x < gridLinesGrid.ActualWidth - RULER_WIDTH; x += 12)
            {
                // Числа выводятся через дюйм
                if (x > 0 && x % 96 == 0)
                {
                    TextBlock txtblk = new TextBlock
                    {
                        Text = (x / 96).ToString("F0"),
                        FontSize = RULER_WIDTH - 2
                    };

                    txtblk.Measure(new Size());
                    Canvas.SetLeft(txtblk, RULER_WIDTH + x - txtblk.ActualWidth / 2);
                    Canvas.SetTop(txtblk, 0);
                    rulerCanvas.Children.Add(txtblk);
                }
                // Деление каждые 1/8 дюйма
                else
                {
                    Line line = new Line
                    {
                        X1 = RULER_WIDTH + x,
                        Y1 = x % 48 == 0 ? 2 : 4,
                        X2 = RULER_WIDTH + x,
                        Y2 = x % 48 == 0 ? RULER_WIDTH - 2 : RULER_WIDTH - 4,
                        Stroke = this.Foreground,
                        StrokeThickness = 1
                    };
                    rulerCanvas.Children.Add(line);
                }
            }

            // Жирная линия под делениями
            Line topLine = new Line
            {
                X1 = RULER_WIDTH - 1,
                Y1 = RULER_WIDTH - 1,
                X2 = rulerCanvas.ActualWidth,
                Y2 = RULER_WIDTH - 1,
                Stroke = this.Foreground,
                StrokeThickness = 2
            };
            rulerCanvas.Children.Add(topLine);

            // Вертикальная линейка слева
            for (double y = 0; y < gridLinesGrid.ActualHeight - RULER_WIDTH; y += 12)
            {
                // Числа выводятся через дюйм
                if (y > 0 && y % 96 == 0)
                {
                    TextBlock txtblk = new TextBlock
                    {
                        Text = (y / 96).ToString("F0"),
                        FontSize = RULER_WIDTH - 2,
                    };

                    txtblk.Measure(new Size());
                    Canvas.SetLeft(txtblk, 2);
                    Canvas.SetTop(txtblk, RULER_WIDTH + y - txtblk.ActualHeight / 2);
                    rulerCanvas.Children.Add(txtblk);
                }
                // Деление каждые 1/8 дюйма
                else
                {
                    Line line = new Line
                    {
                        X1 = y % 48 == 0 ? 2 : 4,
                        Y1 = RULER_WIDTH + y,
                        X2 = y % 48 == 0 ? RULER_WIDTH - 2 : RULER_WIDTH - 4,
                        Y2 = RULER_WIDTH + y,
                        Stroke = this.Foreground,
                        StrokeThickness = 1
                    };
                    rulerCanvas.Children.Add(line);
                }
            }

            Line leftLine = new Line
            {
                X1 = RULER_WIDTH - 1,
                Y1 = RULER_WIDTH - 1,
                X2 = RULER_WIDTH - 1,
                Y2 = rulerCanvas.ActualHeight,
                Stroke = this.Foreground,
                StrokeThickness = 2
            };
            rulerCanvas.Children.Add(leftLine);
        }
    }
}

В этих двух методах широко используется элемент Line, который рисует прямую линии» между точками (X1, Y1) и (X2, Y2).

В коде RedrawRuler также представлен полезный прием для получения отображаемою размера TextBlock: при создании нового элемента TextBlock свойства ActualWidth и ActualHeight равны нулю. Обычно эти свойства не вычисляются до тех пор, пока TextBlock не станет частью визуального дерева и не начнет участвовать в процессе формирования макета. Однако вы можете заставить элемент TextBlock вычислить собственный размер, вызвав его метод Measure(). Этот метод определяется классом UIElement и является важным компонентом системы формирования макета.

Аргумент метода Measure() представляет собой структуру Size, определяющую размер элемента, но для данной цели его можно задать равным нулю:

txtblk.Measure(new Size());

Если потребуется определить размер элемента TextBlock с переносом текста, передайте конструктору Size первый ненулевой аргумент, чтобы элемент TextBlock знал ширину, по которой следует переносить текст.

После вызова Measure() свойства ActualWidth и ActualHeight элемента TextBlock действительны и могут использоваться для позиционирования TextBlock в Canvas. Вызовы свойств Canvas.SetLeft и Canvas.SetTop необходимы только при позиционировании элементов TextBlock в Canvas. При использовании Grid с одной ячейкой или Canvas элементы Line позиционируются на основании своих координат.

Как видно из кода, экземпляр RulerContainer задается свойству Child2 объекта SplitContainer, доминирующему в странице XamlCruncher. Свойство Child1 вроде бы относится к типу TextBox, но в действительности содержит экземпляр другого пользовательского элемента управления TabbableTextBox, производного от TextBox.

Стандартный класс TextBox игнорирует клавишу Tab, а при вводе XAML в области редактора нужно сохранить вводимые символы табуляции. Это основная функция класса TabbableTextBox, полный код которого приведен ниже:

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

namespace XamlCruncher
{
    public class TabbableTextBox : TextBox
    {
        static TabbableTextBox()
        {
            TabSpacesProperty =
                DependencyProperty.Register("TabSpaces",
                    typeof(int), typeof(TabbableTextBox),
                    new PropertyMetadata(4));
        }

        public static DependencyProperty TabSpacesProperty { private set; get; }

        public int TabSpaces
        {
            set { SetValue(TabSpacesProperty, value); }
            get { return (int)GetValue(TabSpacesProperty); }
        }

        public bool IsModified { set; get; }

        public void GetPositionFromIndex(int index, out int line, out int col)
        {
            if (index > Text.Length)
            {
                line = col = -1;
                return;
            }

            line = col = 0;
            int i = 0;

            while (i < index)
            {
                if (Text[i] == '\n')
                {
                    line++;
                    col = 0;
                }
                else if (Text[i] == '\r')
                {
                    index++;
                }
                else
                {
                    col++;
                };
                i++;
            }
        }

        protected override void OnKeyDown(KeyRoutedEventArgs args)
        {
            this.IsModified = true;

            if (args.Key == VirtualKey.Tab)
            {
                int line, col;
                GetPositionFromIndex(this.SelectionStart, out line, out col);
                int insertCount = this.TabSpaces - col % this.TabSpaces;
                this.SelectedText = new string(' ', insertCount);
                this.SelectionStart += insertCount;
                this.SelectionLength = 0;
                args.Handled = true;
                return;
            }
            base.OnKeyDown(args);
        }
    }
}

Чтобы определить, была ли нажата клавиша Tab, класс переопределяет метод OnKeyDown(). Если проверка дает положительный результат, метод вставляет пробелы в объект Text, чтобы курсор перешел в текстовый столбец, кратный свойству TabSpaces, а для этого необходимо знать позицию курсора в текущей строке. Для получения этой информации используется метод GetPositionFromIndex(), также определенный в классе. (Хотя строки в свойстве Text элемента TextBox ограничиваются символами возврата курсора и перевода строки, индекс SelectionStart вычисляется по одному символу конца строки.) Этот метод объявлен открытым; он также используется приложением XamlCruncher для отображения текущей позиции курсора и текущего выделения (если оно есть).

TabbableTextBox также определяет другое свойство, не поддерживаемое свойством зависимости. Это свойство IsModified, которому задается значение true каждый раз, когда происходит событие KeyDown. Как и многие программы, работающие с документами, XamlCruncher следит за тем, изменился ли текстовый файл с момента последнего сохранения. Если пользователь выполняет команду создания нового файла или открытия существующего файла, а текущий документ содержит несохраненные изменения, программа предлагает пользователю сохранить документ.

Часто эта логика реализуется полностью за пределами элемента управления TextBox. Программа сбрасывает флаг IsModified при загрузке нового файла или при сохранении файла и устанавливает его при получении события TextChanged. Однако событие TextChanged инициируется при программном задании свойства Text элемента TextBox, поэтому даже при заполнении TextBox данными только что загруженного файла срабатывает событие TextChanged, а флаг IsModified устанавливается обработчиком TextChanged.

Казалось бы, установки флага IsModified в этом случае можно избежать, устанавливая флаг при программном задании свойства Text. Однако обработчик TextChanged не вызывается до того момента, когда метод, задающий свойство Text, вернет управление операционной системе, из-за чего логика становится довольно громоздкой. Реализация флага IsModified в классе, производном от TextBox, упрощает задачу.

Настройки приложения и модели представления

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

В этой программе я объединяю пользовательские настройки в классе с именем AppSettings. Класс реализует интерфейс INotifyPropertyChanged, чтобы он мог использоваться в привязках данных. По сути это модель представления или (в более крупном приложении) часть модели представления.

Одна из настроек, которую стоит сохранить в приложении XamlCruncher, — ориентация областей редактирования и отображения. Напомню, что SplitContainer содержит два свойства с именами Orientation и SwapChildren. Для хранения пользовательских настроек я решил использовать нечто более специализированное. Поле TextBox (а точнее, TabbableTextBox) может находиться слева, справа, наверху или внизу; все варианты объединены в следующем перечислении:

namespace XamlCruncher
{
    public enum EditOrientation
    {
        Left, Top, Right, Bottom
    }
}

Ниже приведен класс AppSettings со всеми свойствами, образующими конфигурацию программы. Конструктор загружает настройки, а метод Save() сохраняет их. Все значения свойств поддерживаются полями, инициализируемыми настройками программы по умолчанию. Обратите внимание: свойство EditOrientation основано на перечислении EditOrientation:

using System.ComponentModel;
using System.Runtime.CompilerServices;
using Windows.Storage;
using Windows.UI.Xaml.Controls;

namespace XamlCruncher
{
    public class AppSettings : INotifyPropertyChanged
    {
        // Исходные значения настроек приложения
        EditOrientation editOrientation = EditOrientation.Left;
        Orientation orientation = Orientation.Horizontal;
        bool swapEditAndDisplay = false;
        bool autoParsing = false;
        bool showRuler = false;
        bool showGridLines = false;
        double fontSize = 18;
        int tabSpaces = 4;

        public event PropertyChangedEventHandler PropertyChanged;

        public AppSettings()
        {
            ApplicationDataContainer appData = ApplicationData.Current.LocalSettings;

            if (appData.Values.ContainsKey("EditOrientation"))
                this.EditOrientation = (EditOrientation)(int)appData.Values["EditOrientation"];

            if (appData.Values.ContainsKey("AutoParsing"))
                this.AutoParsing = (bool)appData.Values["AutoParsing"];

            if (appData.Values.ContainsKey("ShowRuler"))
                this.ShowRuler = (bool)appData.Values["ShowRuler"];

            if (appData.Values.ContainsKey("ShowGridLines"))
                this.ShowGridLines = (bool)appData.Values["ShowGridLines"];

            if (appData.Values.ContainsKey("FontSize"))
                this.FontSize = (double)appData.Values["FontSize"];

            if (appData.Values.ContainsKey("TabSpaces"))
                this.TabSpaces = (int)appData.Values["TabSpaces"];
        }

        public EditOrientation EditOrientation
        {
            set
            {
                if (SetProperty<EditOrientation>(ref editOrientation, value))
                {
                    switch (editOrientation)
                    {
                        case EditOrientation.Left:
                            this.Orientation = Orientation.Horizontal;
                            this.SwapEditAndDisplay = false;
                            break;

                        case EditOrientation.Top:
                            this.Orientation = Orientation.Vertical;
                            this.SwapEditAndDisplay = false;
                            break;

                        case EditOrientation.Right:
                            this.Orientation = Orientation.Horizontal;
                            this.SwapEditAndDisplay = true;
                            break;

                        case EditOrientation.Bottom:
                            this.Orientation = Orientation.Vertical;
                            this.SwapEditAndDisplay = true;
                            break;
                    }
                }
            }
            get { return editOrientation; }
        }

        public Orientation Orientation
        {
            protected set { SetProperty<Orientation>(ref orientation, value); }
            get { return orientation; }
        }

        public bool SwapEditAndDisplay
        {
            protected set { SetProperty<bool>(ref swapEditAndDisplay, value); }
            get { return swapEditAndDisplay; }
        }

        public bool AutoParsing
        {
            set { SetProperty<bool>(ref autoParsing, value); }
            get { return autoParsing; }
        }

        public bool ShowRuler
        {
            set { SetProperty<bool>(ref showRuler, value); }
            get { return showRuler; }
        }

        public bool ShowGridLines
        {
            set { SetProperty<bool>(ref showGridLines, value); }
            get { return showGridLines; }
        }

        public double FontSize
        {
            set { SetProperty<double>(ref fontSize, value); }
            get { return fontSize; }
        }

        public int TabSpaces
        {
            set { SetProperty<int>(ref tabSpaces, value); }
            get { return tabSpaces; }
        }


        public void Save()
        {
            ApplicationDataContainer appData = ApplicationData.Current.LocalSettings;
            appData.Values.Clear();
            appData.Values.Add("EditOrientation", (int)this.EditOrientation);
            appData.Values.Add("AutoParsing", this.AutoParsing);
            appData.Values.Add("ShowRuler", this.ShowRuler);
            appData.Values.Add("ShowGridLines", this.ShowGridLines);
            appData.Values.Add("FontSize", this.FontSize);
            appData.Values.Add("TabSpaces", this.TabSpaces);
        }

        protected bool SetProperty<T>(ref T storage, T value,
                                      [CallerMemberName] string propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Кроме свойства EditOrientation, AppSettings определяет два дополнительных свойства, которые более точно соответствуют свойствам SplitContainer: Orientation и SwapEditAndDisplay. Set-методы объявлены защищенными, а свойства задаются только из set-метода EditOrientation. Эти два свойства не сохраняются с другими настройками приложения, но легко вычисляются и упрощают привязки данных.

Страница XamlCruncher

Мы создали уже достаточно фрагментов, чтобы перейти к сборке приложения. Вот как выглядит файл MainPage.xaml:

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

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="Auto" />
        </Grid.ColumnDefinitions>

        <TextBlock Name="filenameText"
                   Grid.Row="0"
                   Grid.Column="0"
                   Grid.ColumnSpan="2"
                   FontSize="18"
                   TextTrimming="WordEllipsis" />

        <local:SplitContainer x:Name="splitContainer"
                              Orientation="{Binding Orientation}"
                              SwapChildren="{Binding SwapEditAndDisplay}"
                              MinimumSize="200"
                              Grid.Row="1"
                              Grid.Column="0"
                              Grid.ColumnSpan="2">
            <local:SplitContainer.Child1>
                <local:TabbableTextBox x:Name="editBox"
                                       AcceptsReturn="True"
                                       FontSize="{Binding FontSize}"
                                       TabSpaces="{Binding TabSpaces}"
                                       TextChanged="OnEditBoxTextChanged"
                                       SelectionChanged="OnEditBoxSelectionChanged"/>
            </local:SplitContainer.Child1>

            <local:SplitContainer.Child2>
                <local:RulerContainer x:Name="resultContainer"
                                      ShowRuler="{Binding ShowRuler}"
                                      ShowGridLines="{Binding ShowGridLines}" />
            </local:SplitContainer.Child2>
        </local:SplitContainer>

        <TextBlock Name="statusText"
                   Text="OK"
                   Grid.Row="2"
                   Grid.Column="0"
                   FontSize="18"
                   TextWrapping="Wrap" />

        <TextBlock Name="lineColText"
                   Grid.Row="2"
                   Grid.Column="1"
                   FontSize="18" />
    </Grid>

    <Page.BottomAppBar>
        <AppBar>
            <Grid>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
                    <AppBarButton Click="OnRefreshAppBarButtonClick"
                                  Icon="Refresh"
                                  Label="Обновить" />

                    <AppBarButton Click="OnSettingsAppBarButtonClick"
                                  Icon="Setting"
                                  Label="Настройки" />
                </StackPanel>

                <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
                    <AppBarButton Click="OnOpenAppBarButtonClick"
                                  Icon="OpenFile"
                                  Label="Открыть файл" />

                    <AppBarButton Click="OnSaveAsAppBarButtonClick"
                                  Icon="SaveLocal"
                                  Label="Сохранить как" />

                    <AppBarButton Click="OnSaveAppBarButtonClick"
                                  Icon="Save"
                                  Label="Сохранить" />

                    <AppBarButton Click="OnAddAppBarButtonClick"
                                  Icon="Add"
                                  Label="Добавить" />
                </StackPanel>
            </Grid>
        </AppBar>
    </Page.BottomAppBar>
</Page>

Главная панель Grid содержит три строки:

Строка состояния содержит дна элемента TextBlock с именами statusText (для вывода возможных ошибок разбора XAML) и lineColText (для строки и столбца TabbableTextBox). Объект Grid дополнительно разделен на два столбца для двух компонентов строки состояния.

Большую часть страницы занимает объект SplitContainer. Как вы вскоре увидите, он содержит привязки к свойствам Orientation и SwapEditAndDisplay класса AppSettings. SplitContainer содержит объекты TabbableTextBox (с привязками к свойствам FontSize и TabSpaces класса AppSettings) и RulerContainer (с привязками к свойствам ShowRuler и ShowGridlines). По этим привязкам становится понятно, что свойству DataContext объекта MainPage присваивается экземпляр AppSettings.

Файл XAML завершается определениями кнопок Button для строки приложения. Как и следовало ожидать, файл фонового кода является самым длинным файлом в проекте, но мы рассмотрим различные его секции, чтобы не перегружать вас лишней информацией. Начнем с конструктора, обработчика Loaded и нескольких простых методов:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Windows.ApplicationModel;
using Windows.Globalization;
using Windows.Globalization.Fonts;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.UI;
using Windows.UI.Popups;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Markup;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;

namespace XamlCruncher
{
    public sealed partial class MainPage : Page
    {
        // ...
        AppSettings appSettings;
        StorageFile loadedStorageFile;

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

            // Почему свойства не задаются в сгенерированных файлах C#?
            editBox = splitContainer.Child1 as TabbableTextBox;
            resultContainer = splitContainer.Child2 as RulerContainer;

            // Элементу TextBox назначается моноширинный шрифт
            Language language = new Language(Windows.Globalization.Language.CurrentInputMethodLanguageTag);
            LanguageFontGroup languageFontGroup = new LanguageFontGroup(language.LanguageTag);
            LanguageFont languageFont = languageFontGroup.FixedWidthTextFont;
            editBox.FontFamily = new FontFamily(languageFont.FontFamily);

            Loaded += OnLoaded;
            Application.Current.Suspending += OnApplicationSuspending;
        }

        private async void OnLoaded(object sender, RoutedEventArgs args)
        {
            // Загрузка AppSettings и присваивание DataContext
            appSettings = new AppSettings();
            this.DataContext = appSettings;

            // Загрузка ранее сохраненного файла
            StorageFolder localFolder = ApplicationData.Current.LocalFolder;
            StorageFile storageFile = await localFolder.CreateFileAsync("XamlCruncher.xaml",
                                                    CreationCollisionOption.OpenIfExists);
            editBox.Text = await FileIO.ReadTextAsync(storageFile);

            if (editBox.Text.Length == 0)
                await SetDefaultXamlFile();

            // Прочая инициализация
            ParseText();
            editBox.Focus(FocusState.Programmatic);
            DisplayLineAndColumn();

            // ...
        }

        private async void OnApplicationSuspending(object sender, SuspendingEventArgs args)
        {
            // Сохранение настроек приложения
            appSettings.Save();

            // Сохранение текста
            SuspendingDeferral deferral = args.SuspendingOperation.GetDeferral();
            await PathIO.WriteTextAsync("ms-appdata:///local/XamlCruncher.xaml", editBox.Text);
            deferral.Complete();
        }

        private async Task SetDefaultXamlFile()
        {
            editBox.Text =
                "<Page xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"\r\n" +
                "      xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\">\r\n\r\n" +
                "    <TextBlock Text=\"Привет, Windows 8!\"\r\n" +
                "               FontSize=\"48\" />\r\n\r\n" +
                "</Page>";

            editBox.IsModified = false;
            loadedStorageFile = null;
            filenameText.Text = "";
        }
          
          // ...

        // События TextBox
        private void OnEditBoxSelectionChanged(object sender, RoutedEventArgs args)
        {
            DisplayLineAndColumn();
        }

        private void DisplayLineAndColumn()
        {
            int line, col;
            editBox.GetPositionFromIndex(editBox.SelectionStart, out line, out col);
            lineColText.Text = String.Format("Строка {0} Столбец {1}", line + 1, col + 1);

            if (editBox.SelectionLength > 0)
            {
                editBox.GetPositionFromIndex(editBox.SelectionStart + editBox.SelectionLength - 1,
                                             out line, out col);
                lineColText.Text += String.Format(" - Строка {0} Столбец {1}", line + 1, col + 1);
            }
        }

        // ...
    }
}

Конструктор начинается с исправления маленькой ошибки, относящейся к полям editBox и resultContainer. Парсер XAML создает эти поля в ходе компиляции, но они не задаются при вызове InitializeComponent во время выполнения.

Далее конструктор назначает моноширинный шрифт для поля TabbableTextBox на основании списка предопределенных шрифтов, доступного в классе LanguageFontGroup. По всей видимости, это единственный способ получения фактических имен семейств шрифтов от Windows Runtime.

Остальная инициализация выполняется в обработчике события Loaded. Свойству DataContext страницы задается экземпляр AppSettings (как вы, вероятно, уже догадались по привязкам данных в файле MainPage.xaml).

Выполнение метода OnLoaded() продолжается загрузкой ранее сохраненного файла или (если он не существует) заполнением TabbableTextBox фрагментом XAML по умолчанию и вызовом ParseText для его разбора. (Вскоре вы увидите, как это делается.) Поле TabbableTextBox получает фокус ввода с клавиатуры, а выполнение OnLoaded() завершается выводом данных начальной строки и столбца, которые обновляются при каждом изменении выделения TextBox.

Почему метод SetDefaultXamlFile() определяется с ключевым словом async и возвращает Task, хотя он в действительности не содержит никакого асинхронного кода? Позднее вы увидите, что этот метод используется как аргумент другого метода в логике файлового ввода/вывода, и это является единственной причиной для столь странного определения. Компилятор выдает предупреждение, поскольку метод не содержит логики await.

В следующей статье мы закончим создание приложения XamlCruncher.

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