Привет, Хабр!
На связи Глеб Гутник, мобильный разработчик из компании 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()
.
Разложим поэтапно, что происходит, когда пользователь тапает по полю ввода:
-
Событие клика распространяется по дереву 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
, чтобы добиться желаемого эффекта.
ссылка на оригинал статьи https://habr.com/ru/articles/845124/
Добавить комментарий