| « Обратный инжениринг со Sparx EA за пару кликов | Пейджинг на лету » |
List растягивающийся под контент. AutoResizeableList.
Дело было ранней зимой и я как обычно засыпал на работе у батареи. Потом я проснулся, умылся, взбодрился и сел писать. К сожалению, я не сильно имею представление на сколько это актуально и свежо подходом к решению проблемы, но, по крайне мере, один человек очень получал от этого много удовольствия ![]()
Кстати, перед тем как начать читать, тем кто мало знаком lifecycle компонентов во Flex, я бы посоветовал познакомиться с этим, вот этим и с тем.
Начнем-с.
Необходимо было сделать компонент List само(ра)стягивающимся под данные по высоте, то есть визуализовать все данные находящиеся в дата провайдере. Если упростить понятие данных, то в этом случае это простой текст, который надо отобразить в полном объеме. Другими словами, в качестве рендерера должен выступать любой контейнер содержащий любой UIComponent, способный отображать не обрезанный многострочный текст.
Листинг 1. Например это такой рендерер:
- <mx:VBox xmlns:mx="http://www.adobe.com/2006/mxml"
- mouseChildren="false">
- <mx:Script>
- <![CDATA[
- ...
- ]]>
- </mx:Script>
- <mx:Canvas width="100%">
- <mx:Text id="tf" width="100%"/>
- </mx:Canvas>
- </mx:VBox>
Если же рендерер не содержит в себе особых компонентов и его размеры можно корректно высчитать за один проход валидации, то вот самый примитивный способ. Выставляем List#variableRowHeight=true (в случае если высота варьируется) и снаружи его можно обсчитать с помощью, например, List#measureHeightOfItems() и делать это на событие FlexEvent.UPDATE_COMPLETE.
Листинг 2. Примерно так, без напильников и предохранителей:
- <mx:Script>
- <![CDATA[
- private function setMeasuredHeight(list:List):void
- {
- var h:Number = list.measureHeightOfItems();
- list.height = h + list.getStyle("paddingTop") +
- list.getStyle("paddingBottom");
- }
- ]]>
- </mx:Script>
- <mx:List id="simpleList"
- width="400" variableRowHeight="true"
- itemRenderer="SimpleRenderer"
- dataProvider="{dp}"
- updateComplete="setMeasuredHeight(simpleList)"/>
Особые компоненты.
Что же такое “особые” компоненты и их особенное поведение? Есть стандартные компоненты, способные отображать многострочный текст на базе flash.text.TextField, но из-за странностей в реализации текстовых полей во FlashPlayer, они становятся не обычными. Для них не достаточно одного прохода валидации. Им нужно два, иначе после первого прохода мы получаем некорректный TextField#textHeight. И то, как такие компоненты с этим живут, можно увидеть, например, в mx.controls.Text#updateDisplayList().
Листинг 3. mx.controls.Text#updateDisplayList() строка 326.
- if (isSpecialCase()){
- var firstTime:Boolean = isNaN(lastUnscaledWidth) ||
- lastUnscaledWidth != unscaledWidth;
- lastUnscaledWidth = unscaledWidth;
- if (firstTime){
- invalidateSize();
- return;
- }
- }
Вкратце, если updateDisplayList компонента произошел в первый раз после апдейта данных (присваивание текста в текстовое поле), пинаем еще один measure и выходим из метода. И только во время следующего measure будет вычислен корректный размер и запущен второй updateDisplayList для коррекции визуального представления. Это незаметно снаружи, в случаях когда мы присваиваем текстовому полю какой-нибудь текст и сразу вызываем validateNow(), то мы получаем правильные размеры, но если заглянуть внутрь, то мы увидим, что measure() и updatedDisplayList() выполняются два раза и происходит это в одном кадре не без помощи LayoutManager-а.
Под проходом валидации я понимаю процесс, который затрагивает по крайней мере один из защищенных методов: commitProperties, measure и updateDisplayList. В нашем случае, нас больше итересуют два последних.
Пара слов о компоненте List.
Если List-у не задана высота напрямую пользователем и не жестко диктуется родительским контейнером, то инициализируясь он всегда вычисляет List#measuredHeight руководствуясь List#rowCount и List#rowHeight. Если они не заданы вручную, то List#measuredHeight будет всегда равен 140 px, потому как высота строки по умолчанию 20 и количество строк по умолчанию 7. В ситуации когда высота List-а задана напрямую (e.g List#height = 320) фаза measure будет пропущена. Если же нет, то мы получим высоту в 140 px независимо от количества данных в провайдере и количества данных в рендерерах, которые поместились в этих 140 px.
Еще немного занудных подробностей о lifecycle компонента List. В первый проход updateDisplayList List создаст и отвалидирует рендереры, но сделает это не корректно. Если остановить валидацию на первом проходе мы увидим примерно неопрятную картину.
Картинка 1.

В нормальных условиях особые рендереры, используя трюк с двойной валидацией, возбуждают List к повторному measure и updateDisplayList (см. Листинг 3). Ко второму updateDisplayList рендереры уже будут иметь корректные measured размеры и там List задаст им правильный лэйаут, то есть им будет выставлена правильная высота и тд.
Картинка 2.

Отмечу также, что мы не выставляем никаких значений для List#rowCount и List#rowHeight, так как они попросту не нужны в случае когда List#variableRowHeight=true. Вообще, фаза measure с вариативным рендерером (для стандартного List-a) на мой вкус довольно бесполезна, потому как уже говорилось выше List#measuredHeight всегда будет высчитана по умолчанию в 140 px. Мы постараемся внести в нее немного больше смысла, заставив List растягиваться под контент по высоте.
Итак. Скрытый рендерер.
Необходимо высчитать размер всех рендереров на базе данных и учесть стили. Так как, например, в самом начале при инициализации в фазе measure List не имеет никаких созданных рендереров, то нужда заставляет нас использовать стандартный внутренний механизм скрытого рендерера. Cкрытый рендерер - это рендерер созданный внутри самим List-ом на базе List#itemRenderer с visible=false. С его помощью List производит необходимые вычисления для лэйаута, например, это касается вертикального скролбара или вычисления List#rowHeight.
Листинг 4. Создание скрытого рендерера внутри компонента List. mx.controls.List#getMeasuringRenderer() строка 1732.
- if (!item) {
- item = createItemRenderer(data);
- item.owner = this;
- item.name = "hiddenItem";
- item.visible = false;
- item.styleName = listContent;
- listContent.addChild(DisplayObject(item));
- measuringObjects[factory] = item;
- }
Кульминация. Проблемные места.
Основная идея очевидна и проста. Необходимо пройти по всем данным пихая их в скрытый рендерер, валидируя и снимая с него measured размеры. Таким образом, складывая measured размеры одного и того же рендерера отвалидированного с разными данными в одну кучу, но с учетом паддингов и хрома, мы получаем общую measured высоту для List-a.
Листинг 5. com.riapriority.vertex.control.AutoResizableList#measure() строка 162.
- for (var i:int = 0; i < max; ++i) {
- // Reusing standart mechanism to get hidden renderer.
- renderer = getMeasuringRenderer(dp[i]);
- rect = validateAndMeasure(dp[i], renderer);
- h += rect.height + paddingTop + paddingBottom;
- }
- // We add top and bottom chrom + bottom offset
- measuredHeight = h + (viewMetrics.top + viewMetrics.bottom) + _bottomOffset;
- measuredMinHeight = measuredHeight;
- shouldBeUpdated = true;
На этом первый проход measure заканчивается и за ним следует updateDisplayList, в котором List по необходимости создает рендереры и выставляет им размеры и позиционирование. Но, как уже говорилось, если мы имеем дело с особыми компонентами, то в этот (первый) проход они отдадут неверные размеры, которые и будут им выставлены List-ом. Но эти рендереры сразу же попытаются возбудить List к повторному measure, который в свою очередь должен вызвать дополнительный updateDisplayList для корректировки лэйаута. В нашем случае с расширенным компонентом так и происходит, за исключением повторного вызова updateDisplayList и это не гут (на сколко это плохо мы наблюдали на картинке..). Почему? Все довольно просто, когда на List-е возбуждается вторая по счету всплывшая фаза measure (measure происходит снизу вверх) с подачи особых рендереров, то List снова обсчитывает все рендереры, снимая с них уже корректные размеры, делая это примерно также как я описал в предыдущем листинге и сравнивает результат со своими, а они были выставлены нами очень правильно в еще первый проход measure. В итоге, оба размера правильные и совпадают, а значит вызова updateDisplayList не произойдет вследствие отпимизационных введений. И это значит то, что в нашей ситуации ортодоксальное использование концепции жизненного цикла компонентов слегка хромает. Ибо основная концепция measure - вычисление предпочтительных размеров. Чтобы исправить положение мы воспользуемся ручным управлением.
Листинг 6.
com.riapriority.vertex.control.AutoResizableList#updateDisplayList() строка 204.
- if (shouldBeUpdated){
- shouldBeUpdated = false;
- callLater(function():void{
- invalidateDisplayListFlag = true;
- validateDisplayList();
- });
- }
Если попытаться пнуть это стандартным образом, например, используя List#invalidateDisplayList(), то фаза updateDisplayList не будет запущена по причине того, что мы в ней находимся в данный момент. Можно запихать List#invalidateDisplayList() в callLater и это будет работать, но мы проиграем в производительности, потому что окажемся в этой фазе только через два кадра, а жуть как хотелось бы на кадр пораньше.
Добавка о производительности.
С таким подходом мы удваиваем все вызовы связанные с валидацией, потому как сначала мы прогоняем валидацию сами, используя скрытый рендерер, а затем List, создавая визуальные рендереры, количество которых также соответствует данным в провайдере (так как мы выставили достаточную measuredHeight), прогоняет валидацию для них. Вообще, в рамках идеи “fit to content” производительность уже страдает, так как создаются все визуальные рендереры соответственно данным и если мы имеем очень много данных, то это опасный подход. В случае очень большого количества данных лучше размазывать процесс во времени и скорее всего не использовать List, но здесь все строго индивидуально. В этом примере, при количестве данных в ~500 единиц, мы получаем довольно ощутимую паузу перед рендерингом контрола на сцену, хотя, с моей точки зрения, даже так вполне бодро.
Другой подход?
Можно было бы избежать удваивание вызовов валидации, используя updateDisplayList для выставления явной высоты List-у и не используя measure вообще, но в это приведет к совсем плачевным результатам. Казалось бы, раз в updateDisplayList происходит создание рендереров для List-а, то вместо скрытого рендерера, для вычисление measured размеров, там можно использовать уже созданные. Но не тут то было. И я думаю, многим известно почему. List - достаточно оптимизированный компонент чтобы создавать ровно столько рендереров, сколько необходимо. И так как в measure высота вычисляется в 140 px, то и рендереров будет создано лимитированное количество. А именно, не больше 15 штук. То есть, при дата провайдере в 16 итемов List создаст 7 рендереров для 7 строк по умолчанию. Затем вычислит количество рендереров для буферизации сверху и снизу (16/2=8), но буферизацию для верха создавать не имеет смысла, так как очевидно, что при создании List-а мы и так будем находиться в крайнем верхнем положении с List#verticalScrollPosition=0.
Листинг 7.
mx.controls.listClasses.ListBase#makeRowsAndColumnsWithExtraRows() строка 1344.
- var desiredExtraRowsTop:int = offscreenExtraRows / 2;
- var desiredExtraRowsBottom:int = offscreenExtraRows / 2;
- offscreenExtraRowsTop = Math.min(desiredExtraRowsTop, verticalScrollPosition);
В следствии чего получаем 7+8=15. Соответственно, для провайдера длинной в 58 итемов, при проходе всех данных и снятии разсера через getExplicitOrMeasuredHeight, мы получим ексепшен на 36 рендерере, так как его не будет существовать. А значит, что этот подход нас не удовлетворяет и даже с выставленным List#offscreenExtraRowsOrColumns = List#dataProvider.length.
Пара нюансов.
При тестировании возникла пара проблем с drag-and-drop. Нельзя было перетащить итем в самый низ List-а. В нашем компоненте мы имеем свойство AutoResizableList#bottomOffset, которое позволяет задавать пространство снизу. Оно необходимо для возможности сделать drag-and-drop вниз List-a, а также предотвращает ошибку в случае перетаскивания в пустой List. Вы может продемонстрировать это себе сами, просто перетащите все из левого List-а в правый, а затем попытайтесь перетащить один из итемов обратно (эксепшен тихо проглатывается при отсутствии дебаг версии FlashPlayer). Хотя, этого эксепшена можно избежать перекрыв метод List#showDropFeedback() и пристрочив заплатку.
Наконец-то его можно потрогать и все остальное уже не важно:
Странно, но внутри тоже получилось много комментов.
Скачать пример отсюда. view source.
И наконец.
Eсли вам нет нужды использовать пряники компонента List, например, API по работе с данными и визуалом, drag-and-drop и etc, то тогда не надо заниматься извращениями, а надо просто использовать стандартный mx.controls.VBox и побыстрей забыть это стремное многобуквие ![]()
Ну а теперь самое главное.
С наступающим Первым днем года!
Горячо жму вас!
Трекбек адрес этой записи
URL трекбека (щелкните правой кнопкой мыши и скопируйте ссылку)
4 комментариев
Если бы я вел какие-либо мемориз, то обязательно бы написал "В мемориз"!Как всегда, расширяешь не только классы Flex SDK но и русский язык
Очень хорошая статья, достойна книги! Я полностью согласен с твоими изысканиями о AutoResizeable mx:List, особенно в пункте "Особые компоненты", который меня и интересует более всего.
В моей задаче, я начал с aturesizable mx:List, потом пришел к mx:Grid, потом к mx:VBox, а закончил на mx:Repeater, который автоматически увеличивается в высоте в зависимости от суммы размеров копий объекта SingleNoteView, каждая из которых имеет свою высоту:
Примечание:MXML код был автоматически вырезан с моего комментария выше, но он не так уже и важен
Прошло пол-года, и я снова на этой странице, мы на проекту все-таки решили отказатся от mx:Repeater и вернутся к AutosizableList.Кстати, ты видел решение от Bryan Bartow, он ломал голову над похожей проблемой приблизительно в тоже время что и ты
http://www.bryanbartow.com/2008/12/08/release-flex-autosizinglist-component/
Решение Bryan'а базируется на protected свойстве rowInfo в ListBase.as, умно.
/*
An array of ListRowInfo objects that cache row heights and
other tracking information for the rows in listItems.
*/
protected function get rowInfo() в ListBase.as
и на