Макет экрана Android-приложения: XML-разметка и Jetpack Compose

Макет экрана Android-приложения: XML-разметка и Jetpack Compose Полезное

Макет экрана (layout) в Android — это описание структуры интерфейса: какие элементы есть на экране и по каким правилам они расположены друг относительно друга и относительно краев. Сегодня макет описывают двумя способами: XML-разметкой для классической системы View или кодом на Kotlin в Jetpack Compose. Ниже — как выбрать способ, один и тот же экран в обоих вариантах, виды контейнеров и типовые ошибки.

Сначала мини-словарь, потому что эти слова часто смешивают:

Термин Что это Пример
View Отдельный элемент интерфейса в классической системе TextView, Button, EditText
ViewGroup (контейнер, layout) View, который размещает дочерние элементы по своим правилам LinearLayout, ConstraintLayout
Composable-функция Функция Kotlin с аннотацией @Composable, которая описывает часть UI в Compose Column, Row, Text
Макет в дизайне (мокап) Картинка или прототип экрана в Figma, по которому потом пишут разметку Фрейм 360×800 в Figma

Мокап показывает, как экран должен выглядеть, а layout задает правила, по которым экран подстраивается под разные дисплеи.

XML или Compose: что выбрать

Google называет Jetpack Compose рекомендуемым современным инструментом для нативного UI на Android. При этом XML-разметка никуда не делась: на ней написана большая часть существующих приложений, и в работе с чужим кодом ее придется читать.

Критерий XML + View Jetpack Compose
Где описан экран Файл в res/layout/, логика в Activity/Fragment Kotlin-функции в том же модуле, что и логика
Как обновляется UI Вручную: найти View и поменять свойство Декларативно: меняется состояние — экран перерисовывается
Списки RecyclerView + адаптер LazyColumn / LazyRow
Предпросмотр Layout Editor в Android Studio @Preview в Android Studio
Когда брать Поддержка существующего проекта на View Новый проект или новый экран

Практическое правило: новый экран в новом проекте — Compose; правка экрана в старом проекте — тот же подход, на котором он написан. Оба подхода можно совмещать в одном приложении (ComposeView внутри XML и AndroidView внутри Compose), так что переход бывает постепенным.

Один экран в Compose

Возьмем простой экран напоминания: поле ввода, строка «дата + время» и кнопка «Готово», прижатая вправо. Чтобы пример оставался коротким, вместо выпадающих списков даты и времени в строке стоят подписи. Минимальный полный пример:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun ReminderScreen(modifier: Modifier = Modifier) {
    var name by rememberSaveable { mutableStateOf("") }

    Column(
        modifier = modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Напоминание") },
            modifier = Modifier.fillMaxWidth()
        )
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Text("Дата", modifier = Modifier.weight(1f))
            Text("Время", modifier = Modifier.width(96.dp))
        }
        Button(
            onClick = { /* сохранить напоминание */ },
            enabled = name.isNotBlank(),
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("Готово")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ReminderScreenPreview() {
    ReminderScreen()
}

В панели Preview появится экран: поле во всю ширину, под ним строка, где «Дата» занимает все свободное место, а «Время» — ровно 96 dp, и неактивная кнопка «Готово» справа. Как только в поле появится текст, кнопка станет активной — это и есть декларативность: условие enabled пересчитывается при изменении состояния name.

Разбор по частям:

  • Column и Row — аналоги вертикального и горизонтального LinearLayout, Box — аналог FrameLayout (элементы друг поверх друга).
  • Modifier задает размер, отступы и выравнивание конкретного элемента. Порядок модификаторов важен: padding до fillMaxSize и после дают разный результат.
  • weight(1f) отдает элементу оставшееся место в строке — как layout_weight в XML.
  • rememberSaveable хранит введенный текст и переживает поворот экрана; обычный remember при пересоздании Activity значение потеряет.

Для сложной относительной раскладки в Compose тоже есть ConstraintLayout, но это отдельная библиотека (constraintlayout-compose), и для большинства экранов хватает вложенных Column/Row/Box: вложенность в Compose не несет того штрафа производительности, что глубокие деревья View.

Тот же экран в XML на ConstraintLayout

В системе View экран описывают в файле res/layout/activity_reminder.xml, а в Activity подключают через setContentView(R.layout.activity_reminder). Здесь в строке даты и времени — выпадающие списки Spinner, как в исходной форме:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp">

    <EditText
        android:id="@+id/name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:hint="@string/reminder"
        android:inputType="text"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <Spinner
        android:id="@+id/dates"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        app:layout_constraintTop_toBottomOf="@id/name"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/times" />

    <Spinner
        android:id="@+id/times"
        android:layout_width="96dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/name"
        app:layout_constraintEnd_toEndOf="parent" />

    <Button
        android:id="@+id/done"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/done"
        app:layout_constraintTop_toBottomOf="@id/times"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Каждый элемент привязан ограничениями (constraints) к родителю (parent) или к соседу по id. Правило для ConstraintLayout: у каждого элемента должна быть хотя бы одна привязка по горизонтали и одна по вертикали, иначе его положение на реальном устройстве не определено.

Ключевые детали:

  • @+id/times у dates стоит с плюсом, потому что элемент times объявлен ниже: плюс создает id при первом упоминании.
  • 0dp у ширины в ConstraintLayout означает «match constraints»: элемент растягивается между своими привязками. Поле name занимает все пространство от левого до правого края, dates — от левого края до times.
  • match_parent для дочерних элементов ConstraintLayout документация не рекомендует — вместо него используют 0dp с привязками к обоим краям.
  • Start/End вместо Left/Right — чтобы раскладка корректно зеркалилась в языках с письмом справа налево.

Виды контейнеров в XML

Старые руководства смешивают контейнеры, списки и устаревшие классы. Актуальная картина:

Контейнер Как размещает элементы Статус и когда брать
LinearLayout В одну строку или столбец, layout_weight делит место Простые формы и списки полей
FrameLayout Слоями друг поверх друга Один дочерний элемент, наложение, контейнер для фрагмента
ConstraintLayout Ограничениями к родителю и соседям, цепочки, направляющие Основной выбор для сложного экрана в XML
RelativeLayout Относительно родителя и соседей (layout_below, layout_toStartOf) Встречается в старом коде, для нового — ConstraintLayout
TableLayout, GridLayout Строки и столбцы Редко, обычно заменяет ConstraintLayout или список
AbsoluteLayout По координатам x, y Устарел давно, не использовать: ломается на других экранах

Списки — отдельная история. ListView и GridView из старых статей — это не макеты, а виджеты для прокручиваемых наборов данных; в современном View-коде вместо них берут RecyclerView с LinearLayoutManager или GridLayoutManager. Вкладки делают через TabLayout из Material Components, а не через устаревший TabHost.

Как атрибуты RelativeLayout переносятся в ConstraintLayout

Если вы поддерживаете старый экран, пригодится таблица соответствий:

RelativeLayout ConstraintLayout
layout_below="@id/a" layout_constraintTop_toBottomOf="@id/a"
layout_above="@id/a" layout_constraintBottom_toTopOf="@id/a"
layout_toStartOf="@id/a" layout_constraintEnd_toStartOf="@id/a"
layout_toEndOf="@id/a" layout_constraintStart_toEndOf="@id/a"
layout_alignTop="@id/a" layout_constraintTop_toTopOf="@id/a"
layout_alignBaseline="@id/a" layout_constraintBaseline_toBaselineOf="@id/a"
layout_alignParentEnd="true" layout_constraintEnd_toEndOf="parent"
layout_centerInParent="true" Привязки ко всем четырем сторонам parent

В RelativeLayout атрибуты пишутся с префиксом android:, в ConstraintLayout — с app:, потому что это библиотека AndroidX, а не часть платформы.

Цепочки и направляющие

Цепочка (chain) — группа элементов, связанных ограничениями друг с другом в обе стороны. Стиль задают у первого элемента атрибутом layout_constraintHorizontal_chainStyle: spread делит свободное место поровну между элементами и краями, spread_inside прижимает крайние элементы к краям, packed собирает элементы вместе по центру.

Направляющая (Guideline) — невидимая линия, к которой привязывают элементы. Ее позицию задают в dp от края (layout_constraintGuide_begin) или долей ширины (layout_constraintGuide_percent="0.3"). Удобно, когда у нескольких элементов общий отступ: меняете одну направляющую, а не каждый элемент.

Если не получилось

Симптом Частая причина Что сделать
В редакторе элемент на месте, на устройстве — в левом верхнем углу Нет привязок в ConstraintLayout, позиция задана только для редактора (tools:layout_editor_*) Добавить ограничения по горизонтали и вертикали; Android Studio подсвечивает такой элемент предупреждением lint
Элемент с 0dp не виден Ширина 0dp, но привязан только к одной стороне Привязать к обеим сторонам или поставить wrap_content
Контент залезает под статус-бар и панель навигации На новых версиях Android приложение по умолчанию рисуется от края до края (edge-to-edge) В Compose — Scaffold и его innerPadding или Modifier.safeDrawingPadding(); в View — обработка window insets
В Compose после поворота экрана пропал текст Состояние в remember вместо rememberSaveable Хранить ввод в rememberSaveable или во ViewModel

Выводы

  • Макет экрана Android — это правила расположения элементов, а не картинка: мокап в Figma задает вид, layout в коде — поведение на разных экранах.
  • Для нового проекта Google рекомендует Jetpack Compose (Column, Row, Box, Modifier), XML с View остается стандартом в существующем коде, и эти подходы совмещаются.
  • В XML основной контейнер для сложных экранов — ConstraintLayout: каждому элементу нужна привязка по обеим осям, 0dp означает растяжение между привязками.
  • RelativeLayout, ListView, TabHost и AbsoluteLayout из старых руководств — наследие; их аналоги — ConstraintLayout, RecyclerView, TabLayout и адаптивная раскладка.

Где применяется / связь с практикой

Освойте тему на практике

Верстка экранов — первое, с чем сталкивается начинающий Android-разработчик: любой учебный проект, от списка дел до клиента погоды, начинается с макета главного экрана, затем добавляются списки, навигация и работа с состоянием. Чтобы собрать такое приложение целиком под руководством практикующих разработчиков, посмотрите курс Android Developer. Basic. Разобрать отдельные темы бесплатно можно на открытых уроках Otus.

Смежная тема: что собой представляет Android Studio.

FAQ

Что значат единицы dp и sp в макете?
dp (density-independent pixels) — единица размеров, которая пересчитывается под плотность экрана, поэтому кнопка 48 dp выглядит примерно одинаково на разных телефонах. sp используют для текста: он дополнительно учитывает размер шрифта, заданный пользователем в настройках.

Можно ли создать интерфейс без XML и без Compose, прямо кодом?
Да, View можно создавать в Kotlin или Java (LinearLayout(context), addView(...)), но такой код хуже читается и не имеет предпросмотра. Обычно так добавляют отдельные элементы динамически, а основу экрана оставляют в XML или Compose.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)