Разрабатывая приложение под Android — мы встроили в продукт свой мессенджер и решили, что стандартные emoji в андроиде — это преступление против дизайна.
Telegram и другие популярные мессенджеры давно показали, как должны выглядеть эмоции в чате, а Google всё ещё живёт в 2015-м с Noto Color Emoji.

Хотели просто подменить парочку ??‍?? на свои красивые… И получили войну: курсор, который живёт своей жизнью, тофу, кернинг и полный хаос при вводе.

Эта статья — история о том, как мы прошли все круги ада и всё‑таки победили систему.

Спойлер: победили костылями.


Сегодня существует более 3600 различных emoji и их комбинаций, а также их вариаций отрисовки на разных платформах.

Так выглядит обычный улыбающийся emoji в разных платформах
Так выглядит обычный улыбающийся 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 изображения:

В первой строке наши drawable, во второй родные, от Android
В первой строке наши drawable, во второй родные, от Android

Отлично, отображать текст мы научились, а что на счёт ввода?

Часть 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
)
Задаем прозрачность и смотрим как emoji накладываются на маркер
Задаем прозрачность и смотрим как emoji накладываются на маркер
Результат, который видит пользователь
Результат, который видит пользователь

Заключение

Мы прошли весь путь: от наивной веры в textMeasurer и InlineTextContent до полного осознания, что Noto Color Emoji — это шрифт с кернингом, оптическими компенсациями и злым умыслом Google.  
Пробовали всё: измерять на Canvas, использовать Annotation, бороться с тофу и составными эмодзи.  

В итоге победил самый честный Android-костыль — замена эмодзи на маркер и overlay поверх. Да, это костыль.  Но он:

  • решает проблему курсора раз и навсегда

  • работает с любыми составными emoji

  • при этом работает с родными emoji

  • легко расширяется под сотни кастомных изображений

  • уже живёт в нашем продакшене

Всё, что осталось за рамками этой статьи (maxLines/maxLines, поиск в отдельном потоке, кэширование, копирование текста без маркеров, анимации, RTL, доступность) — это уже приятные улучшения, которые каждый может допилить под свои нужды.  
Главная боль — непредсказуемый размер системных эмодзи при вводе — решена. Остальное гуглится за вечер.

Полный рабочий код лежит на GitHub (форкайте, улучшайте, кидайте PR — я буду рад)

Зато теперь у нас самые красивые ? в чате, и можем себе позволить колобка, бьющегося о стену ;-)

Спасибо, что дочитали до конца.  
Надеюсь, ваш курсор больше не болит.

Комментарии (15)