Skip to content

Instantly share code, notes, and snippets.

@dmitry-osin
Created December 24, 2025 14:00
Show Gist options
  • Select an option

  • Save dmitry-osin/cb4d0b06dac8b670287cc7153433f76c to your computer and use it in GitHub Desktop.

Select an option

Save dmitry-osin/cb4d0b06dac8b670287cc7153433f76c to your computer and use it in GitHub Desktop.
Jetpack Compose от нуля до эксперта на русском

🚀 Полный курс Jetpack Compose: От основ до эксперта

🟢 Модуль 1: Фундамент (Basics)

Глава 1. Введение и Философия

  • Смена парадигмы: Декларативный UI vs Императивный (XML). Как мыслить "состояниями".
  • Анатомия @Composable: Правила написания функций.
  • Preview: Инструменты предпросмотра, конфигурации устройств, @PreviewParameter.
  • Setup: Настройка Gradle, версии Kotlin и Compiler Plugin.

Глава 2. UI Элементы и Модификаторы

  • Базовые компоненты: Text, Button, Image, Icon, TextField.
  • Система Модификаторов:
  • Цепочки вызовов: почему порядок важен.
  • Визуальные изменения (background, border, clip).
  • Лейаут-модификаторы (padding, size, fillMaxSize).
  • Scoping (почему align доступен не везде).

Глава 3. Компоновка (Layouts)

  • Стандартные контейнеры: Column, Row, Box.
  • Позиционирование: Arrangement (распределение) и Alignment (выравнивание).
  • Scaffold: Структура экрана (TopBar, BottomBar, FAB, Drawer).
  • ConstraintLayout: Сложные связи элементов без вложенности.

🟡 Модуль 2: Данные и Логика (State & Data)

Глава 4. Управление состоянием (State)

  • Концепция State: MutableState, State.
  • remember: Сохранение данных между рекомпозициями.
  • Рекомпозиция: Жизненный цикл UI. Интеллектуальное обновление дерева.
  • State Hoisting: Паттерн "Поднятия состояния" (Unidirectional Data Flow).
  • rememberSaveable: Выживание при повороте экрана и смерти процесса.

Глава 5. Списки и Коллекции

  • Lazy Lists: LazyColumn и LazyRow (аналоги RecyclerView).
  • Grids: LazyVerticalGrid и Staggered Grids.
  • Оптимизация: Использование key, contentType.
  • Декорации: Sticky Headers, Item Decorations.

Глава 6. Архитектура и Навигация

  • Navigation Compose: NavHost, Graph, переходы между экранами.
  • Передача данных: Аргументы навигации (примитивы и Parcelable).
  • MVVM в Compose: Связь с ViewModel, collectAsStateWithLifecycle.
  • Hilt/Koin: Внедрение зависимостей в Composable.

🔵 Модуль 3: Визуал и Интерактивность (UX & Design)

Глава 7. Темизация (Material Design 3)

  • Material 3: ColorScheme, Typography, Shapes.
  • Dynamic Colors: Подстройка под обои пользователя.
  • Темная тема: Реализация и переключение.
  • Ресурсы: Безопасная работа со строками, картинками и шрифтами.

Глава 8. Эффекты и Жизненный цикл (Side Effects)

  • Side Effects API: LaunchedEffect, DisposableEffect, SideEffect.
  • Асинхронность: Запуск корутин из UI.
  • State derived: derivedStateOf и rememberUpdatedState.
  • Жизненный цикл: LifecycleEventObserver внутри Compose.

Глава 9. Анимации и Жесты

  • High-level API: animate*AsState, AnimatedVisibility, AnimatedContent.
  • Transition API: Сложные сценарные анимации.
  • Жесты: Tap, DoubleTap, LongPress, Drag & Drop, SwipeToDismiss.

🟣 Модуль 4: Deep Dive (Продвинутый уровень)

Глава 10. Interop и Тестирование

  • Legacy: Compose внутри XML (ComposeView) и XML внутри Compose (AndroidView).
  • UI Testing: ComposeTestRule, поиск нод (Semantics), матчеры, действия.
  • Screenshot Testing: Краткий обзор.

Глава 11. Рисование и Графика (Custom Drawing)

  • Canvas: Рисование примитивов (линии, дуги, пути).
  • DrawScope & DrawModifier: Рисование поверх или позади контента.
  • GraphicsLayer: Трансформации, альфа, аппаратное ускорение.
  • Shaders & Brush: Градиенты и визуальные эффекты.

Глава 12. Кастомные Лейауты (Custom Layouts)

  • Modifier.layout: Ручное измерение и размещение.
  • Layout Composable: Создание своего контейнера с нуля.
  • SubcomposeLayout: Зависимые измерения.
  • Intrinsics: Предварительный расчет размеров.

🔴 Модуль 5: Эксперт и Продакшн (Expert)

Глава 13. Производительность (Performance)

  • Stability: Понятия @Stable и @Immutable. Почему Compose пропускает классы.
  • Метрики: Отчеты компилятора Compose, Layout Inspector.
  • Debug: Поиск лишних рекомпозиций.
  • Best Practices: Отложенное чтение состояния (Lambda reads).

Глава 14. Системная интеграция

  • WindowInsets: Работа с Edge-to-Edge (вырезы, клавиатура, статус-бар).
  • CompositionLocal: Неявная передача данных, создание своих провайдеров.
  • Permissions: Запрос разрешений в стиле Compose.
  • Paging 3: Пагинация больших данных.

Глава 15. Адаптивность и Будущее

  • Adaptive UI: BoxWithConstraints, WindowSizeClasses.
  • Смена конфигурации: Реакция на смену локали, шрифтов системы.
  • Compose Multiplatform: Обзор возможностей (Android, iOS, Desktop, Web).

🟢 Глава 1. Основы и философия Jetpack Compose

В этой главе мы разберем, почему Google отказался от XML, напишем первую функцию и настроим окружение.

1.1. Смена парадигмы: Императивный vs Декларативный UI

Раньше (XML) мы работали в императивном стиле. Мы создавали дерево объектов (View), а потом изменяли их вручную.

  • Пример: «Найди TextView по ID. Установи ему текст "Привет". Сделай кнопку неактивной».
  • Проблема: Со временем трудно отследить, кто и в каком порядке изменил кнопку. Ошибки рассинхронизации UI и данных — классика Android.

Compose — это декларативный UI.

  • Принцип: Ты не меняешь UI. Ты описываешь, как UI должен выглядеть для текущего состояния данных.
  • Формула: UI = f(State) (Интерфейс есть функция от состояния).
  • Когда данные меняются, Compose не «ищет и меняет» кнопку. Он полностью перерисовывает (рекомпозирует) ту часть экрана, которая зависит от этих данных.

Важно: В Compose нет setText(), setVisibility(), setImage(). Вы просто вызываете функцию снова с новыми параметрами.

1.2. Настройка проекта (Gradle & BOM)

Для работы с 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")
}

1.3. Первая @Composable функция

В Compose функции — это строительные блоки (аналог View в XML). Чтобы обычная функция Kotlin стала элементом UI, нужно добавить аннотацию @Composable.

Правила Composable функций:

  1. Аннотация: Обязательно @Composable.
  2. Именование: Всегда PascalCase (Существительное), как классы.
  • Правильно: Greeting, ProfileScreen, BlueButton.
  • Неправильно: getGreeting, drawProfile, blueButton.
  • Почему: Потому что мы описываем объект/сущность, а не действие.
  1. Возвращаемый тип: Обычно 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!")
}

1.4. SetContent: Точка входа

В 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 = "Студент")
            }
        }
    }
}

1.5. @Preview: Предпросмотр без эмулятора

Одна из киллер-фич 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")
}

Полезные параметры @Preview:

  • showSystemUi = true: покажет статус-бар и кнопки навигации.
  • device = "id:pixel_5": покажет, как это выглядит на конкретном телефоне.
  • uiMode: можно включить темную тему (Dark Mode).

💡 Практическое задание к Главе 1

  1. Создай новый проект в Android Studio -> Empty Activity (убедись, что иконка Compose присутствует).
  2. Открой MainActivity.kt.
  3. Напиши функцию @Composable fun UserCard(userName: String), которая выводит текст с именем.
  4. Создай @Preview для этой функции.
  5. Попробуй изменить текст в коде и нажми Build & Refresh (или используй Live Edit, если включен), чтобы увидеть изменения в окне превью справа.

Итог главы: Мы узнали, что Compose — это описание интерфейса через функции. Мы не управляем состоянием View, мы просто говорим: «Нарисуй это с такими данными».


🟢 Глава 2. Фундаментальные элементы UI и Магия Модификаторов

В XML у каждого View (TextView, ImageView) был свой огромный набор атрибутов (android:textColor, android:padding, android:layout_width). В Compose подход другой:

  1. Composables — это просто функции, рисующие контент (Текст, Картинка).
  2. Modifiers — это универсальный способ изменить размер, фон, отступы и поведение любого элемента.

2.1. Базовые компоненты (Кирпичики)

Давай разберем самые частые элементы.

A. Text (Текст)

Самый простой элемент.

Text(
    text = "Привет, Compose!",
    color = Color.Blue, // Цвет текста
    fontSize = 20.sp,   // Размер шрифта (используй .sp для текста!)
    fontWeight = FontWeight.Bold, // Жирность
    maxLines = 1,       // Ограничение по строкам
    overflow = TextOverflow.Ellipsis // Троеточие, если не влезает (...)
)

B. Button (Кнопка)

Кнопка — это контейнер. Она принимает onClick (действие) и content (что внутри кнопки). Внутри кнопки обычно лежит Text, но может лежать и Row с иконкой.

Button(
    onClick = { /* Действие по клику, например Log.d(...) */ },
    enabled = true, // Активна или нет
    // colors = ButtonDefaults.buttonColors(...) // Можно переопределить цвета
) {
    // Внутри лямбды мы описываем содержимое кнопки
    Text("Нажми меня")
}

Разновидности: OutlinedButton (с обводкой), TextButton (прозрачная, для диалогов).

C. Image и Icon (Картинки)

  • 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"
)

D. TextField (Поле ввода)

Пока просто запомни синтаксис. В Compose поле ввода не хранит текст внутри себя (в отличие от EditText). Мы должны передать ему текст снаружи. Подробнее разберем в главе про State.

TextField(
    value = "Текущий текст",
    onValueChange = { newText -> /* Тут мы должны обновить переменную */ },
    label = { Text("Введите имя") }
)

2.2. Система Модификаторов (Modifiers)

Modifier — это сердце верстки в Compose. Это цепочка команд, которая говорит элементу, как он должен выглядеть и вести себя.

Почти каждая Composable функция принимает параметр modifier.

Самые важные модификаторы:

  1. Размеры:
  • .width(100.dp), .height(50.dp), .size(100.dp)
  • .fillMaxWidth() — занять всю ширину (аналог match_parent).
  • .fillMaxSize() — занять всё доступное место.
  1. Оформление:
  • .background(Color.Green) — цвет фона.
  • .border(2.dp, Color.Red) — рамка.
  • .clip(RoundedCornerShape(8.dp)) — скруглить углы (обрезать контент по форме).
  • .alpha(0.5f) — прозрачность.
  1. Отступы:
  • .padding(16.dp) — внутренний отступ (аналог и padding, и margin в зависимости от порядка).
  1. Действия:
  • .clickable { ... } — сделать элемент кликабельным (добавляет эффект ряби/ripple).

Пример использования:

Text(
    text = "Стильный текст",
    modifier = Modifier
        .padding(16.dp) // Отступ снаружи
        .background(Color.LightGray) // Серый фон
        .padding(8.dp) // Отступ внутри (от фона до текста)
        .clickable { /* Клик */ }
)

2.3. Порядок имеет значение! (Критически важно)

Это самая частая ошибка новичков. В 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) // Добавили внутренний отступ для содержимого
    )
}

Совет: Читайте модификаторы сверху вниз как инструкцию: «Возьми элемент -> Сделай отступ -> Закрась фон -> Сделай еще отступ -> Сделай кликабельным».


2.4. Scope-specific модификаторы (Контекстные)

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

Например, модификатор .align() нельзя применить к Text просто так. Text должен находиться внутри контейнера, который знает, как выравнивать.

  • Внутри Box: доступен .align(Alignment.Center)
  • Внутри Column: доступен .align(Alignment.End) (горизонтально)
  • Внутри Row: доступен .align(Alignment.CenterVertically)

Мы детально разберем это в следующей главе про Layouts.


💡 Практическое задание к Главе 2

Давай создадим красивую кнопку-карточку, используя знания этой главы.

  1. Создай Composable функцию StyledCard.
  2. Используй 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) // Внутренний отступ от краев до текста

🟢 Глава 3. Компоновка (Layouts): Строим структуру

Главное правило верстки в Compose: Вложенность — это нормально! В отличие от XML, где глубокая вложенность (Nesting hell) убивала производительность, в Compose вложенные Row и Column работают очень быстро. Не бойся вкладывать их друг в друга.

3.1. Святая троица: Column, Row, Box

A. Column (Столбец)

Располагает элементы вертикально (сверху вниз). Аналог LinearLayout с orientation="vertical".

Column {
    Text("Заголовок")
    Text("Описание")
    Button(onClick = {}) { Text("Ок") }
}

B. Row (Строка)

Располагает элементы горизонтально (слева направо). Аналог LinearLayout с orientation="horizontal".

Row {
    Icon(Icons.Default.Call, contentDescription = null)
    Text("Позвонить")
}

C. Box (Коробка / Стопка)

Располагает элементы друг поверх друга (по оси Z). Аналог FrameLayout. Первый элемент — внизу, последний — наверху. Идеально для:

  • Текста поверх картинки.
  • Кнопки "Закрыть" в углу карточки.
  • Загрузчика (CircularProgressIndicator) поверх всего экрана.
Box {
    Image(painter = painterResource(R.drawable.bg), contentDescription = null)
    Text("Текст поверх картинки", color = Color.White)
}

3.2. Выравнивание: Оси и Arrangement vs Alignment

Это то место, где новички часто путаются. У Column и Row есть две оси:

  1. Main Axis (Главная ось): Направление, куда идут элементы (Вертикаль для Column, Горизонталь для Row).
  2. Cross Axis (Поперечная ось): Перпендикулярное направление.

У нас есть два параметра для настройки:

  • Arrangement (Распределение): Управляет элементами вдоль Главной оси. (Как раскидать элементы?)
  • Alignment (Выравнивание): Управляет элементами вдоль Поперечной оси. (Прижать к краю или по центру?)

Для Column (Главная = Вертикаль):

  • verticalArrangement: Top (по умолчанию), Center, Bottom, SpaceBetween (равномерно), SpaceAround.
  • horizontalAlignment: Start (по умолчанию), CenterHorizontally, End.

Для Row (Главная = Горизонталь):

  • horizontalArrangement: Start (по умолчанию), Center, End, SpaceBetween...
  • verticalAlignment: Top (по умолчанию), CenterVertically, Bottom.

Пример сложной верстки:

Column(
    modifier = Modifier.fillMaxSize(), // Колонка на весь экран
    verticalArrangement = Arrangement.Center, // Контент по центру вертикали
    horizontalAlignment = Alignment.CenterHorizontally // Контент по центру горизонтали
) {
    Text("Я в самом центре экрана!")
}

3.3. Вес элементов (Weight)

Как сказать элементу: «Займи всё оставшееся пространство» или «Поделите ширину пополам»? Используем модификатор .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("Право")
}

3.4. Scaffold: Скелет экрана

Обычно экран приложения состоит из стандартных частей: верхняя панель (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("Нажми кнопку внизу")
    }
}

💡 Практическое задание к Главе 3

Давай сверстаем карточку чата (как в Telegram/WhatsApp). Это классическая задача на Row и Column.

ТЗ:

  1. Контейнер Row (весь элемент).
  2. Слева: Аватарка (круглый Box или Image размером 50.dp).
  3. По центру: Column с Именем (жирный) и Последним сообщением (серый). Эта колонка должна занимать вес (weight(1f)), чтобы толкать время вправо.
  4. Справа: Время сообщения (мелкий текст).

Код-шпаргалка (попробуй сначала сам!):

👀 Показать решение
@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)
    }
}

🟡 Глава 4. Управление состоянием (State) и Рекомпозиция

Формула Compose: UI = f(State). Интерфейс — это результат выполнения функции от текущих данных. Если данные меняются, функция выполняется заново.

4.1. Почему обычные переменные не работают?

Давай попробуем сделать счетчик кликов классическим способом программиста.

@Composable
fun BrokenCounter() {
    // ❌ ЭТО НЕ БУДЕТ РАБОТАТЬ
    var count = 0 

    Button(onClick = { 
        count++ // Мы меняем переменную...
        println("Count is $count") // В логах число растет!
    }) {
        Text("Количество нажатий: $count") // ...но UI всегда показывает 0
    }
}

Почему UI не обновляется?

  1. Compose не следит за обычными переменными (var). Он не знает, что count изменился.
  2. Даже если бы знал, при перерисовке (рекомпозиции) функция BrokenCounter вызвалсь бы снова. А внутри неё строчка var count = 0 сбросила бы всё в ноль.

4.2. Решение: MutableState и remember

Чтобы Compose «видел» изменения, мы используем специальные обертки State. Чтобы Compose «помнил» значение между перерисовками, мы используем remember.

Шаг 1: MutableState

Это коробка, за которой следит Compose. Если значение внутри коробки меняется, Compose запускает Рекомпозицию (перерисовку) всех функций, которые читают эту коробку.

Шаг 2: remember

Функция 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".

4.3. Что такое Рекомпозиция?

Рекомпозиция — это процесс повторного вызова твоей Composable-функции с новыми данными.

  1. Ты нажал кнопку.
  2. count изменился с 0 на 1.
  3. Compose увидел, что State изменился.
  4. Compose ищет, кто читал этот State. Это функция WorkingCounter.
  5. Compose запускает WorkingCounter заново.
  6. remember возвращает уже сохраненную 1.
  7. Text получает строку "Количество нажатий: 1".
  8. Экран обновляется.

Это происходит молниеносно. Compose умен: он перерисовывает только то, что реально изменилось (Intelligent Recomposition).


4.4. State Hoisting (Поднятие состояния)

Это важнейший архитектурный паттерн. Представь, что у тебя есть экран, и на нем два компонента:

  1. Текст «Всего кликов: X»
  2. Кнопка «Добавить»

Они находятся в разных местах кода, но зависят от одной переменной. Где хранить State?

Правило: Состояние должно храниться у наименьшего общего родителя.

Мы «поднимаем» (hoist) состояние из кнопки вверх к родителю.

  • Родитель: Хранит состояние.
  • Ребенок: Принимает состояние как параметр (Read-only) и принимает функцию (Callback) для запроса изменений.

Пример State Hoisting:

// 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: События текут вверх.

4.5. rememberSaveable: Выживаем при повороте

Попробуй запустить 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")
    }
}

💡 Практическое задание к Главе 4

Создай простое приложение "Конвертер валют" (упрощенно).

  1. Создай функцию CurrencyConverter.
  2. Внутри создай состояние для введенной суммы в рублях: var rubles by remember { mutableStateOf("") }.
  3. Размести Column.
  4. Внутри:
  • TextField:

  • value = rubles

  • onValueChange = { rubles = it } (обновляем стейт при вводе).

  • Text: который показывает сумму в долларах. (Логика: если строка не пустая, переводим в Double, делим на 90.0, иначе показываем 0).

Подсказка для конвертации:

val dollars = rubles.toDoubleOrNull()?.div(90.0) ?: 0.0
Text("В долларах: $dollars")

🟡 Глава 5. Списки и Данные (Lists)

В Compose есть два основных подхода к спискам:

  1. Column + verticalScroll: Загружает ВСЕ элементы сразу. Подходит для маленьких списков (настройки, профиль), которые точно влезут в память.
  2. LazyColumn / LazyRow: Загружает только то, что видно на экране + небольшой буфер. Аналог RecyclerView. Используется для списков данных (чаты, лента новостей, контакты).

Мы сосредоточимся на Lazy (Ленивых) списках.

5.1. Прощай, RecyclerView! Привет, LazyColumn.

Допустим, у нас есть простой дата-класс:

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). Когда ты скроллишь вниз, верхние элементы уничтожаются (или переиспользуются), а нижние создаются.

5.2. ContentPadding и Spacing

В 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) // Вынесли отрисовку в отдельную функцию
    }
}

5.3. Оптимизация: Магия ключей (keys)

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

По умолчанию Compose определяет элементы по их позиции в списке (индексу).

  • Проблема: Если ты удалишь 1-й элемент, все остальные сдвинутся вверх. Compose подумает, что все элементы изменились, и может перерисовать весь список или потерять состояние (например, введенный текст в TextField внутри элемента).
  • Решение: Явно указать уникальный ключ (ID) для каждого элемента.
LazyColumn {
    items(
        items = messages,
        key = { message -> message.id } // Уникальный ID из нашей модели данных
    ) { message ->
        MessageCard(message)
    }
}

Теперь, если мы удалим элемент с id=5, Compose поймет это и просто уберет его, не трогая остальные. Это работает намного быстрее (аналог DiffUtil).

5.4. Разные типы ячеек (Heterogeneous lists)

В RecyclerView для этого нужно было переопределять getItemViewType. В Compose мы просто пишем разные функции внутри блока LazyColumn.

LazyColumn {
    // 1. Одиночный элемент (Заголовок)
    item {
        Text("Мои сообщения", style = MaterialTheme.typography.headlineLarge)
    }

    // 2. Список элементов
    items(messages) { message ->
        MessageCard(message)
    }
    
    // 3. Футер (Подвал)
    item {
        Text("Конец списка", color = Color.Gray)
    }
}

5.5. Сетки (Grids)

Нужна галерея фоток? Используем 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)
            )
        }
    }
}

5.6. Интерактивность (Обработка кликов)

Вспоминаем Главу 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")
        }
    }
}

5.7. Липкие заголовки (Sticky Headers)

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

@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))
            }
        }
    }
}

💡 Практическое задание к Главе 5

Давай сделаем простой список покупок.

  1. Создай data class ShoppingItem(val id: Int, val name: String, var isChecked: Boolean).
  • Заметка: Чтобы isChecked обновлял UI внутри элемента, само поле в классе должно быть MutableState или (лучше) весь список должен перерисовываться. Для простоты сейчас: используй val isChecked: Boolean и создавай копию объекта при клике (data.copy(isChecked = !isChecked)).
  1. В MainActivity создай список товаров через remember { mutableStateListOf(...) }.
  2. Используй LazyColumn.
  3. В каждом элементе (Row) сделай Checkbox (стандартный компонент) и Text.
  4. При клике на чекбокс (или на строку) меняй состояние элемента в списке.
  5. (Дополнительно) Сделай кнопку FAB (FloatingActionButton) в Scaffold, которая добавляет случайный товар в список. Список должен автоматически проскроллиться или просто показать новый элемент.

🎨 Глава 6. Темизация и Стилизация (Design System)

В XML мы мучились с styles.xml, themes.xml, атрибутами ?attr/colorPrimary и перезагрузкой Activity при смене темы. В Compose темизация — это просто передача параметров в дерево функций.

Когда ты создаешь новый проект, Android Studio генерирует папку ui/theme. В ней лежит вся магия.

6.1. Структура MaterialTheme

Основа всего дизайна — обертка 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), получает доступ к этим настройкам.


6.2. Цвета (ColorScheme)

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")
}

Dynamic Colors (Динамические цвета)

В Android 12+ появилась фича, когда цвета приложения подстраиваются под обои пользователя. В файле Theme.kt ты увидишь код:

val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
if (dynamicColor && context != null) {
    dynamicDarkColorScheme(context)
} else ...

Это позволяет твоему приложению выглядеть "родным" в системе.


6.3. Типографика (Typography)

Не задавай 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 для всей шкалы.


6.4. Формы (Shapes)

Определяют скругление углов (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)
) { ... }

6.5. Работа с ресурсами (Res)

Хотя мы пишем на Kotlin, строковые ресурсы и картинки все еще лежат в папке res. Compose предоставляет удобные функции для доступа к ним.

Строки (Strings)

Обязательно для локализации (перевода на другие языки).

// Вместо "Hello" пишем:
Text(text = stringResource(id = R.string.hello_text))

// С параметрами (в strings.xml: "Привет, %s!"):
Text(text = stringResource(id = R.string.greeting, "Иван"))

Картинки (Drawables)

// Растр (png, jpg)
Image(
    painter = painterResource(id = R.drawable.my_cat),
    contentDescription = null
)

// Вектор (xml)
Icon(
    painter = painterResource(id = R.drawable.ic_settings), // Своя иконка
    contentDescription = null
)

6.6. Темная тема (Dark Mode) — это просто!

Если ты везде использовал MaterialTheme.colorScheme...:

  1. background
  2. onBackground
  3. surface ...то поддержка темной темы у тебя есть автоматически.

Тебе не нужно писать if (isDark) Color.White else Color.Black. Система сама подставит нужную палитру из Theme.kt.

Как проверить в Preview?

Создай два превью для одного экрана:

@Preview(name = "Light Mode")
@Preview(
    name = "Dark Mode",
    uiMode = Configuration.UI_MODE_NIGHT_YES, // Включаем ночь
    showBackground = true
)
@Composable
fun ScreenPreview() {
    MyAppTheme { // Важно обернуть в тему!
        Surface {
            // Твой экран
            ProfileScreen()
        }
    }
}

💡 Практическое задание к Главе 6

Возьми свой список покупок или чат из прошлых заданий и сделай "Рефакторинг стиля".

  1. Убери все Color.Black, Color.White, Color.Gray.
  2. Замени их на MaterialTheme.colorScheme.onBackground, MaterialTheme.colorScheme.surface и т.д.
  3. Для вторичного текста (например, времени сообщения) используй цвет текста с прозрачностью или специальный токен, например MaterialTheme.colorScheme.onSurfaceVariant.
  4. Запусти приложение на эмуляторе.
  5. В шторке эмулятора включи Dark Theme.
  6. Если твое приложение автоматически перекрасилось в красивые темные цвета и текст остался читаемым — ты справился!

🟣 Глава 7. Эффекты и Жизненный цикл (Side Effects)

В чем проблема?

Composable функции должны быть Side-effect free (без побочных эффектов). Это значит, что функция должна только превращать данные в UI. Она не должна:

  • Запускать таймеры.
  • Делать сетевые запросы.
  • Менять глобальные переменные.
  • Подписываться на сенсоры.

Почему? Потому что Compose может запускать твою функцию 100 раз в секунду (например, при анимации) или вообще прервать ее выполнение на полпути. Если ты напишешь Log.d("Tag", "Hello") прямо в теле функции, твой лог засорится тысячами сообщений.

Для безопасного выполнения таких действий существуют Effect Handlers.


7.1. LaunchedEffect: Запуск корутин

Используется, когда нужно запустить асинхронную задачу (корутину) внутри тела Composable.

  • Как работает: Запускает блок кода, когда элемент появляется на экране (входит в композицию).
  • Очистка: Автоматически отменяет корутину, если элемент уходит с экрана.
  • Ключи (Keys): Если ключ (параметр) меняется, эффект перезапускается.

Пример 1: Таймер (Run Once)

@Composable
fun SplashScreen(onTimeout: () -> Unit) {
    // Unit - это константа. Значит, эффект запустится 1 раз при старте.
    LaunchedEffect(key1 = Unit) {
        delay(3000) // Ждем 3 секунды (suspend функция)
        onTimeout() // Уходим с экрана
    }
    
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("Загрузка...")
    }
}

Пример 2: Сетевой запрос при изменении ID

@Composable
fun UserProfile(userId: String, viewModel: UserViewModel) {
    // Если userId изменится, старый запрос отменится, и запустится новый!
    LaunchedEffect(key1 = userId) {
        viewModel.loadUserData(userId)
    }
    
    // UI ...
}

7.2. rememberCoroutineScope: Корутины по клику

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

7.3. DisposableEffect: Очистка ресурсов

Некоторые вещи требуют не просто отмены (как корутины), а явной очистки (unregister/close). Например: слушатели сенсоров, Analytics, BroadcastReceiver или Lifecycle Observer.

У DisposableEffect есть обязательный блок onDispose, который срабатывает, когда элемент удаляется из дерева UI.

Пример: Отслеживание Жизненного Цикла Activity

@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)
        }
    }
}

7.4. derivedStateOf: Оптимизация производительности

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

Если мы будем слушать 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") }
        }
    }
}

7.5. rememberUpdatedState

Сложная, но важная тема. Представь, что у тебя есть LaunchedEffect, который работает долго (например, таймер на 10 секунд). И ты передал в него лямбду onTimeout.

Если во время работы таймера родитель перерисуется и передаст новую лямбду onTimeout, LaunchedEffect не узнает об этом (он не перезапустится, если мы не укажем лямбду как ключ key1). Но если мы укажем её как ключ, таймер сбросится!

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

@Composable
fun LandingScreen(onTimeout: () -> Unit) {
    // Всегда хранит самую свежую версию функции onTimeout
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(5000)
        // Вызываем именно свежую версию, даже если LandingScreen рекомпозировался
        currentOnTimeout() 
    }
}

💡 Практическое задание к Главе 7

Давай сделаем мини-таймер обратного отсчета с кнопкой запуска.

ТЗ:

  1. Переменная timeLeft (Int), изначально 10.
  2. Переменная isRunning (Boolean), изначально false.
  3. Кнопка "Start", которая меняет isRunning на true.
  4. Используй LaunchedEffect(key1 = isRunning).
  • Внутри: if (isRunning).
  • Цикл: while (timeLeft > 0).
  • delay(1000).
  • timeLeft--.
  1. Когда таймер доходит до 0, isRunning должен стать false.

Вопрос на засыпку: Что произойдет, если нажать "Start", а потом, пока таймер тикает, повернуть экран? (Подсказка: вспомни про rememberSaveable из главы 4).

Попробуй реализовать это. Если получится — ты освоил управление временем в Compose!


🔵 Глава 8. Навигация и Архитектура (MVVM)

8.1. Настройка зависимостей

Навигация не входит в 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")
}

8.2. Основные компоненты

Система состоит из трех частей:

  1. NavController: «Водитель». Объект, который управляет перемещением, хранит стек экранов (BackStack).
  2. NavHost: «Парковка». Контейнер в UI, где отображается текущий экран.
  3. 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).


8.3. Передача аргументов

Как передать 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")

8.4. Архитектура MVVM в Compose

Где хранить логику? В ViewModel. Google рекомендует использовать StateFlow для передачи состояния из ViewModel в UI.

Шаг 1: ViewModel

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 = "Данные получены!"
        }
    }
}

Шаг 2: Связь с UI

Используем функцию 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("Обновить")
        }
    }
}

8.5. Жизненный цикл и потоки (Best Practice)

Есть нюанс. collectAsState() подписывается на поток и слушает его, даже если приложение свернуто (но экран не уничтожен). Это тратит батарею.

Современный стандарт — использовать collectAsStateWithLifecycle(). Для этого нужна зависимость: androidx.lifecycle:lifecycle-runtime-compose.

// Вместо collectAsState()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Это гарантирует, что подписка активна, только когда приложение на переднем плане.


💡 Практическое задание к Главе 8

Создадим мини-приложение "Login".

Структура:

  1. Screen A (Login):
  • TextField (введите имя).
  • Button ("Войти").
  • При нажатии кнопки: если имя не пустое, переходим на экран B и передаем имя.
  1. Screen B (Welcome):
  • Текст "Привет, {name}!".
  • Кнопка "Выйти" (popBackStack()).

План действий:

  1. Создай NavHost в MainActivity.
  2. Определи два маршрута: "login" и "welcome/{name}".
  3. В первом экране считывай текст из стейта и по кнопке вызывай navController.navigate("welcome/$text").
  4. Во втором экране доставай аргумент и показывай его.
👀 Подсказка по навигации
// LoginScreen
Button(onClick = { navController.navigate("welcome/${nameState}") }) { ... }

// NavHost
composable("welcome/{name}") { backStackEntry ->
    val name = backStackEntry.arguments?.getString("name")
    WelcomeScreen(userName = name)
}

🟣 Глава 9. Анимации и Жесты

Мы разделим анимации на три уровня сложности:

  1. State-based: Изменение свойств (цвет, размер, поворот).
  2. Visibility: Появление и исчезновение элементов.
  3. Content: Смена одного контента другим (переходы).

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

9.1. animate*AsState: Анимация свойств

Это самый простой способ (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 }
    )
}

Виды AnimationSpec:

  • tween(durationMillis = 300): Стандартная анимация "от А до Б" за заданное время.
  • spring(): Физика пружины (магнитит к цели, может проскакивать и возвращаться).
  • keyframes { ... }: Точечная настройка кадров.

9.2. AnimatedVisibility: Появление и исчезновение

В 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)
            )
        }
    }
}

9.3. AnimatedContent: Смена содержимого

Идеально для счетчиков, каруселей или переключения статусов (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)
        }
    }
}

9.4. Жесты (Gestures)

clickable — это хорошо, но иногда нужно больше: двойной тап, долгое нажатие, перетаскивание.

Для этого используем модификатор .pointerInput.

Tap, DoubleTap, LongPress

@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)
    }
}

Drag (Перетаскивание)

Перетащить элемент по экрану немного сложнее, так как нам нужно менять его смещение (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
                }
            }
    )
}

9.5. SwipeToDismiss (Свайп для удаления)

Классический паттерн для списков. В 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))
    }
}

💡 Практическое задание к Главе 9

Создай анимированную кнопку "Лайк" (сердечко). ❤️

  1. Создай Composable LikeButton.
  2. Внутри используй Icon (сердце: Icons.Default.Favorite или Icons.Outlined.FavoriteBorder).
  3. При клике меняй состояние isLiked.
  4. Анимация 1: Если лайк поставлен, иконка плавно меняет цвет с серого на красный (animateColorAsState).
  5. Анимация 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)...
)

🟣 Глава 10. Взаимодействие с Legacy (Interop) и Тестирование

10.1. Compose внутри XML (Миграция)

Представь: у тебя огромное приложение на Фрагментах. Ты не можешь переписать всё за неделю. Ты хочешь переписать только один экран или даже одну кнопку.

Для этого используется ComposeView. Это обычный Android View, который умеет рендерить Composable-функции.

Шаг 1. Добавляем в XML-layout

<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>

Шаг 2. Инициализируем во Фрагменте

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!")
            }
        }
    }
}

10.2. XML внутри Compose (AndroidView)

Обратная ситуация. Тебе нужно отобразить карту (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.


10.3. UI Тестирование в Compose

В старом Android (Espresso) тесты были нестабильными (flaky). В Compose тесты синхронизированы с рекомпозицией автоматически.

Настройка

Тесты лежат в папке androidTest. В build.gradle.kts:

androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest") // Для Activity в тестах

Философия: Semantics Tree

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()
    }
}

Как искать элементы (Matchers):

  • onNodeWithText("..."): Самый частый способ.
  • onNodeWithContentDescription("..."): Для иконок/картинок.
  • onNodeWithTag("..."): Если используешь Modifier.testTag("id").

Отладка тестов

Если тест падает, и ты не понимаешь почему, используй printToLog.

composeTestRule.onRoot().printToLog("TAG")

В Logcat выведется всё дерево семантики. Ты увидишь, какие ноды видит тест и какие у них свойства.


💡 Практическое задание к Главе 10

Давай потренируемся на тестах. Возьми счетчик кликов из Главы 4 (где кнопка увеличивает число).

  1. Создай файл CounterTest.kt в папке androidTest.
  2. Напиши тест counter_increments_when_clicked.
  3. Шаги:
  • Проверь, что в начале текст содержит "0".
  • Нажми на кнопку.
  • Проверь, что текст изменился и содержит "1".
  • Нажми еще раз.
  • Проверь, что стало "2".

Подсказка: Чтобы проверить частичное совпадение текста (например "Count: 0"), можно использовать onNodeWithText("Count: 0", substring = true).


🎉 Поздравляю! Ты прошел базовый и продвинутый курс (Модули 1-4).

На этом этапе ты уже Middle Jetpack Compose Developer. Ты можешь:

  1. Верстать любые экраны.
  2. Работать с данными и списками.
  3. Настраивать навигацию и архитектуру.
  4. Делать красиво (анимации, темы).
  5. Интегрировать это в старые проекты и писать тесты.

🔴 Глава 11. Рисование и Графика (Custom Drawing)

В Compose рисование построено на API, очень похожем на старый добрый android.graphics.Canvas, но с более удобным и идиоматичным Kotlin-синтаксисом.

11.1. Canvas: Чистый холст

Самый простой способ что-то нарисовать — использовать 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 растет вниз.

11.2. DrawScope: Инструменты художника

Внутри блока Canvas нам доступен DrawScope. В нем есть методы для рисования примитивов:

  1. drawLine — линия.
  2. drawRect / drawRoundRect — прямоугольник.
  3. drawCircle / drawOval — круг/овал.
  4. drawArc — дуга (для секторных диаграмм).
  5. drawImage — отрисовка растра.
  6. drawPath — произвольная фигура (самое мощное).

Пример: Рисуем Pacman-а

@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)
        )
    }
}

11.3. Modifier.drawBehind и drawWithContent

Не обязательно создавать отдельный Canvas, чтобы нарисовать фон или декорацию. Можно рисовать прямо поверх или позади любого обычного Composable (например, Text).

Modifier.drawBehind

Рисует позади контента (аналог 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()
            )
        }
)

Modifier.drawWithContent

Рисует вместе с контентом. Позволяет перехватить отрисовку самого контента и, например, закрыть его чем-то или нарисовать поверх.

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
            )
        }
)

11.4. Path: Рисуем сложные фигуры

Если нужно нарисовать кривую Безье, график или кастомную форму (вейв/волну), используется 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()) // Только контур
        )
    }
}

11.5. GraphicsLayer: Трансформации

Если тебе нужно просто повернуть, увеличить или сделать прозрачным элемент, не используй 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 и перерисовать только слой. Это максимально плавно.


💡 Практическое задание к Главе 11

Создадим Круговой индикатор прогресса (Donut Chart) с нуля.

ТЗ:

  1. Composable DonutChart(progress: Float, color: Color).
  • progress от 0f до 1f.
  1. Используй Canvas.
  2. Нарисуй серый круг (фон) через drawCircle со стилем Stroke.
  3. Нарисуй дугу прогресса (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)
        )
    }
}

🔴 Глава 12. Пользовательская верстка (Custom Layouts)

В Compose создание своего Layout — это не магия и не хак. Стандартные Column и Row написаны точно так же, как мы сейчас напишем наш лейаут.

12.1. Как работает Layout? (Фазы)

Процесс отрисовки любого элемента состоит из трех шагов:

  1. Composition: "ЧТО" показывать (строим дерево UI).
  2. Layout (Measure & Place): "ГДЕ" показывать и "КАКОГО РАЗМЕРА".
  3. Drawing: "КАК" рисовать (пиксели на экране).

Мы вмешиваемся во вторую фазу. Она работает по принципу договоренности:

  1. Родитель -> Ребенку: "Вот твои ограничения (Constraints). Ты можешь быть от 0 до 100 пикселей в ширину".
  2. Ребенок -> Родителю: "Окей, я измерил свой контент, мой размер будет 50x50".
  3. Родитель: "Хорошо, тогда я ставлю тебя в точку (X, Y)".

Важно: В Compose (в отличие от старого Android) измерение происходит за один проход (Single Pass). Это обеспечивает высокую производительность даже при глубокой вложенности.


12.2. Modifier.layout: Изменяем один элемент

Самый простой способ вмешаться — использовать модификатор .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)
    }
}

12.3. Composable Layout: Создаем свой контейнер

Чтобы расположить группу элементов по своей логике, мы используем функцию 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!


12.4. Пример: CascadeLayout (Лесенка)

Давай сделаем что-то нестандартное. 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
            }
        }
    }
}

12.5. Intrinsics (Интринсики)

Иногда родителю нужно знать размер детей до того, как он их измерит. Классическая проблема:

  • У нас есть 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))
}

12.6. SubcomposeLayout (Продвинутый уровень)

Обычный Layout измеряет всё за один проход. Но что, если размер одного элемента зависит от размера другого? Пример: Scaffold. Ему нужно сначала измерить TopBar, узнать его высоту, и только потом передать оставшееся место под content.

Для этого используется SubcomposeLayout. Это мощно, но дорого (медленнее обычного Layout). Используй его только тогда, когда зависимости размеров действительно сложны. Большинству разработчиков это не пригодится, но знать о нем нужно. BoxWithConstraints работает именно на нем.


💡 Практическое задание к Главе 12

Напиши свой упрощенный FlowRow (теперь он есть в стандартной библиотеке, но мы напишем свой для тренировки).

Логика:

  1. Принимаем элементы.
  2. Размещаем их в строку.
  3. Если элемент не влезает по ширине (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. (Тебе нужно будет просимулировать расстановку дважды или сохранять координаты).


🔴 Глава 13. Производительность и Оптимизация

Compose очень умен. Он пытается пропускать (skip) перерисовку элементов, данные которых не изменились. Но иногда мы нечаянно «мешаем» ему это делать.

13.1. Три фазы кадра

Чтобы понять оптимизацию, нужно помнить, что Compose отрисовывает кадр в три этапа:

  1. Composition (Композиция): «ЧТО» показывать. (Выполняется код Composable-функций).
  2. Layout (Компоновка): «ГДЕ» показывать. (Измерение размеров и координат).
  3. Drawing (Отрисовка): «КАК» показывать. (Рисование пикселей на Canvas).

Золотое правило оптимизации: Старайся сдвинуть чтение состояния (State) как можно дальше по этапам.

  • Если данные нужны только для цвета — читай их на этапе Drawing.
  • Если только для сдвига — на этапе Layout.
  • И только если меняется структура дерева (добавилась кнопка) — на этапе Composition.

13.2. Defer State Reads (Отложенное чтение)

Классическая ошибка: изменение цвета фона при скролле.

❌ Плохо: Рекомпозиция всего экрана

@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)
    ) { ... }
}

✅ Хорошо: Чтение только в фазе Drawing

Мы передаем не цвет 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.

13.3. Stability: Магия пропуска (Skipping)

Почему Compose иногда перерисовывает элемент, даже если данные не изменились? Всё дело в Стабильности (Stability) типов данных.

Compose делит все параметры функций на:

  1. Stable (Стабильные): Компилятор уверен, что если поле изменится, Compose об этом узнает (например, State<T>) или оно вообще неизменяемо (val).
  2. Unstable (Нестабильные): Компилятор не уверен. На всякий случай он будет перерисовывать функцию всегда.

Враг №1: Коллекции (List, Set, Map)

В 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)

13.4. derivedStateOf

Мы уже упоминали это в Главе 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 }
}

13.5. Инструменты отладки

Как понять, что ты накосячил?

A. Layout Inspector

В Android Studio: Tools -> Layout Inspector. Справа в атрибутах ты увидишь два счетчика:

  • Recomposition count: Сколько раз функция была вызвана.
  • Skipped count: Сколько раз Compose понял, что ничего не изменилось, и пропустил вызов.

Твоя цель: Максимизировать Skipped, минимизировать Recomposition.

B. Compose Compiler Metrics

Ты можешь попросить компилятор создать отчет о стабильности твоих классов. Добавь в 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. Это твои кандидаты на оптимизацию.


13.6. R8 и Release build

Никогда не суди о производительности по Debug сборке. В Debug режиме Compose отключает множество оптимизаций, чтобы работал Live Edit и Layout Inspector. Всегда проверяй плавность скролла на Release сборке (minifyEnabled true). Разница может быть в 2-3 раза.


💡 Практическое задание к Главе 13

Давай "починим" нестабильный список.

  1. Создай data class Team(val name: String, val members: List<String>).
  2. Создай Composable TeamRow(team: Team), который выводит название и количество участников.
  3. В MainActivity создай список команд и обновляй какую-то другую переменную (например, счетчик кликов) каждую секунду через LaunchedEffect.
  4. Запусти Layout Inspector. Ты увидишь, что TeamRow постоянно рекомпозируется (мигает или растет счетчик), хотя данные команды не меняются.
  5. Задача: Сделай members типом ImmutableList (или оберни в класс с @Immutable).
  6. Перезапусти и убедись в инспекторе, что теперь TeamRow имеет статус Skipped.

🔴 Глава 14. Системная интеграция и Hardware

14.1. Edge-to-Edge (От края до края) и WindowInsets

В современном Android (Android 10+) стандартом считается Edge-to-Edge дизайн. Это значит, что твое приложение рисуется под прозрачным статус-баром (сверху) и навигационной полоской (снизу).

Больше никаких черных полос!

Шаг 1. Включаем Edge-to-Edge

В MainActivity.ktonCreate):

// Требует зависимость androidx.activity:activity-enable-edge-to-edge
enableEdgeToEdge() 

setContent { ... }

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

Шаг 2. Обрабатываем Insets (Отступы)

Compose предоставляет объект WindowInsets, который знает размеры всех системных элементов.

Мы можем применять их как Padding через модификатор:

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.Yellow) // Фон будет под статус-баром (красиво)
        // А контент сдвинется вниз на безопасное расстояние
        .windowInsetsPadding(WindowInsets.safeDrawing) 
) {
    Text("Я в безопасности!")
}

Виды Insets:

  • WindowInsets.statusBars: Только верхняя полоска.
  • WindowInsets.navigationBars: Только нижняя полоска (жесты/кнопки).
  • WindowInsets.ime: Клавиатура! (Input Method Editor).
  • WindowInsets.safeDrawing: Комбинация всего, что может перекрыть контент (вырезы, бары).

Scaffold и Insets

Scaffold умеет обрабатывать инсеты автоматически через параметр contentWindowInsets.

Scaffold(
    // По умолчанию Scaffold применяет инсеты, чтобы TopBar не уехал под часы.
    // Можно отключить, передав WindowInsets(0,0,0,0)
    contentWindowInsets = WindowInsets.safeDrawing 
) { innerPadding ->
    // innerPadding уже учитывает системные отступы + высоту TopBar
    LazyColumn(contentPadding = innerPadding) { ... }
}

14.2. Клавиатура (IME Animation)

В старом 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".


14.3. CompositionLocal: Неявная передача данных

Представь ситуацию: у тебя есть AnalyticsLogger. Он нужен в кнопке, которая находится на 10-м уровне вложенности. Передавать logger через конструкторы 10 раз (Screen -> List -> Item -> Button) — это "Prop Drilling". Это ужасно.

Compose решает это через CompositionLocal. Это способ передать данные "сквозь" дерево неявно.

Мы все время этим пользовались: LocalContext.current, LocalConfiguration.current, MaterialTheme.colorScheme — это всё CompositionLocal.

Как создать свой Local?

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") }) { ... }
}

14.4. Permissions (Разрешения)

Как запросить камеру в декларативном стиле? Мы используем 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("Открыть камеру")
    }
}

14.5. Paging 3: Бесконечные списки

Когда у тебя 10 000 товаров, LazyColumn справится с отрисовкой, но загружать всё в память (List<Item>) нельзя. Библиотека Paging 3 идеально интегрирована в Compose.

Зависимость: androidx.paging:paging-compose

Использование в UI:

Логика 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()
            }
        }
    }
}

💡 Практическое задание к Главе 14

Реализуй Чат с клавиатурой. Это классическая задача на Insets.

  1. Включи enableEdgeToEdge() в Activity.
  2. Создай экран:
  • Фон на весь экран.
  • Сверху: TopAppBar (убедись, что он не перекрыт часами, используй Scaffold или statusBarsPadding).
  • По центру: LazyColumn с сообщениями (weight(1f)).
  • Снизу: Поле ввода (TextField + кнопка).
  1. Задача: Сделай так, чтобы при нажатии на поле ввода:
  • Клавиатура плавно выезжала.
  • Поле ввода поднималось вместе с клавиатурой (прилипнув к ней).
  • Список сообщений тоже поджимался.
  1. Используй Modifier.windowInsetsPadding(WindowInsets.ime) или Scaffold(contentWindowInsets = ...) для поля ввода.

🔴 Глава 15. Адаптивность и Мультиплатформа

В старом Android мы делали папки layout-land, layout-sw600dp. Это было неудобно. В Compose мы решаем это кодом.

15.1. Window Size Classes (Классы размеров окна)

Google ввела стандарт адаптивности. Вместо точных пикселей мы оперируем тремя классами ширины (и высоты):

  1. Compact (Компактный): Телефоны в портретном режиме. (< 600dp)
  2. Medium (Средний): Планшеты вертикально или большие складные телефоны. (600dp - 840dp)
  3. 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 ->
            // Контент
        }
    }
}

15.2. BoxWithConstraints: Локальная адаптивность

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

BoxWithConstraints предоставляет maxWidth и maxHeight внутри своего scope.

@Composable
fun AdaptiveCard() {
    BoxWithConstraints {
        // У нас есть доступ к this.maxWidth
        if (maxWidth < 400.dp) {
            // Мало места: Вертикальная верстка (Картинка сверху, текст снизу)
            Column {
                Image(...)
                Text(...)
            }
        } else {
            // Много места: Горизонтальная верстка (Картинка слева, текст справа)
            Row {
                Image(...)
                Text(...)
            }
        }
    }
}

Совет: Используй это экономно. Это использует SubcomposeLayout, что чуть дороже обычного Box.


15.3. Обработка поворота экрана (Configuration Changes)

Как узнать, что экран повернули, не перезагружая Activity? Используй LocalConfiguration.

@Composable
fun OrientationAwareContent() {
    val configuration = LocalConfiguration.current
    
    when (configuration.orientation) {
        Configuration.ORIENTATION_LANDSCAPE -> {
            // Показываем контент в две колонки
            Row { ... }
        }
        else -> {
            // Показываем в одну колонку
            Column { ... }
        }
    }
}

Важно: При повороте экрана Activity пересоздается. Если ты хочешь сохранить данные (например, позицию скролла или введенный текст), используй rememberSaveable или ViewModel.


15.4. Compose Multiplatform (Бонус)

Jetpack Compose (от Google) — это часть Compose Multiplatform (от JetBrains). Это значит, что твой код на Kotlin + Compose может работать на:

  1. Android (Native)
  2. iOS (Native отрисовка через Skia, выглядит 1-в-1).
  3. Desktop (Windows, MacOS, Linux).
  4. Web (Wasm - экспериментально).

Тебе не нужно учить Swift или SwiftUI. Ты просто переносишь свой код в модуль commonMain, и он работает везде.

Пример (псевдокод структуры KMP):

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 (классический планшетный интерфейс).

  1. Возьми список чатов из прошлых глав.
  2. Используй calculateWindowSizeClass.
  3. Логика:
  • Если Compact (Телефон): Показываем или список, или чат (навигация через NavController).
  • Если Expanded (Планшет/Ландшафт): Показываем И список (слева, 30% ширины), И выбранный чат (справа, 70% ширины) на одном экране.
  1. Протестируй:
  • Запусти на эмуляторе телефона.
  • Запусти на эмуляторе планшета (Pixel C).
  • Поверни телефон в горизонтальный режим.

🎓 Курс завершен!

Ты прошел путь от "Hello World" до сложной архитектуры, кастомной графики и адаптивности.

Что ты теперь умеешь (Чек-лист Senior Compose Dev):

✅ Мыслить декларативно (UI = f(State)). ✅ Использовать LazyColumn и LazyGrid вместо RecyclerView. ✅ Управлять состоянием через remember, ViewModel и Flow. ✅ Создавать навигацию и передавать данные. ✅ Делать кастомные анимации и жесты. ✅ Рисовать на Canvas и создавать свои Layout. ✅ Оптимизировать рекомпозицию (@Stable, derivedStateOf). ✅ Адаптировать UI под любые экраны.

Что делать дальше?

  1. Практика: Попробуй переписать любой экран из своего старого проекта на Compose.
  2. Пет-проект: Напиши приложение целиком (например, клиент для API Рика и Морти или трекер привычек).
  3. Изучи KMP: Скачай Android Studio (версию Koala или Ladybug) и создай проект "Kotlin Multiplatform Wizard", чтобы запустить свой код на Desktop.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment