Как стать автором
Обновить

Как увеличить шрифт так, чтобы контейнер не поехал? Адаптация UI/UX для людей с проблемами зрения в XML и Compose

Уровень сложностиПростой
Время на прочтение12 мин
Количество просмотров2.6K

Я — Денис, Android-разработчик в «Лайв Тайпинге». В этой статье я продолжу рассказывать о современных подходах разработки адаптивного UI/UX для людей с ограниченными возможностями, разных национальностей и особенностями развития. В этой статьей я расскажу про разработку интерфейсов в XML и Compose для варьирующего размера шрифта. А также покажу почему атрибут contentDescription так важен.

Ссылка на первую часть — тут я рассказал про поддержку RTL.

Зачем адаптировать интерфейс под варьирующийся размер шрифта

У всех людей есть как наследственные так и приобретенные заболевания. Часто — это заболевания связанные со зрением. К примеру, азиаты от рождения близоруки. А с годами могут появиться заболевания глаз: катаракта или глаукома. Они оказывают сильное негативное влияние на зрение и качество жизни.

Интерфейс мобильного приложения должен быть разработан таким образом, чтобы людям, у которых есть заболевания связанные со зрением, могли комфортно пользоваться приложением также как и люди с хорошим зрением.

Untitled

Независимо от того, насколько ваше зрение хорошее, нормальный размер шрифта здесь почти полностью нечитаем. Тем не менее, больший размер (хотя он всё ещё немного нечёткий) на самом деле несколько читаем. Высококачественный дизайн мобильного приложения позволяет увеличить размер шрифта до максимального, не влияя при этом на удобство использования и презентацию приложения.

Ниже вы можете посмотреть как человек видит с разными заболеваниями глаз.

Взрослый здоровый человек с хорошим зрением видит мир ясно и четко, без труда различает детали и наслаждается окружающей красотой.

Untitled

При близорукости человек плохо различает предметы, расположенные на удалении, – все, что находится за пределами его видимости, превращается в какой-то смазанный фон. Неясные очертания, туман… Дело в том, что при этом заболевании глаз изображение фокусируется не на сетчатке, а на плоскости перед ней, именно поэтому картинка воспринимается нечёткой, размытой.

Untitled

Дальнозоркость — нарушение рефракции, противоположное близорукости. То, что расположено близко, затуманивается, а отдалённая перспектива видна совсем неплохо. В этом случае изображение в силу определённых причин фокусируется за сетчаткой, из-за чего и возникают проблемы со зрением.

Untitled

Слово «астигматизм» переводится с латыни как «отсутствие фокуса» – и этим, собственно, все сказано. При астигматизме природные линзы оптической системы человеческого глаза, роговица или хрусталик, имеют неправильную форму, из-за чего свет преломляются и фокусируются неверно, и в результате мир видится, как в кривом зеркале, искажённым.

Untitled

При катаракте природный хрусталик глаза мутнеет, из-за чего окружающий мир кажется человеку, страдающему этим заболеванием, подёрнутым легкой туманной пеленой. Со временем туман сгущается все больше и больше, в результате чего наступает слепота.

Untitled

Глаукома — опасное заболевание, при котором происходят патологические изменения зрительного нерва, нарушается периферическое зрение. По мере развития болезни поля зрения сужаются, слепота, без лечения наступающая при глаукоме, – необратима.

Untitled

Как адаптировать интерфейс в XML

Всегда используйте «независимые от масштаба пиксели» для измерения шрифта

Когда вы работаете с TextView, вы, вероятно, видели, как Android Studio предупреждает вас, когда вы используете измерение размера шрифта, отличное от sp, или «независимые от масштаба пиксели». Для определения размера шрифта всегда используйте sp вместо dp.

Но, если вы по какой-то причине решите указать размер шрифта в sp, но в строковых ресурсах проекта, как в коде ниже:

<string name="text">
  <font size="20">Text in bigger font size 20..</font>
</string>

То, в таком случае система не сможет автоматически увеличить шрифт этого текста при изменение размера шрифта в системе. Если он, конечно не будет дополнительно указан, например в TextView.

Ограничение по высоте контейнера? Ограничьте overflow текста

Одна вещь, которую вы быстро поймете, так как размеры шрифтов могут варьироваться, это то, что ваш пиксель-перфект дизайн может стать раздражающе несовершенным. Если вы проектируете и реализуете экраны вокруг определенного размера некоторого текста, это, скорее всего, вызовет проблемы при изменении размера шрифта.

Одно общее требование к дизайну заключается в том, чтобы все элементы в списке прокрутки были одинаковой высоты. Это нормально. Однако, если вы предполагаете, что весь текст поместится в одну и ту же одну или две строки, этот дизайн может сломаться по мере роста шрифта или текста. В этих случаях лучше:

  • обрежьте количество строк текста, которые должен отображать TextView;

  • выставите ellipsize параметр для overflow текста;

  • позвольте расширить или раскрыть любую недостающую информацию жестом.

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:lines="2"
    android:ellipsize="end"
    android:text="..."
/>

Ниже пример интерфейса с параметрами ellipsize и lines.

Untitled

Добавьте скролл

Если ваш дизайн предполагает, что весь текст всегда должен быть виден, а размер шрифта варьируется в зависимости от предпочтений пользователя, вы можете столкнуться, что текст просто обрезается, если он не вписывается в доступную высоту.

Контейнер для контента переменного размера всегда должен быть скроллящимся. Просто заверните контент в ScrollView, чтобы он мог скроллится, независимо от высоты/ширины элементов. Это даст пользователю возможность увидеть всё, независимо от размера устройства пользователя, выбранного им шрифта и используемого текста.

Untitled

Специальные возможности для проговаривания текста на экране

Адаптация интерфейса под большие шрифты — это хороший старт, однако для слепых никакое увеличение размера текста не поможет им прочитать этот текст, даже несмотря на то, что у них обычно все еще есть некоторое остаточное зрение. Чтобы приложение было полностью доступным для слепых, вы также должны убедиться, что текст на экране должен быть правильно прочитан программой чтения с экрана.

Для Android Google предоставляет две программы чтения с экрана: TalkBack и Select to Speak. Чтобы увидеть эти параметры в настройках специальных возможностей, у вас должен быть установлен Android Accessibility Suite. Его можно скачать в Google play.

Untitled

Основным является TalkBack. Он обеспечивает как доступную навигацию, так и преобразование текста в речь. Когда он включен, он полностью меняет способ работы сенсорного экрана.

Untitled

На устройствах с Android 9.0+ есть вторая программа для чтения с экрана — Select to Speak. Она удобна для сохранения сенсорной навигации при обеспечении быстрого доступа к чтению экрана.

Обычно вы используете ImageView в Android для отображения изображения. Это отлично работает для людей с нормальным зрением, но для людей с нарушениями этот образ, возможно, будет бесполезным. Android Studio даже предупредит вас об отсутствия атрибута contentDescription в XML.

Стоит назначить ImageView значение для contentDescription, которое описывает изображение. Оно будет прочитано программами чтения с экрана, когда они столкнутся с картинкой. Это похоже на то, как атрибут altработает для изображений в веб-браузерах.

Не везде есть смысл использовать contentDescription. Чисто декоративные изображения, которые не имеют значения, должны иметь значение @null, чтобы не говорить что-либо при чтении экрана. Чтобы полностью избавиться от элемента в программе чтения с экрана, можно воспользоваться атрибутом importantForAccessibility.

  • android:contentDescription="@null": когда пользователь дотронется элемента с этим атрибутом — службы доступности произнесут фиктивный текст, такой как "Кнопка" и т.п;

  • android:importantForAccessibility="no": проговаривание будет отключено для этого элемента, поэтому он не будет выделяться, и будет игнорироваться службами доступности.

Как себя поведет truncated или ellipsized текст?

Обычно для TextView вам ничего не нужно делать, чтобы программа чтения с экрана правильно прочитала отображаемый текст. По умолчанию она будет произносить весь текст, который вы в него поместили.

Если вы используете android:ellipsize="end" для автоматического обрезания длинного текста, чтобы он помещался в статическое вертикальное пространство, программы чтения с экрана будут использовать именно тот текст, который вы установили в представлении, а не только то, что отображается на экране. Таким образом, если программа чтения с экрана сталкивается с TextView, она просто будет говорить все содержимое текста, предоставленного кодом.

Программа чтения с экрана использует необработанные данные в иерархии представлений, а не пиксели на экране, хотя она использует положение каждого вида на экране, чтобы определить порядок их чтения.

Вы действительно хотите произнести весь hidden текст?

Подумайте на мгновение, что hidden текст в TextView может скрыть много дополнительного текста. Вы действительно хотите, чтобы программа чтения с экрана прочитала всё это? Если текст очень длинный, пользователю вашего приложения может потребоваться слишком много времени, чтобы прослушать его. Визуально может не быть никаких проблем с hidden текстом, но это может быть катастрофой UX.

Помните, что людям, использующим программы чтения с экрана, труднее просматривать большие объемы текста. Вы можете использовать сокращённую версию текста программами чтения с экрана. Сокращённая версия может укоротит текст до первого предложения, заканчивающегося точкой.

Однако обратите внимание, что точка может иметь больше значений, чем просто окончание предложения.

Лучшее решение заключается в том, чтобы предоставить совершенно новый фрагмент метаданных с явной целью чтения вслух. Эти метаданные можно настроить для использования с программами чтения с экрана.

Как адаптировать интерфейс в Compose

Все советы описанные мной выше также отлично подходят и для Jetpack Compose. Меняется лишь синтаксис. В этой части я хочу рассказать о том как адаптировать текст так, чтобы он автоматически подстраивал размер шрифта под контейнер любого размера.

Для этого давайте напишем обёртку над компонентом Text.

@Composable
fun AutoResizeText(
    text: String,
    fontSizeRange: FontSizeRange,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    // поставьте false, если хотите, чтобы текст
	// не переносился на новую строку
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    // тут определяем стиль текста (его размер)
    style: TextStyle = LocalTextStyle.current,
) {
    // состояние стиля текста, будет меняться в зависимости
    // от размера контейнера
    var fontSizeValue by remember { mutableFloatStateOf(fontSizeRange.max.value) }
    // состояние для плавной отрисовки изменений стиля
    var readyToDraw by remember { mutableStateOf(false) }

    Text(
        text = text,
        color = color,
        maxLines = maxLines,
        fontStyle = fontStyle,
        fontWeight = fontWeight,
        fontFamily = fontFamily,
        letterSpacing = letterSpacing,
        textDecoration = textDecoration,
        textAlign = textAlign,
        lineHeight = lineHeight,
        overflow = overflow,
        softWrap = softWrap,
        style = style,
        fontSize = fontSizeValue.sp,
        onTextLayout = { result ->
            // когда текст на влазит в контейнер по высоте
            if (result.didOverflowHeight && !readyToDraw) {
                val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value
                if (nextFontSizeValue <= fontSizeRange.min.value) {
                    // изменение размера текста
                    fontSizeValue = fontSizeRange.min.value
                    readyToDraw = true
                } else {
                    // если текст все еще не помещается, продолжаем
					// уменьшать его размер
                    fontSizeValue = nextFontSizeValue
                }
            } else {
                readyToDraw = true
            }
        },
        // если состояние было изменено, отрисовываем его
        modifier = modifier.drawWithContent { if (readyToDraw) drawContent() }
    )
}

// класс для установки диапозона размера шрифта
// размер будет варьироваться от мин. до макс.
// пока не поместится в контейнер
data class FontSizeRange(
    val min: TextUnit,
    val max: TextUnit,
    val step: TextUnit = DEFAULT_TEXT_STEP,
) {
    init {
        require(min < max) { "min should be less than max, $this" }
        require(step.value > 0) { "step should be greater than 0, $this" }
    }

    companion object {
        private val DEFAULT_TEXT_STEP = 1.sp
    }
}

Этот компонент можно доработать под себя. Но основная мысль, думаю понятна. Далее мы будем использовать этот компонент для адаптации элемента из прошлого приложения.

Если же вы не хотите адаптировать размер шрифта по какой-то причине, то вы можете написать extension для игнорирования размера шрифта в системе.

@Composable
fun Int.scaledSp(): TextUnit {
    val value: Int = this
    return with(LocalDensity.current) {
        val fontScale = this.fontScale
        val textSize =  value / fontScale
        textSize.sp
}

val Int.scaledSp: TextUnit
    @Composable get() = scaledSp()

Text(text = "Hello World", fontSize = 20.scaledSp)

Тестирование интерфейса с варьирующимся размером шрифтов в Compose

В Android Studio Iguana появилась новая возможность для тестирования Compose-интерфейсов для проверки compatibility.

Untitled

На любом Preview Compose функции вы можете нажать на кнопку Start UI Check Mode. После чего этот компонент/экран отобразится с разными UI конфигурациями.

С появлением новой версии Android Studio, также вышла стабильная версия Jetpack Compose 1.6.0, которая привнесла новые аннотации для Preview. Нас сейчас интересует только — @PreviewFontScale. Добавьте эту аннотацию к вашей Compose Preview функции. Ниже экран с вариативным размером шрифта.

Untitled

Продолжим адаптацию экрана приложения

Продолжим разработку экрана приложения. В прошлый раз мы адаптировали XML и Compose версию экрана под RTL.

Untitled

В этой статье я доработаю этот экран для людей, у которых есть проблемы со зрением. На размерах шрифта в 180%, 200% плашка выглядит криво.

Untitled

Предлагаю попробовать применить AutoResizeText компонент, который мы рассмотрели выше. Перепишем наш прошлый код для отображения текста в плашке «Популярный». До переписывания:

Text(
	text = stringResource(id = R.string.popular_label),
	style = GeometriaTextRegular12,
	modifier = modifier
		.widthIn(min = 100.dp)
		.alpha(if (subscriptionData.subscriptionType == SubscriptionType.MONTH) 100f else 0f)
		.background(
			color = MaterialTheme.colorScheme.primaryContainer,
			shape = RoundedCornerShape(2.dp)
		)
		.padding(vertical = 10.dp, horizontal = 15.dp),
	color = MaterialTheme.colorScheme.onPrimaryContainer,
	textAlign = TextAlign.Center,
)

После переписывания:

Box(
  modifier = modifier
		.width(width = 100.dp)
		.alpha(alpha = if (subscriptionData.subscriptionType == SubscriptionType.MONTH) 100f else 0f)
		.background(
			color = MaterialTheme.colorScheme.primaryContainer,
			shape = RoundedCornerShape(size = 2.dp)
        )
		.padding(vertical = 10.dp, horizontal = 15.dp)
		.align(alignment = Alignment.CenterVertically),
) {
	AutoResizeText(
		text = stringResource(id = R.string.popular_label),
		style = GeometriaTextRegular12,
		maxLines = 1,
		color = MaterialTheme.colorScheme.onPrimaryContainer,
		fontSizeRange = FontSizeRange(
			min = 12.sp,
			max = 14.sp,
		),
		overflow = TextOverflow.Ellipsis,
	)
}

Размер текста на плашке адаптировали. Ещё одна проблема в плашке на этом экране — буллет-лист. Если шрифт слишком большой, буллет ставится по середине двух линий. Для того, чтобы это исправить напишем обёртку над текстом для.

@Suppress("NOTHING_TO_INLINE")
@Stable
inline fun <T> T.toImmutableWrapper(): ImmutableWrapper<T> = ImmutableWrapper(this)

@Immutable
data class ImmutableWrapper<T>(val value: T)

@Composable
fun Int.toSp() = with(LocalDensity.current) { this@toSp.toSp() }

fun String.bulletWithHighlightedTxtAnnotatedString(
    bullet: String,
    restLine: TextUnit,
    highlightedTxt: String,
) = buildAnnotatedString {
    split("\n").forEach {
        var txt = it.trim()
        if (txt.isNotBlank()) {
            withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = restLine))) {
                append(bullet)
                if (highlightedTxt.isNotEmpty()) {
                    while (true) {
                        val i = txt.indexOf(string = highlightedTxt, ignoreCase = true)
                        if (i == -1) break
                        append(txt.subSequence(startIndex = 0, endIndex = i).toString())
                        val j = i + highlightedTxt.length
                        withStyle(style = SpanStyle()) {
                            append(txt.subSequence(startIndex = i, endIndex = j).toString())
                        }
                        txt = txt.subSequence(startIndex = j, endIndex = txt.length).toString()
                    }
                }
                append(txt)
            }
        }
    }
}

@Composable
fun BulletText(
    text: String,
    modifier: Modifier = Modifier,
    bullet: String = "\u2022\u00A0\u00A0",
    highlightedText: String = "",
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    minLines: Int = 1,
    inlineContent: ImmutableWrapper<Map<String, InlineTextContent>> = mapOf<String, InlineTextContent>().toImmutableWrapper(),
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current,
) {
    val restLine = run {
        val textMeasurer = rememberTextMeasurer()
        remember(bullet, style, textMeasurer) {
            textMeasurer.measure(text = bullet, style = style).size.width
        }.toSp()
    }
    Text(
        text = remember(text, bullet, restLine, highlightedText) {
            text.bulletWithHighlightedTxtAnnotatedString(
                bullet = bullet,
                restLine = restLine,
                highlightedTxt = highlightedText,
            )
        },
        overflow = overflow,
        softWrap = softWrap,
        maxLines = maxLines,
        minLines = minLines,
        inlineContent = inlineContent.value,
        onTextLayout = onTextLayout,
        style = style,
        modifier = modifier,
    )
}

И воспользуемся ей в коде карточки.

val bulletList =
    subscriptionData.subscriptionOfferBulletList.map { stringResource(id = it) }

BulletText(
	text = bulletList.joinToString("\n"),
	modifier = Modifier.padding(8.dp),
	overflow = TextOverflow.Ellipsis,
	onTextLayout = {},
)

Исправленный вариант плашки:

Untitled

Вот так выглядит интерфейс в режиме увеличения шрифта в 200%.

Untitled

Теперь давайте добавим проговаривание текста. Логотип это контекстуально бесполезный элемент, его мы опустим. А вот стрелочку адаптируем. Укажем параметр contentDescription для стрелочки:

Icon(
	painter = painterResource(id = R.drawable.ic_left_arrow),
	contentDescription = "Назад", // система дополнительно скажет «кнопка»
	tint = MaterialTheme.colorScheme.onSurface,
)

Проверим работоспособность. Сперва включим режим проговаривание в настройках. Для этого на устройстве должно быть установлено приложение «Специальные возможности».

Готовый код проекта вы можете найти в Github репозитории.

Итог

Адаптировать интерфейс для людей с ограничениями по зрению — важно. Не игнорируйте советы Android studio, когда она говорит добавить тот или иной параметр для поддержки людей с ограниченными возможностями. У большого количества людей есть проблемы со зрением, обратите внимание на людей на улице, своих друзей/коллег/семью. Советую посмотреть YouTube ролик — незрячий человек рассказывает как он пользуется смартфоном, ноутбуком и другими гаджетам.

Если вы нашли неточности/ошибки в статье или просто хотите дополнить её своим мнением — то прошу в комментарии! Или можете написать мне в Telegram — t.me/MolodoyDenis.

Теги:
Хабы:
Всего голосов 15: ↑15 и ↓0+15
Комментарии3

Публикации

Истории

Работа

Ближайшие события

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань