Pull to refresh

WPF layout: Measure и Arrange

Reading time 9 min
Views 21K


Общее представление о том, что такое WPF Layout System, можно получить из msdn (1, 2). Там написано, что элементы управления образуют Visual-дерево, что каждый из элементов управления имеет свой определенный прямоугольник, в рамках которого он отрисовывается, что определение этих прямоугольников возлагается на Layout System и выполняется в 2 этапа (measure и arrange) и что WPF — это retained mode graphic system, в отличие от обычных Immediate и в чем преимущества такого подхода.

Однако при чтении msdn возникает ряд вопросов, на которые в документации ответов нет, и можно только догадываться о том, что происходит. Например — что произойдет, если какой-либо дочерний контрол в measure-стадии запросит для себя размер, превышающий переданный ему availableSize? Или — как при необходимости реализовать методы MeasureOverride и ArrangeOverride правильно, чтобы написанный код не противоречил принятым соглашениям о том, как должны выполняться этапы Measure и Arrange ? Влияет ли результат, полученный на этапе Measure, на этап Arrange и отрисовку, или же на отрисовку влияет только вызов Arrange, а Measure — чисто информационный этап?

Попробуем разобраться более детально в том, что происходит за кулисами.

Retained mode system


Для начала вспомним, что такое retained mode graphic system, коей является WPF. Это просто подход к прорисовке графики, при котором ответственность за определение областей, требующих перерисовки и выполнение этой перерисовки передается графической системе. То есть именно WPF является ответственной за то, чтобы каждое окно и контрол при необходимости были перерисованы. Программист уже не парится по поводу обработки событий типа WM_PAINT, как это было в Win32 API и WindowsForms, а просто задает при необходимости способ отрисовки для контрола, а WPF сама определит, когда и какой кусочек нужно ревалидировать. Программно это выглядит следующим образом: вместо того, чтобы каждый раз по приходу сообщения WM_PAINT определять регионы, которые нужно перерисовать, и выполнять эту процедуру, программист единожды указывает последовательность команд, необходимых для прорисовки контрола. Это делается в методе OnRender. Метод OnRender вызывается системой WPF:
  • Eсли еще неизвестно, как отрисовывать этот контрол
  • Eсли изменилось какое-то DependencyProperty с флагом FrameworkPropertyMetadataOptions.AffectsRender (например, Button.Content) или с другими флагами, которые неявно приводят к AffectsRender
  • Eсли контрол был явно помечен для ревалидации (вызовом InvalidateMeasure, InvalidateArrange или InvalidateVisual)


image

Метод OnRender вызывается с аргументом DrawingContext, у которого мы дергаем якобы отрисовывающие методы типа DrawEllipse итд. Якобы — потому, что на самом деле отрисовки не происходит, а все наши вызовы складываются в очередь команд, и будут использованы тогда, когда WPF решит, что контрол нужно перерисовать.

Собственно здесь лежит ответ на частый вопрос — вот допустим у нас есть кнопка, на которой что-то написано, мы меняем текст этой кнопки — но в какой момент и как она определяет, что нужно перепозиционировать себя и перерисовать? Ведь мы только поменяли значение свойства. А происходит следующее: текст кнопки влияет на рендеринг, и помечен соответствующим флагом, поэтому изменение значения этого свойства приводит к тому, что кнопка помечается как «грязная», то есть та, которая требует перерисовки. И вскоре после этого WPF выполнит обновление рендеринга для неё. Это случится не сразу, а только тогда, когда у WPF будет время для этого. То есть если сразу после замены получить RenderSize, то оно не изменится. Но если вызвать принудительное обновление разметки методом UpdateLayout(), то RenderSize станет тем, который соответствует измененному тексту. Собственно с этим механизмом, кстати, связан и приоритет Dispatcher.Invoke — среди доступных приоритетов есть Priority.Render, что означает, что при вызове делегата с этим приоритетом он будет исполняться наравне с процедурами, выполняемыми для рендеринга элементов.

Measuring


Почему это так важно? Потому что этапы Measure и Arrange, в рамках которых происходит позиционирование элементов, работают аналогичным образом. Они вызываются однократно, и у контрола выставляются флаги MeasureIsValid и ArrangeIsValid. После этого вызовы Measure и Arrange возвращают управление сразу, ничего не делая. Для того, чтобы заставить контрол пересчитать эти вещи, нужно опять же либо изменить какое-то DependencyProperty с флагом AffectsMeasure/AffectsArrange, либо явно сбросить флаги вызовом InvalidateMeasure/InvalidateArrange. Документация также говорит о том, что ревалидация Measure автоматом влечет за собой ревалидацию Arrange, впрочем, это и так достаточно очевидно. В общем, первый вывод таков: если в вашем контроле есть некое свойство, которое при изменении может изменить размеры и/или размещение дочерних контролов, то вы должны сделать его DependencyProperty и поставить флаг AffectsMeasure/AffectsArrange. Если же не всякое изменение значения этого свойства проводит к необходимости ревалидации контрола, то лучше этого не делать, а сделать DependencyProperty с заданным valueChangedCallback, в котором при необходимости вызывать InvalidateMeasure/InvalidateArrange вручную, чтобы не нагружать WPF излишней работой.

Теперь рассмотрим эти два метода с точки зрения программного дизайна. То есть — каковы их зоны ответственности и входные/выходные данные. Это, наверное, самое важное в понимании того, как работает WPF Layout System. Мне лично потребовалось немало времени для того, чтобы вкурить в эту тему. Пришлось даже покопаться в исходниках WPF, благо они доступны.

Measure и Arrange


Документация к Measure написана таким образом, что создается впечатление, будто бы это чисто информационный этап, и на отображение влиять не должен. Вроде бы все логично — родительский контрол опрашивает дочерние, узнает у них, сколько они хотели бы занимать места, ну и решает конкретно кому сколько пространства отрезать, и вызывает Arrange. В общем-то, на уровне PresentationCore так оно и есть (там UIElement содержит пустые виртуальные методы MeasureCore и ArrangeCore), но нас скорее всего интересует более конкретное поведение, поведение FrameworkElement и его наследников, а FrameworkElement определен у нас уже в сборке PresentationFramework.

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

public void Measure(Size availableSize) — метод, который по заданному availableSize определяет желаемые размеры и выставляет их в this.DesiredSize. В описании к методу написано, что результирующий DesiredSize может быть > availableSize, но для наследников FrameworkElement это не так.
Суть метода Measure — сделать следующие вещи:
  1. Вызвать рекурсивно Measure для всех VisualChild-элементов (в противном случае флаг IsMeasureValid у них не будет выставлен и дочерний элемент не сможет быть отрисован). Хотя бы один раз Measure должен быть вызван. Measure может быть вызван неоднократно (например, сначала можно вызвать Measure с аргументом Size=double.PositiveInfinity, чтобы определить полный размер контрола), причем последний вызов Measure должен быть выполнен с теми размерами, которые реально будут использованы для отрисовки этого дочернего контрола.
  2. Подготовить состояние контрола к тому, чтобы он уместился в отведенные размеры availableSize. Это и есть причина того, что последний вызов Measure должен определять реальные размеры элемента. Причина — потому что если контрол выставит в DesiredSize значение, превышающее availableSize, то ячейка грида с фиксированными размерами не будет знать, что ей делать. Вроде бы она имеет фиксированные размеры, ан нет — дочерний элемент стучит кулаком по столу и хочет больше! Поэтому фраза документации о том, что availableSize является 'soft constraint' строго говоря неверна в контексте FrameworkElement.


Второй пункт очень важен. Действительно, в Measure-фазе и происходит подготовка к рисованию. Например, листбокс при вызове Measure, если понимает, что не помещается в размеры, определяет, что появится скроллбар. И при вызове Arrange с бОльшими размерами скроллбар все равно останется. А если наоборот, Measure был выполнен с PositiveInfinity, а Arrange — с реальными размерами, все что выйдет за пределы arrangeRect — просто обрежется.

Кстати а почему именно FrameworkElement ? На нем свет сошелся? Оказывается, действительно сошелся, и FrameworkElement переопределяет UIElement.MeasureCore и UIElement.ArrangeCore с модификатором sealed, то есть все наследники FrameworkElement (все контролы, окна итд) уже при всем желании поведение MeasureCore и ArrangeCore поменять не смогут. Они смогут только оставлять пожелания — для этого и сделаны методы MeasureOverride и ArrangeOverride. И вот в MeasureOverride availableSize действительно является soft constraint, и можно вполне легально вернуть значение, превышающее значение аргумента.

public void Arrange(Rect finalRect) — просто указывает контролу его место (x, y) и размеры прямоугольника. Все, что выйдет за эти пределы — будет обрезано.

Между Measure и Arrange существует следующая взаимосвязь — в идеале последний вызов Measure должен принимать Size, совпадающий с размером при последующем вызове Arrange. Если это так, то контрол будет отрисован идеально. Если же условие не выполняется, то возможны проблемы. То есть возможно, что все отрисуется корректно, а может быть и не совсем. Например, листбокс в этой ситуации (когда arrangeSize < measureSize) справа сдвигается наура (скроллбар сползает влево вместе с границами, а не обрезается), а снизу — обрезается.

Теперь осталось рассмотреть методы MeasureOverride и ArrangeOverride.

MeasureOverride и ArrangeOverride


protected virtual Size MeasureOverride(Size availableSize) спроектирован для того, чтобы дать возможность разработчикам сделать свои панели для контролов со своей логикой размещения. Обычно алгоритм MeasureOverride должен выполнить следующие шаги:
  1. Оценить полный размер детей — рекурсивным вызовом Measure с параметрами Size.Width и Size.Height = double.PositiveInfinity
  2. Оценить свой собственный полный размер с учетом размеров детей
  3. Если влезаем в availableSize, то возвращаем значение собственного полного размера
  4. В противном случае нам возможно понадобиться повторно вызвать Measure у детей, но уже не с PositiveInfinity а с конкретными значениями, чтобы уложиться в отведенные нам availableSize. Конкретная реализация этого этапа зависит от логики размещения, которую мы хотим имплементировать.
  5. Возвращаем availableSize в качестве DesiredSize, если получилось уложиться в availableSize, ну или минимальное значение, превышающее availableSize, которое позволит нашему контролу быть отрендеренным целиком

protected virtual Size ArrangeOverride(Size finalSize) — а здесь мы просто вызываем для каждого дочернего элемента метод Arrange с соответствующими границами и положением.

Заметьте — в MeasureOverride можно вернуть значение, превышающее availableSize! Но если это сделать и проверить DesiredSize контрола — то мы будем удивлены тем, что DesiredSize = availableSize. То есть кто-то проигнорировал наш результат и записал туда значение аргумента Measure. Однако при дальнейшем вызове ArrangeOverride в качестве аргумента нам волшебным образом снова попадает наше значение, которое мы вернули из MeasureOverride.

Что происходит? А происходит вот что.

Если вызывается FrameworkElement.Measure с небесконечными констрейнтами, то вне зависимости от того, что вернет наш MeasureOverride, FrameworkElement.MeasureCore обрежет его и установит DesiredSize <= availableSize. А наш DesiredSize закеширует у себя, впоследствии передав нам его в ArrangeOverride. Это происходит потому что FrameworkElement гарантирует, что при вызове Measure он уместится в отведенный ему кусочек, даже если ему придется обрезать наш контент.

В противном случае было бы так, что ячейки грида с конкретными значениями width/height разъезжались бы на контролах, возвращающих DesiredSize > availableSize. А так получается что FrameworkElement сохраняет у себя реальные требования к DesiredSize, а когда приходит время Arrange, вызывает наш метод ArrangeOverride с тем значением DesiredSize, которое мы вернули в MeasureOverride. И мы в ArrangeOverride располагаем дочерние элементы так, как мы хотим.
После этого FrameworkElement.ArrangeCore, в контексте которого вызывается наш ArrangeOverride, выполняет клипинг нашего содержимого, и мы видим в гриде часть нашего контрола. А какую именно часть — зависит от свойств Horizontal/VerticalAlignment и др. Но контент виртуально прорисован целиком так как мы хотели — потому что DesiredSize наш был сохранен и передан в ArrangeOverride.

И нам при реализации наследников FrameworkElement не надо париться насчет клипинга в таких ситуациях — он все сделает за нас. А если нам нужна панель, которая обрабатывает DesiredSize > availableSize, то мы либо что-то неправильно делаем, либо нам придется спускаться на уровень ниже к UIElement'у, который не финализирует (seal) MeasureCore и ArrangeCore.

Для проверки всего этого можно создать наследника Button и возвращать в MeasureOverride фиксированный размер, а кнопку поместить в ячейку грида с меньшими размерами (скажем, 50х50). Кнопка будет обрезана.

protected override Size MeasureOverride(Size constraint) {
return new Size(80, 80);
}


Эта особенность описана в http://social.msdn.microsoft.com/

В завершение хотелось бы привести код к некоторым методам из исходников WPF (классы UIElement и FrameworkElement), относящимся к рассмотренному материалу. Куски с комментариями, достаточно подробно излагающими суть.

Целиком же исходники WPF можно скачать отсюда (качать .Net — 4).

UPD: afsherman подсказывает, что исходники WPF качать необязательно. У кого установлен ReSharper могут воспользоваться встроенной в него опцией загрузки исходников по Ctrl+Click на имени класса/метода/свойства и т.д.
Tags:
Hubs:
+29
Comments 13
Comments Comments 13

Articles