Site icon AppTractor

Создание адаптивных макетов в Jetpack Compose

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

Ваше приложение может работать на телефонах, планшетах, складных устройствах, ChromeOS, в режиме разделенного экрана и с изменяемым размером окон. Размер одного и того же устройства может меняться во время работы приложения. Складное устройство может открыться. Приложение для планшета может перейти в режим разделенного экрана. Телефон может повернуться.

Вот почему адаптивная компоновка не должна спрашивать:

Это устройство — планшет?

Она должна спрашивать:

Сколько места сейчас занимает окно моего приложения?

В Jetpack Compose основным инструментом для этого является WindowSizeClass. Вы можете использовать его, чтобы решить, должен ли ваш пользовательский интерфейс отображать одну панель, две панели, нижнюю панель навигации, навигационную панель или какой-либо другой макет.

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

Что мы создаём

Представьте себе простое почтовое приложение.

На телефоне это работает так:

  1. Пользователь видит список писем.
  2. Он нажимает на одно письмо.
  3. Открывается экран с подробной информацией.
  4. Он нажимает «Назад», чтобы вернуться к списку.

На планшете или большом окне это работает так:

  1. Список остаётся видимым слева.
  2. Выбранное письмо отображается справа.
  3. Пользователь может переключаться между письмами, не теряя контекста.

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

Добавьте зависимости

Вам нужен Material 3 и адаптивные библиотеки.

dependencies {
    implementation("androidx.compose.material3:material3:<version>")
    implementation("androidx.compose.material3:material3-window-size-class:<version>")

    implementation("androidx.compose.material3.adaptive:adaptive:<version>")
    implementation("androidx.compose.material3.adaptive:adaptive-layout:<version>")
    implementation("androidx.compose.material3.adaptive:adaptive-navigation:<version>")
}

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

Чтение размера окна

Адаптивная библиотека предоставляет доступ к информации о текущем окне в Compose:

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass
}

Ширину обычно можно рассматривать следующим образом:

WindowWidthSizeClass.Compact
WindowWidthSizeClass.Medium
WindowWidthSizeClass.Expanded

Простое правило таково:

val showTwoPane =
    width == WindowWidthSizeClass.Expanded &&
    height != WindowHeightSizeClass.Compact

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

Создаем модель

Мы будем использовать небольшой пример:

data class Mail(
    val id: Long,
    val sender: String,
    val subject: String,
    val body: String
)

А также некоторые фейковые данные:

private fun sampleMails(): List<Mail> = List(12) { index ->
    Mail(
        id = index.toLong(),
        sender = "Sender ${index + 1}",
        subject = "Message ${index + 1}",
        body = "This is the body for message ${index + 1}."
    )
}

Адаптивный экран

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

Важным состоянием является selectedMailId. На маленьких экранах, если оно равно null, мы показываем список. Если оно не равно null, мы показываем подробности. На больших экранах мы показываем и то, и другое.

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass

    val showTwoPane =
        width == WindowWidthSizeClass.Expanded &&
        height != WindowHeightSizeClass.Compact

    val mails = remember { sampleMails() }

    var selectedMailId by rememberSaveable {
        mutableStateOf<Long?>(null)
    }

    val selectedMail = mails.firstOrNull {
        it.id == selectedMailId
    }

    LaunchedEffect(showTwoPane) {
        if (showTwoPane && selectedMailId == null) {
            selectedMailId = mails.firstOrNull()?.id
        }
    }

    if (!showTwoPane && selectedMail != null) {
        BackHandler {
            selectedMailId = null
        }
    }

    if (showTwoPane) {
        TwoPaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            selectedMailId = selectedMailId,
            onMailSelected = { selectedMailId = it }
        )
    } else {
        SinglePaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            onMailSelected = { selectedMailId = it },
            onBack = { selectedMailId = null }
        )
    }
}

Это основа адаптивного поведения.

Пользовательскому интерфейсу не нужны отдельные экраны для телефонов и планшетов. Одно и то же состояние управляет обоими макетами.

Содержимое в одном окне

Версия с одной панелью — это макет для телефонов.

Она отображает либо список, либо подробную информацию.

@Composable
private fun SinglePaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    onMailSelected: (Long) -> Unit,
    onBack: () -> Unit
) {
    if (selectedMail == null) {
        MailList(
            mails = mails,
            selectedMailId = null,
            onMailSelected = onMailSelected,
            modifier = Modifier.fillMaxSize()
        )
    } else {
        MailDetail(
            mail = selectedMail,
            showBackButton = true,
            onBack = onBack,
            modifier = Modifier.fillMaxSize()
        )
    }
}

Это упрощает работу с телефоном. Нет необходимости отображать крошечную панель с подробной информацией рядом со списком.

Содержимое в двух окнах

Версия с двумя панелями предназначена для больших экранов.

@Composable
private fun TwoPaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    selectedMailId: Long?,
    onMailSelected: (Long) -> Unit
) {
    Row(Modifier.fillMaxSize()) {
        MailList(
            mails = mails,
            selectedMailId = selectedMailId,
            onMailSelected = onMailSelected,
            modifier = Modifier.weight(0.4f)
        )

        VerticalDivider()

        MailDetail(
            mail = selectedMail,
            showBackButton = false,
            onBack = null,
            modifier = Modifier.weight(0.6f)
        )
    }
}

Список занимает 40% ширины. Подробная информация занимает 60%.

Это не правило. Это просто хорошая отправная точка. Для приложений с большим количеством контента панели с подробной информацией может потребоваться больше места. Для приложений с большим количеством списков список может потребовать больше места.

Список

Вот список писем:

@Composable
private fun MailList(
    mails: List<Mail>,
    selectedMailId: Long?,
    onMailSelected: (Long) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier.padding(8.dp)
    ) {
        items(
            items = mails,
            key = { it.id }
        ) { mail ->
            val selected = mail.id == selectedMailId

            ListItem(
                headlineContent = {
                    Text(mail.subject)
                },
                supportingContent = {
                    Text(mail.sender)
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        onMailSelected(mail.id)
                    },
                colors = ListItemDefaults.colors(
                    containerColor = if (selected) {
                        MaterialTheme.colorScheme.secondaryContainer
                    } else {
                        Color.Transparent
                    }
                )
            )
        }
    }
}

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

Экран с подробной информацией

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

@Composable
private fun MailDetail(
    mail: Mail?,
    showBackButton: Boolean,
    onBack: (() -> Unit)?,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.padding(24.dp)
    ) {
        if (showBackButton && onBack != null) {
            TextButton(onClick = onBack) {
                Text("Back")
            }

            Spacer(Modifier.height(8.dp))
        }

        if (mail == null) {
            Text(
                text = "Select an email",
                style = MaterialTheme.typography.titleMedium
            )
            return
        }

        Text(
            text = mail.subject,
            style = MaterialTheme.typography.headlineSmall
        )

        Spacer(Modifier.height(8.dp))

        Text(
            text = "From: ${mail.sender}",
            style = MaterialTheme.typography.labelLarge
        )

        Spacer(Modifier.height(16.dp))

        Text(
            text = mail.body,
            style = MaterialTheme.typography.bodyLarge
        )
    }
}

Обратите внимание, что кнопка «Назад» необязательна. На телефонах она необходима. На больших экранах она не нужна, поскольку список и так уже виден.

Добавление адаптивной навигации

Тот же принцип применим и к навигации в приложении.

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

Простая версия выглядит так:

@Composable
fun AdaptiveAppShell(
    useBottomBar: Boolean,
    content: @Composable Modifier.() -> Unit
) {
    if (useBottomBar) {
        Scaffold(
            bottomBar = {
                NavigationBar {
                    NavigationBarItem(
                        selected = true,
                        onClick = {},
                        icon = { Icon(Icons.Default.Home, null) },
                        label = { Text("Home") }
                    )
                }
            }
        ) { padding ->
            Box(
                modifier = Modifier
                    .padding(padding)
                    .content()
            )
        }
    } else {
        Row(Modifier.fillMaxSize()) {
            NavigationRail {
                NavigationRailItem(
                    selected = true,
                    onClick = {},
                    icon = { Icon(Icons.Default.Home, null) },
                    label = { Text("Home") }
                )
            }

            Box(
                modifier = Modifier
                    .weight(1f)
                    .content()
            )
        }
    }
}

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

Когда использовать официальные адаптивные шаблоны

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

Для приложений, предназначенных для продакшена, следует рассмотреть адаптивные шаблоны Material 3:

ListDetailPaneScaffold
NavigableListDetailPaneScaffold
NavigationSuiteScaffold

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

Используйте NavigableListDetailPaneScaffold, когда:

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

Распространенные ошибки

Первая ошибка — проверка типа устройства.

if (isTablet) {
    // show tablet UI
}

Это ненадежно. На планшете ваше приложение может работать в маленьком окне с разделенным экраном. На складном устройстве размер окна может меняться. Телефон может быть в альбомной ориентации. Важен текущий размер окна.

Вторая ошибка — проверка только ширины.

val showTwoPane = width == WindowWidthSizeClass.Expanded

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

val showTwoPane =
    width == WindowWidthSizeClass.Expanded &&
    height != WindowHeightSizeClass.Compact

Третья ошибка — это растягивание пользовательского интерфейса телефона.

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

Четвертая ошибка — это дублирование слишком большого количества элементов интерфейса.

Обычно вам не нужен отдельный экран для телефона и планшета. Сохраняйте то же состояние и используйте одни и те же элементы списка/подробной информации. Меняйте только их расположение.

Заключительные мысли

Адаптивная компоновка — это не поддержка планшетов как отдельная функция. Это создание интерфейса, который реагирует на имеющееся в данный момент пространство.

Основной принцип прост:

val showTwoPane =
    width == WindowWidthSizeClass.Expanded &&
    height != WindowHeightSizeClass.Compact

Далее, храните состояние в корневом экране, повторно используйте композабл и изменяйте только структуру макета.

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

 

Exit mobile version