Разрабатывая приложение под 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 — я буду рад)
Зато теперь у нас самые красивые ? в чате, и можем себе позволить колобка, бьющегося о стену ;-)
Спасибо, что дочитали до конца.
Надеюсь, ваш курсор больше не болит.