Irbisa · Шпаргалка

Доступность и локализация в Android

TalkBack, contentDescription, semantics в Compose, plurals, язык приложения, RTL, масштаб шрифта

Middle-разработчик · 21 вопрос

  1. 01

    Что входит в доступность Android-приложения?

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

  2. 02

    Что такое contentDescription и как подписать кнопку-иконку в Compose и во View?

    contentDescription задаёт текст, который TalkBack читает для элемента без видимого текста.

    // Wrong: no label at all. TalkBack focuses it and says just "button".IconButton(onClick = ::share) {  Icon(Icons.Default.Share, contentDescription = null)} // Right: IconButton merges its children, so the icon's label becomes the button's…
  3. 03

    Как локализовать Android-приложение и что в нём должно быть с самого начала, чтобы его можно было перевести?

    Все строки, которые видит пользователь, держите в res/values/strings.xml, переводы кладите в папки values-<язык>, а грамматику отдайте ресурсам: позиционные плейсхолдеры, <plurals>, разметка через start/end, форматирование с учётом локали и поддержка per-app language.

  4. 04

    Как скрыть от TalkBack декоративный элемент или целое поддерево в Compose и во View?

    Декор помечается contentDescription = null в Compose или importantForAccessibility="no" во View; целое поддерево убирается через Modifier.clearAndSetSemantics { } или noHideDescendants.

    // Decoration in Compose: no description means no node, so TalkBack skips itImage(painterResource(R.drawable.header_gradient), contentDescription = null) // Hide one node from services but keep it addressable in testsText("3", Modifier.semantics { hideFromAccessibility() }.testTag("unreadBadge"))…
  5. 05

    Каким должен быть минимальный размер зоны касания и контраст в Android UI и как получить 48dp, не перерисовывая иконку?

    Дайте каждому контролу зону касания 48x48dp с 8dp между зонами и держите контраст основного текста не ниже 4.5:1: зона вырастает вокруг иконки 24dp без перерисовки, а контраст зависит от реальных цветов за пикселем.

    // Modifier order decides the target: padding AFTER clickable is inside itIcon(  Icons.Default.Close,  contentDescription = stringResource(R.string.close),  modifier = Modifier    .clickable(onClick = onClose)…
  6. 06

    Что в Compose меняют в речи TalkBack mergeDescendants, clearAndSetSemantics, stateDescription и role?

    mergeDescendants превращает поддерево в одну остановку фокуса, clearAndSetSemantics целиком его заменяет, stateDescription переименовывает произносимое состояние («Подписан» вместо «вкл.»), а role говорит, что это за контрол.

    // literals for brevity; use stringResource in real code// One stop, one sentence instead of five starsRow(  Modifier.semantics(mergeDescendants = true) {    contentDescription = "Rating: 4 out of 5"  },…
  7. 07

    Что TalkBack озвучит для этой строки настроек и как это исправить?

    @Composablefun WifiRow(checked: Boolean, onToggle: (Boolean) -> Unit) {  Row(    Modifier      .fillMaxWidth()      .clickable { onToggle(!checked) }      .padding(16.dp),    verticalAlignment = Alignment.CenterVertically,  ) {    Text("Wi-Fi", Modifier.weight(1f))    Switch(checked = checked, onCheckedChange = onToggle)  }}

    TalkBack делает две остановки: строка («Wi-Fi», без состояния) и Switch («Вкл., переключатель», без метки).

  8. 08

    Как сделать удаление свайпом и перетаскивание доступными для пользователей TalkBack и Switch Access?

    Кастомные действия доступности: у каждой возможности, доступной только жестом, должно быть именованное действие на узле, и TalkBack покажет его в меню действий.

    // Views: actions the user reaches from TalkBack's actions menuViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.delete)) { _, _ ->  onDelete(item.id); true}ViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.move_up)) { _, _ ->  onMove(item.id, offset = -1); true…
  9. 09

    Как сообщить пользователю TalkBack об изменении, например о завершении загрузки, и почему announceForAccessibility объявлен устаревшим?

    Используйте live region: узел сам озвучивается при изменении своего текста, а фокус не переезжает.

    // Deprecated in Android 16 (API 36): interrupts and leaves nothing to go back tostatusView.announceForAccessibility(getString(R.string.upload_finished)) // A live region speaks when its own text changes; focus stays where it wasViewCompat.setAccessibilityLiveRegion(  binding.status,…
  10. 10

    Открывается самописная шторка фильтров, TalkBack продолжает читать список под ней, а после закрытия фокус прыгает наверх. Как это исправить?

    Шторка не модальна в дереве узлов, и никто не возвращает фокус на кнопку, открывшую её: используйте настоящее модальное окно (ModalBottomSheet, Dialog) или скройте содержимое под ней, а при закрытии верните фокус на открывшую кнопку.

    // A real modal window hides what is behind it and is announced as a new pane.// On dismiss, hand focus back to the control that opened it.var open by remember { mutableStateOf(false) }val filterButton = remember { FocusRequester() }IconButton(onClick = { open = true }, modifier = Modifier.focusRequester(filterButton)) {  Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.filters))…
  11. 11

    Почему размер текста задают в sp и что изменило нелинейное масштабирование шрифта в Android 14?

    Настройке размера шрифта следует только текст в sp.

    // Wrong: text sized in dp, in a box that cannot growRow(Modifier.fillMaxWidth().height(56.dp)) {                  // clips at 200%  Text(    item.title,    fontSize = with(LocalDensity.current) { 16.dp.toSp() },   // cancels the font scale    maxLines = 1,…
  12. 12

    Как тестировать доступность на Android и чего не видят Accessibility Scanner и автоматические проверки?

    Автоматические проверки доказывают, что подпись, размер таргета и контраст существуют; понять, значит ли подпись хоть что-нибудь и можно ли довести сценарий до конца, способен только человек с включённым TalkBack.

    // View tests: one setup call turns ATF on for every Espresso interaction@Before fun enableChecks() {  AccessibilityChecks.enable()    .setRunChecksFromRootView(true)              // the whole screen, not just the view    .setSuppressingResultMatcher(                // carry one tracked failure      matchesViews(withId(R.id.legacy_banner)),…
  13. 13

    Найдите баг: кнопка лайка работает от пальца, но когда пользователь TalkBack делает на ней двойное касание, ничего не происходит.

    // Like button: shrinks while pressed, toggles the like on releasebinding.likeButton.setOnTouchListener { view, event ->  when (event.actionMasked) {    MotionEvent.ACTION_DOWN -> view.animate().scaleX(0.85f).scaleY(0.85f)    MotionEvent.ACTION_UP -> {      view.animate().scaleX(1f).scaleY(1f)      viewModel.toggleLike(post.id)    }    MotionEvent.ACTION_CANCEL -> view.animate().scaleX(1f).scaleY(1f)  }  true}

    Лайк переключается только внутри OnTouchListener, который поглощает все события, а TalkBack, Switch Access и клавиатура касаний не присылают: они вызывают performClick(), у которого нет click-листенера, поэтому для них кнопка ничего не делает.

  14. 14

    Откуда TalkBack берёт дерево узлов и как открыть для него части графика, нарисованного на Canvas?

    Узла не существует, пока служба доступности его не запросила: фреймворк собирает объекты AccessibilityNodeInfo по требованию из дерева View, а у всего, что вы нарисовали, а не разложили, узла нет, пока вы его не создадите.

    // Ten bars on one canvas are ONE node until you publish virtual onesclass ChartTouchHelper(private val view: ChartView) : ExploreByTouchHelper(view) {   override fun getVirtualViewAt(x: Float, y: Float): Int =    view.barIndexAt(x, y) ?: HOST_ID…
  15. 15

    Что не так с этими строковыми ресурсами, когда приложение выходит на русском, и как это исправить?

    <!-- res/values/strings.xml --><string name="cart_count">%d items in cart</string><string name="moved">Moved %s to %s</string> <!-- res/values-ru/strings.xml --><string name="cart_count">%d товаров в корзине</string><string name="moved">В папку %s перемещён файл %s</string> <!-- call sites  getString(R.string.cart_count, count)  getString(R.string.moved, file.name, folder.name)-->

    cart_count должен быть ресурсом <plurals>: в русском разные формы слова для 1, 2–4 и 5+.

  16. 16

    В values/ у вас английский, есть values-ru; в списке языков пользователя украинский, затем русский. Какие строки он увидит и почему?

    Русские. Начиная с API 24 у пользователя упорядоченный LocaleList, и Android проходит его целиком, прежде чем откатиться к values/ без квалификатора, так что второй выбор пользователя побеждает ваш язык по умолчанию.

  17. 17

    Как реализовать переключатель языка внутри приложения через per-app language на Android 13+ и на старых версиях?

    Объявите поставляемые локали в locales_config.xml и меняйте язык только через AppCompatDelegate.setApplicationLocales(): на Android 13+ вызов уходит в системный LocaleManager, ниже 13 AppCompat сам сохраняет и применяет выбор.

    // res/xml/locales_config.xml lists the shipped locales and is referenced by// <application android:localeConfig="@xml/locales_config">,// or generated by AGP: androidResources { generateLocaleConfig = true } // One call, every API level. 33+ hands it to LocaleManager and recreates the// activities; below 33 AppCompat stores and re-applies it (autoStoreLocales).…
  18. 18

    Найдите баг: поиск мест рядом работает на телефонах с английским языком, но ничего не находит, если язык телефона русский или немецкий.

    class PlacesRepository(private val http: HttpClient) {   suspend fun nearby(lat: Double, lon: Double, radiusKm: Double): List<Place> {    val url = "https://api.example.com/places?lat=%.6f&lon=%.6f&radius=%.1f"      .format(lat, lon, radiusKm)    return http.get(url).body()  }}

    .format берёт локаль устройства по умолчанию, поэтому на телефоне с русским или немецким языком в URL уходит lat=55,755800 с запятой, которую сервер не может распарсить, и поиск падает.

  19. 19

    Как поддержать языки с письмом справа налево и что Android не отзеркалит за вас?

    С android:supportsRtl="true" фреймворк зеркалит атрибуты вёрстки и drawable с автозеркалированием, и это весь список: всё, что вы позиционировали абсолютно, нарисовали сами или положили как обычный направленный ассет, останется смотреть туда, куда вы его нарисовали.

    // Mirrors for free: start/end everywhere, never left/rightRow(Modifier.padding(start = 16.dp, end = 8.dp)) {  Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))  Text(order.title, Modifier.weight(1f))}…
  20. 20

    Как форматировать даты, числа и валюты под локаль пользователя и что ломается с самописными паттернами и toDouble()?

    Всегда идите через форматтер, знающий про локаль (DateTimeFormatter.ofLocalizedDate, NumberFormat.getCurrencyInstance с явной Currency, NumberFormat.parse), потому что написанные руками паттерны и toDouble() ломаются на других цифрах и разделителях.

    // Wrong: three bugs in three linesval date = SimpleDateFormat("dd/MM/yyyy").format(order.date)  // hand-made patternval total = "$" + String.format("%.2f", order.amount)         // always dollarsval parsed = input.toDouble()                                 // throws on "1.234,50" // Right: the locale formats; the data decides what is being formatted…
  21. 21

    Как выстроить процесс локализации, чтобы переводы, пришедшие за несколько дней до релиза, ничего не сломали?

    Всё, кроме самих переводов, можно сделать заранее: псевдолокали доказывают, что вёрстка выживет, lint доказывает, что не осталось захардкоженных строк, а заморозка строк определяет, что именно получат переводчики.