Привет, Хабр!

На связи Глеб Гутник, мобильный разработчик из компании xStack. В этой статье мы рассмотрим, как можно эффективно кастомизировать взаимодействие с клавиатурой в Jetpack Compose и Compose Multiplatform для создания комфортного UX.

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

На Android для этого предусмотрен флаг android:windowSoftInputMode="adjustResize", но он сжимает окно приложения без учета анимации клавиатуры, поэтому пользователь видит пустое поле долю секунды, пока клавиатура открывается:

Интерфейс до открытия клавиатуры
Интерфейс до открытия клавиатуры

Интерфейс в момент, когда фокус на поле уже наведен, но клавиатура только начала открываться
Интерфейс в момент, когда фокус на поле уже наведен, но клавиатура только начала открываться

Чтобы исправить это поведение, разработчики Jetpack Compose предложили новый способ взаимодействия с клавиатурой: edge-to-edge режим в сочетании с Modifier.imePadding().

Скрытый текст

Важно: в Android Modifier.imePadding() работает только при обязательном выполнении двух условий: флаг adjustResize в AndroidManifest и enableEdgeToEdge() в коллбэке onCreate() в нашем Activity.

Итак, применим этот модификатор к основной Column нашей формы:

Column(
    Modifier
        .padding(horizontal = 32.dp)
        .imePadding()
        .verticalScroll(rememberScrollState())
) {
    Spacer(Modifier.height(it.calculateTopPadding()))
    32.dp.VerticalSpacer()
    AccountIcon()
    32.dp.VerticalSpacer()
    Text(
        text = stringResource(Res.string.create_your_account),
        style = MaterialTheme.typography.titleLarge,
        fontSize = 32.sp,
        modifier = Modifier.align(Alignment.CenterHorizontally)
    )
    // ...
}

Теперь поведение уже намного лучше: интерфейс плавно адаптируется под открывающуюся клавиатуру, а если она перекрывает поле ввода, то выделенное поле плавно «едет» вверх ровно настолько, на сколько нужно:

Казалось бы, это то, чего мы добивались. Однако если мы захотим «пошарить» наш интерфейс на iOS и перенести приложение на Compose Multiplatform, возникнет новый нюанс: в новых версиях iOS клавиатура полупрозрачная с типичным для Apple размытием по Гауссу. Но наш Modifier.imePadding() — это именно отступ (разработчики библиотеки нам не врут в нейминге метода), поэтому интерфейс формы не будет «просвечивать» под полупрозрачной клавиатурой:

К сожалению, «коробочных» методов решения этой проблемы в Compose не предусмотрено. Чтобы наш интерфейс выглядел более нативно, напишем собственную обертку, которая будет привязывать состояние скролла к высоте клавиатуры. Я назвал ее ImeAdaptiveColumn. С ней пришлось довольно изрядно поэкспериментировать, но результат оказался вполне удовлетворительным.

class FocusedAreaEvent {
    var id: String by mutableStateOf("")
    var rect: Rect? by mutableStateOf(null)
    var spaceFromBottom: Float? by mutableStateOf(null)
}

class FocusedArea {
    var rect: Rect? = null
}

data class History<T>(val previous: T?, val current: T)

// emits null, History(null,1), History(1,2)...
fun <T> Flow<T>.runningHistory(): Flow<History<T>> =
    runningFold(
        initial = null as (History<T>?),
        operation = { accumulator, new -> History(accumulator?.current, new) }
    ).filterNotNull()

data class ClickData(
    val unconsumed: Boolean = true,
    val offset: Offset = Offset.Zero
)

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImeAdaptiveColumn(
    scrollState: ScrollState = rememberScrollState(),
    scrollable: Boolean = true,
    modifier: Modifier = Modifier,
    horizontalPadding: Dp = 16.dp,
    content: @Composable ColumnScope.() -> Unit
) {
    val screenHeight = LocalScreenSize.height
    val imeHeight by rememberUpdatedState(imeHeight())

    var clickData by remember { mutableStateOf(ClickData()) }
    val focusedAreaEvent = remember { FocusedAreaEvent() }
    val focusedArea = remember { FocusedArea() }
    LaunchedEffect(
        key1 = focusedAreaEvent.id
    ) {
        if (focusedAreaEvent.id.isNotEmpty()) {
            focusedAreaEvent.spaceFromBottom?.let { capturedBottom ->
                snapshotFlow { imeHeight }
                    .runningHistory()
                    .collectLatest { (prev, height) ->
                        val prevHeight = prev ?: 0
                        if (height > capturedBottom) {
                            if (prevHeight < capturedBottom) {
                                val difference = height - capturedBottom
                                scrollState.scrollBy(difference)
                            } else {
                                val difference = height - prevHeight
                                scrollState.scrollBy(difference.toFloat())
                            }
                        } else {
                            if (prevHeight > capturedBottom) {
                                val difference = prevHeight - capturedBottom
                                scrollState.scrollBy(-difference)
                            }
                        }
                    }
            }
        }
    }

    Column(
        modifier = modifier
            .onFocusedBoundsChanged { coordinates ->
                coordinates?.boundsInWindow()?.let {
                    focusedArea.rect = it
                    if (clickData.unconsumed && clickData.offset in it) {
                        focusedAreaEvent.run {
                            id = uuid()
                            rect = it
                            spaceFromBottom = screenHeight - it.bottom
                        }
                        clickData = clickData.copy(unconsumed = false)
                    }
                }
            }
            .pointerInput(Unit) {
                awaitEachGesture {
                    val event = awaitPointerEvent(PointerEventPass.Main)
                    // If the software keyboard is hidden, register a new focused area.
                    if (event.type == PointerEventType.Press && imeHeight == 0) {
                        val offset = event.changes.firstOrNull()?.position ?: Offset.Zero
                        clickData = ClickData(
                            unconsumed = true,
                            offset = offset
                        )
                    }
                }
            }
            .background(MaterialTheme.colorScheme.surface)
            .padding(horizontal = horizontalPadding)
            .verticalScroll(scrollState, enabled = scrollable),
        content = content
    )
}

@Composable
fun imeHeight() = WindowInsets.ime.getBottom(LocalDensity.current)

Чтоздесь происходит? Я ставил себе цель написать такое Composable API, которое подстраивалось бы под клавиатуру независимо от содержимого. Иначе говоря, это такой «content‑agnostic» компонент, когда пользователю кода не нужно ничего дополнительного вызывать на своей стороне, все происходит под капотом.

Например, можно теперь вызвать ImeAdaptiveColumn таким образом:

ImeAdaptiveColumn(horizontalPadding = 32.dp) {
    Spacer(Modifier.height(it.calculateTopPadding()))
    32.dp.VerticalSpacer()
    AccountIcon()
    32.dp.VerticalSpacer()
    Text(
        text = stringResource(Res.string.create_your_account),
        style = MaterialTheme.typography.titleLarge,
        fontSize = 32.sp,
        modifier = Modifier.align(Alignment.CenterHorizontally)
    )
    32.dp.VerticalSpacer()
    SignupFormTextField(
        label = stringResource(Res.string.first_name_title),
        placeholder = stringResource(Res.string.first_name_placeholder)
    )
    // ...
}

Такого удобства удается достичь за счет двух главных API, доступных в Compose: Modifier.pointerInput() и Modifier.onFocusBoundsChanged().

Разложим поэтапно, что происходит, когда пользователь тапает по полю ввода:

  1. Событие клика распространяется по дереву UI‑компонентов. Наш метод модификатора (pointerInput) отслеживает это событие, если клавиатура в данный момент закрыта, и складывает координату клика в переменную (clickData.offset).

  2. Область, на которую наведен фокус, меняется, вызывается коллбэк onFocusBoundsChanged, который складывает событие клика в focusedAreaEvent.

  3. На каждое событие focusedAreaEvent запускается LaunchedEffect, который отслеживает изменения в высоте клавиатуры и скроллит нашу Column, если эта высота перекрывает полученные в focusedAreaEvent координаты.

Таким образом, мы получаем такой результат на iOS (который лучше всего виден, если включить темную тему). Первое видео — интерфейс с обычным imePadding, второе — с ImeAdaptiveColumn.

Этот не слишком замысловатый прием лишний раз показывает, насколько технологии, рассчитанные под одну платформу (Android), по‑другому ведут себя, если перенести их на другую (iOS). И все же мне кажется, что одна такая небольшая уловка стоит того, чтобы приблизить user experience в Compose Multiplatform к нативным технологиям iOS.

Скрытый текст

Полный код, написанный для этой статьи, доступен здесь.

Отмечу вместо постскриптума, что с iOS частью необходимо использовать ignoreSafeArea(.all) и OnFocusBehavior.DoNothing, чтобы добиться желаемого эффекта.

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