Разбор XAML

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

В этой статье мы продолжим создавать приложение редактора разметки - XamlCruncher. Основная функция XamlCruncher — передача фрагмента XAML методу XamlReader.Load() и получение объекта. Свойство AutoParsing класса AppSettings позволяет выполнять эту операцию с каждым нажатием клавиши, или же программа ожидает нажатия кнопки "Обновить" в строке приложения.

Если метод XamlReader.Load() обнаруживает ошибку, он выдает исключение. Программа выводит сообщение об ошибке красным цветом в строке состояния, а текст в TabbableTextBox также окрашивается в красный цвет:

// ...

namespace XamlCruncher
{
    public sealed partial class MainPage : Page
    {
        Brush textBlockBrush, textBoxBrush, errorBrush;
        // ...

        public MainPage()
        {
            this.InitializeComponent();

            // Задание кисти
            textBlockBrush = Resources["ApplicationForegroundThemeBrush"] as SolidColorBrush;
            textBoxBrush = Resources["TextBoxForegroundThemeBrush"] as SolidColorBrush;
            errorBrush = new SolidColorBrush(Colors.Red);

            // ...
        }

        // ...

        private void OnRefreshAppBarButtonClick(object sender, RoutedEventArgs args)
        {
            ParseText();
            this.BottomAppBar.IsOpen = false;
        }

        private void OnEditBoxTextChanged(object sender, RoutedEventArgs e)
        {
            if (appSettings.AutoParsing)
                ParseText();
        }

        private void ParseText()
        {
            object result = null;

            try
            {
                result = XamlReader.Load(editBox.Text);
            }
            catch (Exception exc)
            {
                SetErrorText(exc.Message);
                return;
            }

            if (result == null)
            {
                SetErrorText("Результат: Null");
            }
            else if (!(result is UIElement))
            {
                SetErrorText("Результат: " + result.GetType().Name);
            }
            else
            {
                resultContainer.Child = result as UIElement;
                SetOkText();
                return;
            }
        }

        private void SetErrorText(string text)
        {
            SetStatusText(text, errorBrush, errorBrush);
        }

        private void SetOkText()
        {
            SetStatusText("OK", textBlockBrush, textBoxBrush);
        }

        private void SetStatusText(string text, Brush statusBrush, Brush editBrush)
        {
            statusText.Text = text;
            statusText.Foreground = statusBrush;
            editBox.Foreground = editBrush;
        }
    }
}

Может оказаться, что фрагмент XAML без ошибок пройдет вызов XamlReader.Load() но позднее произойдет исключение. Подобные ситуации особенно часто возникают с анимациями XAML, потому что анимация не запускается до загрузки визуального дерева. Единственное приемлемое решение этой проблемы — установка обработчика события UnhandledException, определяемого объектом Application. Это делается в конце обработчика Loaded:

private async void OnLoaded(object sender, RoutedEventArgs args)
{
     // ...

    Application.Current.UnhandledException += (excSender, excArgs) =>
    {
         SetErrorText(excArgs.Message);
                excArgs.Handled = true;
    };
}

У такого решения есть недостаток — вам придется особенно тщательно следить за тем, чтобы в программе не возникали другие необработанные исключения, вызванные ошибками в коде.

Кроме того, при выполнении программы в отладчике Visual Studio старается перехватывать необработанные исключения, чтобы передать информацию о них разработчику. Чтобы указать, какие исключения должны перехватываться Visual Studio, а какие должны оставаться для обработки программой, используйте диалоговое окно Exceptions из меню Debug.

Загрузка и сохранение

Каждый раз, когда я берусь за код загрузки и сохранения документов, задача оказывается сложнее, чем кажется на первый взгляд. Основная проблема: каждый раз при выполнении команды New или Open необходимо проверить, нет ли в текущем документе несохраненных изменений. Если такие изменения присутствуют, следуй открыть диалоговое окно с предложением сохранить файл. На выбор пользователю предоставляются три варианта: сохранить (Save), не сохранять (Don't Save) и отменить операцию (Cancel).

Самый простой вариант — отмена. Программе делать ничего не нужно. Если пользователь отказывается от сохранения, то текущий документ уничтожается, а приложение переходит к выполнению команды New или Open.

Если пользователь выбирает сохранение, то существующий документ необходимо сохранить в файле. Однако имя файла может не существовать, если документ не был загружен с диска или если он ранее не сохранялся. Тогда необходимо вывести диалоговое окно Save As. Но в этом окне пользователь может выбрать кнопку отмены, и тогда операция New или Open завершается. В противном случае перед ее выполнением происходит сохранение существующего файла.

Начнем с рассмотрения методов, участвующих в сохранении документов. В строке приложения расположены кнопки "Сохранить" и "Сохранить как", но при отсутствии имени файла у документа кнопка "Сохранить" должна вызывать диалоговое окно:

// Обработчики сохранения файла
private async void OnSaveAsAppBarButtonClick(object sender, RoutedEventArgs args)
{
    StorageFile storageFile = await GetFileFromSavePicker();

    if (storageFile == null)
        return;

    await SaveXamlToFile(storageFile);
}

private async void OnSaveAppBarButtonClick(object sender, RoutedEventArgs args)
{
    Button button = sender as Button;
    button.IsEnabled = false;

    if (loadedStorageFile != null)
    {
        await SaveXamlToFile(loadedStorageFile);
    }
    else
    {
        StorageFile storageFile = await GetFileFromSavePicker();

        if (storageFile != null)
        {
            await SaveXamlToFile(storageFile);
        }
    }
    button.IsEnabled = true;
}

private async Task<StorageFile> GetFileFromSavePicker()
{
    FileSavePicker picker = new FileSavePicker();
    picker.DefaultFileExtension = ".xaml";
    picker.FileTypeChoices.Add("XAML", new List<string> { ".xaml" });
    picker.SuggestedSaveFile = loadedStorageFile;
    return await picker.PickSaveFileAsync();
}

private async Task SaveXamlToFile(StorageFile storageFile)
{
    loadedStorageFile = storageFile;
    string exception = null;

    try
    {
        await FileIO.WriteTextAsync(storageFile, editBox.Text);
    }
    catch (Exception exc)
    {
        exception = exc.Message;
    }

    if (exception != null)
    {
        string message = String.Format("Нельзя сохранить файл {0}: {1}",
                       storageFile.Name, exception);
        MessageDialog msgdlg = new MessageDialog(message, "XAML Cruncher");
        await msgdlg.ShowAsync();
    }
    else
    {
        editBox.IsModified = false;
        filenameText.Text = storageFile.Path;
    }
}

Обработчик кнопки "Сохранить" блокирует кнопку, а затем снимает блокировку при завершении операции. Если кнопка будет повторно нажата во время сохранения файла, могут возникнуть проблемы при попытке повторного сохранения, пока первое сохранение не было завершено.

В последнем методе вызов FileIO.WriteTextAsync() заключен в блок try. Если в процессе сохранения происходит исключение, для оповещения пользователя должно использоваться окно сообщения MessageDialog. Однако асинхронные методы — такие, как ShowAsync() — не могут вызываться в блоке catch, поэтому исключение просто сохраняется для последующей проверки.

Для операций Add и Open приложение XamlCruncher должно проверить, имеются ли в файле несохраненные изменения. Если изменения будут обнаружены, следует выдать окно сообщения с запросом дальнейших инструкций от пользователя. Это происходит в методе, который я назвал CheckIfOkToTrashFile(). Так как этот метод относится к кнопкам Add и Open, я определил его с аргументом commandAction типа Func<Task> — делегат, представляющий метод без аргументов, который возвращает Task. Обработчик Click кнопки Open передает в этом аргументе метод LoadFileFromOpenPicker(), а обработчик кнопки Add использует вышеупомянутый метод SetDefaultXamlFile():

private async void OnAddAppBarButtonClick(object sender, RoutedEventArgs args)
{
    Button button = sender as Button;
    button.IsEnabled = false;
    await CheckIfOkToTrashFile(SetDefaultXamlFile);
    button.IsEnabled = true;
    this.BottomAppBar.IsOpen = false;
}

private async void OnOpenAppBarButtonClick(object sender, RoutedEventArgs args)
{
    Button button = sender as Button;
    button.IsEnabled = false;
    await CheckIfOkToTrashFile(LoadFileFromOpenPicker);
    button.IsEnabled = true;
    this.BottomAppBar.IsOpen = false;
}

private async Task CheckIfOkToTrashFile(Func<Task> commandAction)
{
    if (!editBox.IsModified)
    {
        await commandAction();
        return;
    }

    string message =
        String.Format("Вы хотите сохранить изменения в {0}?",
            loadedStorageFile == null ? "(untitled)" : loadedStorageFile.Name);

    MessageDialog msgdlg = new MessageDialog(message, "XAML Cruncher");
    msgdlg.Commands.Add(new UICommand("Сохранить", null, "save"));
    msgdlg.Commands.Add(new UICommand("Не сохранять", null, "dont"));
    msgdlg.Commands.Add(new UICommand("Отмена", null, "cancel"));
    msgdlg.DefaultCommandIndex = 0;
    msgdlg.CancelCommandIndex = 2;
    IUICommand command = await msgdlg.ShowAsync();

    if ((string)command.Id == "cancel")
        return;

    if ((string)command.Id == "dont")
    {
        await commandAction();
        return;
    }

    if (loadedStorageFile == null)
    {
        StorageFile storageFile = await GetFileFromSavePicker();

        if (storageFile == null)
            return;

        loadedStorageFile = storageFile;
    }

    await SaveXamlToFile(loadedStorageFile);
    await commandAction();
}

private async Task LoadFileFromOpenPicker()
{
    FileOpenPicker picker = new FileOpenPicker();
    picker.FileTypeFilter.Add(".xaml");
    StorageFile storageFile = await picker.PickSingleFileAsync();

    if (storageFile != null)
    {
        string exception = null;

        try
        {
            editBox.Text = await FileIO.ReadTextAsync(storageFile);
        }
        catch (Exception exc)
        {
            exception = exc.Message;
        }

        if (exception != null)
        {
            string message = String.Format("Невозможно загрузить файл {0}: {1}",
                   storageFile.Name, exception);
            MessageDialog msgdlg = new MessageDialog(message, "XAML Cruncher");
            await msgdlg.ShowAsync();
        }
        else
        {
            editBox.IsModified = false;
            loadedStorageFile = storageFile;
            filenameText.Text = loadedStorageFile.Path;
        }
    }
}

Диалоговое окно настроек

Когда пользователь щелкает на кнопке "Настройки", обработчик создает экземпляр класс SettingsDialog, производного от UserControl, и назначает его потомком Popup. В частности, в нем можно выбрать ориентацию областей приложения. Напомню, что для четырех вариантов ориентации я определил перечисление EditOrientation. Соответственно проект также содержит класс EditOrientationRadioButton для хранения одного из четырех значений в виде пользовательского тега:

using Windows.UI.Xaml.Controls;

namespace XamlCruncher
{
    public class EditOrientationRadioButton : RadioButton
    {
        public EditOrientation EditOrientationTag { set; get; }
    }
}

Файл SettingsDialog.xaml размещает элементы управления на панели StackPanel:

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

        <Style x:Key="DialogCaptionTextStyle"
               TargetType="TextBlock"
               BasedOn="{StaticResource CaptionTextBlockStyle}">
            <Setter Property="FontSize" Value="14.67" />
            <Setter Property="FontWeight" Value="SemiLight" />
            <Setter Property="Margin" Value="7 0 0 0" />
        </Style>

    </UserControl.Resources>
    
    <Border Background="{StaticResource ApplicationPageBackgroundThemeBrush}"
            BorderBrush="{StaticResource ApplicationForegroundThemeBrush}"
            BorderThickness="1">
        <StackPanel Margin="24">
            <TextBlock Text="Настройки XamlCruncher"
                       Style="{StaticResource SubheaderTextBlockStyle}"
                       Margin="0 0 0 12" />

            <ToggleSwitch Header="Автоматический разбор"
                             OffContent="Откл"
                          OnContent="Вкл"
                          IsOn="{Binding AutoParsing, Mode=TwoWay}" />

            <TextBlock Text="Ориентация"
                       Style="{StaticResource DialogCaptionTextStyle}" />
            
            <Grid Name="orientationRadioButtonGrid"
                  Margin="7 0 0 0">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                </Grid.RowDefinitions>
                
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                </Grid.ColumnDefinitions>
                
                <Grid.Resources>
                    <Style TargetType="Border">
                        <Setter Property="BorderBrush" 
                                Value="{StaticResource ApplicationForegroundThemeBrush}" />
                        <Setter Property="BorderThickness" Value="1" />
                        <Setter Property="Padding" Value="3" />
                    </Style>
                    
                    <Style TargetType="TextBlock">
                        <Setter Property="TextAlignment" Value="Center" />
                    </Style>

                    <Style TargetType="local:EditOrientationRadioButton">
                        <Setter Property="Margin" Value="0 6 12 6" />
                    </Style>
                </Grid.Resources>
                
                <local:EditOrientationRadioButton Grid.Row="0" Grid.Column="0"
                                                  EditOrientationTag="Left"
                             Checked="OnOrientationRadioButtonChecked">
                    <StackPanel Orientation="Horizontal">
                        <Border>
                            <TextBlock Text="разметка" />
                        </Border>
                        <Border>
                            <TextBlock Text="результат" />
                        </Border>
                    </StackPanel>
                </local:EditOrientationRadioButton>

                <local:EditOrientationRadioButton Grid.Row="0" Grid.Column="1"
                                                  EditOrientationTag="Bottom"
                             Checked="OnOrientationRadioButtonChecked">
                    <StackPanel>
                        <Border>
                            <TextBlock Text="результат" />
                        </Border>
                        <Border>
                            <TextBlock Text="разметка" />
                        </Border>
                    </StackPanel>
                </local:EditOrientationRadioButton>

                <local:EditOrientationRadioButton Grid.Row="1" Grid.Column="0"
                                                  EditOrientationTag="Top"
                             Checked="OnOrientationRadioButtonChecked">
                    <StackPanel>
                        <Border>
                            <TextBlock Text="разметка" />
                        </Border>
                        <Border>
                            <TextBlock Text="результат" />
                        </Border>
                    </StackPanel>
                </local:EditOrientationRadioButton>

                <local:EditOrientationRadioButton Grid.Row="1" Grid.Column="1"
                                                  EditOrientationTag="Right"
                             Checked="OnOrientationRadioButtonChecked">
                    <StackPanel Orientation="Horizontal">
                        <Border>
                            <TextBlock Text="результат" />
                        </Border>
                        <Border>
                            <TextBlock Text="разметка" />
                        </Border>
                    </StackPanel>
                </local:EditOrientationRadioButton>
            </Grid>

            <ToggleSwitch Header="Линейка"
                          OnContent="Показать"
                          OffContent="Скрыть"
                          IsOn="{Binding ShowRuler, Mode=TwoWay}" />

            <ToggleSwitch Header="Линии сетки"
                          OnContent="Показать"
                          OffContent="Скрыть"
                          IsOn="{Binding ShowGridLines, Mode=TwoWay}" />

            <TextBlock Text="Размер шрифта"
                       Style="{StaticResource DialogCaptionTextStyle}" />
            
            <Slider Value="{Binding FontSize, Mode=TwoWay}"
                    Minimum="10"
                    Maximum="48"
                    Margin="7 0 0 0" />

            <TextBlock Text="Размер табуляции в пробелах"
                       Style="{StaticResource DialogCaptionTextStyle}" />

            <Slider Value="{Binding TabSpaces, Mode=TwoWay}"
                    Minimum="1"
                    Maximum="12"
                    Margin="7 0 0 0" />
        </StackPanel>
    </Border>
</UserControl>

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

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

Существенная часть файла XAML посвящена определению четырех элементов управления EditOrientationRadioButton. Содержимое каждого элемента представляет собой StackPanel с двумя обрамленными элементами TextBlock для создания миниатюр, напоминающих четыре варианта макета.

Диалоговое окно содержит три экземпляра ToggleSwitch. По умолчанию свойствам OnContent и OffContent задаются текстовые строки «On» и «Off», но я решил, что надписи «Вкл» и «Откл» лучше подходят для линейки и сетки.

Класс ToggleSwitch также содержит свойство Header для вывода текста над переключателем. На мой взгляд надписи выглядят хорошо, поэтому я постарался повторить шрифт и расположение в определении Style с именем DialogCaptionTextStyle.

Для задания размера шрифта используется элемент управления Slider, и это разумно, но я также использую Slider для задания величины позиций табуляции, что, честно говоря, выглядит совсем не однозначно. Хотя класс AppSettings определяет свойство TabSpaces как целое число, привязка к свойству Value элемента управления Slider все равно работает, так что Slider является удобным механизмом изменения свойства.

Последнее, что осталось сделать в файле фонового кода - организовать управление элементами управления RadioButton:

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

namespace XamlCruncher
{
    public sealed partial class SettingsDialog : UserControl
    {
        public SettingsDialog()
        {
            this.InitializeComponent();
            Loaded += OnLoaded;
        }

        // Инициализация элемента RadioButton для 
        // ориентации области редактирования разметки
        private void OnLoaded(object sender, RoutedEventArgs args)
        {
            AppSettings appSettings = DataContext as AppSettings;

            if (appSettings != null)
            {
                foreach (UIElement child in orientationRadioButtonGrid.Children)
                {
                    EditOrientationRadioButton radioButton = child as EditOrientationRadioButton;
                    radioButton.IsChecked =
                        appSettings.EditOrientation == radioButton.EditOrientationTag;
                }
            }
        }

        // Задать EditOrientation в зависимости от установленного переключателя RadioButton
        private void OnOrientationRadioButtonChecked(object sender, RoutedEventArgs args)
        {
            AppSettings appSettings = DataContext as AppSettings;
            EditOrientationRadioButton radioButton = sender as EditOrientationRadioButton;

            if (appSettings != null)
                appSettings.EditOrientation = radioButton.EditOrientationTag;
        }
    }
}

Обработчик события Closed для Popup закрывает строку приложения. Новые настройки сохраняются в обработчике события Suspending, который вы уже видели.

За пределами Windows Runtime

Ранее я упоминал о некоторых ограничениях на разметку XAML, которую можно вводить в XamlCruncher. Элементам не могут назначаться события, потому что для событий нужны обработчики, а обработчики событий должны быть реализованы в коде. Кроме того, XAML не может содержать ссылки на внешние классы или сборки.

Однако разобранная разметка XAML выполняется в процессе XamlCruncher; это означает, что для нее доступны все классы, доступные для XamlCruncher, включая пользовательские классы, созданные для программы. Приведенный ниже фрагмент XAML включает объявление пространства имен для local. Это позволяет ему использовать SplitContainer с двумя вложенными экземплярами:

Создание разметки с локальными ссылками в редакторе XAML

Все это означает, что приложение XamlCruncher способно выйти за пределы Windows Runtime, и с его помощью вы сможете поэкспериментировать с пользовательскими классами.

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