- Смена парадигмы: Декларативный UI vs Императивный (XML). Как мыслить "состояниями".
- Анатомия @Composable: Правила написания функций.
- Preview: Инструменты предпросмотра, конфигурации устройств,
@PreviewParameter. - Setup: Настройка Gradle, версии Kotlin и Compiler Plugin.
- Базовые компоненты:
Text,Button,Image,Icon,TextField. - Система Модификаторов:
- Цепочки вызовов: почему порядок важен.
- Визуальные изменения (background, border, clip).
- Лейаут-модификаторы (padding, size, fillMaxSize).
- Scoping (почему
alignдоступен не везде).
- Стандартные контейнеры:
Column,Row,Box. - Позиционирование: Arrangement (распределение) и Alignment (выравнивание).
- Scaffold: Структура экрана (TopBar, BottomBar, FAB, Drawer).
- ConstraintLayout: Сложные связи элементов без вложенности.
- Концепция State:
MutableState,State. - remember: Сохранение данных между рекомпозициями.
- Рекомпозиция: Жизненный цикл UI. Интеллектуальное обновление дерева.
- State Hoisting: Паттерн "Поднятия состояния" (Unidirectional Data Flow).
- rememberSaveable: Выживание при повороте экрана и смерти процесса.
- Lazy Lists:
LazyColumnиLazyRow(аналоги RecyclerView). - Grids:
LazyVerticalGridи Staggered Grids. - Оптимизация: Использование
key,contentType. - Декорации: Sticky Headers, Item Decorations.
- Navigation Compose: NavHost, Graph, переходы между экранами.
- Передача данных: Аргументы навигации (примитивы и Parcelable).
- MVVM в Compose: Связь с ViewModel,
collectAsStateWithLifecycle. - Hilt/Koin: Внедрение зависимостей в Composable.
- Material 3: ColorScheme, Typography, Shapes.
- Dynamic Colors: Подстройка под обои пользователя.
- Темная тема: Реализация и переключение.
- Ресурсы: Безопасная работа со строками, картинками и шрифтами.
- Side Effects API:
LaunchedEffect,DisposableEffect,SideEffect. - Асинхронность: Запуск корутин из UI.
- State derived:
derivedStateOfиrememberUpdatedState. - Жизненный цикл:
LifecycleEventObserverвнутри Compose.
- High-level API:
animate*AsState,AnimatedVisibility,AnimatedContent. - Transition API: Сложные сценарные анимации.
- Жесты: Tap, DoubleTap, LongPress, Drag & Drop, SwipeToDismiss.
- Legacy: Compose внутри XML (
ComposeView) и XML внутри Compose (AndroidView). - UI Testing:
ComposeTestRule, поиск нод (Semantics), матчеры, действия. - Screenshot Testing: Краткий обзор.
- Canvas: Рисование примитивов (линии, дуги, пути).
- DrawScope & DrawModifier: Рисование поверх или позади контента.
- GraphicsLayer: Трансформации, альфа, аппаратное ускорение.
- Shaders & Brush: Градиенты и визуальные эффекты.
- Modifier.layout: Ручное измерение и размещение.
- Layout Composable: Создание своего контейнера с нуля.
- SubcomposeLayout: Зависимые измерения.
- Intrinsics: Предварительный расчет размеров.
- Stability: Понятия
@Stableи@Immutable. Почему Compose пропускает классы. - Метрики: Отчеты компилятора Compose, Layout Inspector.
- Debug: Поиск лишних рекомпозиций.
- Best Practices: Отложенное чтение состояния (Lambda reads).
- WindowInsets: Работа с Edge-to-Edge (вырезы, клавиатура, статус-бар).
- CompositionLocal: Неявная передача данных, создание своих провайдеров.
- Permissions: Запрос разрешений в стиле Compose.
- Paging 3: Пагинация больших данных.
- Adaptive UI:
BoxWithConstraints, WindowSizeClasses. - Смена конфигурации: Реакция на смену локали, шрифтов системы.
- Compose Multiplatform: Обзор возможностей (Android, iOS, Desktop, Web).
В этой главе мы разберем, почему Google отказался от XML, напишем первую функцию и настроим окружение.
Раньше (XML) мы работали в императивном стиле. Мы создавали дерево объектов (View), а потом изменяли их вручную.
- Пример: «Найди TextView по ID. Установи ему текст "Привет". Сделай кнопку неактивной».
- Проблема: Со временем трудно отследить, кто и в каком порядке изменил кнопку. Ошибки рассинхронизации UI и данных — классика Android.
Compose — это декларативный UI.
- Принцип: Ты не меняешь UI. Ты описываешь, как UI должен выглядеть для текущего состояния данных.
- Формула:
UI = f(State)(Интерфейс есть функция от состояния). - Когда данные меняются, Compose не «ищет и меняет» кнопку. Он полностью перерисовывает (рекомпозирует) ту часть экрана, которая зависит от этих данных.
Важно: В Compose нет
setText(),setVisibility(),setImage(). Вы просто вызываете функцию снова с новыми параметрами.
Для работы с Compose рекомендуется использовать Bill of Materials (BOM). Это «карта версий», которая гарантирует, что все библиотеки Compose совместимы друг с другом.
В файле build.gradle.kts (Module: app):
dependencies {
// Импорт BOM. Обрати внимание: у самих библиотек ниже НЕТ версий.
// Версия BOM управляет ими.
implementation(platform("androidx.compose:compose-bom:2024.02.00")) // Проверь актуальную версию
// Основные библиотеки
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") // Для превью
implementation("androidx.compose.material3:material3") // Material Design 3
// Интеграция с Activity
implementation("androidx.activity:activity-compose:1.8.2")
// Для отладки (Debug)
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
В Compose функции — это строительные блоки (аналог View в XML). Чтобы обычная функция Kotlin стала элементом UI, нужно добавить аннотацию @Composable.
- Аннотация: Обязательно
@Composable. - Именование: Всегда PascalCase (Существительное), как классы.
- Правильно:
Greeting,ProfileScreen,BlueButton. - Неправильно:
getGreeting,drawProfile,blueButton. - Почему: Потому что мы описываем объект/сущность, а не действие.
- Возвращаемый тип: Обычно
Unit. Функция ничего не возвращает, она «излучает» (emits) UI в дерево композиции.
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
// Это UI-компонент. Он принимает данные (name) и рисует текст.
@Composable
fun Greeting(name: String) {
// Text - это стандартная Composable функция из библиотеки Material3
Text(text = "Привет, $name!")
}
В MainActivity мы больше не используем setContentView(R.layout.activity_main). Вместо этого мы используем расширение setContent.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Точка входа в Compose
setContent {
// Surface — это "холст" или контейнер (обычно фон экрана)
Surface(
modifier = Modifier.fillMaxSize(), // Занять весь экран
color = Color.White // Белый фон
) {
// Вызываем нашу функцию
Greeting(name = "Студент")
}
}
}
}
Одна из киллер-фич Compose — возможность видеть изменения в реальном времени прямо в Android Studio (режим Split или Design). Для этого создается отдельная функция с аннотацией @Preview.
import androidx.compose.ui.tooling.preview.Preview
@Preview(
showBackground = true, // Показать белый фон (иначе текст будет прозрачным)
name = "Обычный превью", // Имя для удобства
locale = "ru" // Можно предпросмотреть локализацию
)
@Composable
fun GreetingPreview() {
// В превью мы подставляем фейковые данные
Greeting(name = "Android")
}
showSystemUi = true: покажет статус-бар и кнопки навигации.device = "id:pixel_5": покажет, как это выглядит на конкретном телефоне.uiMode: можно включить темную тему (Dark Mode).
- Создай новый проект в Android Studio -> Empty Activity (убедись, что иконка Compose присутствует).
- Открой
MainActivity.kt. - Напиши функцию
@Composable fun UserCard(userName: String), которая выводит текст с именем. - Создай
@Previewдля этой функции. - Попробуй изменить текст в коде и нажми Build & Refresh (или используй Live Edit, если включен), чтобы увидеть изменения в окне превью справа.
Итог главы:
Мы узнали, что Compose — это описание интерфейса через функции. Мы не управляем состоянием View, мы просто говорим: «Нарисуй это с такими данными».
В XML у каждого View (TextView, ImageView) был свой огромный набор атрибутов (android:textColor, android:padding, android:layout_width).
В Compose подход другой:
- Composables — это просто функции, рисующие контент (Текст, Картинка).
- Modifiers — это универсальный способ изменить размер, фон, отступы и поведение любого элемента.
Давай разберем самые частые элементы.
Самый простой элемент.
Text(
text = "Привет, Compose!",
color = Color.Blue, // Цвет текста
fontSize = 20.sp, // Размер шрифта (используй .sp для текста!)
fontWeight = FontWeight.Bold, // Жирность
maxLines = 1, // Ограничение по строкам
overflow = TextOverflow.Ellipsis // Троеточие, если не влезает (...)
)
Кнопка — это контейнер. Она принимает onClick (действие) и content (что внутри кнопки). Внутри кнопки обычно лежит Text, но может лежать и Row с иконкой.
Button(
onClick = { /* Действие по клику, например Log.d(...) */ },
enabled = true, // Активна или нет
// colors = ButtonDefaults.buttonColors(...) // Можно переопределить цвета
) {
// Внутри лямбды мы описываем содержимое кнопки
Text("Нажми меня")
}
Разновидности: OutlinedButton (с обводкой), TextButton (прозрачная, для диалогов).
- Icon: Для векторных иконок (маленькие, одноцветные).
- Image: Для растровых картинок (фотографии, сложные логотипы).
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
// Векторная иконка из встроенной библиотеки
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Лайк", // Обязательно для доступности (TalkBack)
tint = Color.Red // Можно перекрасить вектор
)
// Растровая картинка из ресурсов (res/drawable)
Image(
painter = painterResource(id = R.drawable.my_photo),
contentDescription = "Мое фото",
contentScale = ContentScale.Crop // Аналог scaleType="centerCrop"
)
Пока просто запомни синтаксис. В Compose поле ввода не хранит текст внутри себя (в отличие от EditText). Мы должны передать ему текст снаружи. Подробнее разберем в главе про State.
TextField(
value = "Текущий текст",
onValueChange = { newText -> /* Тут мы должны обновить переменную */ },
label = { Text("Введите имя") }
)
Modifier — это сердце верстки в Compose. Это цепочка команд, которая говорит элементу, как он должен выглядеть и вести себя.
Почти каждая Composable функция принимает параметр modifier.
- Размеры:
.width(100.dp),.height(50.dp),.size(100.dp).fillMaxWidth()— занять всю ширину (аналогmatch_parent)..fillMaxSize()— занять всё доступное место.
- Оформление:
.background(Color.Green)— цвет фона..border(2.dp, Color.Red)— рамка..clip(RoundedCornerShape(8.dp))— скруглить углы (обрезать контент по форме)..alpha(0.5f)— прозрачность.
- Отступы:
.padding(16.dp)— внутренний отступ (аналог и padding, и margin в зависимости от порядка).
- Действия:
.clickable { ... }— сделать элемент кликабельным (добавляет эффект ряби/ripple).
Text(
text = "Стильный текст",
modifier = Modifier
.padding(16.dp) // Отступ снаружи
.background(Color.LightGray) // Серый фон
.padding(8.dp) // Отступ внутри (от фона до текста)
.clickable { /* Клик */ }
)
Это самая частая ошибка новичков. В Compose модификаторы применяются последовательно, как слои луковицы или оберточная бумага.
То, что написано раньше — применяется раньше (снаружи).
Давай рассмотрим на примере двух квадратов.
@Composable
fun ModifierOrderDemo() {
// Вариант 1: Сначала Padding, потом Background
// Эффект: Отступ (margin), а потом рисуется фон.
// Визуально: Квадрат сдвинут, между краем экрана и цветом есть дырка.
Box(
modifier = Modifier
.size(100.dp)
.padding(20.dp) // Сначала отступили
.background(Color.Red) // Потом закрасили то, что осталось
)
// Вариант 2: Сначала Background, потом Padding
// Эффект: Сначала закрасили, потом отступили внутрь.
// Визуально: Квадрат закрашен полностью 100dp, но контент внутри будет сжат.
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue) // Сначала закрасили все 100dp
.padding(20.dp) // Добавили внутренний отступ для содержимого
)
}
Совет: Читайте модификаторы сверху вниз как инструкцию: «Возьми элемент -> Сделай отступ -> Закрась фон -> Сделай еще отступ -> Сделай кликабельным».
Некоторые модификаторы доступны только внутри определенных контейнеров. IDE подскажет вам, но важно понимать принцип.
Например, модификатор .align() нельзя применить к Text просто так. Text должен находиться внутри контейнера, который знает, как выравнивать.
- Внутри
Box: доступен.align(Alignment.Center) - Внутри
Column: доступен.align(Alignment.End)(горизонтально) - Внутри
Row: доступен.align(Alignment.CenterVertically)
Мы детально разберем это в следующей главе про Layouts.
Давай создадим красивую кнопку-карточку, используя знания этой главы.
- Создай Composable функцию
StyledCard. - Используй
Text, но добавь емуmodifier:
- Задай фиксированную ширину 200.dp.
- Сделай фон
Color.Cyan. - Добавь внешний отступ 16.dp.
- Добавь внутренний отступ (padding) 8.dp.
- Сделай углы скругленными (
.clip(RoundedCornerShape(10.dp))). Внимание:clipдолжен идти передbackground, чтобы фон обрезался, или после? Поэкспериментируй! (Правильный ответ:clipобрезает то, что рисуется после него, но обычно фон задают черезSurfaceили модификаторbackgroundс формой). - Коррекция: Самый простой способ скруглить фон:
.background(color, shape). - Сделай текст кликабельным.
Пример правильного порядка для скругленной кнопки:
Modifier
.padding(16.dp) // Внешний отступ
.clip(RoundedCornerShape(16.dp)) // Задаем форму обрезки
.background(Color.Cyan) // Красим (краска обрежется по форме выше)
.clickable { } // Эффект нажатия (будет обрезан по форме)
.padding(16.dp) // Внутренний отступ от краев до текста
Главное правило верстки в Compose: Вложенность — это нормально!
В отличие от XML, где глубокая вложенность (Nesting hell) убивала производительность, в Compose вложенные Row и Column работают очень быстро. Не бойся вкладывать их друг в друга.
Располагает элементы вертикально (сверху вниз). Аналог LinearLayout с orientation="vertical".
Column {
Text("Заголовок")
Text("Описание")
Button(onClick = {}) { Text("Ок") }
}
Располагает элементы горизонтально (слева направо). Аналог LinearLayout с orientation="horizontal".
Row {
Icon(Icons.Default.Call, contentDescription = null)
Text("Позвонить")
}
Располагает элементы друг поверх друга (по оси Z). Аналог FrameLayout. Первый элемент — внизу, последний — наверху.
Идеально для:
- Текста поверх картинки.
- Кнопки "Закрыть" в углу карточки.
- Загрузчика (CircularProgressIndicator) поверх всего экрана.
Box {
Image(painter = painterResource(R.drawable.bg), contentDescription = null)
Text("Текст поверх картинки", color = Color.White)
}
Это то место, где новички часто путаются. У Column и Row есть две оси:
- Main Axis (Главная ось): Направление, куда идут элементы (Вертикаль для Column, Горизонталь для Row).
- Cross Axis (Поперечная ось): Перпендикулярное направление.
У нас есть два параметра для настройки:
- Arrangement (Распределение): Управляет элементами вдоль Главной оси. (Как раскидать элементы?)
- Alignment (Выравнивание): Управляет элементами вдоль Поперечной оси. (Прижать к краю или по центру?)
verticalArrangement: Top (по умолчанию), Center, Bottom, SpaceBetween (равномерно), SpaceAround.horizontalAlignment: Start (по умолчанию), CenterHorizontally, End.
horizontalArrangement: Start (по умолчанию), Center, End, SpaceBetween...verticalAlignment: Top (по умолчанию), CenterVertically, Bottom.
Column(
modifier = Modifier.fillMaxSize(), // Колонка на весь экран
verticalArrangement = Arrangement.Center, // Контент по центру вертикали
horizontalAlignment = Alignment.CenterHorizontally // Контент по центру горизонтали
) {
Text("Я в самом центре экрана!")
}
Как сказать элементу: «Займи всё оставшееся пространство» или «Поделите ширину пополам»?
Используем модификатор .weight(float). Он доступен только внутри Row или Column (через RowScope/ColumnScope).
Row(modifier = Modifier.fillMaxWidth()) {
// Этот текст займет 30% места (вес 1)
Text("Мало места", modifier = Modifier.weight(1f).background(Color.Red))
// Этот текст займет 70% места (вес 2, так как всего 1+2=3 части)
Text("Много места", modifier = Modifier.weight(2f).background(Color.Green))
}
Лайфхак: Часто используют
Spacer(modifier = Modifier.weight(1f))чтобы "растолкать" элементы по краям.
Row {
Text("Лево")
Spacer(Modifier.weight(1f)) // Пружина, толкающая элементы
Text("Право")
}
Обычно экран приложения состоит из стандартных частей: верхняя панель (TopBar), контент, нижняя навигация, плавающая кнопка (FAB). Чтобы не верстать это вручную, Compose дает нам Scaffold (Строительные леса).
Это компонент, в котором есть "слоты" (дырки) под стандартные элементы UI.
Scaffold(
topBar = {
// Используем стандартный TopAppBar (экспериментальный в M3, требует импорта)
CenterAlignedTopAppBar(title = { Text("Настройки") })
},
floatingActionButton = {
FloatingActionButton(onClick = {}) {
Icon(Icons.Default.Add, contentDescription = "Добавить")
}
}
) { innerPadding ->
// innerPadding — это отступы, которые Scaffold вычислил для нас.
// Мы ОБЯЗАНЫ их применить к контенту, иначе контент залезет под TopBar.
Column(modifier = Modifier.padding(innerPadding)) {
Text("Содержимое экрана")
Text("Нажми кнопку внизу")
}
}
Давай сверстаем карточку чата (как в Telegram/WhatsApp). Это классическая задача на Row и Column.
ТЗ:
- Контейнер
Row(весь элемент). - Слева: Аватарка (круглый
BoxилиImageразмером 50.dp). - По центру:
Columnс Именем (жирный) и Последним сообщением (серый). Эта колонка должна занимать вес (weight(1f)), чтобы толкать время вправо. - Справа: Время сообщения (мелкий текст).
Код-шпаргалка (попробуй сначала сам!):
👀 Показать решение
@Composable
fun ChatItem() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp), // Общий отступ
verticalAlignment = Alignment.CenterVertically // Выровнять аву и текст по центру строки
) {
// 1. Аватарка
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(Color.Gray)
)
// Отступ между авой и текстом
Spacer(modifier = Modifier.width(16.dp))
// 2. Колонка с текстом (занимает всё доступное место)
Column(modifier = Modifier.weight(1f)) {
Text(text = "Иван Иванов", fontWeight = FontWeight.Bold)
Text(text = "Привет! Как дела с Compose?", color = Color.Gray)
}
// 3. Время
Text(text = "12:45", fontSize = 12.sp)
}
}
Формула Compose: UI = f(State). Интерфейс — это результат выполнения функции от текущих данных. Если данные меняются, функция выполняется заново.
Давай попробуем сделать счетчик кликов классическим способом программиста.
@Composable
fun BrokenCounter() {
// ❌ ЭТО НЕ БУДЕТ РАБОТАТЬ
var count = 0
Button(onClick = {
count++ // Мы меняем переменную...
println("Count is $count") // В логах число растет!
}) {
Text("Количество нажатий: $count") // ...но UI всегда показывает 0
}
}
- Compose не следит за обычными переменными (
var). Он не знает, чтоcountизменился. - Даже если бы знал, при перерисовке (рекомпозиции) функция
BrokenCounterвызвалсь бы снова. А внутри неё строчкаvar count = 0сбросила бы всё в ноль.
Чтобы Compose «видел» изменения, мы используем специальные обертки State.
Чтобы Compose «помнил» значение между перерисовками, мы используем remember.
Это коробка, за которой следит Compose. Если значение внутри коробки меняется, Compose запускает Рекомпозицию (перерисовку) всех функций, которые читают эту коробку.
Функция remember { ... } вычисляет значение один раз при первом показе, и сохраняет его. При следующих рекомпозициях она просто отдает сохраненное значение.
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue // Нужно для делегата 'by'
import androidx.compose.runtime.setValue // Нужно для делегата 'by'
@Composable
fun WorkingCounter() {
// ✅ remember сохраняет значение между перерисовками
// mutableStateOf сообщает Compose, что при изменении нужно обновить UI
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Количество нажатий: $count")
}
}
Обрати внимание на
by: Чтобы использовать синтаксисvar count by ...(вместоcount.value), тебе нужны импортыgetValueиsetValue. Если IDE ругается красным — нажми Alt+Enter и выбери "Import members from androidx.compose.runtime".
Рекомпозиция — это процесс повторного вызова твоей Composable-функции с новыми данными.
- Ты нажал кнопку.
countизменился с 0 на 1.- Compose увидел, что
Stateизменился. - Compose ищет, кто читал этот
State. Это функцияWorkingCounter. - Compose запускает
WorkingCounterзаново. rememberвозвращает уже сохраненную 1.Textполучает строку "Количество нажатий: 1".- Экран обновляется.
Это происходит молниеносно. Compose умен: он перерисовывает только то, что реально изменилось (Intelligent Recomposition).
Это важнейший архитектурный паттерн. Представь, что у тебя есть экран, и на нем два компонента:
- Текст «Всего кликов: X»
- Кнопка «Добавить»
Они находятся в разных местах кода, но зависят от одной переменной. Где хранить State?
Правило: Состояние должно храниться у наименьшего общего родителя.
Мы «поднимаем» (hoist) состояние из кнопки вверх к родителю.
- Родитель: Хранит состояние.
- Ребенок: Принимает состояние как параметр (Read-only) и принимает функцию (Callback) для запроса изменений.
// 1. Родитель (Умный компонент)
@Composable
fun CounterScreen() {
// Состояние живет здесь
var count by remember { mutableStateOf(0) }
Column {
// Передаем данные ВНИЗ
CounterDisplay(count = count)
// Передаем событие ВНИЗ (лямбду), чтобы изменить данные НАВЕРХУ
CounterButton(
onIncrement = { count++ }
)
}
}
// 2. Отображение (Глупый компонент, Stateless)
@Composable
fun CounterDisplay(count: Int) {
Text("Счет: $count", fontSize = 30.sp)
}
// 3. Управление (Глупый компонент, Stateless)
@Composable
fun CounterButton(onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Нажми меня")
}
}
Это называется Unidirectional Data Flow (UDF) — Однонаправленный поток данных.
- State flows Down: Данные текут вниз.
- Events flow Up: События текут вверх.
Попробуй запустить WorkingCounter, накликать до 10, а потом повернуть телефон (или сменить тему).
Счетчик сбросится в 0. 😱
Почему? При повороте экрана Activity уничтожается и создается заново. remember хранит данные только пока жив Composable в памяти. При пересоздании всё забывается.
Решение: Используй rememberSaveable. Оно сохраняет данные в Bundle (как onSaveInstanceState в старом Android).
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun RobustCounter() {
// Теперь число выживет при повороте экрана и смерти процесса
var count by rememberSaveable { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Я выживу: $count")
}
}
Создай простое приложение "Конвертер валют" (упрощенно).
- Создай функцию
CurrencyConverter. - Внутри создай состояние для введенной суммы в рублях:
var rubles by remember { mutableStateOf("") }. - Размести
Column. - Внутри:
-
TextField: -
value = rubles -
onValueChange = { rubles = it }(обновляем стейт при вводе). -
Text: который показывает сумму в долларах. (Логика: если строка не пустая, переводим в Double, делим на 90.0, иначе показываем 0).
Подсказка для конвертации:
val dollars = rubles.toDoubleOrNull()?.div(90.0) ?: 0.0
Text("В долларах: $dollars")
В Compose есть два основных подхода к спискам:
- Column + verticalScroll: Загружает ВСЕ элементы сразу. Подходит для маленьких списков (настройки, профиль), которые точно влезут в память.
- LazyColumn / LazyRow: Загружает только то, что видно на экране + небольшой буфер. Аналог RecyclerView. Используется для списков данных (чаты, лента новостей, контакты).
Мы сосредоточимся на Lazy (Ленивых) списках.
Допустим, у нас есть простой дата-класс:
data class Message(val id: Int, val author: String, val text: String)
// Генерация фейковых данных
val messages = List(100) { index ->
Message(id = index, author = "User $index", text = "Привет, это сообщение #$index")
}
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // Важный импорт!
@Composable
fun MessageList(messages: List<Message>) {
// LazyColumn уже имеет встроенный скролл
LazyColumn {
// DSL-блок (LazyListScope)
// items() принимает список данных и рисует UI для КАЖДОГО элемента
items(messages) { message ->
Text(
text = "${message.author}: ${message.text}",
modifier = Modifier.padding(8.dp)
)
}
}
}
Всё! Это полный аналог RecyclerView с адаптером.
Как это работает:
Compose вызывает блок внутри items только для тех элементов, которые сейчас находятся в видимой области экрана (viewport). Когда ты скроллишь вниз, верхние элементы уничтожаются (или переиспользуются), а нижние создаются.
В XML мы часто мучились с отступами для списка (чтобы контент не прилипал к краям экрана или не перекрывался BottomNavigation).
В LazyColumn есть удобные параметры:
LazyColumn(
// Внутренние отступы всего контейнера списка.
// clipToPadding = false (по умолчанию true) - если нужно, чтобы контент прокручивался ПОД отступами
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
// Расстояние МЕЖДУ элементами списка
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(messages) { message ->
MessageCard(message) // Вынесли отрисовку в отдельную функцию
}
}
Это критически важный момент для производительности и корректности, особенно если твой список изменяется (удаление, перемещение, добавление элементов).
По умолчанию Compose определяет элементы по их позиции в списке (индексу).
- Проблема: Если ты удалишь 1-й элемент, все остальные сдвинутся вверх. Compose подумает, что все элементы изменились, и может перерисовать весь список или потерять состояние (например, введенный текст в TextField внутри элемента).
- Решение: Явно указать уникальный ключ (ID) для каждого элемента.
LazyColumn {
items(
items = messages,
key = { message -> message.id } // Уникальный ID из нашей модели данных
) { message ->
MessageCard(message)
}
}
Теперь, если мы удалим элемент с id=5, Compose поймет это и просто уберет его, не трогая остальные. Это работает намного быстрее (аналог DiffUtil).
В RecyclerView для этого нужно было переопределять getItemViewType. В Compose мы просто пишем разные функции внутри блока LazyColumn.
LazyColumn {
// 1. Одиночный элемент (Заголовок)
item {
Text("Мои сообщения", style = MaterialTheme.typography.headlineLarge)
}
// 2. Список элементов
items(messages) { message ->
MessageCard(message)
}
// 3. Футер (Подвал)
item {
Text("Конец списка", color = Color.Gray)
}
}
Нужна галерея фоток? Используем LazyVerticalGrid.
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@Composable
fun PhotoGrid() {
LazyVerticalGrid(
// Описываем структуру колонок:
// GridCells.Fixed(3) - ровно 3 колонки
// GridCells.Adaptive(minSize = 128.dp) - сколько влезет, но не меньше 128dp (супер для адаптивности!)
columns = GridCells.Adaptive(minSize = 100.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items(100) { index ->
// Просто цветной квадрат вместо фото
Box(
modifier = Modifier
.height(100.dp)
.background(Color.Blue)
)
}
}
}
Вспоминаем Главу 4 про State Hoisting. Сам список или элемент списка не должен решать, что делать при клике. Он должен дергать лямбду.
@Composable
fun MessageListScreen() {
// Данные (обычно приходят из ViewModel)
// mutableStateListOf - это специальный список, который сообщает об изменениях (добавление/удаление)
val messages = remember { mutableStateListOf<Message>(/*...*/) }
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(
message = message,
onItemClick = { clickedMessage ->
Log.d("Track", "Clicked: ${clickedMessage.id}")
},
onDeleteClick = {
messages.remove(message) // Удаляем из списка, UI обновится сам!
}
)
}
}
}
@Composable
fun MessageRow(
message: Message,
onItemClick: (Message) -> Unit,
onDeleteClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onItemClick(message) } // Клик по всей строке
.padding(16.dp)
) {
Text(message.text, modifier = Modifier.weight(1f))
IconButton(onClick = onDeleteClick) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
Бонусная фича. Делается одной строкой (требует экспериментальную аннотацию в старых версиях, но в новых уже стабильно).
@OptIn(ExperimentalFoundationApi::class) // Если требуется
@Composable
fun ContactsList(groupedContacts: Map<Char, List<Contact>>) {
LazyColumn {
groupedContacts.forEach { (initial, contacts) ->
// Этот заголовок будет "прилипать" к верху экрана при скролле
stickyHeader {
Text(
text = initial.toString(),
modifier = Modifier.fillMaxWidth().background(Color.LightGray).padding(8.dp)
)
}
items(contacts) { contact ->
Text(contact.name, modifier = Modifier.padding(16.dp))
}
}
}
}
Давай сделаем простой список покупок.
- Создай data class
ShoppingItem(val id: Int, val name: String, var isChecked: Boolean).
- Заметка: Чтобы
isCheckedобновлял UI внутри элемента, само поле в классе должно бытьMutableStateили (лучше) весь список должен перерисовываться. Для простоты сейчас: используйval isChecked: Booleanи создавай копию объекта при клике (data.copy(isChecked = !isChecked)).
- В
MainActivityсоздай список товаров черезremember { mutableStateListOf(...) }. - Используй
LazyColumn. - В каждом элементе (
Row) сделайCheckbox(стандартный компонент) иText. - При клике на чекбокс (или на строку) меняй состояние элемента в списке.
- (Дополнительно) Сделай кнопку FAB (FloatingActionButton) в
Scaffold, которая добавляет случайный товар в список. Список должен автоматически проскроллиться или просто показать новый элемент.
В XML мы мучились с styles.xml, themes.xml, атрибутами ?attr/colorPrimary и перезагрузкой Activity при смене темы.
В Compose темизация — это просто передача параметров в дерево функций.
Когда ты создаешь новый проект, Android Studio генерирует папку ui/theme. В ней лежит вся магия.
Основа всего дизайна — обертка MaterialTheme. Обычно она находится в файле Theme.kt и оборачивает всё твое приложение в MainActivity.
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(), // Авто-определение темы системы
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme, // Цвета
typography = Typography, // Шрифты
shapes = Shapes, // Формы (скругления)
content = content
)
}
Все, что находится внутри MaterialTheme (наш content), получает доступ к этим настройкам.
Material Design 3 использует семантические имена цветов. Мы больше не называем цвета Blue или Red. Мы называем их по роли:
- Primary: Главный цвет бренда (кнопки, активные элементы).
- OnPrimary: Цвет текста/иконки поверх Primary (обычно белый или черный).
- Surface: Цвет поверхностей (карточки, меню).
- OnSurface: Цвет текста на поверхности.
- Background: Цвет фона экрана.
- Error: Цвет ошибки.
Никогда (по возможности) не пиши Color.Blue или Color.Black хардкодом. Используй ссылки на тему.
// ❌ ПЛОХО: Хардкод. В темной теме это будет выглядеть ужасно (черный текст на черном фоне).
Text("Привет", color = Color.Black)
// ✅ ХОРОШО: Используем тему.
// В светлой теме это будет черный (или темно-серый).
// В темной теме это автоматически станет белым (или светло-серым).
Text(
text = "Привет",
color = MaterialTheme.colorScheme.onBackground
)
// Пример кнопки с правильными цветами
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary, // Фон кнопки
contentColor = MaterialTheme.colorScheme.onPrimary // Текст на кнопке
)
) {
Text("Action")
}
В Android 12+ появилась фича, когда цвета приложения подстраиваются под обои пользователя. В файле Theme.kt ты увидишь код:
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
if (dynamicColor && context != null) {
dynamicDarkColorScheme(context)
} else ...
Это позволяет твоему приложению выглядеть "родным" в системе.
Не задавай fontSize вручную (24.sp, 14.sp). Используй стили. Это гарантирует единообразие.
Все стили лежат в Type.kt.
Стандартная шкала Material 3:
displayLarge...displaySmall(Огромные заголовки)headlineLarge...headlineSmall(Заголовки экранов)titleLarge...titleSmall(Заголовки секций)bodyLarge...bodySmall(Основной текст)labelLarge...labelSmall(Текст на кнопках, подписи)
// Использование
Text(
text = "Главный заголовок",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "Описание товара...",
style = MaterialTheme.typography.bodyLarge
)
Как изменить шрифт (например, на Roboto или Montserrat)?
В файле Type.kt можно переопределить fontFamily для всей шкалы.
Определяют скругление углов (CornerRadius). Лежат в Shapes.kt.
Shapes.small(обычно 4.dp - 8.dp)Shapes.medium(12.dp - 16.dp)Shapes.large(24.dp+)
Card(
shape = MaterialTheme.shapes.medium, // Используем форму из темы
modifier = Modifier.size(100.dp)
) { ... }
Хотя мы пишем на Kotlin, строковые ресурсы и картинки все еще лежат в папке res. Compose предоставляет удобные функции для доступа к ним.
Обязательно для локализации (перевода на другие языки).
// Вместо "Hello" пишем:
Text(text = stringResource(id = R.string.hello_text))
// С параметрами (в strings.xml: "Привет, %s!"):
Text(text = stringResource(id = R.string.greeting, "Иван"))
// Растр (png, jpg)
Image(
painter = painterResource(id = R.drawable.my_cat),
contentDescription = null
)
// Вектор (xml)
Icon(
painter = painterResource(id = R.drawable.ic_settings), // Своя иконка
contentDescription = null
)
Если ты везде использовал MaterialTheme.colorScheme...:
backgroundonBackgroundsurface...то поддержка темной темы у тебя есть автоматически.
Тебе не нужно писать if (isDark) Color.White else Color.Black. Система сама подставит нужную палитру из Theme.kt.
Создай два превью для одного экрана:
@Preview(name = "Light Mode")
@Preview(
name = "Dark Mode",
uiMode = Configuration.UI_MODE_NIGHT_YES, // Включаем ночь
showBackground = true
)
@Composable
fun ScreenPreview() {
MyAppTheme { // Важно обернуть в тему!
Surface {
// Твой экран
ProfileScreen()
}
}
}
Возьми свой список покупок или чат из прошлых заданий и сделай "Рефакторинг стиля".
- Убери все
Color.Black,Color.White,Color.Gray. - Замени их на
MaterialTheme.colorScheme.onBackground,MaterialTheme.colorScheme.surfaceи т.д. - Для вторичного текста (например, времени сообщения) используй цвет текста с прозрачностью или специальный токен, например
MaterialTheme.colorScheme.onSurfaceVariant. - Запусти приложение на эмуляторе.
- В шторке эмулятора включи Dark Theme.
- Если твое приложение автоматически перекрасилось в красивые темные цвета и текст остался читаемым — ты справился!
Composable функции должны быть Side-effect free (без побочных эффектов). Это значит, что функция должна только превращать данные в UI. Она не должна:
- Запускать таймеры.
- Делать сетевые запросы.
- Менять глобальные переменные.
- Подписываться на сенсоры.
Почему? Потому что Compose может запускать твою функцию 100 раз в секунду (например, при анимации) или вообще прервать ее выполнение на полпути. Если ты напишешь Log.d("Tag", "Hello") прямо в теле функции, твой лог засорится тысячами сообщений.
Для безопасного выполнения таких действий существуют Effect Handlers.
Используется, когда нужно запустить асинхронную задачу (корутину) внутри тела Composable.
- Как работает: Запускает блок кода, когда элемент появляется на экране (входит в композицию).
- Очистка: Автоматически отменяет корутину, если элемент уходит с экрана.
- Ключи (Keys): Если ключ (параметр) меняется, эффект перезапускается.
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
// Unit - это константа. Значит, эффект запустится 1 раз при старте.
LaunchedEffect(key1 = Unit) {
delay(3000) // Ждем 3 секунды (suspend функция)
onTimeout() // Уходим с экрана
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Загрузка...")
}
}
@Composable
fun UserProfile(userId: String, viewModel: UserViewModel) {
// Если userId изменится, старый запрос отменится, и запустится новый!
LaunchedEffect(key1 = userId) {
viewModel.loadUserData(userId)
}
// UI ...
}
LaunchedEffect работает сам по себе. Но что, если нам нужно запустить корутину в ответ на действие пользователя (нажатие кнопки)?
В onClick (который является обычным колбэком, не @Composable) нельзя вызывать suspend функции.
Нам нужен CoroutineScope.
@Composable
fun ScrollToTopButton(lazyListState: LazyListState) {
// 1. Создаем Scope, привязанный к жизни этого экрана
val scope = rememberCoroutineScope()
Button(
onClick = {
// 2. Запускаем корутину внутри onClick
scope.launch {
// animateScrollToItem - это suspend функция!
lazyListState.animateScrollToItem(0)
}
}
) {
Text("Наверх")
}
}
Правило:
- Нужна корутина сразу при старте экрана? -> LaunchedEffect
- Нужна корутина при нажатии кнопки? -> rememberCoroutineScope
Некоторые вещи требуют не просто отмены (как корутины), а явной очистки (unregister/close). Например: слушатели сенсоров, Analytics, BroadcastReceiver или Lifecycle Observer.
У DisposableEffect есть обязательный блок onDispose, который срабатывает, когда элемент удаляется из дерева UI.
@Composable
fun VideoPlayer(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
// Если lifecycleOwner изменится, эффект перезапустится
DisposableEffect(lifecycleOwner) {
// 1. Создаем наблюдателя
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
println("Video paused")
} else if (event == Lifecycle.Event.ON_RESUME) {
println("Video resumed")
}
}
// 2. Подписываемся
lifecycleOwner.lifecycle.addObserver(observer)
// 3. Блок очистки (ОБЯЗАТЕЛЬНО)
onDispose {
println("Cleaning up observer...")
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
Иногда состояние меняется слишком часто (например, позиция скролла меняется каждый пиксель), но нам нужно реагировать только на пороговые изменения.
Если мы будем слушать lazyListState.firstVisibleItemIndex напрямую, кнопка "Наверх" будет рекомпозироваться сотни раз при скролле.
@Composable
fun OptimizedList() {
val listState = rememberLazyListState()
// ❌ ПЛОХО: Рекомпозиция при КАЖДОМ пикселе скролла
// val showButton = listState.firstVisibleItemIndex > 0
// ✅ ХОРОШО: Рекомпозиция только когда true меняется на false и наоборот
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
LazyColumn(state = listState) { /* ... */ }
if (showButton) {
Button(onClick = { /*...*/ }) { Text("Up") }
}
}
}
Сложная, но важная тема. Представь, что у тебя есть LaunchedEffect, который работает долго (например, таймер на 10 секунд). И ты передал в него лямбду onTimeout.
Если во время работы таймера родитель перерисуется и передаст новую лямбду onTimeout, LaunchedEffect не узнает об этом (он не перезапустится, если мы не укажем лямбду как ключ key1). Но если мы укажем её как ключ, таймер сбросится!
Чтобы использовать новую лямбду внутри старого эффекта без перезапуска, используем rememberUpdatedState.
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// Всегда хранит самую свежую версию функции onTimeout
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5000)
// Вызываем именно свежую версию, даже если LandingScreen рекомпозировался
currentOnTimeout()
}
}
Давай сделаем мини-таймер обратного отсчета с кнопкой запуска.
ТЗ:
- Переменная
timeLeft(Int), изначально 10. - Переменная
isRunning(Boolean), изначальноfalse. - Кнопка "Start", которая меняет
isRunningнаtrue. - Используй
LaunchedEffect(key1 = isRunning).
- Внутри:
if (isRunning). - Цикл:
while (timeLeft > 0). delay(1000).timeLeft--.
- Когда таймер доходит до 0,
isRunningдолжен статьfalse.
Вопрос на засыпку: Что произойдет, если нажать "Start", а потом, пока таймер тикает, повернуть экран? (Подсказка: вспомни про rememberSaveable из главы 4).
Попробуй реализовать это. Если получится — ты освоил управление временем в Compose!
Навигация не входит в ui, это отдельная библиотека. Добавь в build.gradle.kts:
dependencies {
val nav_version = "2.7.7" // Проверь актуальную версию
implementation("androidx.navigation:navigation-compose:$nav_version")
// Для ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}
Система состоит из трех частей:
- NavController: «Водитель». Объект, который управляет перемещением, хранит стек экранов (BackStack).
- NavHost: «Парковка». Контейнер в UI, где отображается текущий экран.
- Destinations: «Адреса». Сами экраны, помеченные маршрутами (строками).
import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@Composable
fun AppNavigation() {
// 1. Создаем контроллер (обычно в корне приложения)
val navController = rememberNavController()
// 2. Создаем NavHost.
// startDestination - экран, который откроется первым.
NavHost(navController = navController, startDestination = "home") {
// 3. Описываем карту маршрутов (Graph)
// Экран "Домой"
composable("home") {
HomeScreen(
onNavigateToProfile = {
navController.navigate("profile")
}
)
}
// Экран "Профиль"
composable("profile") {
ProfileScreen(
onBack = {
navController.popBackStack() // Кнопка "Назад"
}
)
}
}
}
Важно: Старайся не передавать
navControllerвнутрь экранов. Лучше передавай лямбды (onNavigateToProfile: () -> Unit). Это делает экраны независимыми и удобными для тестирования (State Hoisting).
Как передать ID пользователя на экран профиля? Маршруты в Compose похожи на URL в браузере.
Пример маршрута: "profile/42" или "search?query=cats".
import androidx.navigation.NavType
import androidx.navigation.navArgument
// Внутри NavHost:
composable(
route = "details/{userId}", // {userId} - это плейсхолдер
arguments = listOf(navArgument("userId") { type = NavType.IntType }) // Указываем тип
) { backStackEntry ->
// Получаем аргументы
val userId = backStackEntry.arguments?.getInt("userId") ?: 0
DetailsScreen(id = userId)
}
// Как вызвать переход:
navController.navigate("details/123")
Где хранить логику? В ViewModel.
Google рекомендует использовать StateFlow для передачи состояния из ViewModel в UI.
class HomeViewModel : ViewModel() {
// _state - приватный mutable (изменяемый) поток
private val _uiState = MutableStateFlow("Загрузка...")
// uiState - публичный read-only поток
val uiState: StateFlow<String> = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
delay(2000)
_uiState.value = "Данные получены!"
}
}
}
Используем функцию viewModel() для получения инстанса (она сама переживет поворот экрана) и collectAsState() для превращения потока в State.
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.collectAsState
@Composable
fun HomeScreen(
// Compose сам найдет или создаст ViewModel
viewModel: HomeViewModel = viewModel()
) {
// Подписываемся на поток. Теперь uiState - это обычная переменная String.
val uiState by viewModel.uiState.collectAsState()
Column {
Text(text = uiState)
Button(onClick = { viewModel.loadData() }) {
Text("Обновить")
}
}
}
Есть нюанс. collectAsState() подписывается на поток и слушает его, даже если приложение свернуто (но экран не уничтожен). Это тратит батарею.
Современный стандарт — использовать collectAsStateWithLifecycle().
Для этого нужна зависимость: androidx.lifecycle:lifecycle-runtime-compose.
// Вместо collectAsState()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Это гарантирует, что подписка активна, только когда приложение на переднем плане.
Создадим мини-приложение "Login".
Структура:
- Screen A (Login):
TextField(введите имя).Button("Войти").- При нажатии кнопки: если имя не пустое, переходим на экран B и передаем имя.
- Screen B (Welcome):
- Текст "Привет, {name}!".
- Кнопка "Выйти" (
popBackStack()).
План действий:
- Создай
NavHostвMainActivity. - Определи два маршрута:
"login"и"welcome/{name}". - В первом экране считывай текст из стейта и по кнопке вызывай
navController.navigate("welcome/$text"). - Во втором экране доставай аргумент и показывай его.
👀 Подсказка по навигации
// LoginScreen
Button(onClick = { navController.navigate("welcome/${nameState}") }) { ... }
// NavHost
composable("welcome/{name}") { backStackEntry ->
val name = backStackEntry.arguments?.getString("name")
WelcomeScreen(userName = name)
}
Мы разделим анимации на три уровня сложности:
- State-based: Изменение свойств (цвет, размер, поворот).
- Visibility: Появление и исчезновение элементов.
- Content: Смена одного контента другим (переходы).
А также разберем, как ловить свайпы и долгие нажатия.
Это самый простой способ (Fire-and-forget). Представь, что у тебя есть кнопка. При нажатии она должна менять цвет и размер.
@Composable
fun AnimatedBox() {
// 1. Состояние: нажата или нет
var isSelected by remember { mutableStateOf(false) }
// 2. Анимируем цвет
// Функция вернет State<Color>. Значение будет плавно меняться от Red к Green.
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) Color.Green else Color.Red,
label = "colorAnim" // Метка для дебаггера
)
// 3. Анимируем размер с настройкой физики (animationSpec)
val size by animateDpAsState(
targetValue = if (isSelected) 200.dp else 100.dp,
// tween - линейная анимация, spring - пружина (отскок)
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "sizeAnim"
)
Box(
modifier = Modifier
.size(size) // Используем анимированное значение
.background(backgroundColor) // И здесь
.clickable { isSelected = !isSelected }
)
}
tween(durationMillis = 300): Стандартная анимация "от А до Б" за заданное время.spring(): Физика пружины (магнитит к цели, может проскакивать и возвращаться).keyframes { ... }: Точечная настройка кадров.
В XML мы делали visibility = View.GONE. Элемент просто исчезал.
В Compose мы оборачиваем элемент в AnimatedVisibility.
@Composable
fun VisibilityDemo() {
var isVisible by remember { mutableStateOf(true) }
Column {
Button(onClick = { isVisible = !isVisible }) {
Text(if (isVisible) "Скрыть" else "Показать")
}
// Контейнер, который управляет появлением детей
AnimatedVisibility(
visible = isVisible,
// Настройка анимации входа
enter = slideInHorizontally() + fadeIn(),
// Настройка анимации выхода
exit = slideOutHorizontally() + fadeOut()
) {
// Этот контент будет анимирован
Box(
Modifier
.size(100.dp)
.background(Color.Blue)
)
}
}
}
Идеально для счетчиков, каруселей или переключения статусов (Loading -> Success).
@Composable
fun CounterAnimation() {
var count by remember { mutableStateOf(0) }
Column {
Button(onClick = { count++ }) { Text("Добавить") }
// targetState - то, что меняется. Когда цифра сменится, запустится анимация.
AnimatedContent(
targetState = count,
transitionSpec = {
// Логика: новая цифра выезжает снизу, старая уезжает вверх
slideInVertically { height -> height } + fadeIn() togetherWith
slideOutVertically { height -> -height } + fadeOut()
},
label = "counter"
) { targetCount ->
// Важно использовать targetCount, а не count напрямую!
Text(text = "$targetCount", fontSize = 50.sp)
}
}
}
clickable — это хорошо, но иногда нужно больше: двойной тап, долгое нажатие, перетаскивание.
Для этого используем модификатор .pointerInput.
@Composable
fun GestureBox() {
var message by remember { mutableStateOf("Нажми меня") }
Box(
modifier = Modifier
.size(100.dp)
.background(Color.LightGray)
.pointerInput(Unit) {
detectTapGestures(
onTap = { message = "Клик" },
onDoubleTap = { message = "Двойной клик!" },
onLongPress = { message = "Долгое нажатие..." }
)
},
contentAlignment = Alignment.Center
) {
Text(message)
}
}
Перетащить элемент по экрану немного сложнее, так как нам нужно менять его смещение (offset).
@Composable
fun DraggableBox() {
// Храним смещение по X и Y
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } // Применяем смещение
.size(100.dp)
.background(Color.Cyan)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume() // Сообщаем системе, что мы обработали жест
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
Классический паттерн для списков. В Material 3 есть готовый компонент.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDeleteItem() {
// Состояние свайпа (начальное, скрытое и т.д.)
val dismissState = rememberSwipeToDismissBoxState()
// Если свайпнули до конца - можно удалить элемент из списка данных
if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) {
// Тут вызываем лямбду удаления: onDelete()
}
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
// Что показывать ПОД элементом (красный фон с корзиной)
Box(
modifier = Modifier.fillMaxSize().background(Color.Red),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = null, tint = Color.White)
}
}
) {
// Сам контент списка (белая карточка)
Text("Свайпни меня влево!", modifier = Modifier.fillMaxWidth().background(Color.White).padding(16.dp))
}
}
Создай анимированную кнопку "Лайк" (сердечко). ❤️
- Создай Composable
LikeButton. - Внутри используй
Icon(сердце:Icons.Default.FavoriteилиIcons.Outlined.FavoriteBorder). - При клике меняй состояние
isLiked. - Анимация 1: Если лайк поставлен, иконка плавно меняет цвет с серого на красный (
animateColorAsState). - Анимация 2: При нажатии иконка должна "пружинить" (увеличиться и вернуться).
- Подсказка: Используй
animateDpAsStateилиanimateFloatAsState. Тебе нужно, чтобы размер на мгновение стал большим, а потом вернулся. - Продвинутый вариант: Можно использовать
AnimatableвнутриLaunchedEffectдля последовательной анимации (scale 1.0 -> 1.5 -> 1.0).
👀 Подсказка по пружинящей анимации
// Продвинутый способ через Animatable (запуск импульса)
val scale = remember { Animatable(1f) }
LaunchedEffect(isLiked) {
if (isLiked) {
// Увеличить и уменьшить
scale.animateTo(1.5f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
scale.animateTo(1f)
}
}
Icon(
modifier = Modifier.scale(scale.value)...
)
Представь: у тебя огромное приложение на Фрагментах. Ты не можешь переписать всё за неделю. Ты хочешь переписать только один экран или даже одну кнопку.
Для этого используется ComposeView. Это обычный Android View, который умеет рендерить Composable-функции.
<LinearLayout ... >
<TextView android:text="Старый заголовок XML" ... />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
class ProfileFragment : Fragment(R.layout.fragment_profile) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val composeView = view.findViewById<ComposeView>(R.id.compose_view)
// Важно! Настраиваем стратегию уничтожения, чтобы не утекла память.
// DisposeOnViewTreeLifecycleDestroyed - стандарт для фрагментов.
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
// Здесь начинается Compose. Можно использовать тему MaterialTheme.
MaterialTheme {
Text("Привет, я Compose внутри XML!")
}
}
}
}
Обратная ситуация. Тебе нужно отобразить карту (Google Maps), веб-страницу (WebView) или рекламный баннер, у которых еще нет нативной версии для Compose.
Используем AndroidView.
@Composable
fun WebPageScreen(url: String) {
AndroidView(
modifier = Modifier.fillMaxSize(),
// 1. Factory: Создание View (выполняется 1 раз)
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = WebViewClient()
loadUrl(url)
}
},
// 2. Update: Обновление View (выполняется при каждой рекомпозиции)
// Сюда попадает View, созданная в factory.
update = { webView ->
// Если url изменится, этот блок выполнится снова
if (webView.url != url) {
webView.loadUrl(url)
}
}
)
}
Важно: Блок update работает как побочный эффект. Не создавай новые объекты там, только обновляй свойства существующей View.
В старом Android (Espresso) тесты были нестабильными (flaky). В Compose тесты синхронизированы с рекомпозицией автоматически.
Тесты лежат в папке androidTest.
В build.gradle.kts:
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest") // Для Activity в тестах
Compose не использует View.findViewById. Тесты смотрят на Семантическое дерево (Semantics Tree). Это то же самое дерево, которое используют сервисы доступности (TalkBack для незрячих).
Мы ищем элементы по тексту, описанию или тегам.
Допустим, у нас есть экран авторизации.
@Composable
fun LoginScreen() {
var text by remember { mutableStateOf("") }
var result by remember { mutableStateOf("") }
Column {
TextField(
value = text,
onValueChange = { text = it },
// Добавляем тег для поиска, если нет уникального текста
modifier = Modifier.testTag("input_field")
)
Button(
onClick = { result = "Привет, $text" },
enabled = text.isNotEmpty()
) {
Text("Войти")
}
Text(result)
}
}
Тест:
class LoginTest {
// 1. Создаем правило (запускает Compose окружение)
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginFlow_correctInput_showsGreeting() {
// 2. Устанавливаем контент
composeTestRule.setContent {
LoginScreen()
}
// 3. Ищем поле ввода и печатаем текст
composeTestRule
.onNodeWithTag("input_field")
.performTextInput("User")
// 4. Ищем кнопку по тексту и нажимаем
composeTestRule
.onNodeWithText("Войти")
.assertIsEnabled() // Проверяем, что кнопка активна
.performClick()
// 5. Проверяем результат
composeTestRule
.onNodeWithText("Привет, User")
.assertIsDisplayed()
}
}
onNodeWithText("..."): Самый частый способ.onNodeWithContentDescription("..."): Для иконок/картинок.onNodeWithTag("..."): Если используешьModifier.testTag("id").
Если тест падает, и ты не понимаешь почему, используй printToLog.
composeTestRule.onRoot().printToLog("TAG")
В Logcat выведется всё дерево семантики. Ты увидишь, какие ноды видит тест и какие у них свойства.
Давай потренируемся на тестах. Возьми счетчик кликов из Главы 4 (где кнопка увеличивает число).
- Создай файл
CounterTest.ktв папкеandroidTest. - Напиши тест
counter_increments_when_clicked. - Шаги:
- Проверь, что в начале текст содержит "0".
- Нажми на кнопку.
- Проверь, что текст изменился и содержит "1".
- Нажми еще раз.
- Проверь, что стало "2".
Подсказка: Чтобы проверить частичное совпадение текста (например "Count: 0"), можно использовать onNodeWithText("Count: 0", substring = true).
🎉 Поздравляю! Ты прошел базовый и продвинутый курс (Модули 1-4).
На этом этапе ты уже Middle Jetpack Compose Developer. Ты можешь:
- Верстать любые экраны.
- Работать с данными и списками.
- Настраивать навигацию и архитектуру.
- Делать красиво (анимации, темы).
- Интегрировать это в старые проекты и писать тесты.
В Compose рисование построено на API, очень похожем на старый добрый android.graphics.Canvas, но с более удобным и идиоматичным Kotlin-синтаксисом.
Самый простой способ что-то нарисовать — использовать Composable функцию Canvas.
Она требует модификатор размера (иначе она будет 0x0) и блок onDraw.
@Composable
fun SimpleCircle() {
Canvas(modifier = Modifier.size(100.dp)) {
// Здесь мы находимся в DrawScope
// this.size - это текущий размер холста в пикселях
drawCircle(
color = Color.Red,
radius = size.minDimension / 2,
center = center // center - удобное свойство DrawScope (x=w/2, y=h/2)
)
}
}
- (0, 0) — левый верхний угол.
- X растет вправо.
- Y растет вниз.
Внутри блока Canvas нам доступен DrawScope. В нем есть методы для рисования примитивов:
drawLine— линия.drawRect/drawRoundRect— прямоугольник.drawCircle/drawOval— круг/овал.drawArc— дуга (для секторных диаграмм).drawImage— отрисовка растра.drawPath— произвольная фигура (самое мощное).
@Composable
fun Pacman() {
Canvas(modifier = Modifier.size(100.dp)) {
drawArc(
color = Color.Yellow,
startAngle = 30f, // Начало угла (0 - это 3 часа)
sweepAngle = 300f, // Длина дуги (сколько градусов закрасить)
useCenter = true, // Соединять ли концы дуги с центром (получится пирог)
topLeft = Offset.Zero,
size = this.size
)
// Рисуем глаз
drawCircle(
color = Color.Black,
radius = 4.dp.toPx(), // Конвертируем dp в пиксели
center = Offset(size.width * 0.6f, size.height * 0.25f)
)
}
}
Не обязательно создавать отдельный Canvas, чтобы нарисовать фон или декорацию. Можно рисовать прямо поверх или позади любого обычного Composable (например, Text).
Рисует позади контента (аналог background, но гибче).
Text(
text = "Подчеркнутый текст",
modifier = Modifier
.padding(16.dp)
.drawBehind {
// Рисуем линию снизу
drawLine(
color = Color.Blue,
start = Offset(0f, size.height), // Левый нижний угол
end = Offset(size.width, size.height), // Правый нижний угол
strokeWidth = 2.dp.toPx()
)
}
)
Рисует вместе с контентом. Позволяет перехватить отрисовку самого контента и, например, закрыть его чем-то или нарисовать поверх.
Image(
painter = painterResource(id = R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.drawWithContent {
// 1. Рисуем саму картинку
drawContent()
// 2. Рисуем красный крест ПОВЕРХ картинки
drawLine(
color = Color.Red,
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
strokeWidth = 5f
)
}
)
Если нужно нарисовать кривую Безье, график или кастомную форму (вейв/волну), используется Path.
@Composable
fun WaveGraph() {
Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) {
val path = Path().apply {
moveTo(0f, size.height) // Начинаем снизу слева
// Кривая кубического Безье
cubicTo(
x1 = size.width * 0.3f, y1 = size.height * 0.3f, // Первая контрольная точка
x2 = size.width * 0.7f, y2 = size.height * 0.8f, // Вторая контрольная точка
x3 = size.width, y3 = 0f // Конечная точка (верх право)
)
lineTo(size.width, size.height) // Замыкаем вниз
close() // Замыкаем к началу
}
drawPath(
path = path,
brush = Brush.verticalGradient( // Градиентная заливка!
colors = listOf(Color.Cyan, Color.Transparent)
)
)
// Рисуем обводку того же пути
drawPath(
path = path,
color = Color.Blue,
style = Stroke(width = 3.dp.toPx()) // Только контур
)
}
}
Если тебе нужно просто повернуть, увеличить или сделать прозрачным элемент, не используй Canvas. Используй graphicsLayer.
Это супер-эффективно, так как выполняется на GPU (RenderNode), не вызывая рекомпозицию.
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer {
rotationZ = 45f // Поворот
scaleX = 1.2f // Скейл
alpha = 0.5f // Прозрачность
translationY = 10f // Сдвиг
// Тень и форма
shadowElevation = 10.dp.toPx()
shape = CircleShape
clip = true
}
.background(Color.Blue)
)
Совет по оптимизации: Если ты анимируешь свойства внутри
graphicsLayer, Compose может пропустить этапы Measure и Layout и перерисовать только слой. Это максимально плавно.
Создадим Круговой индикатор прогресса (Donut Chart) с нуля.
ТЗ:
- Composable
DonutChart(progress: Float, color: Color).
progressот 0f до 1f.
- Используй
Canvas. - Нарисуй серый круг (фон) через
drawCircleсо стилемStroke. - Нарисуй дугу прогресса (
drawArc) поверх круга.
- Используй стиль
Stroke(width = ...)иCap.Round(скругленные концы линии). - Дуга должна начинаться с -90 градусов (сверху, а не справа).
- Длина дуги =
progress * 360.
Бонус: Добавь анимацию. Пусть progress анимируется через animateFloatAsState при нажатии на график.
👀 Подсказка с кодом DonutChart
@Composable
fun AnimatedDonut(targetProgress: Float) {
val animatedProgress by animateFloatAsState(
targetValue = targetProgress,
animationSpec = tween(1000)
)
Canvas(modifier = Modifier.size(200.dp)) {
val strokeWidth = 20.dp.toPx()
// 1. Серый фон
drawCircle(
color = Color.LightGray,
style = Stroke(width = strokeWidth)
)
// 2. Дуга прогресса
drawArc(
color = Color.Blue,
startAngle = -90f,
sweepAngle = animatedProgress * 360f,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
В Compose создание своего Layout — это не магия и не хак. Стандартные Column и Row написаны точно так же, как мы сейчас напишем наш лейаут.
Процесс отрисовки любого элемента состоит из трех шагов:
- Composition: "ЧТО" показывать (строим дерево UI).
- Layout (Measure & Place): "ГДЕ" показывать и "КАКОГО РАЗМЕРА".
- Drawing: "КАК" рисовать (пиксели на экране).
Мы вмешиваемся во вторую фазу. Она работает по принципу договоренности:
- Родитель -> Ребенку: "Вот твои ограничения (Constraints). Ты можешь быть от 0 до 100 пикселей в ширину".
- Ребенок -> Родителю: "Окей, я измерил свой контент, мой размер будет 50x50".
- Родитель: "Хорошо, тогда я ставлю тебя в точку (X, Y)".
Важно: В Compose (в отличие от старого Android) измерение происходит за один проход (Single Pass). Это обеспечивает высокую производительность даже при глубокой вложенности.
Самый простой способ вмешаться — использовать модификатор .layout. Он позволяет изменить размер элемента или его отступы "виртуально", не влияя на соседние элементы так, как это делает padding.
Пример: Мы хотим, чтобы элемент занимал место как обычно, но визуально был сдвинут (или увеличен), не расталкивая соседей.
fun Modifier.customOffset(x: Int, y: Int) = this.layout { measurable, constraints ->
// 1. Измеряем элемент (получаем Placeable)
// Мы просто передаем ограничения дальше без изменений
val placeable = measurable.measure(constraints)
// 2. Сообщаем наш размер (такой же, как у контента)
layout(placeable.width, placeable.height) {
// 3. Размещаем элемент со смещением
placeable.placeRelative(x, y)
}
}
Чтобы расположить группу элементов по своей логике, мы используем функцию Layout.
Анатомия Custom Layout:
@Composable
fun MyCustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
// measurables - список детей, которых нужно измерить
// constraints - ограничения от родителя (min/max width/height)
// ШАГ 1: Измеряем детей
val placeables = measurables.map { measurable ->
// Можно изменить constraints для каждого ребенка
measurable.measure(constraints)
}
// ШАГ 2: Вычисляем размер нашего контейнера
// Например, берем ширину самого широкого ребенка
val width = placeables.maxOfOrNull { it.width } ?: 0
// И сумму высот (как в Column)
val height = placeables.sumOf { it.height }
// ШАГ 3: Размещаем детей
layout(width, height) {
var yPosition = 0
placeables.forEach { placeable ->
// Ставим элемент
placeable.placeRelative(x = 0, y = yPosition)
// Сдвигаем курсор вниз для следующего
yPosition += placeable.height
}
}
}
}
Поздравляю, мы только что написали примитивный Column!
Давай сделаем что-то нестандартное. Layout, который размещает элементы по диагонали, накладывая их друг на друга (как карты в пасьянсе).
@Composable
fun CascadeLayout(
spacing: Int = 0,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
// 1. Измеряем всех детей. Ограничений не меняем.
val placeables = measurables.map { it.measure(constraints) }
// 2. Считаем размер контейнера.
// Если элементов нет - размер 0.
if (placeables.isEmpty()) {
return@Layout layout(0, 0) {}
}
// Ширина = ширина первого + сдвиги остальных
val layoutWidth = placeables.first().width + (placeables.size - 1) * spacing
// Высота = высота первого + сдвиги остальных
val layoutHeight = placeables.first().height + (placeables.size - 1) * spacing
// 3. Размещаем
layout(layoutWidth, layoutHeight) {
var x = 0
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(x, y)
x += spacing
y += spacing
}
}
}
}
Иногда родителю нужно знать размер детей до того, как он их измерит. Классическая проблема:
- У нас есть
Row. - В ней два текста. Один короткий, другой длинный (на 5 строк).
- Мы хотим поставить вертикальный разделитель (
Divider) между ними. - Какой высоты должен быть разделитель?
По умолчанию Divider не знает высоты соседей и займет 0 или fillMaxHeight. Но fillMaxHeight в Row может не сработать, если у самой Row нет фиксированной высоты.
Решение: IntrinsicSize.Min (Минимальная внутренняя высота).
Row(
// Скажи Row: "Твоя высота должна быть равна МИНИМАЛЬНОЙ высоте,
// при которой все дети влезут корректно (то есть высоте самого высокого ребенка)"
modifier = Modifier.height(IntrinsicSize.Min)
) {
Text("Короткий\nтекст", modifier = Modifier.weight(1f))
// Теперь разделитель растянется ровно по высоте текста
VerticalDivider(color = Color.Black)
Text("Очень\nдлинный\nтекст\nна\nмного строк", modifier = Modifier.weight(1f))
}
Обычный Layout измеряет всё за один проход. Но что, если размер одного элемента зависит от размера другого?
Пример: Scaffold. Ему нужно сначала измерить TopBar, узнать его высоту, и только потом передать оставшееся место под content.
Для этого используется SubcomposeLayout. Это мощно, но дорого (медленнее обычного Layout).
Используй его только тогда, когда зависимости размеров действительно сложны. Большинству разработчиков это не пригодится, но знать о нем нужно. BoxWithConstraints работает именно на нем.
Напиши свой упрощенный FlowRow (теперь он есть в стандартной библиотеке, но мы напишем свой для тренировки).
Логика:
- Принимаем элементы.
- Размещаем их в строку.
- Если элемент не влезает по ширине (
currentX + placeable.width > constraints.maxWidth) — переносим его на новую строку (y += currentRowHeight,x = 0).
Заготовка:
@Composable
fun SimpleFlowRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
// Логика расчета ширины и высоты контейнера сложная,
// для упрощения задания пусть контейнер занимает всю ширину и вычисленную высоту.
layout(constraints.maxWidth, /* посчитай высоту сам */ 1000) {
var x = 0
var y = 0
var rowHeight = 0
placeables.forEach { placeable ->
if (x + placeable.width > constraints.maxWidth) {
// Перенос строки
x = 0
y += rowHeight
rowHeight = 0
}
placeable.placeRelative(x, y)
x += placeable.width
rowHeight = maxOf(rowHeight, placeable.height)
}
}
}
}
Задача: Допиши вычисление итоговой высоты контейнера, чтобы не хардкодить 1000. (Тебе нужно будет просимулировать расстановку дважды или сохранять координаты).
Compose очень умен. Он пытается пропускать (skip) перерисовку элементов, данные которых не изменились. Но иногда мы нечаянно «мешаем» ему это делать.
Чтобы понять оптимизацию, нужно помнить, что Compose отрисовывает кадр в три этапа:
- Composition (Композиция): «ЧТО» показывать. (Выполняется код Composable-функций).
- Layout (Компоновка): «ГДЕ» показывать. (Измерение размеров и координат).
- Drawing (Отрисовка): «КАК» показывать. (Рисование пикселей на Canvas).
Золотое правило оптимизации: Старайся сдвинуть чтение состояния (State) как можно дальше по этапам.
- Если данные нужны только для цвета — читай их на этапе Drawing.
- Если только для сдвига — на этапе Layout.
- И только если меняется структура дерева (добавилась кнопка) — на этапе Composition.
Классическая ошибка: изменение цвета фона при скролле.
@Composable
fun BadScrollColor() {
val scrollState = rememberScrollState()
// МЫ ЧИТАЕМ scrollState.value ПРЯМО В ТЕЛЕ ФУНКЦИИ
// При каждом пикселе скролла вся функция BadScrollColor перезапускается!
val color = if (scrollState.value > 100) Color.Red else Color.Blue
Box(
modifier = Modifier
.fillMaxSize()
.background(color) // Применяем цвет
.verticalScroll(scrollState)
) { ... }
}
Мы передаем не цвет Color, а лямбду () -> Color. Модификатор сам вызовет её, когда будет рисовать, не вызывая рекомпозицию самой функции.
@Composable
fun GoodScrollColor() {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
// Используем drawBehind или graphicsLayer
.drawBehind {
// Читаем state только внутри блока рисования!
// Composition и Layout пропускаются. Работает только GPU.
val color = if (scrollState.value > 100) Color.Red else Color.Blue
drawRect(color)
}
.verticalScroll(scrollState)
) { ... }
}
То же самое касается смещений (offset).
- ❌
.offset(x = 10.dp)— вызывает рекомпозицию, если 10.dp — это стейт. - ✅
.offset { IntOffset(x = 10, y = 0) }— вызывает только фазу Layout.
Почему Compose иногда перерисовывает элемент, даже если данные не изменились? Всё дело в Стабильности (Stability) типов данных.
Compose делит все параметры функций на:
- Stable (Стабильные): Компилятор уверен, что если поле изменится, Compose об этом узнает (например,
State<T>) или оно вообще неизменяемо (val). - Unstable (Нестабильные): Компилятор не уверен. На всякий случай он будет перерисовывать функцию всегда.
В Kotlin List — это интерфейс. Теоретически под ним может скрываться MutableList, который кто-то изменит извне. Compose не может рисковать, поэтому считает все стандартные коллекции нестабильными.
data class User(val name: String, val tags: List<String>) // tags - Unstable!
@Composable
fun UserRow(user: User) {
// Эта функция БУДЕТ рекомпозироваться каждый раз, когда рекомпозируется родитель,
// даже если tags не менялись!
Text(user.name)
}
Способ 1: Immutable Collections (Рекомендуемый)
Используй библиотеку kotlinx.collections.immutable.
// build.gradle: implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
data class User(
val name: String,
val tags: ImmutableList<String> // Теперь это Stable!
)
Способ 2: Аннотации @Stable или @Immutable Если ты не можешь сменить тип коллекции, создай обертку и пометь её аннотацией. Ты клянешься компилятору: "Мамой клянусь, этот список не изменится без твоего ведома".
@Immutable
data class UserTags(val tags: List<String>)
@Composable
fun UserRow(tags: UserTags) { ... } // Теперь пропускается (Skippable)
Мы уже упоминали это в Главе 7, но здесь это критично. Используй это, чтобы превращать частый поток изменений (скролл, таймер) в редкий (boolean).
val listState = rememberLazyListState()
// ❌ ПЛОХО: showButton меняется true/true/true/true...
// Compose делает лишнюю работу по сравнению.
val showButton = listState.firstVisibleItemIndex > 0
// ✅ ХОРОШО: derivedStateOf уведомляет подписчиков ТОЛЬКО когда значение меняется с true на false
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Как понять, что ты накосячил?
В Android Studio: Tools -> Layout Inspector. Справа в атрибутах ты увидишь два счетчика:
- Recomposition count: Сколько раз функция была вызвана.
- Skipped count: Сколько раз Compose понял, что ничего не изменилось, и пропустил вызов.
Твоя цель: Максимизировать Skipped, минимизировать Recomposition.
Ты можешь попросить компилятор создать отчет о стабильности твоих классов.
Добавь в build.gradle.kts:
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
)
}
}
После сборки (./gradlew assembleRelease) в папке build/compose_metrics появятся файлы. В файле ...-composables.txt ищи функции, помеченные как restartable но НЕ skippable. Это твои кандидаты на оптимизацию.
Никогда не суди о производительности по Debug сборке.
В Debug режиме Compose отключает множество оптимизаций, чтобы работал Live Edit и Layout Inspector.
Всегда проверяй плавность скролла на Release сборке (minifyEnabled true). Разница может быть в 2-3 раза.
Давай "починим" нестабильный список.
- Создай
data class Team(val name: String, val members: List<String>). - Создай Composable
TeamRow(team: Team), который выводит название и количество участников. - В
MainActivityсоздай список команд и обновляй какую-то другую переменную (например, счетчик кликов) каждую секунду черезLaunchedEffect. - Запусти Layout Inspector. Ты увидишь, что
TeamRowпостоянно рекомпозируется (мигает или растет счетчик), хотя данные команды не меняются. - Задача: Сделай
membersтипомImmutableList(или оберни в класс с@Immutable). - Перезапусти и убедись в инспекторе, что теперь
TeamRowимеет статус Skipped.
В современном Android (Android 10+) стандартом считается Edge-to-Edge дизайн. Это значит, что твое приложение рисуется под прозрачным статус-баром (сверху) и навигационной полоской (снизу).
Больше никаких черных полос!
В MainActivity.kt (в onCreate):
// Требует зависимость androidx.activity:activity-enable-edge-to-edge
enableEdgeToEdge()
setContent { ... }
Теперь твой контент залезет под часы и батарейку. Это красиво, но кнопки могут стать ненажимаемыми.
Compose предоставляет объект WindowInsets, который знает размеры всех системных элементов.
Мы можем применять их как Padding через модификатор:
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Yellow) // Фон будет под статус-баром (красиво)
// А контент сдвинется вниз на безопасное расстояние
.windowInsetsPadding(WindowInsets.safeDrawing)
) {
Text("Я в безопасности!")
}
WindowInsets.statusBars: Только верхняя полоска.WindowInsets.navigationBars: Только нижняя полоска (жесты/кнопки).WindowInsets.ime: Клавиатура! (Input Method Editor).WindowInsets.safeDrawing: Комбинация всего, что может перекрыть контент (вырезы, бары).
Scaffold умеет обрабатывать инсеты автоматически через параметр contentWindowInsets.
Scaffold(
// По умолчанию Scaffold применяет инсеты, чтобы TopBar не уехал под часы.
// Можно отключить, передав WindowInsets(0,0,0,0)
contentWindowInsets = WindowInsets.safeDrawing
) { innerPadding ->
// innerPadding уже учитывает системные отступы + высоту TopBar
LazyColumn(contentPadding = innerPadding) { ... }
}
В старом Android работа с клавиатурой была адом (adjustResize).
В Compose ты можешь анимировать UI синхронно с движением клавиатуры.
// Этот Box будет плавно отъезжать вверх вместе с клавиатурой
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.ime) // Магия здесь!
) {
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.align(Alignment.BottomCenter)
)
}
Примечание: В AndroidManifest.xml у Activity должно стоять android:windowSoftInputMode="adjustResize".
Представь ситуацию: у тебя есть AnalyticsLogger. Он нужен в кнопке, которая находится на 10-м уровне вложенности.
Передавать logger через конструкторы 10 раз (Screen -> List -> Item -> Button) — это "Prop Drilling". Это ужасно.
Compose решает это через CompositionLocal. Это способ передать данные "сквозь" дерево неявно.
Мы все время этим пользовались: LocalContext.current, LocalConfiguration.current, MaterialTheme.colorScheme — это всё CompositionLocal.
1. Объявляем:
// staticCompositionLocalOf используется, если данные меняются РЕДКО (тема, логгер).
// Если данные меняются часто - используй compositionLocalOf.
val LocalAnalytics = staticCompositionLocalOf<AnalyticsLogger> {
error("Logger not provided!") // Ошибка, если забыли предоставить
}
2. Предоставляем (Provider): Где-то наверху (в MainActivity):
val logger = FirebaseAnalyticsLogger()
CompositionLocalProvider(
LocalAnalytics provides logger // Кладём объект в "туннель"
) {
// Весь код внутри имеет доступ к logger без передачи аргументов
MyApp()
}
3. Читаем: Где угодно в глубине:
@Composable
fun MyDeepButton() {
// Достаем из "туннеля"
val logger = LocalAnalytics.current
Button(onClick = { logger.logEvent("Click") }) { ... }
}
Как запросить камеру в декларативном стиле?
Мы используем rememberLauncherForActivityResult.
@Composable
fun CameraScreen() {
val context = LocalContext.current
// 1. Создаем лаунчер (обработчик результата)
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Toast.makeText(context, "Ура, камера доступна!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Печаль :(", Toast.LENGTH_SHORT).show()
}
}
Button(
onClick = {
// 2. Проверяем, есть ли уже разрешение
val permissionCheck = ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
)
if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
// Уже есть
} else {
// 3. Запускаем запрос
launcher.launch(Manifest.permission.CAMERA)
}
}
) {
Text("Открыть камеру")
}
}
Когда у тебя 10 000 товаров, LazyColumn справится с отрисовкой, но загружать всё в память (List<Item>) нельзя.
Библиотека Paging 3 идеально интегрирована в Compose.
Зависимость: androidx.paging:paging-compose
Логика PagingSource и Pager остается во ViewModel (это не касается UI).
В Compose мы получаем специальный объект LazyPagingItems.
@Composable
fun UserList(viewModel: UserViewModel) {
// collectAsLazyPagingItems превращает Flow<PagingData> в ленивые элементы
val users: LazyPagingItems<User> = viewModel.userPager.collectAsLazyPagingItems()
LazyColumn {
items(
count = users.itemCount, // Paging 3 знает примерное кол-во
key = users.itemKey { it.id }
) { index ->
val user = users[index] // Получение элемента может инициировать загрузку следующей страницы!
if (user != null) {
UserRow(user)
} else {
// Paging 3 может возвращать null, если данные еще грузятся (Placeholders)
UserPlaceholder()
}
}
// Обработка состояний (Загрузка внизу списка)
item {
if (users.loadState.append is LoadState.Loading) {
CircularProgressIndicator()
}
}
}
}
Реализуй Чат с клавиатурой. Это классическая задача на Insets.
- Включи
enableEdgeToEdge()в Activity. - Создай экран:
- Фон на весь экран.
- Сверху:
TopAppBar(убедись, что он не перекрыт часами, используйScaffoldилиstatusBarsPadding). - По центру:
LazyColumnс сообщениями (weight(1f)). - Снизу: Поле ввода (
TextField+ кнопка).
- Задача: Сделай так, чтобы при нажатии на поле ввода:
- Клавиатура плавно выезжала.
- Поле ввода поднималось вместе с клавиатурой (прилипнув к ней).
- Список сообщений тоже поджимался.
- Используй
Modifier.windowInsetsPadding(WindowInsets.ime)илиScaffold(contentWindowInsets = ...)для поля ввода.
В старом Android мы делали папки layout-land, layout-sw600dp. Это было неудобно.
В Compose мы решаем это кодом.
Google ввела стандарт адаптивности. Вместо точных пикселей мы оперируем тремя классами ширины (и высоты):
- Compact (Компактный): Телефоны в портретном режиме. (< 600dp)
- Medium (Средний): Планшеты вертикально или большие складные телефоны. (600dp - 840dp)
- Expanded (Расширенный): Планшеты горизонтально, Десктоп. (> 840dp)
В build.gradle.kts:
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
- На телефоне -> BottomNavigation (снизу).
- На планшете -> NavigationRail (слева).
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MyApp(activity: Activity) {
// Вычисляем класс размера окна для текущей Activity
val windowSize = calculateWindowSizeClass(activity)
// Определяем тип навигации
val navType = when (windowSize.widthSizeClass) {
WindowWidthSizeClass.Compact -> NavType.BOTTOM_BAR
WindowWidthSizeClass.Medium -> NavType.NAV_RAIL
WindowWidthSizeClass.Expanded -> NavType.PERMANENT_DRAWER
else -> NavType.BOTTOM_BAR
}
MainScreen(navType = navType)
}
@Composable
fun MainScreen(navType: NavType) {
Row {
if (navType == NavType.NAV_RAIL) {
NavigationRail { /* Кнопки слева */ }
}
Scaffold(
bottomBar = {
if (navType == NavType.BOTTOM_BAR) {
NavigationBar { /* Кнопки снизу */ }
}
}
) { innerPadding ->
// Контент
}
}
}
WindowSizeClass говорит о размере всего экрана. Но иногда отдельной кнопке или карточке нужно знать, сколько места у неё есть, чтобы перестроиться.
BoxWithConstraints предоставляет maxWidth и maxHeight внутри своего scope.
@Composable
fun AdaptiveCard() {
BoxWithConstraints {
// У нас есть доступ к this.maxWidth
if (maxWidth < 400.dp) {
// Мало места: Вертикальная верстка (Картинка сверху, текст снизу)
Column {
Image(...)
Text(...)
}
} else {
// Много места: Горизонтальная верстка (Картинка слева, текст справа)
Row {
Image(...)
Text(...)
}
}
}
}
Совет: Используй это экономно. Это использует
SubcomposeLayout, что чуть дороже обычного Box.
Как узнать, что экран повернули, не перезагружая Activity?
Используй LocalConfiguration.
@Composable
fun OrientationAwareContent() {
val configuration = LocalConfiguration.current
when (configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
// Показываем контент в две колонки
Row { ... }
}
else -> {
// Показываем в одну колонку
Column { ... }
}
}
}
Важно: При повороте экрана Activity пересоздается. Если ты хочешь сохранить данные (например, позицию скролла или введенный текст), используй rememberSaveable или ViewModel.
Jetpack Compose (от Google) — это часть Compose Multiplatform (от JetBrains). Это значит, что твой код на Kotlin + Compose может работать на:
- Android (Native)
- iOS (Native отрисовка через Skia, выглядит 1-в-1).
- Desktop (Windows, MacOS, Linux).
- Web (Wasm - экспериментально).
Тебе не нужно учить Swift или SwiftUI. Ты просто переносишь свой код в модуль commonMain, и он работает везде.
project/
├── commonMain/ <-- ТВОЙ COMPOSE КОД ЗДЕСЬ
│ └── App.kt (fun App() { Text("Hello World") })
├── androidMain/
│ └── MainActivity.kt (setContent { App() })
├── iosMain/
│ └── MainViewController.kt (ComposeUIViewController { App() })
└── desktopMain/
└── Main.kt (Window { App() })
Если ты освоил этот курс, ты уже на 80% готов писать под iOS на Kotlin.
Сделай своё приложение Master-Detail (классический планшетный интерфейс).
- Возьми список чатов из прошлых глав.
- Используй
calculateWindowSizeClass. - Логика:
- Если
Compact(Телефон): Показываем или список, или чат (навигация черезNavController). - Если
Expanded(Планшет/Ландшафт): Показываем И список (слева, 30% ширины), И выбранный чат (справа, 70% ширины) на одном экране.
- Протестируй:
- Запусти на эмуляторе телефона.
- Запусти на эмуляторе планшета (Pixel C).
- Поверни телефон в горизонтальный режим.
Ты прошел путь от "Hello World" до сложной архитектуры, кастомной графики и адаптивности.
✅ Мыслить декларативно (UI = f(State)).
✅ Использовать LazyColumn и LazyGrid вместо RecyclerView.
✅ Управлять состоянием через remember, ViewModel и Flow.
✅ Создавать навигацию и передавать данные.
✅ Делать кастомные анимации и жесты.
✅ Рисовать на Canvas и создавать свои Layout.
✅ Оптимизировать рекомпозицию (@Stable, derivedStateOf).
✅ Адаптировать UI под любые экраны.
- Практика: Попробуй переписать любой экран из своего старого проекта на Compose.
- Пет-проект: Напиши приложение целиком (например, клиент для API Рика и Морти или трекер привычек).
- Изучи KMP: Скачай Android Studio (версию Koala или Ladybug) и создай проект "Kotlin Multiplatform Wizard", чтобы запустить свой код на Desktop.