Взаимодействие с клавиатурой в Compose: особенности и подводные камни

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

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

61d32538ddedd06651d708e12f90de08.png

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(val previous: T?, val current: T)

// emits null, History(null,1), History(1,2)...
fun  Flow.runningHistory(): Flow> =
    runningFold(
        initial = null as (History?),
        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.

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

Полный код, написанный для этой статьи, доступен здесь: https://github.com/gleb-skobinsky/AdaptiveComponents

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

© Habrahabr.ru