Иконочный шрифт: свои иконки одним файлом
Зачем это нужно
Последний пункт списка звучит неожиданно: «Создайте шрифт из представленных иконок». Не «добавьте иконки», а именно создайте шрифт.
Задание не выдуманное и не бессмысленное. Иконочный шрифт — старый и до сих пор живой приём: все иконки лежат в одном файле, каждая доступна как обычный символ, красится цветом текста и масштабируется вместе с ним. Никаких отдельных размеров под каждую плотность экрана.
В этой главе мы соберём такой шрифт из четырёх SVG и подключим его в Compose. Всё ниже — настоящий прогон: файлы собраны, проверены и скомпилированы.
Из SVG в шрифт
Исходники — обычные SVG, по файлу на иконку:
svg/
cart.svg
heart.svg
search.svg
user.svg
Сборка одной командой:
npx fantasticon@4.1.0 svg -o out -n kit_icons -t ttf -g json --font-height 1000 --normalize
✔ 4 SVGs found in svg
✔ Generated out/kit_icons.ttf
✔ Generated out/kit_icons.json
Done
Что получилось:
-rw-r--r-- 1932 kit_icons.ttf
-rw-r--r-- 81 kit_icons.json
Четыре иконки в 1932 байтах — меньше, чем одна картинка среднего размера.
Если сборщик ругается Invalid option outputDir: out is not a directory — папку вывода надо создать самому: mkdir -p out. Сборщик её не создаёт, и сообщение об этом не говорит.
Проверяем, что собралось
Верить сборщику на слово не стоит — файл шрифта можно открыть и посмотреть:
import fontforge
f = fontforge.open('out/kit_icons.ttf')
print('семейство:', f.familyname, '| глифов:', len(list(f.glyphs())))
for g in f.glyphs():
if g.unicode > 0:
print(' U+%04X %-8s ширина %d' % (g.unicode, g.glyphname, g.width))
print('единиц на em:', f.em)
семейство: kit_icons | глифов: 5
U+F101 user ширина 1000
U+F102 search ширина 1000
U+F103 heart ширина 1000
U+F104 cart ширина 1000
единиц на em: 1000
Четыре глифа на местах, имена совпадают с именами файлов, ширина у всех одинаковая. Пятый глиф — служебный пустой, он есть в любом шрифте.
Одинаковая ширина у всех иконок — не случайность и не мелочь. Если одна окажется шире, ряд иконок в панели поедет, а выравнивание по сетке развалится. Именно это делает флаг --normalize: приводит все исходники к одной высоте и ширине.
Почему коды начинаются с F1
Карта символов, которую выдал сборщик:
{
"user": 61697,
"search": 61698,
"heart": 61699,
"cart": 61700
}
В шестнадцатеричном виде это U+F101–U+F104 — область для частного использования. Юникод специально оставил диапазон U+E000…U+F8FF пустым: туда никогда не попадёт ни буква, ни знак препинания, ни эмодзи.
Смысл в том, чтобы не пересечься с настоящим текстом. Посади иконку на код буквы A — и слово «Cart», написанное этим шрифтом, превратилось бы в набор картинок.
Отсюда же ограничение: текст, набранный иконочным шрифтом, не читается ничем. Ни поиском, ни переводчиком, ни экранным диктором — для них там непечатаемый мусор из частной области. Поэтому доступность приходится задавать руками, и об этом — отдельный раздел ниже.
Подключаем в Compose
Файл кладётся в ресурсы:
ui/src/main/res/font/kit_icons.ttf
Имя файла в res/font подчиняется правилам ресурсов Android: только строчные латинские буквы, цифры и подчёркивание. kit_icons.ttf годится, kit-icons.ttf или kitIcons.ttf — нет, сборка упадёт.
Дальше — семейство шрифта и перечисление иконок:
val KitIconFont = FontFamily(Font(R.font.kit_icons))
enum class KitIcon(val glyph: Char) {
User('\uF101'),
Search('\uF102'),
Heart('\uF103'),
Cart('\uF104'),
}
И сам компонент:
@Composable
fun KitIconGlyph(
icon: KitIcon,
modifier: Modifier = Modifier,
size: TextUnit = 24.sp,
tint: Color = LocalContentColor.current,
description: String? = null,
) {
Text(
text = icon.glyph.toString(),
modifier = modifier.clearAndSetSemantics {
if (description != null) contentDescription = description
},
fontFamily = KitIconFont,
fontSize = size,
color = tint,
)
}
Иконка со смыслом и иконка-украшение
Экранный диктор читает содержимое экрана вслух. Символ из частной области для него — бессмысленный набор байтов, и прочитает он его как мусор. Поэтому у каждой иконки должно быть одно из двух:
Описание — когда иконка единственный носитель смысла:
KitIconGlyph(icon = KitIcon.Cart, description = "Корзина")
Кнопка с одной корзиной и без подписи без описания становится немой: диктор скажет «кнопка» и всё.
Явное «ничего» — когда рядом есть текст с тем же смыслом:
Row {
KitIconGlyph(icon = KitIcon.Cart) // description = null
Text("Корзина")
}
Здесь описание только помешало бы: диктор прочитал бы «Корзина. Корзина».
Худший вариант — не задать ничего и не убрать символ из дерева доступности. Тогда диктор попытается прочитать сам глиф. Именно это и делает clearAndSetSemantics: убирает символ, а описание ставит только если оно есть.
Когда иконочный шрифт не подходит
Приём хороший, но не всеобщий. Три случая, когда берут обычные векторные ресурсы:
- иконка многоцветная — шрифт одноцветный по устройству, цвет у него один на весь глиф;
- иконка анимируется — шрифт не умеет ни морфинга, ни частичной отрисовки;
- иконок три штуки — заводить сборку шрифта ради трёх значков дороже, чем положить три файла.
Отраслевой ход последних лет — обратный: Material перешёл с иконочного шрифта на векторные ресурсы, потому что те поддерживают несколько цветов и переменные начертания. Так что знать приём стоит, а применять — когда он прямо требуется заданием или когда иконок действительно много и все одноцветные.
Типичные ошибки
Ошибка 1. Папка вывода не создана.
Invalid option outputDir: out is not a directory
mkdir -p out перед сборкой.
Ошибка 2. Имя файла с дефисом или заглавными.
res/font принимает только строчные, цифры и подчёркивание. Сборка падает на этапе ресурсов.
Ошибка 3. Коды вписаны в разметку прямо.
Text(text = "\uF104", fontFamily = KitIconFont)
Работает, но через месяц никто не помнит, что такое F104. Перечисление с именами — одна строка кода и никакой магии.
Ошибка 4. Размер в dp.
Иконка перестанет расти вместе с системным шрифтом, а рядом стоящий текст вырастет — значок окажется меньше подписи.
Ошибка 5. Нет ни описания, ни его отсутствия.
Диктор читает символ из частной области как мусор.
Ошибка 6. Иконки разной ширины.
Без --normalize исходники приходят как есть, и ряд значков в панели съезжает.
Ошибка 7. Шрифт пересобран, а коды не проверены.
Сборщик раздаёт коды по порядку обхода файлов. Добавили иконку с именем, которое встало в середину списка, — и все последующие коды сдвинулись. Карту символов после каждой пересборки берут заново.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Собрать шрифт | npx fantasticon svg -o out -n kit_icons -t ttf -g json --font-height 1000 --normalize |
| Создать папку вывода | mkdir -p out — сборщик её не создаёт |
| Проверить результат | fontforge -script или любой просмотрщик шрифтов |
| Куда положить | res/font/kit_icons.ttf, имя только строчными |
| Семейство | FontFamily(Font(R.font.kit_icons)) |
| Имена вместо кодов | enum class KitIcon(val glyph: Char) |
| Размер | в sp, не в dp |
| Цвет | LocalContentColor.current по умолчанию |
| Доступность | clearAndSetSemantics + описание или его осознанное отсутствие |
Слова главы
| Термин | Что это |
|---|---|
| Иконочный шрифт | Шрифт, где вместо букв нарисованы значки |
| Глиф | Одно начертание в шрифте; у нас — одна иконка |
| Область частного использования | U+E000…U+F8FF: коды, которые Юникод оставил свободными |
| Единиц на em | Внутренняя точность шрифта; 1000 — принятое значение |
| Дерево доступности | То, что читает экранный диктор |
| Украшение | Иконка, дублирующая соседний текст; диктор её пропускает |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
mkdir -p out
Что должен уметь после главы
- собрать иконочный шрифт из набора SVG одной командой;
- проверить получившийся файл: имена глифов, коды и ширину;
- объяснить, почему коды берут из области частного использования;
- подключить шрифт в Compose и завести имена вместо кодов;
- отличить иконку со смыслом от украшения и задать доступность;
- назвать три случая, когда иконочный шрифт не подходит.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Сводная проверка по 7 главам блока: 25 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.
Готовность: экзамены глав сданы 0 из 7. Блок можно сдавать и так, но шанс выше после всех глав.
Куда дальше: проверенные ресурсы
- fantasticon — сборщик, которым собран шрифт этой главы.
- Шрифты в Compose —
FontFamily, ресурсы шрифтов и их правила именования. - Private Use Area — почему диапазон свободен и как им пользоваться.
- Доступность в Compose — семантика, описания и когда их не задают.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.