Разрабатывая приложение под Android — мы встроили в продукт свой мессенджер и решили, что стандартные emoji в андроиде — это преступление против дизайна.
Telegram и другие популярные мессенджеры давно показали, как должны выглядеть эмоции в чате, а Google всё ещё живёт в 2015-м с Noto Color Emoji.
Хотели просто подменить парочку 😎👩💻🙂 на свои красивые… И получили войну: курсор, который живёт своей жизнью, тофу, кернинг и полный хаос при вводе.
Эта статья — история о том, как мы прошли все круги ада и всё‑таки победили систему.
Спойлер: победили костылями.
Сегодня существует более 3600 различных emoji и их комбинаций, а также их вариаций отрисовки на разных платформах.

И вот, в нашем мессенджере в Android нас одолела тоска, глядя на это

Я не хочу умалять достоинства дизайнеров Google, о вкусах не спорят, но если вам что-то не нравится - никто не мешает сделать красивое самому.
Для начала стоит понять, что emoji уже имеют свои стандарты, и что в текстовое поле мессенджера могут вставить заранее скопированный текст, содержащий emoji.
Кроме того, современные emoji могут состоять не из одного UTF‑символа, а из нескольких, объединяясь и преобразуясь в другие с помощью Zero Width Joiner (ZWJ) — специального символа Unicode, который «склеивает» несколько эмодзи-кодов в один.
Пример:
👨👩👧👦 (семья) = 👨 (мужчина) + ZWJ + 👩 (женщина) + ZWJ + 👧 (девочка) + ZWJ + 👦 (мальчик).
В Android эмодзи устроены по тем же общим принципам, но с особенностями реализации.
Ключевое отличие — Google разработала свой собственный шрифт "Noto Color Emoji", который интегрирован глубоко в Android OS. Это означает, что любое приложение, использующее стандартные механизмы рендеринга текста, автоматически отображает эмодзи через этот системный шрифт.
Часть 2. Самое простое
Так как наш мессенджер написан на Compose — основная задача научиться выводить наши emoji, вместо стандартных. С компонентом Text всё просто: необходимо создать AnnotatedString, который найдёт emoji в тексте и через appendInlineContent подставит изображение. Для примера научимся подменять три emoji: 🙂👩💻😎
Emoji.kt
/** * Объект, который содержит информацию об обрабатываемом emoji */ data class Emoji(val emoji: String, @DrawableRes val resource: Int) /** * Список emoji, которые ��ы умеем кастомизировать */ val EMOJIS: List<Emoji> = listOf( Emoji("\ud83d\ude42", R.drawable.simple_smile), // 🙂 Emoji("\ud83d\udc69\u200d\ud83d\udcbb", R.drawable.female_engineer), // 👩💻 Emoji("\ud83d\ude0e", R.drawable.smiling_with_sunglasses) // 😎 ) /** * Генерирует регулярное выражение для поиска всех emoji из списка */ fun List<Emoji>.toRegex(): Regex { val pattern = joinToString(separator = "|") { emoji -> Regex.escape(emoji.emoji) } return Regex(pattern) } /** * Получает Emoji объект по найденному тексту emoji */ fun List<Emoji>.getEmojiByText(emojiText: String): Emoji? { return firstOrNull { it.emoji == emojiText } } /** * Находит все вхождения emoji из списка в тексте */ fun List<Emoji>.findEmojisInText(text: String): List<EmojiRange> { return toRegex().findAll(text).mapNotNull { match -> getEmojiByText(match.value)?.let { emoji -> EmojiRange(emoji, match.range) } }.toList() } data class EmojiRange( val emoji: Emoji, val range: IntRange, )
А вот и сам компонент, который умеет выводить кастомные emoji.
TextWithCustomEmoji.kt
@Composable fun TextWithCustomEmoji( text: String, modifier: Modifier = Modifier, emojis: List<Emoji> = EMOJIS ) { val emojiRanges = emojis.findEmojisInText(text) val annotatedString = buildAnnotatedString { var lastIndex = 0 emojiRanges.forEach { emojiRange -> append(text.substring(lastIndex, emojiRange.range.first)) appendInlineContent( id = "emoji_${emojiRange.range.first}", alternateText = emojiRange.emoji.emoji ) lastIndex = emojiRange.range.last + 1 } if (lastIndex < text.length) { append(text.substring(lastIndex)) } } val inlineContent = emojiRanges.associate { emojiRange -> "emoji_${emojiRange.range.first}" to InlineTextContent( placeholder = Placeholder( width = 20.sp, height = 20.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { Image( painter = painterResource(id = emojiRange.emoji.resource), contentDescription = emojiRange.emoji.emoji ) } } Text( text = annotatedString, inlineContent = inlineContent, modifier = modifier ) }
Тут кажется все довольно понятно. Emoji, которые мы умеем кастомизировать - будут заменены на наши drawable изображения:

Отлично, отображать текст мы научились, а что на счёт ввода?
Часть 3. Экспериментальная
Задача:
Преобразовывать вводимые emoji сразу в кастомные.
BasicTextField дает нам decorationBox, которым можно “украсить” вводимый текст. Это Composable функция, в которую входным параметром передается innerTextField, который сам также является Composable функцией, на которую мы уже повлиять не можем. Нам дают возможность, добавить leadIcons или оформить вводимый текст в рамку, но вот с самим полем ввода сделать ничего нельзя. Проблема еще и в том, что innerTextField – управляет курсором. И если мы его уберем, то придется отрисовывать и обрабатывать действия курсора. А вот это может превратиться уже в очень большую задачу.
Решение:
Сделать текст в innerTextField прозрачным (через стили), при этом сохранив поведение курсора. Поверх него наложить Text, который умеет отображать кастомные emoji.
TextFieldWithCustomEmoji.kt
BasicTextField( value = value, onValueChange = onValueChange, textStyle = textStyle.copy(color = Color.Transparent), // Делаем весь текст прозрачным decorationBox = { innerTextField -> TextFieldDefaults.DecorationBox( value = value, innerTextField = { Box { // Прозрачное поле для ввода и курсора innerTextField() // А поверх обычный Text, который умеет отображать кастомные emoji DisplayTextWithEmoji( value = AnnotatedString(value), textStyle = textStyle ) } } ) } )
DisplayTextWithEmoji, отображающий наши emoji
DisplayTextWithEmoji.kt
@Composable private fun DisplayTextWithEmoji( value: AnnotatedString, textStyle: TextStyle, ) { val textMeasurer = rememberTextMeasurer() val density = LocalDensity.current val emojiRanges = EMOJIS.findEmojisInText(value.text) val annotatedString = buildAnnotatedString { var lastIndex = 0 emojiRanges.forEach { emojiRange -> append(value.text.substring(lastIndex, emojiRange.range.first)) appendInlineContent( id = "emoji_${emojiRange.range.first}", alternateText = emojiRange.emoji.emoji ) lastIndex = emojiRange.range.last + 1 } if (lastIndex < value.text.length) { append(value.text.substring(lastIndex)) } } val inlineContent = emojiRanges.associate { emojiRange -> val originalEmojiMeasurement = textMeasurer.measure( text = AnnotatedString(emojiRange.emoji.emoji), style = textStyle ) val widthSp = with(density) { originalEmojiMeasurement.size.width.toFloat().toDp().toSp() } val heightSp = with(density) { originalEmojiMeasurement.size.height.toFloat().toDp().toSp() } "emoji_${emojiRange.range.first}" to InlineTextContent( placeholder = Placeholder( width = widthSp, height = heightSp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { Image( painter = painterResource(id = emojiRange.emoji.resource), contentDescription = emojiRange.emoji.emoji, modifier = Modifier.fillMaxSize() ) } } Text( text = annotatedString, inlineContent = inlineContent, style = textStyle ) }
Основное отличие DisplayTextWithEmoji от Text в том, что мы ищем emoji в тексте и измеряем его размер через textMeasurer, чтобы в границах оригинального emoji отрисовать наше изображение.

Представим, что одного emoji нам недостаточно для выражения наших чувств:

Однако, если вводить просто текст без улыбок, то все работает как и ожидалось:

Чтобы понять что происходит - попробуем отобразить сразу два слоя - оригинальный input и наш DisplayTextWithEmoji и чтобы было нагляднее - оригинальный текст сделаем красным, а наложенному – добавим прозрачности:

Теперь отчетливо видно, что буква “i” начинает отображаться уже перед курсором. Но как же так, ведь мы через textMeasurer измерили оригинальный emoji и ровно в эти размеры вписали наш! Были опробованы разные способы измерить оригинальные emoji, вплоть до отрисовки его на canvas. И каждый раз получалась погрешность, которую не удалось систематизировать.
Давайте еще раз вспомним - как устроены emoji в Android:
Google разработала свой собственный шрифт эмодзи под названием "Noto Color Emoji"
Т.е. каждый смайлик это элемент шрифта. А у шрифта очень много параметров, например оптический кернинг или отрицательный трекинг. И все эти свойства могут меняться в зависимости от соседних символов, размеров шрифта и еще кучи разных параметров, которые необходимы для достижения наилучшей читаемости. Поверьте, в Google очень хорошо над этим потрудились. И чтобы победить это – придется сильно постараться. Вычислять размер нескольких emoji идущих подряд, или замерять все emoji, находящиеся в строке, тоже не помогало. Единственное, что всегда имело правильный размер при подсчетах – обычные буквы. И даже если вы решите, что ваш пользователь не будет вводить много emoji – вы можете нарваться на “тофу”, символ, который не отрисован в шрифте и обычно выглядит как прямоугольник.
Если вписать наш кастомный emoji в этот символ, то он будет существенно меньше, чем остальные и будет смотреться максимально нелепо.
Кроме того, если вы захотите использовать составные emoji, которых точно нет в вашем шрифте, то ваш кастомный emoji также будет вписан либо в слишком широкую, либо в слишком узкую область.

Часть 4. Право неожиданности
Раз размеры обычных букв измеряются корректно – заменим emoji буквой, занимающем максимально большое пространство (например М). Осталось только продумать как пометить, какому символу М соответствует тот или иной emoji, а какой настоящий и к emoji не относится.
И вот оно, в compose есть AnnotationStrings! И он вполне успешно используется в TextFieldValue, а значит мы можем заменять оригинальные emoji на любой символ или их набор, рисовать поверх этого символа наш кастомный emoji, а сам символ заключить в тег с информацией об оригинальном emoji.
Тем более, что у нас уже есть emojiRange, в котором имеются все “координаты” наших emoji. Но этот вариант потерпит неудачу.
Все дело в том, что для управления текстом у вас есть входной value и тот, что вы получите на выходе в onValueChange. И вот тут кроется неочевидная подстава:
val message = TextFieldValue(annotatedString = AnnotatedString("some string with tags")) //<-- Строка с тегами BasicTextField( value = message, onValueChange = { newValue: TextFieldValue -> newValue.annotatedString // <-- Вот тут вы получите строку без тегов } )
Мы нашли все emoji в тексте, заменили их на маркер M, тегами указали, что теперь эта конкретная М является emoji с кодом "\ud83d\udc69\u200d\ud83d\udcbb" и как только пользователь изменит строку - вы потеряете всю служебную информацию о заменах и получите только М в тех местах, где должны быть смайлы. Все дело в том, что под капотом onValueChange преобразует ваш value в String, произведет изменения и затем вернет этот String заново, обернутый в чистый AnnotatedString.
Часть 5. Костыльная
Делаем ход конем. AnnotationString со всеми тегами, которые попадают в value для TextField, там и сохраняются. AnnotationString отображается со всеми тегами так, как и положено. Значит все, что нам нужно сделать - это добиться того, чтобы оригинальные emoji в тексте вообще не занимали пространство, при этом перед ними добавим нашу букву М, на которую будем накладывать наш кастомный emoji. А чтобы нашу букву отличать от обычных М - сразу после нее добавим служебный непечатаемый символ для того, чтобы при передаче текста “вверх” по иерархии – возвращать его в обычном виде, без служебной информации.
Напишем функции расширения, которые будут добавлять и удалять маркеры к оригинальному тексту, которые будут устанавливать размер текста для оригинальных emoji в 0.sp, а перед ними добавлять и удалять наш маркер.
А для функции DisplayTextWithEmoji укажем, что теперь мы измеряем размер маркера, а не оригинального emoji:
textMeasurer.measure( text = MARKER.toString(), style = textStyle )


Заключение
Мы прошли весь путь: от наивной веры в textMeasurer и InlineTextContent до полного осознания, что Noto Color Emoji — это шрифт с кернингом, оптическими компенсациями и злым умыслом Google.
Пробовали всё: измерять на Canvas, использовать Annotation, бороться с тофу и составными эмодзи.
В итоге победил самый честный Android-костыль — замена эмодзи на маркер и overlay поверх. Да, это костыль. Но он:
решает проблему курсора раз и навсегда
работает с любыми составными emoji
при этом работает с родными emoji
легко расширяется под сотни кастомных изображений
уже живёт в нашем продакшене
Всё, что осталось за рамками этой статьи (maxLines/maxLines, поиск в отдельном потоке, кэширование, копирование текста без маркеров, анимации, RTL, доступность) — это уже приятные улучшения, которые каждый может допилить под свои нужды.
Главная боль — непредсказуемый размер системных эмодзи при вводе — решена. Остальное гуглится за вечер.
Полный рабочий код лежит на GitHub (форкайте, улучшайте, кидайте PR — я буду рад)
Зато теперь у нас самые красивые 😎 в чате, и можем себе позволить колобка, бьющегося о стену ;-)
Спасибо, что дочитали до конца.
Надеюсь, ваш курсор больше не болит.
