Экспериментальные UI

Пробуем выйти за пределы Grid

Чарльз Петцольд

Загрузка образца кода

Холст (Canvas) один из нескольких вариантов разметки, доступных в Windows Presentation Foundation (WPF) и Silverlight. При заполнении Canvas дочерними элементами вы размещаете каждый из них, указывая координаты через подключаемые свойства Canvas.Left и Canvas.Top. Эта парадигма довольно сильно отличается от других панелей (panels), которые размещают дочерние элементы на основе простых алгоритмов, не требуя от программиста задавать реальные координаты.

Слово «холст», по-видимому, у многих ассоциируется с рисованием и картинами. Возможно, по этой причине программисты, использующие WPF и Silverlight, склонны низводить Canvas до области векторной графики. Тем не менее, когда вы используете Canvas для отображения элементов Line, Polyline, Polygon и Path, сами элементы включают координатные точки, которые определяют позиции этих элементов на холсте. В итоге вам не нужно возиться с подключаемыми свойствами Canvas.Left и Canvas.Top.

Почему же использовать Canvas, если не требуются предоставляемые подключаемые свойства? Есть ли лучший подход?

Canvas и Grid

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

Поначалу работа с Canvas и одноячейковым Grid кажется очень похожей. Независимо от того, используете вы для векторной графики Canvas или одноячейковый Grid, элементы Line, Polyline, Polygon и Path будут размещаться относительно левого верхнего угла контейнера на основе их координатных точек.

Разница между Canvas и одноячейковым Grid в том, как контейнер появляется для остальной части системы разметки. WPF и Silverlight включают двухпроходную разметку сверху вниз, где каждый элемент запрашивает размер своих дочерних элементов, а затем отвечает за корректное размещение своих дочерних элементов относительно себя. В рамках этой системы разметки Canvas и одноячейковый Grid различаются очень сильно.

  • Для своих дочерних элементов Grid имеет те же размеры, что и его родительский элемент. Обычно эти размеры конечны, а Canvas всегда кажется дочерним элементам «безразмерным».
  • Grid сообщает своему родителю композитный размер своих дочерних элементов. Но Canvas всегда имеет кажущийся (apparent) размер, равный 0, независимо от содержащихся в нем дочерних элементов.

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

Но поместите все эти многоугольники в Canvas, и тот сообщит системе разметки, что у него нулевой размер. Уверен:включая в свое приложение составное векторное изображение, вы почти наверняка предпочтете поведение одноячейкового Grid, а не Canvas.

Так что же, Canvas совершенно бесполезен? Ничуть. Фокус в том, чтобы извлечь себе пользу из странностой в поведении Canvas. По-настоящему Canvas не участвует в разметке. Следовательно, вы можете использовать его всякий раз, когда вам нужно выйти за границы области разметки, например чтобы показать графику, нарушающую границы системы разметки или «висящую» за ее пределами. По умолчанию Canvas не отсекает свои дочерние элементы, поэтому, даже если он очень мал, он все равно размещает их за своими пределами. Canvas в большей мере является точкой отсчета для отображения элементов или графики, чем контейнером.

Canvas отлично подходит для приемов, которые я называю «thinking outside the Grid» («пробуем выйти за пределы Grid»). Хотя я буду показывать примеры кода на Silverlight, вы можете применять те же приемы в WPF. Исходный код для этой статьи можно скачать как решение Visual Studio с именем ThinkingOutsideTheGrid, а с готовыми программами вы можете поиграть на charlespetzold.com/silverlight/ThinkingOutsideTheGrid.

Визуальное связывание элементов управления

Допустим, у вас есть несколько элементов управления в приложении Silverlight или WPF и вам нужна какая-то визуальная связь между двумя или более элементами управления. Может, вы хотите прочертить линию от одного элемента управления к другому, а может, эта линия будет пересекать другие элементы управления, которые находятся между этими двумя.

Конечно, эта линия должна реагировать на изменения в разметке, например при изменении размеров окна или страницы. Чтобы быть в курсе изменений разметки, используйте событие LayoutUpdated — до изучения проблематики, которой посвящена эта статья, мне ни разу не выпадала оказия задействовать его где-либо. LayoutUpdated определяется UIElement в WPF и FrameworkElement в Silverlight. Как и предполагает его название, событие срабатывает после того, как в результате прохода разметки размещение элементов на экране меняется.

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

XAML-файл программы ConnectTheElements структурирован так:

<UserControl ... >
  <Grid ... >
    <local:SimpleUniformGrid ... >
      <Button ... />
      <Button ... />
      ...
    </local:SimpleUniformGrid>

    <Canvas>
      <Path ... /> 
      <Path ... />
      <Path ... />
    </Canvas>
  </Grid>
</UserControl>

Grid содержит SimpleUniformGrid, который вычисляет количество строк и столбцов для отображения своих дочерних элементов, исходя из их общего размера и аспекта (aspect ratio). Когда вы меняете размер окна, изменяется количество строк и столбцов, и ячейки сместятся. Из 32 кнопок в этом SimpleUniformGrid двум присвоены имена btnA и btnB. Canvas занимает ту же область, что и SimpleUniformGrid, но размещается поверх него. Этот Canvas содержит элементы Path, которые программа использует для рисования эллипсов вокруг этих двух именованных кнопок и линии между ними.

Файл отделенного кода выполняет всю свою работу при обработке события LayoutUpdated. Ему нужно найти позиции двух именованных кнопок относительно Canvas, который к тому же удобно выровнен с SimpleUniformGrid, Grid и самим MainPage.

Чтобы найти позицию любого элемента относительно любого другого в том же визуальном дереве, используйте метод TransformToVisual. Этот метод определен классом Visual в WPF и UIElement в Silverlight, но в обеих средах работает одинаково. Допустим, элемент el1 находится где-то внутри области, занятой el2. (В ConnectTheElements el1 — это Button, а el2 — MainPage.) Вызов этого метода вернет объект типа GeneralTransform, который является абстрактным родительским классом для всех других классов графических преобразований:

el1.TransformToVisual(el2)

С классом GeneralTransform нельзя сделать ничего, кроме как вызвать его метод Transform, который преобразует точку из одного пространства координат в другое.

Предположим, что вы хотите найти центр el1, но в пространстве координат el2. Вот код:

Point el1Center = new Point(
  el1.ActualWidth / 2, el1.ActualHeight / 2); 
Point centerInEl2 = 
  el1.TransformToVisual(el2).Transform(el1Center);

Если el2 является Canvas или находится в нем, вы можете использовать точку centerInEl2 для размещения какой-либо графики в Canvas, которая будет расположена в центре el1.

ConnectTheElements выполняет это преобразование в своем методе WrapEllipseAroundElement, чтобы нарисовать два эллипса вокруг двух именованных кнопок, а затем вычислить координаты линии между эллипсами, исходя из пересечения линией центров кнопок. Результат показан на рис. 1.

image: The ConnectTheElements Display

Рис. 1 Экран ConnectTheElements

Если вы пытаетесь программировать это в WPF, замените SimpleUniformGrid на WrapPanel для более динамичного изменения разметки при масштабировании окна программы.

Ползунки с отслеживанием

Изменение графики и других визуальных объектов в ответ на изменение состояния полосы прокрутки или ползунка (slider) — штука самая элементарная, и в WPF/Silverlight это делается либо в коде, либо в связанном XAML. Но как быть, если вам нужно выровнять графику точно в соответствии с позицией ползунка?

Эта идея положена в основу проекта TriangleAngles, который я создал, как своего рода демонстрацию интерактивной тригонометрии. Я разместил два ползунка (вертикальный и горизонтальный) под прямым углом друг к другу. Позиции на двух ползунках определяют две вершины в прямоугольном треугольнике, как показано на рис. 2.

image: The TriangleAngles Display

Рис. 2 Экран TriangleAngles

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

Очевидно, что это еще одна задача для наложения Canvas, но с дополнительным уровнем сложности, поскольку программе нужен доступ к позиции ползунка. Позиция слайдера является частью шаблона элемента управления: этим позициям назначаются имена в шаблоне, но, увы, они недоступны вне шаблона.

И здесь на помощь спешит статический класс VisualTreeHelper. Он позволяет проходить (или, скорее, карабкаться) по любому дереву визуальных элементов в WPF или Silverlight через методы GetParent, GetChildenCount и GetChild. Для универсализации процесса поиска дочернего элемента конкретного типа я написал небольшой обобщенный метод с рекурсией:

T FindChild<T>(DependencyObject parent) 
  where T : DependencyObject

Вызывается он так:

Thumb vertThumb = FindChild<Thumb>(vertSlider);
Thumb horzThumb = FindChild<Thumb>(horzSlider);

В этот момент я мог бы использовать TransformToVisual применительно к двум позициям на ползунках, чтобы получить их координаты относительно наложенного Canvas.

Увы, это сработало бы для одного ползунка, но не для другого, а через некоторое время я вспомнил бы, что шаблон элемента управления Slider содержит ‰‚ позиции (thumbs) — одну для горизонтальной ориентации и вторую для вертикальной. В зависимости от заданной ориентации для Slider свойство Visibility соответствующей половины шаблона устанавливается как Collapsed. Я добавил второй аргумент (mustBeVisible) в метод FindChild и с его помощью отменял поиск по любой ветви дочерних элементов, где элемент невидим.

Установив в false свойство HitTestVisible объекта Polygon, который образует треугольник, я блокировал его реагирование на ввод от мыши, направленный позиции Slider.

Прокрутка за пределы ItemsControl

Допустим, вы используете ItemsControl или ListBox с DataTemplate для отображения объектов из набора этого элемента управления. Можно ли включить Canvas в этот DataTemplate, чтобы информация, касающаяся конкретного элемента в нем, показывалась за пределами этого элемента управления и в то же время при его прокрутке казалось, что он отслеживает свой элемент?

Я не нашел хорошего способа сделать именно так. Самой крупной проблемой оказалась область отсечения (clipping region), налагаемая ScrollViewer. Этот ScrollViewer отсекает любой Canvas, который выходит за его границы, и соответственно отсекается все, что размещено на этом Canvas.

Однако, располагая некоторыми сведениями о внутреннем устройстве ItemsControl, вы можете сделать нечто похожее на то, что вам нужно.

Я рассматриваю эту штуковину как выноску в том плане, что это нечто, принадлежащее элементу (item) в ItemsControl, но на самом деле она реализуется самим ItemsControl. Данный прием демонстрируется проектом ItemsControlPopouts. Чтобы предоставить что-то для отображения через ItemsControl, я создал небольшую базу данных ProduceItems.xml, которая размещается в подкаталоге Data каталога ClientBin. ProduceItems состоит из нескольких элементов с тегом name, установленным в ProduceItem, каждый из которых содержит атрибуты Name, Photo (ссылающийся на растровую картинку элемента) и необязательный Message (его содержимое будет показываться как «выноска» самим ItemsControl). (Фотографии и прочие картинки взяты из коллекции клипов Microsoft Office.)

Классы ProduceItem и ProduceItems содержат код для XML-файла, а ProduceItemsPresenter считывает XML-файл и десериализует его в объект ProduceItems. Он присваивается свойству DataContext визуального дерева, которое содержит ScrollViewer и ItemsControl. ItemsControl содержит простой DataTemplate для отображения элементов.

К этому моменту вы, вероятно, заметили небольшую проблему. Программа в конечном счете вставляет объекты типа ProduceItem в ItemsControl. На внутреннем уровне ItemsControl формирует визуальное дерево для каждого элемента на основе DataTemplate. Для отслеживания перемещений этих элементов вам нужен доступ к их внутреннему визуальному дереву, чтобы знать, где именно находятся элементы относительно остальных.

Эта информация доступна. В ItemsControl определено свойство ItemContainerGenerator только для чтения, которое возвращает объект типа ItemContainerGenerator. Это класс, отвечающий за генерацию визуальных деревьев, связанных с каждым элементов (item) в ItemsControl, и он содержит удобные методы вроде ContainerFromItem, который предоставляет контейнер (на самом деле ContentPresenter) для каждого объекта в этом элементе управления.

Как и две другие программы, ItemsControlPopouts накладывает Canvas на всю страницу. И вновь событие LayoutUpdated позволяет программе проверять, нужно ли что-то изменить на Canvas. Обработчик LayoutUpdated в этой программе перечисляет через ProduceItem объекты в ItemsControl и проверяет свойство Message на неравенство null и empty. Каждое из свойств Message должно соответствовать объекту типа PopOut в Canvas. PopOut — это просто небольшой класс, производный от ContentControl с шаблоном для отображения линии и текста сообщения. Если PopOut отсутствует, он создается и добавляется в Canvas. А если он есть, т опросто используется повторно.

Затем PopOut нужно позиционировать внутри Canvas. Программа получает контейнер, который соответствует объекту данных, и преобразует его координаты относительно холста. Если эта позиция оказывается между верхней и нижней частью ScrollViewer, то свойство Visibility объекта PopOut устанавливается в Visible. В ином случае PopOut скрывается.

Заключение

WPF и Silverlight сильно облегчают работу с разметкой. Grid и другие панели корректно помещают элементы в ячейках и гарантируют, что там они и останутся. Но было бы заблуждением делать из этого вывод, будто бы это обязательно ограничивает вашу свободу в размещении элементов там, где вам вздумается.

Чарльз Петцольд (Charles Petzold)  — давний «пишущий» редактор журнала MSDN Magazine*. Его самая последняя книга — «The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine» (Wiley, 2008). Ведет блог на своем веб-сайте charlespetzold.com.*

Выражаю благодарность за рецензирование статьи Арати Рамани (Arathi Ramani) и группе WPF Layout