Взаимодействие с клавиатурой в 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<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.

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/845124/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *