Элемент ScrollViewer в WinRT

89

А что произойдет, если StackPanel содержит слишком много элементов, которые не помещаются на экране? В реальной жизни такая ситуация встречается довольно часто, поэтому панель StackPanel со сколько-нибудь значительным количеством элементов почти всегда помещается в элемент ScrollViewer.

У ScrollViewer имеется свойство Content, позволяющее задать любое содержимое которое не помещается в отведенном пространстве - например, большой элемент Image. ScrollViewer предоставляет полосы прокрутки для операций с мышью (также поддерживается прокрутка содержимого пальцами). По умолчанию ScrollViewer также добавляет поддержку жестов сжатия/масштабирования для увеличения или уменьшения содержимого. При желании эту поддержку можно отключить, для чего свойству ZoomMode задается значение Disabled.

ScrollViewer определяет пару других важных свойств. Чаще всего этот элемент применяется для вертикальной прокрутки - например, в сочетании с вертикальной панелью StackPanel. Соответственно свойство VerticalScrollBarVisibility по умолчанию содержит значение ScrollBarVisibility.Visible. Это не означает, что полоса прокрутки постоянно остается на экране. Для пользователей мыши она появляется только при подведении указателя мыши к правой части ScrollViewer, а при смещении указателя мыши снова исчезает. При прокрутке пальцами появляется более узкий индикатор прокрутки.

С горизонтальной прокруткой дело обстоит иначе: по умолчанию свойство HorizontalScrollBarVisibility равно Disabled, поэтому для включения горизонтальной прокрутки его необходимо изменить. Два других варианта - Hidden (возможность прокрутки пальцами, но не мышью) и Auto (эквивалент Visible, если содержимое требует прокрутки, и Disabled в противном случае).

Использовать прокрутку с помощью элемента ScrollViewer стоит осмотрительно - обязательно нужно учитывать компоновку как родительских панелей, так и панелей-потомков. Для примера вы можете рассмотреть какие-нибудь популярные программы, например проводники для Windows, такие как Total Commander или Q-Dir, где с помощью прокрутки реализуется просмотр списка файлов.

Файл XAML следующей программы определяет панель StackPanel, вложенную в ScrollViewer. Обратите внимание на задание свойства FontSize в корневом теге, чтобы его значение наследовалось в странице:

<Page ...
    FontSize="36"
    Foreground="LimeGreen">

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <ScrollViewer>
            <StackPanel x:Name="stackPanel" />
        </ScrollViewer>
    </Grid>
</Page>

Остается сгенерировать в файле отделенного кода столько элементов содержимого StackPanel, чтобы они не помещались на экране одновременно. Как это лучше сделать? Например, можно воспользоваться рефлексией .NET для получения 141 статического свойства Color, определенного в классе Colors:

using System;
using System.Collections.Generic;
using System.Reflection;
using Windows.UI;
using Windows.UI.Xaml.Controls;

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

            IEnumerable<PropertyInfo> properties =
                                    typeof(Colors).GetTypeInfo().DeclaredProperties;

            foreach (PropertyInfo property in properties)
            {
                Color clr = (Color)property.GetValue(null);
                TextBlock txb = new TextBlock();
                txb.Text = String.Format("{0} \x2014 #{1:X2}{2:X2}{3:X2}{4:X2}",
                                            property.Name, clr.A, clr.R, clr.G, clr.B);
                stackPanel.Children.Add(txb);
            }
        }
    }
}

Принцип работы рефлексии в Windows 8 несколько отличается от рефлексии .NET. В общем случае для получения интересующей вас информации от объекта Type нужно вызвать метод расширения GetTypeInfo(). Возвращаемый объект TypeInfo предоставляет дополнительную информацию о типе. В нашей программе свойство DeclaredProperties объекта TypeInfo получает все свойства класса Colors в виде объектов PropertyInfo. Поскольку все свойства класса Colors являются статическими, для получения их значений следует вызвать GetValue() для каждого объекта PropertyInfo с параметром null. В каждый элемент TextBlock заносится название цвета, длинное тире (0x2014 в Юникоде) и шестнадцатеричные байты цвета. Результат выглядит примерно так:

Загрузка списка цветов в элемент ScrollViewer с прокруткой

Конечно, список можно прокручивать мышью или пальцем.

Экспериментируя с программой, вы увидите, что ScrollViewer плавно и быстро реагирует на движения пальцев. Элемент ScrollViewer стоит использовать практически в любых ситуациях, требующих использования прокрутки. Многие элементы управления со встроенной прокруткой - такие, как ListBox и GridView, с которыми мы познакомимся позже, - используют встроенный элемент ScrollViewer. Меня не удивит если на начальном экране Windows 8 используется все тот же элемент ScrollViewer. А еще было бы неплохо, если бы вместе с названиями и значениями выводились образцы цветов. Вскоре мы реализуем эту возможность!

Несколько раз в статьях ранее я представлял частичные иерархии классов. Если вы пытались найти эти иерархии классов в документации Windows 8, то вы, вероятно обнаружили, что в документации каждого класса указывается только иерархия предков, но не производные классы. Как же я создал иерархии классов для этого руководства? Ответ - при помощи написанной мной программы, которая использует ScrollViewer и StackPanel для построения списка всех классов, производных от DependencyObject.

Файл XAML аналогичен предыдущему, не считая того, что я уменьшил размер и изменил цвет шрифта:

<Page ...
    FontSize="32"
    Foreground="{StaticResource ApplicationForegroundThemeBrush}">
    
    ...
</Page>

Программа строит иерархическое дерево классов. Каждый узел представляет класс и содержит коллекцию его непосредственных производных классов, поэтому я включил в проект еще один кодовый файл для класса, представляющего этот узел:

using System;
using System.Collections.Generic;

namespace WinRTTestApp
{
    public class ClassAndSubclasses
    {
        public ClassAndSubclasses(Type parent)
        {
            this.Type = parent;
            this.Subclasses = new List<ClassAndSubclasses>();
        }

        public Type Type { set; get; }
        public List<ClassAndSubclasses> Subclasses { set; get; }
    }
}

По аналогии с использованием рефлексии для получения всех свойств, определяемых классом, можно воспользоваться рефлексией для получения всех открытых классов, определенных в сборке. Эта информация хранится в свойстве ExportedType объекта Assembly. Концептуально вся среда Windows Runtime ассоциируется с одной сборкой, и для получения ссылки на эту сборку достаточно одного типа. Объект Assembly хранится в свойстве Assembly объекта TypeInfo этого типа:

using System;
using System.Collections.Generic;
using System.Reflection;
using Windows.UI;
using Windows.UI.ViewManagement;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Documents;
using Windows.UI.Xaml.Media;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        Type rootType = typeof(DependencyObject);
        TypeInfo rootTypeInfo = typeof(DependencyObject).GetTypeInfo();
        List<Type> classes = new List<Type>();
        Brush highlightBrush;

        public MainPage()
        {
            this.InitializeComponent();
            highlightBrush = new SolidColorBrush(
                new UISettings().UIElementColor(UIElementType.Highlight));

            // Добавить список классов, производных от DependencyObject 
            AddToClassList(typeof(Windows.UI.Xaml.DependencyObject));

            // Алфавитная сортировка по имени
            classes.Sort((t1, t2) =>
            {
                return String.Compare(t1.GetTypeInfo().Name, t2.GetTypeInfo().Name);
            });

            // Организация отсортированных фалов в древовидную структуру
            ClassAndSubclasses rootClass = new ClassAndSubclasses(rootType);
            AddToTree(rootClass, classes);

            // Отображение дерева с использованием элементов TextBlock, 
            // добавленных в StackPanel
            Display(rootClass, 0);
        }

        private void AddToClassList(Type sampleType)
        {
            Assembly assembly = sampleType.GetTypeInfo().Assembly;

            foreach (Type type in assembly.ExportedTypes)
            {
                TypeInfo typeInfo = type.GetTypeInfo();

                if (typeInfo.IsPublic && rootTypeInfo.IsAssignableFrom(typeInfo))
                    classes.Add(type);
            }
        }

        private void AddToTree(ClassAndSubclasses parentClass, List<Type> classes)
        {
            foreach (Type type in classes)
            {
                Type baseType = type.GetTypeInfo().BaseType;

                if (baseType == parentClass.Type)
                {
                    ClassAndSubclasses subClass = new ClassAndSubclasses(type);
                    parentClass.Subclasses.Add(subClass);
                    AddToTree(subClass, classes);
                }
            }
        }

        private void Display(ClassAndSubclasses parentClass, int indent)
        {
            TypeInfo typeInfo = parentClass.Type.GetTypeInfo();

            // Создание элемента TextBlock с именем типа
            TextBlock txtblk = new TextBlock();
            txtblk.Inlines.Add(new Run { Text = new string(' ', 8 * indent) });
            txtblk.Inlines.Add(new Run { Text = typeInfo.Name });

            // Проверка того, является ли класс запечатанным (модификатор sealed)
            if (typeInfo.IsSealed)
                txtblk.Inlines.Add(new Run
                {
                    Text = " (sealed)",
                    Foreground = highlightBrush
                });

            // Проверка возможности создания экземпляров класса
            IEnumerable<ConstructorInfo> constructorInfos = typeInfo.DeclaredConstructors;
            int publicConstructorCount = 0;

            foreach (ConstructorInfo constructorInfo in constructorInfos)
                if (constructorInfo.IsPublic)
                    publicConstructorCount += 1;

            if (publicConstructorCount == 0)
                txtblk.Inlines.Add(new Run
                {
                    Text = " (non-instantiable)",
                    Foreground = highlightBrush
                });

            // Добавить в StackPanel
            stackPanel.Children.Add(txtblk);

            // Рекурсивный вызов метода для всех подклассов
            foreach (ClassAndSubclasses subclass in parentClass.Subclasses)
                Display(subclass, indent + 1);
        }
    }
}

Обратите внимание на конструирование элемента TextBlock для каждого класса посредством включения элементов Run в коллекцию Inlines. Иногда в иерархии классов бывает полезно выводить дополнительную информацию, поэтому программа также проверяет, помечен ли класс как запечатанный (sealed), и возможно ли создание его экземпляров. В Windows Presentation Foundation и Silverlight классы, экземпляры которых не могут создаваться, обычно определяются как абстрактные. В Window Runtime они вместо этого имеют защищенные конструкторы.

На рисунке ниже изображена часть иерархии классов:

Иерархия классов, унаследованных от DependencyObject

Работа с компоновкой: аномалия или норма?

Знание механики формирования макета является важным аспектом грамотного разработчика Windows Runtime. А лучший способ приобретения таких знаний - разработка собственных классов, производных от Panel. Эту задачу мы решим позже, но и простые эксперименты могут дать много полезной информации.

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

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <StackPanel>
            <ScrollViewer>
                <StackPanel x:Name="stackPanel" />
            </ScrollViewer>
        </StackPanel>
</Grid>

При запуске программы выясняется, что прокрутка не работает. Что случилась? Конфликт возник из-за различий в способах вычисления желательной высоты элементами StackPanel и ScrollViewer. StackPanel вычисляет желательную высоту на основании общей высоты всех своих потомков. При вертикальном размещении (используемом по умолчанию) вычисление высоты для StackPanel полностью определяется потомками. Для вычисления общей высоты StackPanel предоставляет каждому из своих потомков бесконечную высоту. (Когда вы займетесь написанием собственных классов, производных от Panel, вы увидите, что это не метафора и не абстракция - значение Double.PositiveInfinity реально используется!) Потомки отвечают на запрос, вычисляя желательную высоту по своим естественным размерам. StackPanel суммирует эти высоты для вычисления собственной желательной высоты.

С другой стороны, высота ScrollViewer определяется родителем. В ней отражено только то пространство, которое было предоставлено родителем. В одном из предыдущих примеров это была высота панели Grid, которая соответствовала высоте Page, которая, в свою очередь, соответствовала высоте окна. Элемент ScrollViewer может определить, как прокручивать свое содержимое, потому что он знает разность высоты потомка (часто StackPanel) и своей собственной высоты.

Теперь разместите ScrollViewer с вертикальным размещением как потомка вертикальной панели StackPanel. Чтобы определить желательную высоту потомка ScrollViewer, StackPanel предоставляет ему бесконечную высоту. Но сколько места в действительности нужно ScrollViewer? Высота ScrollViewer теперь определяется потомком, а не родителем, а его желательная высота совпадает с высотой потомка - то есть общей высотой внутреннего элемента StackPanel, которая вычисляется как суммарная накопленная высота всех потомков StackPanel.

С точки зрения элемента ScrollViewer его высота равна высоте содержимого - а значит, и прокручивать ничего не нужно.

Иначе говоря, когда элемент ScrollViewer с вертикальной прокруткой помещается в вертикальную панель StackPanel, потеря возможности прокрутки является абсолютно логичным поведением!

А вот еще одна странность, которая в действительности абсолютно нормальна: попробуйте занести в TextBlock очень длинный фрагмент текста и задать свойству TextWrapping значение Wrap. В большинстве случаев текст будет переноситься, как и предполагалось. Теперь поместите TextBlock в элемент StackPanel, у которого свойство Orientation равно Horizontal. Чтобы определить ширину TextBlock, StackPanel предоставляет ему бесконечную ширину - и в соответствии с этой бесконечной шириной TextBlock перестает переносить текст!

Ранее вы видели, как горизонтальный элемент StackPanel фактически выполняет конкатенацию элементов TextBlock, у одного из которых задана привязка к свойству Text. А если потребуется применить тот же прием к абзацу текста с переносами? Если часть текста абзаца должна стать результатом привязки? Сделать это с горизонтальной панелью StackPanel не удастся, потому что текст не будет переноситься. Сделать это с элементом Run элемента TextBlock не удастся, потому что свойство Text объекта Run не является свойством зависимости. Конечно, проблема решается выполнением операции в коде. Другое решение, основанное на использовании элемента RichTextBlock, будет продемонстрировано позже.

Горизонтальный элемент StackPanel с горизонтальным размещением не может применить перенос текста к дочерним элементам TextBlock, но для вертикального элемента StackPanel это вполне возможно. Вертикальный элемент StackPanel имеет конечную ширину, поэтому он является идеальным контейнером для элементов TextBlock с переносом текста.

Создание электронной книги с прокруткой

У элемента TextBlock, размещенного в вертикальном элементе StackPanel, свойство TextWrapping может быть равно Wrap; это означает, что элемент предназначен для вывода целого абзаца текста вместо одного-двух слов. Если разместить на той же панели элементы Image, получится простейшая иллюстрированная электронная книга.

На сайте знаменитого проекта «Гутенберг» размещена иллюстрированная версия классической детской книги Беатрис Поттер «Сказка про котенка Тома». Я создал проект Visual Studio с именем TheTaleOfTomKitten, а в проекте - папку с именем Images. Далее в папке Images проекта Visual Studio были сохранены иллюстрации в формате JPEG, загруженные из HTML-версии книги. Имена файлов строятся по схеме tom[].jpg, где [] - исходный номер страницы книги, на которой находится иллюстрация.

Основная работа проводилась с файлом MainPage.xaml. Каждый абзац книги превратился в TextBlock, а между этими элементами были вставлены элементы Image с файлами JPEG из папки Images.

Тем не менее я счел необходимым немного отклониться от порядка текста и графики в HTML-файле. В файле PDF с исходным изданием этой книги на сайте Internet Archive видно, как иллюстрации были связаны с текстом книги. Встречаются две схемы:

  1. Текст находится на левой (четной) странице, а соответствующая иллюстрация - на правой (нечетной) странице.

  2. Текст находится на правой странице, а соответствующая иллюстрация - на левой странице.

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

При таком количестве элементов TextBlock и Image стили становятся практически обязательными:

<Page.Resources>
        <Style x:Key="commonTextStyle" TargetType="TextBlock">
            <Setter Property="FontFamily" Value="Century Schoolbook" />
            <Setter Property="FontSize" Value="36" />
            <Setter Property="Foreground" Value="Black" />
            <Setter Property="Margin" Value="0 12" />
        </Style>

        <Style x:Key="paragraphTextStyle" TargetType="TextBlock" 
               BasedOn="{StaticResource commonTextStyle}">
            <Setter Property="TextWrapping" Value="Wrap" />
        </Style>

        <Style x:Key="frontMatterTextStyle" TargetType="TextBlock" 
               BasedOn="{StaticResource commonTextStyle}">
            <Setter Property="TextAlignment" Value="Center" />
        </Style>

        <Style x:Key="imageStyle" TargetType="Image">
            <Setter Property="Stretch" Value="None" />
            <Setter Property="HorizontalAlignment" Value="Center" />
        </Style>
</Page.Resources>

Обратите внимание на значение Margin, обеспечивающее небольшие интервалы между абзацами. Каждый элемент TextBlock ссылается либо на paragraphTextStyle (для абзацев текста книги), либо на frontMatterTextStyle (для заголовков и вступительного текста в начале книги). Стиль элемента Image также можно сделать неявным, просто удалив атрибут x:Key и удалив атрибуты Style из элементов Image.

Многие элементы TextBlock, составляющие вступительный текст, имеют разные локальные настройки FontSize. Книги обычно печатаются черной краской на белых страницах, поэтому я жестко задал свойству Foreground элемента TextBlock черный, а свойству Background элемента Grid - белый цвет. Чтобы ограничить длину строки текста, мы задаем элементу StackPanel свойство MaxWidth, равное 640, и выравниваем его по центру ScrollViewer. Ниже показан небольшой фрагмент разметки с чередованием элементов TextBlock и Image:

<Grid Background="White">
    <ScrollViewer>
          <StackPanel MaxWidth="640"
                      HorizontalAlignment="Center">

                ...

                <!-- стр. 17 -->
                <TextBlock Style="{StaticResource paragraphTextStyle}">
                    &#x2003;&#x2003;Then she combed their tails and whiskers 
                    (this is Tom Kitten).
                </TextBlock>

                <TextBlock Style="{StaticResource paragraphTextStyle}">
                    &#x2003;&#x2003;Tom was very naughty, and he scratched.
                </TextBlock>

                <Image Source="Images/tom16.jpg" Style="{StaticResource imageStyle}" />

                <!-- стр. 18 -->
                <TextBlock Style="{StaticResource paragraphTextStyle}">
                    &#x2003;&#x2003;Mrs. Tabitha dressed Moppet and Mittens in 
                    clean pinafores and tuckers; and then she took all sorts of 
                    elegant uncomfortable clothes out of a chest of drawers, in 
                    order to dress up her son Thomas.
                </TextBlock>

                <Image Source="Images/tom19.jpg" Style="{StaticResource imageStyle}" />

                ...

          </StackPanel>
    </ScrollViewer>
</Grid>

Две последовательности &#x2003; в начале каждого абзаца - широкие пробелы. Они создают отступ в первой строке, который, к сожалению, не поддерживается классом TextBlock.

Пример реализации электронной книги в Windows Runtime

Книгу можно читать как в книжной, так и в альбомной ориентации.

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