Реалізація програмної клавіатури для Android засобами Compose

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Максим, я 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), зокрема екранна клавіатура. Також важливо зазначити, що треба бути уважним, коли вводиш чутливу інформацію через клавіатури сторонніх розробників. Ми не можемо бути на сто відсотків переконані, що вони не використовують введену інформацію не за призначенням.

Дякую за увагу! Сподіваюся, що ця стаття була корисною для вас.

Посилання на робочий приклад (GitHub project)

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Найти бы клавиатуру с Ctrl/Alt/Esc кнопками как на компе. Раньше встречались а сейчас я так понимаю не поддерживаются больше?

Якщо я правильно зрозумів то Alt/Esc є у KeyEvent класі (InputEvent), Ctrl можна програмно створити (не знайшов в цьому класі, може десь в іншому класі або називається не так).
Тобто обмежень не має бути у створенні такої клавіатури. Треба шукати або самому писати)

Android прикольний тим, що можна все кастомізувати, але по факту мало хто змінює дефолтну клавіатуру і вже точно не для використання тільки в одному застосунку. Як на мене кастомна бібліотека що буде обгорткою над BasicText та у шарі діалогів наприклад вспливати і давати вводити тільки певні символи чи дозволяти навішувати фільтри на інпут має більше шансів бути корисною

Це не зміна дефолтної клавіатури це створення нової, яка буде доступна всюди де можна вводити текст (якщо користувач її включить в налаштуваннях у розділі екранна клавіатура).
До речі тема нішова сам з нею не стикався до реалізації. Але є багато/декілька опенсорс проектів та клавіатур (додатків) в Google Play.
Цікава ідея про BasicText

У мене в деяких проектах так і є — потрібна була ергономічна цифрова клавіатура для конкретних випадків яка б ще і вписувалась у дизайн і вирішувала проблему з фокусом і ресайзом актівіті, то працює чудово і не потрібно ніяких налаштувань зі сторони юзера

Підписатись на коментарі