Привет, Хабр! Меня зовут Максим Сазонов, я android-разработчик в ПСБ. 

Сегодня предлагаю разобрать путь создания кастомного тулбара от первой кривой реализации до оптимизированного решения. 

И главная проблема, которую надо решить при создании этого компонента  это центрирование title и subtitle. Эта проблема возникает потому что у нас может быть тулбар с разным количеством иконок по обе стороны от заголовка, текстом и иконками или только с title. И если ширина иконок у нас стандартна (44 dp), и достаточно просто умножить эту ширину на количество иконок справа, чтобы понять свободное пространство для title и subtitle, то с текстом с одной или с двух сторон всё намного сложнее, так как мы не можем заранее вычислить ширину этого текста, а значит не можем правильно центрировать и задать ширину title и subtitle.

В этой статье я расскажу, как я решил этот вопрос.

Всего было несколько попыток реализовать кастомный тулбар, чтобы вы понимали о чем речь:   

Перед тем, как перейти к финальному варианту реализации, расскажу о нескольких не самых удачных попытках.

Вариант 1: простой Row с Weight

@Composable
fun ToolbarSimple() {
   Row(
       modifier = Modifier.fillMaxWidth(),
       verticalAlignment = Alignment.CenterVertically,
   ) {
       var widthStart: Int by remember { mutableStateOf(0) }
       // Левая часть (вес 1)
       Row(modifier = Modifier.width(widthStart)) {
           IconButton(onClick = {}) {
               Icon(Icons.Default.ArrowBack, "Назад")
           }
       }
       // Центр (вес 2)
       Column(
           modifier = Modifier,
           horizontalAlignment = Alignment.CenterHorizontally
       ) {
           Text(
               "ЗаголовокЗаголовокЗаголовокЗаголовок",
               maxLines = 1,
               overflow = TextOverflow.Ellipsis
           )
           Text("Подзаголовок")
       }
       // Правая часть (вес 1)
       Row {
           Text(
               modifier = Modifier.onSizeChanged {
                   widthStart = it.width
               },
               text = "Очень длинный текст",
               maxLines = 1,
               overflow = TextOverflow.Ellipsis,
           )
       }
   }
}

Результат:

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

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

modifier = Modifier.onSizeChanged {
   widthStart = it.width
}

И заканчивая самописной функцией расширения:

// Расширение для вычисления ширины текста
fun String.calculateTextWidth(
   fontSize: TextUnit,
): Float {
   val paint = android.graphics.Paint().apply {
       textSize = fontSize.value
       typeface = Typeface.DEFAULT
   }
   return paint.measureText(this)
}

Но всё это приводит к лишним рекомпозициям и непредсказуемому поведению, да и не особо решает проблему.

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

Фазы отрисовки Jetpack Compose 

Jetpack Compose разделяет процесс отрисовки UI на три основные фазы. Понимание этих фаз поможет создавать более оптимальные интерфейсы и избегать лишних рекомпозиций.

Фаза первая: композиция (Composition)

Композиция — это фаза, на которой происходит построение и анализ дерева компоновки. Во время композиции Jetpack Compose: 

  • Анализирует функции @Composable и создаёт структуру UI, которая состоит из всех видимых компонентов. 

  • Решает, какие части UI нуждаются в перерисовке при изменении состояния. 

Основное внимание в этой фазе уделяется созданию структуры компонентов. Именно на этапе композиции определяется, какие части UI нужно построить и в каком порядке они должны быть расположены. 

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

Фаза вторая: макет (Layout) 

На этапе макета Jetpack Compose измеряет и располагает элементы UI на экране. Этот процесс включает: 

  • Измерение размеров каждого компонента. 

  • Определение точного расположения компонентов на экране. 

Важно отметить, что фаза макета обычно выполняется только один раз для каждого элемента UI, что обеспечивает высокую степень производительности. 

Фаза третья: отрисовка (Drawing) 

Фаза отрисовки — это последний этап, на котором элементы UI фактически выводятся на экран. Compose прорисовывает каждый компонент и создаёт видимый интерфейс. На этом этапе происходит: 

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

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

Модификаторы и их влияние на фазы отрисовки 

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

На этапе макета применяются модификаторы, влияющие на измерение и позиционирование элемента: padding, size, offset. 

На этапе отрисовки модификаторы, добавляющие визуальные эффекты: background, border. 

Понимание того, на какой этап попадает модификатор, важно для минимизации количества рекомпозиций. Частые рекомпозиции могут негативно сказаться на плавности UI. 

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

Вернемся к примеру который мы рассмотрели ранее: 

@Composable
fun ToolbarSimple() {
   Row(
       modifier = Modifier.fillMaxWidth(),
       verticalAlignment = Alignment.CenterVertically,
   ) {
       var widthStart: Int by remember { mutableStateOf(0) }
       // Левая часть (вес 1)
       Row(modifier = Modifier.width(widthStart)) {
           IconButton(onClick = {}) {
               Icon(Icons.Default.ArrowBack, "Назад")
           }
       }

       // Центр (вес 2)
       Column(
           modifier = Modifier,
           horizontalAlignment = Alignment.CenterHorizontally
       ) {
           Text(
               "ЗаголовокЗаголовокЗаголовокЗаголовок",
               maxLines = 1,
               overflow = TextOverflow.Ellipsis
           )
           Text("Подзаголовок")
       }

       // Правая часть (вес 1)
       Row {
           Text(
               modifier = Modifier.onSizeChanged {
                   widthStart = it.width
               },
               text = "Очень длинный текст",
               maxLines = 1,
               overflow = TextOverflow.Ellipsis,
           )
       }
   }
}

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

  1. При первой отрисовки на экране, размер текста будет иметь значение 0, так как ещё не было ни одной фазы макета, в который бы вычислился размер этого компонента. 

  2. После того как Text() проходит фазу макета и вызывается колбэк-модификатор onSizeChanged, происходит обновление значения widthStart

  3. Это вызовет рекомпозицию, так как Row внутри содержит переменные состояния, которые только что были изменены с 0 на размеры текста. 

Таким образом, из-за внутренней логики у нас произойдёт ненужная рекомпозиция.

Вариант 2: Используем компонент Layout

Для начала напомню пару базовых моментов. Layout — это основной компонент для создания кастомных layout. Он измеряет и позиционирует все части Toolbar, учитывая ограничения и динамическую ширину текста и иконок.
Его можно использовать для измерения и позиционирования дочерних элементов макета. 

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

  • content дочерний элемент, который нужно отобразить.

  • modifier модификаторы, которые нужно применить к компоненту.

  • measurePolicy политика, определяющая измерение и позиционирование элемента.

Как получить все измерения и отрисовку за один проход?

Последним аргументом компонен��а Layout является функция, принимающая в себя список всех элементов для измерения measurables и набор constraints. 

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

Layout constraints 

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

Алгоритм работает следующим образом: 

  1. Чтобы определить размер, который он займёт, корневой узел в дереве UI измеряет свои дочерние элемены, передавая те же Constraints своему первому дочернему элементу. 

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

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

  4. Родительский узел корректирует Constraints на основании измерений этого дочернего элемента и передаёт изменённые Constraints следующему дочернему элементу.

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

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

@Composable
fun Toolbar(
   modifier: Modifier = Modifier,
   leftContent: @Composable () -> Unit,
   centerTitle: String,
   centerSubtitle: String? = null,
   rightContent: @Composable () -> Unit,
) {
   Layout(
       modifier = modifier
           .fillMaxWidth()
           .height(44.dp),
       content = {
           leftContent()
           Column(
               modifier = Modifier,
               horizontalAlignment = Alignment.CenterHorizontally,
               verticalArrangement = Arrangement.Center,
           ) {
               Text(
                   text = centerTitle,
                   style = ExtendedTheme.typography.title3,
               )
               if (centerSubtitle != null) {
                   Text(
                       text = centerSubtitle,
                       style = ExtendedTheme.typography.body1,
                   )
               }
           }
           rightContent()
       }
   ) { measurables, constraints ->
       val maxActionWidth = (constraints.maxWidth * 0.4f).toInt()

       val leftPlaceable = measurables[0].measure(
           constraints.copy(
               minWidth = 0,
               maxWidth = maxActionWidth
           )
       )

       val rightPlaceable = measurables[2].measure(
           constraints.copy(
               minWidth = 0,
               maxWidth = maxActionWidth
           )
       )

       val centerPlaceable = measurables[1].measure(
           constraints.copy(
               minWidth = 0,
               maxWidth = constraints.maxWidth - maxActionWidth * 2
           )
       )

       val totalWidth = constraints.maxWidth
       val height = constraints.maxHeight

       layout(totalWidth, height) {
           // Левый контент
           leftPlaceable.placeRelative(
               x = 0,
               y = (height - leftPlaceable.height) / 2
           )

           // Центральный контент (заголовок + подзаголовок)
           centerPlaceable.placeRelative(
               x = (totalWidth - centerPlaceable.width) / 2,
               y = (height - centerPlaceable.height) / 2
           )

           // Правый контент
           rightPlaceable.placeRelative(
               x = totalWidth - rightPlaceable.width,
               y = (height - rightPlaceable.height) / 2
           )
       }
   }
}

Результат: 

Преимущества кастомного Layout в сравнении с готовыми элементами интерфейса

Кастомный Layout позволяет избежать ограничений стандартных Row с weight и т.д., предоставляя полный контроль над измерениями и расположением элементов независимо от динамичного содержания.

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

Вот так я решил вопрос с кастомизацией тулбара. Делитесь своим мнением и опытом в комментариях!

Комментарии (0)