Реалізація програмної клавіатури для Android засобами Compose
Привіт! Мене звати Максим, я Android-розробник. Наразі працюю над власним застосунком і розглядаю можливості для нових професійних викликів.
Під час роботи над власним проєктом для Android, графічна частина якого розроблялася за допомогою Compose, в мене виникла ідея покращення досвіду взаємодії користувача з застосунком. Вона полягала в тому, щоб полегшити введення специфічних символів та дій над ними для Android-застосунку в одній із його частин. Та була реалізована створенням власної програмної клавіатури засобами Compose через те, що вся графічна частина була вже побудована цим інструментом.
Головною проблемою була відсутність, на час пошуку, структурованої інформації, щодо побудови такої клавіатури за допомогою Compose. Так виникла думка про написання цієї статті. У ній ділюся корисними фрагментами побудови клавіатури.
Для прикладу уявимо, що нам потрібно реалізувати цифрову клавіатуру в 16 системі числення, з можливістю введення пробілу, символів: «-», «,», «.», «p», значення «0x» та з діями редагування та завершення. Ця клавіатура дозволить відобразити 16 число у звичному форматі відображення 0×3afe1, або з плаваючою точкою −0×3.14p3 (в читабельному виді).
Клавіатура буде виглядати так.
Світла тема
Темна тема
Основні дії будуть розгортатися над 4 основними компонентами нашої системи. Це сервіс, який буде виступати як редактор методу введення (IME), буде розширяти клас InputMethodService та створить нашу клавіатуру. Назвемо його IMEHexadecimalService. Щоб прив’язати клавіатуру до нашого сервісу, потрібно, щоб вона була представлена у View (Вигляд). Нагадаю, вся графічна частина буде написана на Compose.
Тому наступним компонентом буде клас, який відповідатиме за «перетворення» клавіатури у View. Він буде розширяти абстрактний клас AbstractComposeView, назвемо його ComposeHexadecimalKeyBoardView. Звідси можна зробити висновок, що в цей клас треба вставити клавіатуру Compose-функцію.
Наступним компонентом буде головна функція Compose, яка відповідатиме за побудову клавіатури, та складатиметься з компонентів, які будуть описані нижче. Назвемо її HexadecimalKeyBoard.
Остання Compose-функція буде відповідати за графічне представлення символа клавіатури та взаємодію його з користувачем (натискування та затискання клавіш, інформування його, що дія відбилася візуально, тактильно та звуком). Ім’я її буде HexadecimalKey.
Почнемо з найважливішої частини, яка відповідає редактору методу введення (IME).
IMEHexadecimalService
Розібравши офіційну документацію Create an input method, нам потрібно розширити клас InputMethodService, задекларувати розширений клас в manifest та повернути клавіатуру в методі onCreateInputView(). Наче все. Але не все так просто і не так складно одночасно. Оскільки ми будемо використовувати Compose для побудови графічної частини, а onCreateInputView метод приймає тільки View, нам потрібно обгорнути її класом AbstractComposeView, щоб «перетворити» Compose-функцію у View. Але цей клас має обмеження, AbstractComposeView та підтримує лише додавання в ієрархії представлення, що поширюють LifecycleOwner і SavedStateRegistryOwner через setViewTreeLifecycleOwner і setViewTreeSavedStateRegistryOwner.
Для цього нам потрібно прив’язати вищезгадані сутності до кореневого контейнера в методі onCreateInputView(). На щастя, InputMethodService має таку можливість. За допомогою методу getWindow() (Kotlin — window) можна отримати Dialog з нього Window через getWindow() (Kotlin — window) і насамкінець кореневий контейнер для нашого View getDecorView() (Kotlin — decorView).
Опустимо всі деталі, щоб перейти нарешті до фінального коду.
class IMEHexadecimalService : InputMethodService(), LifecycleOwner, SavedStateRegistryOwner { private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = lifecycleRegistry private val savedStateRegistryController = SavedStateRegistryController.create(this) override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry override fun onCreateInputView(): View { window?.window?.decorView?.let { decorView -> decorView.setViewTreeLifecycleOwner(this) decorView.setViewTreeSavedStateRegistryOwner(this) } return ComposeHexadecimalKeyBoardView(this) } override fun onCreate() { super.onCreate() savedStateRegistryController.performRestore(null) handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean) { super.onStartInputView(editorInfo, restarting) handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } override fun onFinishInputView(finishingInput: Boolean) { super.onFinishInputView(finishingInput) handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) } override fun onDestroy() { super.onDestroy() handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } private fun handleLifecycleEvent(event: Lifecycle.Event) = lifecycleRegistry.handleLifecycleEvent(event) }
Важливо переписати методи onCreate(), onStartInputView(), onFinishInputView() та onDestroy(), як описано вище, щоб клавіатура працювала. Порядок розташування важливий лише для Lifecycle.Event.ON_CREATE і має бути останнім.
Після цього треба об’явити наш сервіс в маніфесті, як описано в прикладі нижче.
<service android:name=".service.IMEHexadecimalService" android:exported="false" android:label="@string/keyboard_name" android:permission="android.permission.BIND_INPUT_METHOD"> <intent-filter> <action android:name="android.view.InputMethod" /> </intent-filter> <meta-data android:name="android.view.im" android:resource="@xml/method" /> </service>
Де xml/method:
<input-method xmlns:android="http://schemas.android.com/apk/res/android"> <subtype android:imeSubtypeMode="keyboard" /> </input-method>
Перейдемо тепер до сутності ComposeHexadecimalKeyBoardView, яку ми використовували в onCreateInputView().
ComposeHexadecimalKeyBoardView
Тут має бути простіше, ось сам клас, який відповідає за «перетворення» Compose-клавіатури у View.
class ComposeHexadecimalKeyBoardView(context: Context) : AbstractComposeView(context) { @Composable override fun Content() { HexadecimalBoardTheme { HexadecimalKeyBoard() } } }
Де HexadecimalBoardTheme — тема для клавітури.
Почнемо заглиблюватися в реалізацію графічного інтерфейсу HexadecimalKeyBoard.
HexadecimalKeyBoard
Тут знаходиться код, який описує Composable-функцію HexadecimalKeyBoard(), яка складається з основних будівельних блоків HexadecimalKey, які відповідають за побудову графіки кнопки та її взаємодію з користувачем.
@Composable fun HexadecimalKeyBoard() { val hexadecimalKeySymbols = listOf( "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "-", "0x", ".", "p", ).chunked(7) Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.primary) ) { Spacer( modifier = Modifier .fillMaxWidth() .height(10.dp) ) GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[2]) { HexadecimalKey( key = KeyItem( keyAction = KeyAction.Delete, keyType = KeyType.KeyIcon( icon = ImageVector.vectorResource(R.drawable.ic_delete_text), description = R.string.clear ) ), vibrateOnClick = true, soundOnClick = true, keyPadding = 2, keyHeight = 54f, keyWidth = 60f, keyBorderWidth = 1f, keyRadius = 5f, modifier = Modifier.weight(1f) ) } GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[1]) { HexadecimalKey( key = KeyItem( keyAction = KeyAction.Done, keyType = KeyType.KeyIcon( icon = Icons.Default.Done, description = R.string.done ) ), vibrateOnClick = true, soundOnClick = true, keyPadding = 2, keyHeight = 54f, keyWidth = 60f, keyBorderWidth = 1f, keyRadius = 5f, modifier = Modifier.weight(1f) ) } GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[0]) { HexadecimalKey( key = KeyItem( keyAction = KeyAction.CommitText(text = " "), keyType = KeyType.KeyText( value = " ", description = R.string.space ) ), vibrateOnClick = true, soundOnClick = true, keyPadding = 2, keyHeight = 54f, keyWidth = 60f, keyBorderWidth = 1f, keyRadius = 5f, modifier = Modifier.weight(2f) ) HexadecimalKey( key = KeyItem( keyAction = KeyAction.Enter, keyType = KeyType.KeyIcon( icon = ImageVector.vectorResource(R.drawable.ic_enter), description = R.string.enter ) ), vibrateOnClick = true, soundOnClick = true, keyPadding = 2, keyHeight = 54f, keyWidth = 60f, keyBorderWidth = 1f, keyRadius = 5f, modifier = Modifier.weight(1f) ) } Spacer( modifier = Modifier .fillMaxWidth() .height(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) 60.dp else 10.dp) ) } }
Де hexadecimalKeySymbols — список символів для побудови клавіатури, яка складається з трьох рядків. Перші два набори мають по сім членів, а останній шість. GenerateRowWithKeyTexts — допоміжна функція для автоматизації створення рядка з текстових кнопок, також є можливість додавати унікальні кнопки із іконками або текстом та з різною вагою (змінювати стандартну ширину).
@Composable private fun GenerateRowWithKeyTexts( itemsText: List<String>, rightKey: @Composable () -> Unit = {} ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { for (item in itemsText) { HexadecimalKey( key = KeyItem( keyAction = KeyAction.CommitText(text = item), keyType = KeyType.KeyText(value = item) ), vibrateOnClick = true, soundOnClick = true, keyPadding = 2, keyHeight = 54f, keyWidth = 60f, keyBorderWidth = 1f, keyRadius = 5f, modifier = Modifier.weight(1f) ) } rightKey() } }
Розглянемо більш детально основу клавіатури HexadecimalKey.
HexadecimalKey
Цей компонент є одним з найважливіших через те, що ним користувач може взаємодіяти з редактором методу введення (IME).
private const val MINUTE_IN_MILLISECONDS = 60000L private const val REPEATABLE_ACTION_TIME_DELAY = 60L @OptIn(ExperimentalFoundationApi::class) @Composable fun HexadecimalKey( key: KeyItem, keyPadding: Int, keyHeight: Float, keyWidth: Float, keyBorderWidth: Float, keyRadius: Float, vibrateOnClick: Boolean, soundOnClick: Boolean, modifier: Modifier ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val context = LocalContext.current val ime = context as? IMEHexadecimalService val coroutineScope = rememberCoroutineScope() val longClickPressed = remember { mutableStateOf(false) } val view = LocalView.current val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager val backgroundColor = if (!isPressed) { MaterialTheme.colorScheme.secondary } else { MaterialTheme.colorScheme.primary } val keyInfoColor = if (!isPressed) { MaterialTheme.colorScheme.onSecondary } else { MaterialTheme.colorScheme.onPrimary } val keyBorderColour = MaterialTheme.colorScheme.outline fun soundAndVibrate() { if (vibrateOnClick) { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } if (soundOnClick) { audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK, .1f) } } fun onLongClick() { if (key.keyAction != KeyAction.DoneKey) { longClickPressed.value = true coroutineScope.launch(Dispatchers.IO) { withTimeout(MINUTE_IN_MILLISECONDS) { while (true) { performKeyAction( action = key.keyAction, ime = ime, ) delay( REPEATABLE_ACTION_TIME_DELAY ) } } } } else { performKeyAction( action = key.keyAction, ime = ime, ) } soundAndVibrate() } LaunchedEffect(key1 = isPressed, key2 = longClickPressed) { if (isPressed) { soundAndVibrate() } else { if (longClickPressed.value) { coroutineScope.coroutineContext.cancelChildren() longClickPressed.value = false } } } val keyboardKeyModifier = modifier .height(keyHeight.dp) .defaultMinSize(minWidth = keyWidth.dp) .padding(keyPadding.dp) .clip(RoundedCornerShape(keyRadius.dp)) .then( if (keyBorderWidth > 0.0) { Modifier.border( keyBorderWidth.dp, keyBorderColour, shape = RoundedCornerShape(keyRadius.dp), ) } else { (Modifier) }, ) .background(color = backgroundColor) .combinedClickable( interactionSource = interactionSource, indication = null, onClick = { performKeyAction( action = key.keyAction, ime = ime, ) }, onLongClick = { onLongClick() } ) Box( modifier = keyboardKeyModifier ) { when (val type = key.keyType) { is KeyType.KeyText -> { Text( text = type.value, style = MaterialTheme.typography.titleMedium, color = keyInfoColor, modifier = Modifier.align(Alignment.Center), ) if (type.showDescription) { Text( text = "(${stringResource(type.description!!)})", style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Serif, color = keyInfoColor, modifier = Modifier.align(Alignment.BottomCenter), ) } } is KeyType.KeyIcon -> { Icon( imageVector = type.icon, contentDescription = stringResource( type.description ?: R.string.description_not_available ), tint = keyInfoColor, modifier = Modifier.align(Alignment.Center) ) } } } }
Параметр key вищезгаданої функції має тип KeyItem. Він відповідає за графічний опис типу кнопки KeyType та за тип дій, які вона може виконати KeyAction.
KeyItem:
data class KeyItem(val keyAction: KeyAction, val keyType: KeyType)
KeyAction:
sealed interface KeyAction { data class CommitText( val text: String ) : KeyAction data object Delete : KeyAction data object Done : KeyAction data object Enter : KeyAction }
KeyType:
sealed class KeyType( open val description: Int? = null, open val showDescription: Boolean = false ) { data class KeyIcon( val icon: ImageVector, override val description: Int? = null, override val showDescription: Boolean = false ) : KeyType(description, showDescription) data class KeyText( val value: String, override val description: Int? = null, override val showDescription: Boolean = false ) : KeyType(description, showDescription) }
За взаємодію з редактором методом введення (ІМЕ) відповідає функція верхнього рівня performKeyAction, в яку можна передати аргумент IMEHexadecimalService нульового типу.
Параметр ime зроблений нульовим типом для того, щоб функції HexadecimalKeyBoard() та HexadecimalKey() можна було попередньо переглянути за допомогою @Preview, через використання приведення контексту до IMEHexadecimalService, щоб взаємодіяти з ним цій функції (надсилати події, закріплення тексту, видалення тощо).
performKeyAction:
fun performKeyAction( action: KeyAction, ime: IMEHexadecimalService? = null ) { when (action) { is KeyAction.CommitText -> { val text = action.text ime?.currentInputConnection?.commitText( text, 1, ) Log.d("Test", "committing key text: $text") } is KeyAction.Delete -> { val event = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL) ime?.currentInputConnection?.sendKeyEvent(event) Log.d("Test", "delete") } KeyAction.Done -> { ime?.requestHideSelf(0) Log.d("Test", "hide") } KeyAction.Enter -> { val event = KeyEvent( KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, ) ime?.currentInputConnection?.sendKeyEvent( event ) Log.d("Test", "Enter") } }
Також в HexadecimalKey() є можливість повторювати дію з певним інтервалом часу, окрім дії Done, під час затискання клавіші за допомогою корутини. У внутрішній функції onLongClick(), виконання якої залежить від значень isPressed та longClickPressed, це було зроблено для того, щоб можна було зупинити виконання автоматичних повторювальних дій, коли користувач забирає палець з клавіатури. Також важливо закривати виконання цих дій за допомогою cancelChildren(), щоб можна було повторити автоматичні операції знову.
Фінальна частина
Робочий приклад має такий вигляд.
Початковий вигляд
Введення символів
Отже, щоб використати нашу власну клавіатуру для введення тексту, треба увімкнути Hexadecimal Keyboard в системних налаштуваннях: «Екранна клавіатура». А потім вибрати її у діалоговому вікні «Вибрати метод введення» у нашому випадку.
На відео далі показано, як можна увімкнути це в налаштуваннях. А також продемонстрований вибір методу введення на прикладі тестового прикладу. Посилання на проєкт буде наприкінці статті.
Увімкнення Hexadecimal Keyboard в системних налаштуваннях:
Вибір Hexadecimal Keyboard методу введення:
Висновок
Як бачимо з цього прикладу, Compose можна використовувати для побудови графічної частини власної екранної клавіатури залежно від потреб. Ця можливість дає змогу створити унікальні методи введення залежно від вимог за допомогою сучасних інструментів побудови графічних інтерфейсів, що своєю чергою може покращити UI/UX-досвід користувачів в застосунках, де цю можливість реалізовано.
Створені клавіатури можна використовувати в інших місцях, де використовуються методи введення тексту (IME), зокрема екранна клавіатура. Також важливо зазначити, що треба бути уважним, коли вводиш чутливу інформацію через клавіатури сторонніх розробників. Ми не можемо бути на сто відсотків переконані, що вони не використовують введену інформацію не за призначенням.
Дякую за увагу! Сподіваюся, що ця стаття була корисною для вас.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів