Search
Write a publication
Pull to refresh
75.88

2D-скролл во Flutter

Level of difficultyHard
Reading time18 min
Views2.3K

Да кто такой этот ваш 2D-скролл? Зачем он нужен, если не делать аналог excel? На самом деле, двумерный скролл нужен в огромном количестве сценариев использования мобильных приложений. В виде таблицы можно представить самые разные сущности — как, например, наш видеоредактор. 

Есть только одна маленькая проблема: видеоредактор Yappy кроссплатформенный, написан на Flutter, и до недавнего времени команда фреймворка предлагала только один способ реализовать 2D-скролл — не очень-то и производительный. В статье рассказываю, в чём была была загвоздка, что есть во Flutter сейчас и каких впечатляющих результатов можно добиться собственной реализацией. 

Меня зовут Фёдор Благодырь, я занимаюсь кроссплатформенной разработкой, являюсь техлидом видеоредактора Yappy и соведущим сообщества «Oh, my Flutter», контрибьютил в репозиторий Flutter, пока недавно меня там не заблокировали.

В этой статье рассмотрим, как на Flutter можно реализовать двумерный скролл с помощью решений из коробки, почему они могут не подойти и что тогда делать. Подробно разберём, как сделать собственную производительную реализацию 2D-скролла (весь её полный код выложен на github), и оценим влияние на производительность на примере видеоредактора Yappy — приложения вертикальных видео. Спойлер: прирост просто сумасшедший.    

Это 2D-скролл или двунаправленная прокрутка — просто прокрутка некой области экрана по горизонтальной и вертикальной оси
Это 2D-скролл или двунаправленная прокрутка — просто прокрутка некой области экрана по горизонтальной и вертикальной оси

Типичная реализация 2D-скролла раньше

Для реализации двумерного скролла во Flutter обычно использовалась комбинация из двух SingleChildScrollView по двум осям: горизонтальной и вертикальной. Показательным будет пример с использованием таблицы с помощью виджета DataTable вкупе с SingleChildScrollView, ведь в таблице, как правило, много контента, который как раз можно будет скроллить.

@override
 Widget build(BuildContext context) {
   return SingleChildScrollView(
 	scrollDirection: Axis.horizontal,
 	child: SingleChildScrollView(
   	scrollDirection: Axis.vertical,
   	child: DataTable(
     	columns: /*columns*/,
     	rows: /*rows*/,
 	),
   );
 }

Проблема в том, что чем больше строк и столбцов в таблице, тем меньше в конечном итоге будет FPS в таком решении. Контент будет отрисован, даже если он находится за пределами видимости экрана устройства. Что в целом не звучит как рациональное решение и деградирует с увеличением размеров таблицы и/или сложности содержимого ячеек.

В документации по этому поводу было однозначно написано: «дорого», «больно», «не трогай», не вставляйте никаких SingleChildScrollView, возьмите PaginatedDataTable

Displaying data in a table is expensive, because to lay out the table all the data must be measured twice, once to negotiate the dimensions to use for each column, and once to actually lay out the table given the results of the negotiation.

For this reason, if you have a lot of data (say, more than a dozen rows with a dozen columns, though the precise limits depend on the target device), it is suggested that you use a PaginatedDataTable which automatically splits the data into multiple pages.

Wrapping a DataTable with SingleChildScrollView is expensive as SingleChildScrollView mounts and paints the entire DataTable even when only some rows are visible. If scrolling in one direction is necessary, then consider using a CustomScrollView, otherwise use PaginatedDataTable to split the data into smaller pages.

То есть команда Flutter предлагает нам раздробить контент и выводить его буквально постранично — как будто мы застряли в 2010-м. 

Сейчас такая пагинация в мобильных приложениях это явно не user-friendly сценарий. И мы, и пользователи привыкли к бесконечным лентам и просто хотим скролить контент куда и как угодно. 

Но недавно документация DataTable обновилась и в предостережениях добавилась ещё одна опция реализации 2D-скролла —  TableView из пакета two_dimensional_scrollables

Performance considerations

Columns are sized automatically based on the table's contents. It's expensive to display large amounts of data with this widget, since it must be measured twice: once to negotiate each column's dimensions, and again when the table is laid out.

A SingleChildScrollView mounts and paints the entire child, even when only some of it is visible. For a table that effectively handles large amounts of data, here are some other options to consider:

  • TableView, a widget from the two_dimensional_scrollables package.

  • PaginatedDataTable, which automatically splits the data into multiple pages.

  • CustomScrollView, for greater control over scrolling effects.

Современный подход — TwoDimensionalScrollView

Теперь во фреймворке появилась базовая абстракция TwoDimensionalScrollView, в которой есть основные привычные нам штуки для того, что обычно скролится: ось, скролл-контроллеры, build-делегаты и т.д. 

abstract class TwoDimensionalScrollView extends StatelessWidget {
 const TwoDimensionalScrollView({
   super.key,
   this.primary,
   this.mainAxis = Axis.vertical,
   this.verticalDetails = const ScrollableDetails.vertical(),
   this.horizontalDetails = const ScrollableDetails.horizontal(),
   required this.delegate,
   this.cacheExtent,
   this.diagonalDragBehavior = DiagonalDragBehavior.none,
   this.dragStartBehavior = DragStartBehavior.start,
   this.keyboardDismissBehavior,
   this.clipBehavior = Clip.hardEdge,
   this.hitTestBehavior = HitTestBehavior.opaque,
 });
}

Можно взять виджет TableView.builder, использовать его и получить 2D-скролл. Будет ли это работать лучше, чем комбинация DataTable и два SingleChildScrollView? Посмотрим в профайлере на тесте с прокруткой 1000 элементов. 

Производительность DataTable — в среднем 10–11 FPS
Производительность DataTable — в среднем 10–11 FPS
Производительность TableView.builder — 60 FPS
Производительность TableView.builder — 60 FPS

Разница колоссальная: 60 FPS c TableView.builder вместо 11 FPS в старом варианте. Это бесплатный перфоманс — с точки зрения пользователя-разработчика нужно просто прокинуть строки и столбцы в один виджет вместо другого. 

Такой выигрыш происходит потому, что TableView билдит только те виджеты, которые находятся в видимой области, в отличие от связки DataTable и SingleChildScrollView. 

При использовании TableView в build() 32 виджета (выделены тёмным на рисунке
При использовании TableView в build() 32 виджета (выделены тёмным на рисунке

Пакет two_dimensional_scrollables

Можно считать, что это решение «из коробки». Несмотря на то, что формально к самому фреймворку относится только абстракция TwoDimensionalScrollView, а реализация вынесена в отдельный пакет  two_dimensional_scrollables, эту реализацию поддерживает та же  Flutter-команда. 

В пакете есть поддержка двух виджетов TableView и TreeView (оба являются наследниками TwoDimensionalScrollView). Они позволяют сделать производительный скролл по двум осям по сути бесплатно. DataTable и два SingleChildScrollView в прошлом — можно брать и использовать two_dimensional_scrollables. 

Но! Иногда этого недостаточно.

Почему может понадобиться собственная реализация 2D-скролла?

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

1. Динамический размер фрагментов

Наш видеоредактор — частный случай таблицы, которая скролится по двум осям. При этом ячейки имеют переменный размер: пользователь может менять масштаб таймлайна (т.е. самой таблицы), может обрезать фрагменты аудио и видео (то есть менять размер отдельных ячеек) и т.д. 

Пример масштабирования таймлайна в видеоредакторе Yappy
Пример масштабирования таймлайна в видеоредакторе Yappy
Изменение длительности фрагмента аудио и видео в видеоредакторе Yappy —  изменение размера ячейки
Изменение длительности фрагмента аудио и видео в видеоредакторе Yappy —  изменение размера ячейки

То есть нам нужен полный контроль над лейаутом.

2. Списки внутри фрагментов

Один из самых сложных для реализации кейсов выглядит следующим образом: есть видеофрагмент, внутри него есть раскадровка — набор изображений, связанных с данным фрагментом. 

Здесь каждая ячейка содержит список ещё каких-то виджетов, который нам нужно уметь эффективно отрисовывать. То есть мы не должны отрисовывать то, что не видно пользователю во Viewport.

Базовая реализация Flutter-команды не подразумевает наличие Sliver-механизма. Нельзя просто прописать, что есть список внутри списка, и чтобы всё сразу заработало как надо. Придётся заморочиться, чтобы отрисовывать те кадры, которые попадают в видимую область, и не отрисовывать все остальные без необходимости.

Почему это важно? Эффективность превыше всего

Разберём на конкретном примере. Представьте один видеофрагмент на таймлайне при изначальном масштабе, где видно 5 кадров, которые идеально помещаются во Viewport. Это означает, что будет построено только 5 виджетов, но после увеличения масштаба, вдруг появляется уже 100 кадров, при том что во Viewport по-прежнему может поместиться только 5 кадров. Крайне важно строить только эти 5 видимых виджетов и не тратить ресурсы на рендеринг остальных 95, которые находятся вне области видимости.

Что произойдёт, если проигнорировать этот момент? Представьте видеофрагмент с большой длительностью. При максимальном масштабировании тысячи — или даже десятки тысяч — виджетов с изображениями будут постоянно строиться и рендериться, хотя пользователь их никогда не увидит. Это огромная трата производительности, замедляющая работу приложения и бессмысленно потребляющая ресурсы.

3. Взаимодействие с фрагментами

В видеоредакторе пользователь может различными способами взаимодействовать с фрагментами: изменять положение и пропорции. Причём для фрагментов видео и аудио один и тот же тип взаимодействия может отображаться по-разному. 

Это ещё одна причина, по которой нам нужен низкоуровневый контроль, чтобы мы могли следить и управлять каждым пикселем. 

4. Z-index

В CSS Z-index — это, грубо говоря, приоритет отрисовки конкретного UI-элемента. Пример, когда приоритет имеет значение, можно увидеть выше на гифках и ниже на скринах. 

Слева показано, как выглядит элемент без приоритезации отрисовки, справа — с приоритезацией
Слева показано, как выглядит элемент без приоритезации отрисовки, справа — с приоритезацией

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

Проблема в том, что во Flutter нет как такового механизма Z-index (зато есть открытые issue, с которыми ничего не происходит) и если не отработать этот момент, то мы можем получить различные неприятные артефакты на UI. Какие есть способы приоритизировать элементы: 

  • Stack — чтобы жонглировать очередностью виджетов в коллекции.  Stack не предназначен для работы с большими коллекциями/списками, производительность будет не на высоте, но в принципе возможно.

  • Overlay — это отображение виджетов поверх других виджетов независимо от основного дерева. Этот способ тоже имеет право на существование и когда-то мы его тоже использовали. Однако, так как Overlay живёт по своим правилам и в другом «измерении», это сложно обслуживать и стыковать позицию и размеры виджетов, на которые Overlay применяется. flutter_portal, хоть и позволяет решать вопросы с размерами и позицией, но не избавляет от всех проблем, связанных с тем, что Overlay живёт отдельно.

  • Custom layout — опять приходим к тому, что полный контроль над layout позволит обработать любые ситуации.

5. Анимации

Рассмотрим на примере.

Может показаться, что тут всё просто — просто плавно изменяется высота одного виджета. Но обратите внимание, когда это происходит, смещается вообще всё, что находится ниже него. То есть изменение размеров одной ячейки приводит к полному изменению layout'а всей «таблицы». Эффективнее всего реализовать такую анимацию мы можем на уровне непосредственно render-объекта, который точно обладает полным контролем и информацией обо всех своих child, которые участвуют в фазе layout. Также это позволяет минимизировать лишние операции, минуя привычный вызов setState на высоком уровне в пользу markNeedsLayout/markNeedsPaint на чуть более низком уровне. Это еще один пункт, где нам нужно больше контроля, чем можно получить «из коробки».

Мы решили реализовать собственное решение, а не полагаться полностью на существующее от Flutter-команды, потому что нам оно не даёт достаточно средств для кастомизации.

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

Собственная реализация 2D-скролла во Flutter

Для наглядности сделаем кастомную реализацию 2D-скролла в синтетическом расписании конференции Mobius, на которой я недавно выступал с этой темой: с докладами, временными метками, возможностью скроллить, зумить, открывать превью и т.д. 

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

Модель

Чтобы что-то отрисовать, нужно понимать, что это. Поэтому сперва нам нужна модель данных, которые мы будем отрисовывать. 

У расписания мероприятия есть: длительность — пусть это будет 4 часа; id и перечисление выступлений. 

class MobiusSchedule {
 static const capacity = Duration(hours: 4);
 final String id;
 final Iterable<MobiusSpeech> speeches;
}

 У каждого выступления есть: максимальная длительность (пусть 1 час), id, название, начало и непосредственно длительность выступления.

class MobiusSpeech {
 static const reservedDuration = Duration(hours: 1);
 final String id;
 final String title;
 final Duration duration;
 final Duration start;
 final Iterable<MobiusSpeechTimestamp> timestamps;
}

У временных меток всё примерно то же самое, плюс обложка, чтобы красиво было.

class MobiusSpeechTimestamp {
 final Duration startsAt;
 final Duration duration;
 final String title;
 final String? description;
 final String coverUrl;
}

Визуализация

Следующим шагом нам нужно продумать концепцию и визуализировать расписание «на бумаге», чтобы представить, что нужно учесть в коде.

Мероприятие состоит из четырёх часовых слотов. 

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

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

Описание UI

Итак, у нас есть модель, теперь нужно описать UI. Но есть проблема — данные модели не подходят для описания UI и layout, так как нам нужны размеры в пикселях, а не длительность.

Опишем нужные нам данные и начнём с класса, который предназначен для всей таблицы. Он содержит строки, их ширину и максимальное количество ячеек (чуть позже будет понятно, зачем это нужно).

class MobiusScheduleViewData {
 final Iterable<MobiusScheduleViewRow> rows;
 final double width;
 final int maxCellsCount;
 final String? selectedSpeechId;
}

У строки есть: идентификатор расписания, флажок наличия выбранной ячейки, ячейки и высота в свернутом и развернутом состоянии. 

class MobiusScheduleViewRow {
 final String scheduleId;
 final bool hasSelectedCell;
 final Iterable<MobiusScheduleViewCell> cells;
 final double collapsedHeight;
 final double expandedHeight;
}

У ячейки всё примерно то же самое: позиция начала в пикселях, ширина, свернутая и развернутая высота, выбрана ли эта ячейка.

class MobiusScheduleViewRow {
 final String scheduleId;
 final bool hasSelectedCell;
 final Iterable<MobiusScheduleViewCell> cells;
 final double collapsedHeight;
 final double expandedHeight;
}

Далее заготовим механизм преобразования длительности в пиксели. 

const _durationFactor = 1000000;

extension DurationX on Duration {
 double toWidth(double screenWidth) =>
     inMicroseconds * screenWidth / (MobiusSchedule.capacity.inSeconds * _durationFactor);
}

extension DoubleX on double {
 Duration toDuration(double screenWidth) =>
     Duration(
       microseconds: this * MobiusSchedule.capacity.inSeconds * _durationFactor ~/ screenWidth,
     );
}

Фабричный конструктор будет заниматься сборкой этих данных для layout.

factory MobiusScheduleViewData({
 required Iterable<MobiusSchedule> schedules,
 required double screenWidth,
 String? selectedSpeechId,
}) {
 // Берём некие стартовые значения
 var width = 0.0;
 final rows = <MobiusScheduleViewRow>[];
 // Проходимся в цикле


 for (final schedule in schedules) {
   // "Собираем" строки
   final row = MobiusScheduleViewRow(
     schedule: schedule,
     screenWidth: screenWidth,
     selectedSpeechId: selectedSpeechId,
   );
   rows.add(row);
   // Считаем максимальное количество ячеек
   maxCellsCount = max(maxCellsCount, row.cells.length);
   // Считаем ширину таблицы, которая по факту равна ширине самой широкой строки
   width = max(
     width,
     // Каждое выступление имеет выделенную длительность 1 час
     (MobiusSpeech.reservedDuration * row.cells.length).toWidth(screenWidth),
   );
 }
}

Дальше мы переходим к строке. 

factory MobiusScheduleViewRow({
  required MobiusSchedule schedule,
  required double screenWidth,
  required String? selectedSpeechId,
}) {
  // Стартовые значения
  var (hasSelectedCell, maxExpandedHeight, maxCollapsedHeight) =
      (false, 0.0, 0.0);
  final cells = <MobiusScheduleViewCell>[];
  
  // Итерируемся по каждому выступлению в расписании
  for (final speech in schedule.speeches) {
    // Проверяем выбрано ли текущее выступление
    final isSelected = speech.id == selectedSpeechId;

    if (isSelected) hasSelectedCell = true;
    
    // Создаём ячейку для данного выступления
    final cell = MobiusScheduleViewCell(
      speech: speech,
      isSelected: isSelected,
      screenWidth: screenWidth,
    );
    
    // "Собираем" ячейки
    cells.add(cell);
    
    maxExpandedHeight = math.max(maxExpandedHeight, cell.expandedHeight);
    maxCollapsedHeight = math.max(maxCollapsedHeight, cell.collapsedHeight);
  }

  // Сортируем ячейки, чтобы у выбранной был наивысший приоритет орисовки (Z index)
  cells.sort((a, _) => a.speechId == selectedSpeechId ? 1 : 0);
}

Тут важны два момента:

  • определить есть ли в строке выбранная ячейка здесь, это имеет значение для самой строки;

  • высота в свёрнутом и развёрнутом состоянии — берём максимальную, чтобы всё остальное поместилось.

А главное — «реализация» Z-index. Последняя строчка кода показывает самый грязный хак на свете: мы хотим, чтобы виджет с нашим видеофрагментом был приоритетнее всех остальных на стадии отрисовки, и именно это и делаем. Потому что мы сами формируем layout и нам вообще неважно, что по этому поводу думает Flutter. Это хак, но он эффективный. 

С ячейками всё аналогично: позиция, высота, выбранные они или нет.

factory MobiusScheduleViewCell({
 required MobiusSpeech speech,
 required double screenWidth,
 required bool isSelected,
}) {
 return MobiusScheduleViewCell._(
   startsAt: speech.start.toWidth(screenWidth),
   width: speech.duration.toWidth(screenWidth),
   speechId: speech.id,
   isSelected: isSelected,
   collapsedHeight: 100,
   expandedHeight: 250,
 );
}

И наконец мы готовы перейти к реальному UI.

UI

Приступим к реализации TwoDimensionalScrollView. Он принимает описанные выше данные и TickerProvider, чтобы творить магию анимаций. 

class MobiusScheduleScrollView extends TwoDimensionalScrollView {
 final MobiusScheduleViewData data;
 final TickerProvider vsync;
 /*constructor*/
 @override
 Widget buildViewport(
   BuildContext context,
   ViewportOffset verticalOffset,
   ViewportOffset horizontalOffset,
 ) {
   // ...
 }
}

Во второй части фрагмента кода от нас ожидается реализация одного метода — buildViewport. Значит, давайте сделаем — и так появляется Viewport:

class MobiusScheduleViewport extends TwoDimensionalViewport {
 /*constructor*/
 @override
 RenderTwoDimensionalViewport createRenderObject(BuildContext context) {
   // createRenderObject
 }
 @override
 void updateRenderObject(
   BuildContext context,
   RenderMobiusScheduleViewport renderObject,
 ) {
   // updateRenderObject
 }
}

Viewport в свою очередь требует RenderObject и его обновления. Окей, продолжаем разбирать матрёшку: 

class RenderScheduleMobiusViewport extends RenderTwoDimensionalViewport {
/*constructor*/

 @override
 void layoutChildSequence() {
   // боль
 }
}

В итоге приходим к сердцу реализации — одному единственному методу, который в итоге от нас требуется реализовать — layoutChildSequence. Здесь и начинается агония, боль, слёзы, бессонные ночи. Потому что нужно всего лишь полностью и самостоятельно определить весь layout для всех child по двум осям, учесть всё, что нужно отрисовать, и не отрисовывать ничего лишнего.

Собственно, переходим к реализации. 

// Горизонтальное и вертикальное положение 2D скролла
final horizontalPixels = horizontalOffset.pixels;
final verticalPixels = verticalOffset.pixels;

// Размеры экрана телефона + область прекэширования cacheExtent
final viewportWidth = viewportDimension.width + cacheExtent;
final viewportHeight = viewportDimension.height + cacheExtent;
final rowsCount = _data.rows.length;

// Общая высота всех строк
var allRowsHeight = 0.0;

// Общая высота всех строк с учётом потенциальных отступов и т.д.
var totalHeight = 0.0;

// Итерируемся по строкам
for (var rowIndex = 0; rowIndex < rowsCount; rowIndex++) {
  final row = _data.rows.elementAt(rowIndex);
  final rowHeight =
      row.hasSelectedCell ? row.expandedHeight : row.collapsedHeight;
  
  // Позиция строки по вертикали
  final rowStarts = allRowsHeight;
  final rowEnds = rowStarts + rowHeight;

  // Проверяем видимость строки в рамках viewport на основе позиции и размеров
  final isPartiallyVisibleAbove =
      rowStarts < verticalPixels + viewportHeight;
  final isPartiallyVisibleBelow = rowEnds > verticalPixels;
  final isVisibleVertically =
      isPartiallyVisibleAbove && isPartiallyVisibleBelow;
  totalHeight = math.max(rowEnds, totalHeight);

  // Если строка находится вне области видимости - не отрисовываем её
  if (!isVisibleVertically) {
    // Однако учитываем её высоту для дальнейших вычислений
    allRowsHeight += rowHeight;
    continue;
  }

  // Итерируемся по ячейкам внутри строки
  for (var cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
    final cell = row.cells.elementAt(cellIndex);
    final cellWidth = cell.width;
    
    // Позиция ячейки по горизонтали
    final cellStarts = cell.startsAt;
    final cellEnds = cellStarts + cellWidth;
    final cellHeight =
        cell.isSelected ? cell.expandedHeight : cell.collapsedHeight;

    // Такая же проверка как и для строки.
    // Не отрисовываем ячейку полностью если она находится вне области видимости.
    if (cellStarts > horizontalPixels + viewportWidth) continue;
    if (cellEnds < horizontalPixels) continue;

    // Опредяляем позицию ячейки, используя ChildVicinity
    // ChildVicinity представляет собой координаты по x и y (индекс ячейки и строки)
    final vicinity = ChildVicinity(
      xIndex: cellIndex,
      yIndex: rowIndex,
    );

  
    // Вызов buildOrObtainChildFor - ключевой метод, который творит магию
    // Он проверяет был ли уже создан RenderObject на основе хэш таблицы (vicinity в качестве ключа)
    // Если нет - используется builder функция из TwoDimensionalScrollView, чтобы построить виджет
    // Возвращает RenderBox, который ассоциируется с построенным виджетом для осущствления лэйаута
    final child = buildOrObtainChildFor(vicinity)!
      ..layout(
        BoxConstraints(
          maxWidth: cellWidth,
          minHeight: cellHeight,
          maxHeight: rowHeight,
        ).normalize(),
      );

    // Позиционируем после лэйаута
    parentDataOf(child).layoutOffset = Offset(
      cellStarts - horizontalPixels,
      rowStarts - verticalPixels,
    );
  }

  // Обновляем вертикальные и горизонтальные смещения на основе получившихся вычислений
  verticalOffset.applyContentDimensions(
    0,
    (totalHeight - viewportDimension.height).clamp(0, double.infinity),
  );

  horizontalOffset.applyContentDimensions(
    0,
    (data.width - viewportDimension.width).clamp(0, double.infinity),
  );
}

Более глубокие моменты вы найдете в репозитории, в примере выше только самое основное.

Теперь можно использовать новоиспечённый TwoDimensionalScrollView виджет:

return MobiusScheduleView(
  data: _data,
  delegate: TwoDimensionalChildBuilderDelegate(
    maxXIndex: _data.maxCellsCount - 1, // do you remeber me?
    maxYIndex: _data.rows.length - 1,
    builder: (context, vicinity) {
      final row = _data.rows.elementAtOrNull(vicinity.yIndex);
      if (row == null) return null;
      final cell = row.cells.elementAtOrNull(vicinity.xIndex);
      if (cell == null) return null;
      return MyCoolCellWidget();
    },
  ),
);

Помните как я чуть раньше говорил, что позже будет понятно зачем нам нужно посчитать максимальное количество ячеек? Уверен, что нет, но я-то помню!

Это как раз нужно для того, чтобы не выполнять лишние вычисления при непосредственном построении виджета, а прийти «с готовым».

Да, названия у TwoDimensionalScrollView выглядят немного «пугающе» и могут запутать: maxXIndex и maxYIndex. Но фактически это просто как itemCount у ListView.builder только для горизонтальной и вертикальной осей. Если думать об этом в таком ключе, всё сразу становится проще и понятнее. itemCount по вертикали считать даже и не нужно, это просто количество строк — _data.rows.length -1. А вот подсчёт itemCount по горизонтали в build-методе привел бы к вложенным циклам и значительному замедлению вычислений UI и снижению FPS, чего мы точно не хотим. Поэтому на стадии вычислений данных для лэйаута, мы просто «припасли» это значение до тех пор, пока оно нам не понадобилось.

Анимация выбранной ячейки

Как вы помните, мы принимаем решение о высоте строки в зависимости от наличия выбранной ячейки. Для анимации реализация остаётся примерно такой же, но появляются Tween-ы, которые опираются на AnimationController, созданный как раз с помощью TickerProvider

    final rowHeightTween = _rowTweens[row.scheduleId] ??= Tween(
       begin: row.collapsedHeight,
       end: row.hasSelectedCell ? row.expandedHeight : row.collapsedHeight,
    );
    final rowHeight = rowHeightTween.evaluate(_animationController);

Так выглядит упрощённая версия, иллюстрирующая подход. Большая часть реального кода, который вы найдёте в репозитории, сводится к обслуживанию анимации для строк и ячеек.

Список внутри ячейки

И наконец самый сложный кейс, как с точки зрения реализации, так и производительности. 

Ячейка — основной элемент нашего layout. При этом, если внутри ячейки есть ещё список, то можно интерпретировать это как дочерний Viewport. Родительский Viewport просто сообщает дочерним их координаты, величины, scrollOffset, layoutOffset и тд.

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

В коде (в упрощённом для наглядности в статье виде) это выражается следующим образом: берём рендер-объект, знакомый метод layoutChildSequence и делегат, который передаёт все нужные уже вычисленные значения о координатах и ограничениях:  

class RenderChildViewport extends RenderViewport {
 @override
 double layoutChildSequence(/*method arguments*/) {
   final data = delegateLayout(/*method arguments*/);
   return super.layoutChildSequence(/*delegated data arguments*/);
 }
}

Наш TwoDimensionalScrollView уже обладает всей этой информацией — ведь её формируем мы! Так что никаких проблем и лишних вычислений. 

Замеры производительности

Наконец, посмотрим, как собственная реализация двумерного скролла может улучшить производительность вашего приложения. 

Видеоредактор Yappy — очень хороший пример для замеров, потому что, естественно, первоначально у нас использовались два SingleChildScrollView: по одному для каждой оси. По сравнению с такой реализацией, которая стабильно давала тормоза в UI, описанный в этой статье подход позволяет увеличить производительность в 10 раз (а может и больше).  

1. Перемотка таймлайна

Ниже результаты замеров с перемоткой таймлайна: прокручиваем таймлайн редактора с фрагментами видео влево и вправо, замеряем средний FPS. Использовались слабые устройства — Huawei Mate 20 lite  и iPhone 7 — чтобы эффект был заметнее и проблемы производительности проявлялись уже на простых действиях. 

Результаты сравнения производительности перемотки таймлайна (по вертикальной оси графика отмечены значения в FPS)
Результаты сравнения производительности перемотки таймлайна (по вертикальной оси графика отмечены значения в FPS)

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

2. Масштабирование таймлайна

Для масштабирования таймлайна кастомная реализация 2D-скролла дала самые впечатляющие результаты. Раньше в определённых условиях FPS на зуме таймлайна мог падать до 4 кадров в секунду. Теперь всё работает плавно и разница видна невооруженным глазом.

Результаты сравнения производительности масштабирования таймлайна (по вертикальной оси графика отмечены значения в FPS)
Результаты сравнения производительности масштабирования таймлайна (по вертикальной оси графика отмечены значения в FPS)

3. Активное воспроизведение

Так мы называем ситуацию, когда пользователь нажал play, видео проигрывается с нормальной скоростью в основном экране и параллельно прокручивается таймлайн с раскадровкой. 

Результаты на 10-секундном видео такие же, как и в других замерах.  

Результаты сравнения производительности активного воспроизведения (по вертикальной оси графика отмечены значения в FPS)
Результаты сравнения производительности активного воспроизведения (по вертикальной оси графика отмечены значения в FPS)

Итоги и выводы

Появление TwoDimensionalScrollView во Flutter — однозначно хорошая новость. С помощью этого решения и готовой реализации от Flutter-команды можно получить сумасшедший прирост производительности просто даром. Возьмите TableView вместо DataTable + SingleChildScrollView и уже получите классные результаты.  

Это нововведение даёт огромные возможности для реализации сложных приложений на Flutter, таких как, например, видеоредактор. Только вдумайтесь: в этой статье мы рассмотрели по сути один виджет, но это целый видеоредактор. 

Правда, получившуюся реализацию виджета 2D-скролла не назвать простой, и путь к ней был сложным. Очень не хватает готового механизма по типу Sliver для отображения вложенных списков. А также мало возможностей влиять на внутренние механизмы базового класса, рано или поздно всё равно потребуется что-то переопределять. 

Внимательно ознакомиться с реализацией всего описанного в статье, прочувствовать всю пережитую боль и разделить эмоции от реализации 2D-скролла во Flutter можно здесь: https://github.com/feduke-nukem/mobius_2d_scroll.

Больше Flutter-новостей и разбора сложных кейсов сотрите в сообществе Oh, my Flutter. А если вам интересна разработка видеосервисов, то подписывайтесь на канал Смотри за IT — там инженеры Цифровых активов «Газпром-Медиа Холдинга», таких как RUTUBE, Yappy, PREMIER делятся опытом и тонкостями создания медиаплатформ. До встречи в следующих статьях!

Tags:
Hubs:
+16
Comments0

Articles

Information

Website
rutube.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия
Representative
Евгения Финкельштейн