Взаимодействие с клавиатурой в 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
. С ней пришлось довольно изрядно поэкспериментировать, но результат оказался вполне удовлетворительным.
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()
.
Разложим поэтапно, что происходит, когда пользователь тапает по полю ввода:
Событие клика распространяется по дереву UI-компонентов. Наш метод модификатора (pointerInput) отслеживает это событие, если клавиатура в данный момент закрыта, и складывает координату клика в переменную (clickData.offset).
Область, на которую наведен фокус, меняется, вызывается коллбэк onFocusBoundsChanged, который складывает событие клика в focusedAreaEvent.
На каждое событие 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
, чтобы добиться желаемого эффекта.