Мобильный UI и навигация: паттерны, жесты, адаптация под экраны
Интерфейс мобильного приложения выглядит как упрощённый веб: меньше экран, крупнее кнопки, меньше элементов. Это самое дорогое заблуждение трека. Мобильный UI отличается не размером холста, а условиями исполнения: указатель — палец шириной 8–10 мм без наведения и без правого клика; сессия прерывается звонком в любую секунду; система вправе убить процесс и потом попросить нарисовать тот же экран заново; половина жестов уже занята операционной системой; а исправить неудачную навигацию можно только новым релизом, который поедет через ревью и раскатку.
Дальше — интерфейс как инженерная задача: что диктует физика ввода, почему навигация является структурой данных, а не набором переходов, как устроено разрешение конфликтов жестов, какие состояния экрана обязательны и как адаптироваться к ширине окна вместо гадания «телефон или планшет». Ограничения процесса и песочницы разобраны в статье о платформах, слои и хранение состояния — в следующей статье про архитектуру.
Чем мобильный UI отличается от веб-интерфейса
| Что | Веб на десктопе | Мобильное приложение |
|---|---|---|
| Указатель | курсор ~1 px, есть hover и right-click | палец ~44 pt, нет hover, нет второй кнопки |
| Возврат назад | кнопка браузера, одинаковая для всех сайтов | системный жест или кнопка на Android, только внутриэкранный возврат на iOS |
| Единица раскладки | CSS-пиксель, медиазапросы по вьюпорту | pt/dp с масштабом устройства, size class окна |
| Прерывания | вкладка спокойно живёт в фоне | звонок, шторка, сплит-скрин, смерть процесса |
| Цена анимации | кадры «на розетке» | кадры из батареи, троттлинг при нагреве |
| Ожидания пользователя | «как в интернете» | «как в других приложениях на этой ОС» |
| Исправление ошибки | деплой за минуты | релиз, ревью, раскатка; часть людей не обновится никогда |
Из последней строки следует вывод, меняющий приоритеты: ошибка навигации дороже ошибки визуала. Кривой отступ переживут, а экран, из которого нельзя выйти системной кнопкой «назад», породит поток одинаковых отзывов в сторе — и жить с ним придётся всю неделю раскатки.
Физика ввода: палец вместо курсора
Точность нажатия описывается законом Фиттса: время попадания растёт с расстоянием и падает с размером цели (P. M. Fitts, 1954). На десктопе оба параметра под контролем — курсор точен, цели можно делать маленькими. На телефоне размер цели ограничен снизу анатомией: контактное пятно пальца даёт разброс в несколько миллиметров, и уменьшение кнопки ниже порога превращает промахи в лавину. Отсюда цифры гайдлайнов: 44×44 pt на iOS, 48×48 dp на Android, а WCAG 2.2 требует абсолютного минимума 24×24 CSS-пикселя (Target Size).
Второй фактор — досягаемость. Около 75% людей работают с телефоном одной рукой и большим пальцем (исследование Стивена Хубера, UXmatters), а экраны с тех пор выросли. Верхний дальний угол физически неудобен — именно поэтому нижние панели вытеснили верхние меню, а адресная строка Safari уехала вниз.
Ключевое следствие: размер иконки и размер цели нажатия — разные величины. Иконка 24 dp живёт внутри цели 48 dp; расширяется зона, а не картинка.
// Android, Compose: цель — 48.dp, иконка внутри неё — 24.dp.
IconButton(onClick = onClose, modifier = Modifier.size(48.dp)) { // зона нажатия
// contentDescription обязателен: без него TalkBack прочитает просто «кнопка»
Icon(Icons.Default.Close, "Закрыть", modifier = Modifier.size(24.dp)) // визуальный размер
}
// iOS, SwiftUI: без contentShape тап ловят только непрозрачные пиксели символа —
// нажатие «рядом с крестиком» внутри рамки молча теряется.
Image(systemName: "xmark")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.onTapGesture(perform: close)
.accessibilityLabel("Закрыть")
Три ошибки, которые видно на любом ревью макета: цели меньше нормы; две цели вплотную без 8 dp просвета; деструктивное действие рядом с частым в зоне большого пальца — «Удалить» под «Отправить» гарантирует поток жалоб.
Единицы, плотность и типографика
В вебе есть один CSS-пиксель. На мобильном — три сущности сразу: физический пиксель, независимая от плотности единица (pt на iOS, dp на Android) и масштаб шрифта.
- iOS: 1 pt = 1 px на @1x, 2 px на @2x, 3 px на @3x. Вы верстаете в pt, растеризацию берёт на себя система.
- Android: 1 dp = 1 px при 160 dpi, на xxhdpi это уже 3 px. Плотностей больше, и они не кратны красиво — верстать в px нельзя в принципе.
- Шрифты на Android задают в sp: эта единица дополнительно умножается на системный масштаб. На iOS ту же работу делает Dynamic Type для системных стилей.
Пользователь имеет право увеличить шрифт до 200% и больше, и делает это чаще, чем кажется. Поэтому фиксированная высота контейнера с текстом — баг, а не «стабильная вёрстка»: ограничивать надо снизу.
Text("Оформить заказ")
.font(.body) // системный стиль масштабируется сам
.lineLimit(nil)
.frame(maxWidth: .infinity, minHeight: 44) // minHeight, а не height
// Compose-эквивалент: Modifier.heightIn(min = 48.dp) и стиль из MaterialTheme.typography
Проверка занимает минуту: включите максимальный размер шрифта и пройдите ключевые экраны. Обрезанные подписи, съехавшие кнопки и трёхстрочные заголовки в шапке — типичный улов. Подробнее — в статье Визуальная доступность.
UI = f(state): один принцип на четыре стека
SwiftUI, Jetpack Compose, Flutter и React Native пришли к одной модели: интерфейс — чистая функция от состояния, а разницу вычисляет фреймворк. Императивное «найти вью и присвоить ему текст» осталось в UIKit и старой View-системе Android — знать его нужно ровно для поддержки легаси.
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) } // состояние, а не ссылка на вью
Column { Text("Нажатий: $count"); Button(onClick = { count++ }) { Text("Ещё") } }
}
Ровно так же устроены @State в SwiftUI, setState в State<T> во Flutter и useState в React Native. Навык переносится между стеками почти целиком, и это весомый аргумент при найме: сильный Compose-разработчик осваивает SwiftUI за недели, а не за полгода. Но у декларативности есть цена — состояние надо где-то держать так, чтобы оно пережило поворот экрана и смерть процесса. Идеи те же, что в статье Управление состоянием, с одним отличием: в вебе требования пережить process death просто нет.
Навигация: четыре контейнера и один стек
Почти вся путаница в приложениях рождается из того, что выбран не тот примитив.
- Стек — push/pop. Экраны продолжают одну задачу: список → карточка → отзывы. У каждого экрана есть родитель, возврат предсказуем.
- Вкладки — параллельные разделы, между которыми переключаются часто. Каждая вкладка держит свой стек, свой скролл и своё состояние.
- Модальные экраны и листы — задача с началом и концом, которую можно отменить: выбор адреса, фильтры, оплата. Модалка выключает контекст под собой.
- Боковое меню — нормально для Android как часть Material; на iOS выглядит чужеродно и уводит функции в невидимую зону.
Куда его поместить?"] --> Q1{"Самостоятельный раздел,
куда возвращаются постоянно?"} Q1 -->|"да, разделов 3-5"| TABS["Вкладки снизу,
свой стек в каждой"] Q1 -->|"да, но их больше 5
или заходят редко"| MORE["Раздел «Ещё» или drawer.
На iOS — крайний случай"] Q1 -->|нет| Q2{"Экран продолжает
текущую задачу?"} Q2 -->|да| PUSH["push в текущий стек:
заголовок, «назад», свайп от края"] Q2 -->|"нет, отдельная задача"| Q3{"Её можно отменить
одним действием?"} Q3 -->|да| SHEET["Модальный лист:
«Отмена» или крестик"] Q3 -->|"нет, мастер из шагов"| FLOW["Модальный стек:
свой back внутри, выход явный"] TABS --> CHK{"Куда ведёт системная
кнопка «назад»?"} PUSH --> CHK SHEET --> CHK FLOW --> CHK MORE --> CHK CHK -->|"туда, откуда пришли"| OK["Ок"] CHK -->|"в неожиданное место
или из приложения"| BAD["Переделать: главный источник
однозвёздочных отзывов"]
Две платформенные детали ломают наивные реализации.
Android: «назад» — системный ресурс. Кнопка или жест существуют всегда, и пользователь ждёт от них «отмени последнее». С Android 13+ жест стал предиктивным: система показывает анимацию выхода ещё до отпускания пальца, поэтому перехват «в лоб» ломает анимацию. Правильный путь — OnBackPressedDispatcher/BackHandler, включаемый только когда он действительно нужен (predictive back).
// Перехватываем «назад» только пока есть несохранённый черновик, иначе не мешаем системе.
BackHandler(enabled = draft.hasUnsavedChanges) { showDiscardDialog = true }
iOS: системной кнопки назад нет. Возврат — это свайп от левого края и кнопка в навбаре. Подменив её кастомной, легко потерять interactivePopGestureRecognizer — и часть пользователей физически лишится выхода с экрана.
Навигация как данные, а не как последовательность вызовов
Императивная навигация — pushViewController, startActivity, Navigator.push — прячет состояние навигации внутри UI-компонента. На пяти экранах это работает; на двадцати появляются вопросы без ответа: как открыть третий экран, минуя второй; как восстановить стек после смерти процесса; как написать тест на переход. Современный подход противоположен: стек — сериализуемая структура данных, а UI — её отражение.
Что это даёт: диплинк строит весь стек сразу и подменяет его атомарно; тест на навигацию превращается в тест чистой функции; аналитика получает имя экрана бесплатно; восстановление после process death — это десериализация списка.
// iOS: путь навигации — обычный массив значений, связанный с NavigationStack.
enum Route: Hashable { case list(category: String), details(id: String), checkout(cartId: String) }
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeScreen().navigationDestination(for: Route.self) { route in
switch route {
case .list(let c): ProductListScreen(category: c)
case .details(let id): ProductDetailsScreen(id: id)
case .checkout(let c): CheckoutScreen(cartId: c)
}
}
}
// Диплинк заменяет стек целиком, а не «доталкивает» экран поверх случайного места.
.onOpenURL { url in path = DeepLinkParser.stack(for: url) }
}
}
// Android, Navigation Compose: типобезопасные маршруты вместо шаблонов "product/{id}".
@Serializable data object Home
@Serializable data class ProductList(val category: String, val onlyInStock: Boolean = false)
@Serializable data class ProductDetails(val productId: String)
@Composable
fun AppNavHost(nav: NavHostController) {
NavHost(navController = nav, startDestination = Home) {
composable<Home> { HomeScreen(onOpenCategory = { nav.navigate(ProductList(it)) }) }
composable<ProductList> { e ->
val args = e.toRoute<ProductList>() // типизированный доступ к параметрам
ProductListScreen(args.category, args.onlyInStock)
}
composable<ProductDetails> { e -> ProductDetailsScreen(e.toRoute<ProductDetails>().productId) }
}
}
// Flutter, go_router: вложенность путей задаёт back-стек, redirect — единственная точка,
// где решается «пускать ли сюда вообще».
final router = GoRouter(routes: [
GoRoute(path: '/', builder: (c, s) => const HomeScreen(), routes: [
GoRoute(path: 'product/:id', // '/product/42' восстановит и '/' под собой
builder: (c, s) => ProductScreen(id: s.pathParameters['id']!)),
]),
], redirect: (c, s) {
if (c.read<Session>().isLoggedIn || s.matchedLocation == '/login') return null;
return '/login?from=${Uri.encodeComponent(s.uri.toString())}';
});
В React Native ту же роль играет типизированный RootStackParamList в React Navigation: navigation.navigate('ProductDetails', { id }) перестаёт компилироваться при опечатке, вместо того чтобы открывать пустой экран у пользователя. Идея совпадает с веб-роутингом (см. Роутинг и рендеринг) с одной разницей: в браузере URL хранит состояние навигации бесплатно, а в мобильном приложении вы обязаны хранить и восстанавливать его сами.
Диплинки, холодный старт и восстановление стека
Диплинк — это не «открыть экран». Это требование собрать правдоподобную историю навигации в приложении, которое, возможно, не запущено, у пользователя, который, возможно, не авторизован.
Universal Links / App Links alt домен подтверждён OS->>A: холодный старт с URL else подпись домена не проверена OS->>U: открывается браузер, приложение не участвует end A->>N: DeepLinkParser строит стек
Home → ProductList → ProductDetails alt сессия валидна N-->>A: показать ProductDetails, под ним живой back-стек A->>API: загрузка карточки else требуется вход N->>N: сохранить целевой стек как pendingIntent A-->>U: экран входа U-->>A: успешный вход N->>N: восстановить pendingIntent и применить end Note over A,N: Ошибка новичка — показать экран товара поверх пустого стека:
«назад» выбросит из приложения, метрика возвратов упадёт.
- Верификация домена обязательна. Без
apple-app-site-associationдля Universal Links иassetlinks.jsonдля App Links ссылка уйдёт в браузер. Кастомная схемаmyapp://работает, но зарегистрировать её может кто угодно — это средство отладки, а не доставки. - Синтетический back-стек. Открывая глубокий экран, восстановите путь к нему: на Android для этого есть
TaskStackBuilder, в современных роутерах — просто список маршрутов. - Парсинг до авторизации. Сначала распарсить и сохранить намерение, потом авторизовать, потом применить. Обратный порядок теряет диплинк после логина — классический баг воронки.
- Идемпотентность. Двойной тап по ссылке не должен порождать два экрана; общая теория — в статье Идемпотентность и доставка.
Отдельная тема — восстановление после смерти процесса. Система убивает фоновое приложение и через час возвращает пользователя «в то же место»: iOS восстанавливает сцену, Android пересоздаёт задачу с savedInstanceState. Если NavState жил только в памяти, человек увидит стартовый экран вместо корзины, которую заполнял. Проверяется это одной командой:
adb shell am kill com.example.app # ровно то, что делает система под нехваткой памяти
# затем вернуться в приложение из списка недавних: стек, скролл, черновики форм на месте?
Пять обязательных состояний экрана
Экран с данными имеет минимум пять состояний, и «забыли нарисовать» — самая частая претензия дизайн-ревью. В мобильном добавляется шестое, которого нет в вебе: актуальные только что, но устаревшие сейчас данные без сети.
// Закрытая иерархия состояний: компилятор заставит обработать все ветки when.
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data object Empty : UiState<Nothing>
// isStale — данные из кэша при недоступной сети, isRefreshing — обновление поверх контента
data class Content<T>(val data: T, val isStale: Boolean = false, val isRefreshing: Boolean = false) : UiState<T>
data class Failure(val reason: Reason) : UiState<Nothing>
enum class Reason { NoNetwork, ServerError, Forbidden, Unknown }
}
- До 100 мс индикатор не нужен: мигание раздражает сильнее ожидания.
- 100 мс – 1 с — скелетон, повторяющий будущую раскладку: он снижает воспринимаемую задержку и не даёт контенту «прыгать».
- Больше 1 с — скелетон плюс возможность уйти с экрана; спиннер без выхода — ловушка.
- Пустое состояние — экран продукта, а не заглушка. Пустая корзина с кнопкой «В каталог» работает на конверсию лучше строки «Список пуст».
- Оптимистичный UI для действий, которые почти всегда успешны: лайк, «прочитано», добавление в корзину — показать результат сразу, откатить при ошибке. Правила отката и разрешения конфликтов — в статье Данные и оффлайн.
Жесты: кто выигрывает касание
Жест — не «обработчик события», а результат соревнования распознавателей за поток касаний. Понимание этого механизма отделяет работающий свайп от «иногда срабатывает».
Конфликты разрешаются по-разному. iOS/UIKit строит граф зависимостей распознавателей — require(toFail:), shouldRecognizeSimultaneouslyWith, приоритеты; в SwiftUI то же выражают simultaneousGesture, highPriorityGesture и exclusively. Compose даёт pointerInput со сканером событий и явным потреблением касания, а вложенные скроллы координирует nestedScroll: кто событие «съел», тот и выиграл. Flutter использует арену жестов, где распознаватели по очереди объявляют победу или сдаются, и выигрывает первый уверенный.
// Свайп по строке. Порог minimumDistance бережёт системный «назад»,
// а проверка доминирующей оси не даёт украсть вертикальный скролл списка.
RowContent()
.offset(x: offset)
.gesture(
DragGesture(minimumDistance: 20)
.onChanged { v in
guard abs(v.translation.width) > abs(v.translation.height) else { return }
offset = min(0, v.translation.width) // только влево
}
.onEnded { _ in
if -offset > 120 { onArchive() } else { withAnimation(.snappy) { offset = 0 } }
}
)
.accessibilityAction(named: "Архивировать", onArchive) // жест невидим для VoiceOver
// Готовый компонент. Направление слева-направо намеренно выключено:
// эта полоса принадлежит системному жесту «назад».
items(items = messages, key = { it.id }) { message ->
val state = rememberSwipeToDismissBoxState(confirmValueChange = { v ->
if (v == SwipeToDismissBoxValue.EndToStart) { onArchive(message.id); true } else false
})
SwipeToDismissBox(state, { ArchiveBackground() }, enableDismissFromStartToEnd = false) {
MessageRow(message)
}
}
Правило, экономящее недели: жест — ускоритель, а не единственный путь. Всё, что делается свайпом, должно делаться и видимым элементом, иначе функцией не пользуются те, кто о ней не знает, и она недоступна скринридеру (см. Скринридеры).
Списки: главный источник тормозов
Почти весь мобильный UI — списки, и почти весь мобильный jank рождается в них. Механика везде одна: окно переиспользования, в котором живёт десяток элементов, а остальных не существует.
// key даёт стабильную идентичность: без него вставка в начало перерисует весь список
// и «поедут» анимации. contentType помогает переиспользовать разнотипные строки.
LazyColumn {
items(items = feed, key = { it.id }, contentType = { it.kind }) { item -> FeedRow(item) }
}
// ListView.builder создаёт только видимые элементы; ListView(children: [...]) на 2000 строк
// построит все 2000 сразу — частая причина фриза при открытии экрана.
ListView.builder(itemCount: feed.length, itemBuilder: (c, i) => FeedRow(item: feed[i]));
Бюджет кадра — 16.6 мс при 60 Гц и 8.3 мс при 120 Гц; всё, что не уложилось, пользователь читает как «дешёвое приложение». Типичные пожиратели бюджета внутри строки: форматирование дат и чисел на каждый кадр вместо предподготовленной модели, декодирование картинок в главном потоке, вложенные скроллы, тени и clip на каждой карточке, слишком раннее чтение изменяющегося состояния в конвейере рендеринга. Детальный разбор — в статье Производительность мобильного.
Анимация и переходы
Анимация делает три работы: объясняет, откуда взялся новый экран; удерживает связь между источником и результатом; маскирует задержку. Всё остальное — расход батареи.
- Длительности. 200–300 мс для типичных переходов, 100–150 мс для мелкой обратной связи, 400+ мс — только для крупных полноэкранных. В Material тайминги формализованы как motion-токены.
- Пружины вместо кривых. Жест прерывается в любой момент:
springпродолжается с текущей скорости, аeaseInOutфиксированной длительности дёргается. - Дёшево — трансформации и прозрачность (их выполняет композитор); дорого — изменение размеров и перерасчёт раскладки.
- Reduce Motion — не опция. У части людей анимация вызывает тошноту, у части просто включена экономия; на Android системный масштаб анимаций может стоять в нуле. Читаем флаг и подчиняемся:
content.animation(reduceMotion ? nil : .snappy(duration: 0.25), value: isExpanded)в SwiftUI через@Environment(\.accessibilityReduceMotion).
Адаптация под экраны: окно, а не устройство
Главная ошибка мобильной адаптации — ветвиться по «телефон или планшет». Планшет в разделённом экране отдаёт приложению телефонную ширину, складное устройство меняет геометрию прямо в руках, а на Android приложение может оказаться в свободном окне на десктопе. Правильная величина — текущая ширина окна, выраженная в size class.
// Единая точка принятия решения о раскладке.
@Composable
fun MailApp() {
val width = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
if (width == WindowWidthSizeClass.COMPACT) SinglePaneMail() else ListDetailMail()
}
@Environment(\.horizontalSizeClass) private var hSize // меняется и при повороте, и при Slide Over
var body: some View {
if hSize == .compact { NavigationStack { MessageList() } }
else { NavigationSplitView { MessageList() } detail: { MessageDetail() } }
}
Во Flutter ту же роль играет LayoutBuilder: брейкпоинт берут по ширине контейнера, а не экрана — тогда он работает и в сплит-скрине, и внутри вложенных панелей.
- Insets и safe area. Вырезы, скруглённые углы, полоса жестов и шторка отличаются у всех устройств; константа
padding: 24вместо системных отступов ломается на первом же новом флагмане. - Клавиатура. Она уменьшает окно, и поле ввода под ней — самый массовый баг форм:
imePadding()в Compose, keyboard avoidance в SwiftUI,resizeToAvoidBottomInsetво Flutter. - Поворот и смена конфигурации. На Android поворот пересоздаёт Activity, поэтому состояние обязано жить вне UI. Запрет поворота — не решение, а долг: многооконный режим всё равно случится.
- RTL. Арабский и иврит зеркалят раскладку целиком, включая направление свайпов;
start/endвместоleft/rightделают это бесплатно. - Канонические раскладки. Для широких окон работают три схемы: list-detail, supporting pane и feed (canonical layouts). Изобретать четвёртую редко окупается.
Платформенные ожидания: iOS ≠ Android
Пользователь сравнивает ваше приложение не с макетом, а с десятком приложений на своём телефоне. Нарушение идиомы читается как «неродное», даже если человек не может объяснить почему.
| Аспект | iOS, HIG | Android, Material 3 |
|---|---|---|
| Возврат назад | свайп от левого края и кнопка в навбаре | системный жест или кнопка плюс «вверх» в топбаре |
| Основная навигация | Tab Bar снизу, 3–5 вкладок | Navigation Bar снизу, рельс или drawer на широких окнах |
| Заголовок экрана | по центру, крупный вверху списка | слева, топбар сжимается при скролле |
| Главное действие | кнопка в навбаре или внизу экрана | FAB справа снизу |
| Списки с действиями | свайп по строке | свайп или долгое нажатие с режимом выбора |
| Диалоги | Alert на 1–3 действия, Action Sheet снизу | Dialog, Bottom Sheet, Snackbar с действием |
| Короткое сообщение | стандартного тоста нет, нужен свой баннер | Snackbar и Toast встроены |
| Шрифт и иконки | San Francisco, SF Symbols | Roboto, Material Symbols |
Разумный компромисс индустрии: бренд задаёт цвет, типографику и иллюстрации; платформа задаёт навигацию, жесты и системные элементы.
Кроссплатформа: что происходит с UI и сколько это стоит
| Flutter | React Native | KMP + нативный UI | |
|---|---|---|---|
| Как рисует | свой рендерер поверх канвы | реальные нативные вью через Fabric | каждая платформа своими средствами |
| Пиксельная одинаковость | максимальная | средняя: платформа влияет на компоненты | нулевая по замыслу |
| Платформенные идиомы | воспроизводятся вручную, отстают от ОС | базовые получаются из коробки | родные по определению |
| Новое в iOS/Android | ждём поддержки во фреймворке | ждём обёртку или пишем нативный модуль | доступно в день релиза ОС |
| Экономия на UI | 80–90% кода экрана общее | 70–85% | 0% на UI, 40–70% на логике |
| Найм | рынок уже, ставки ниже нативных | пересечение с вебом, самый широкий рынок | нужны обе нативные команды плюс Kotlin |
| Где болит | системные интеграции, вес, доступность | производительность списков и анимаций | двойная работа по UI и синхронизация реализаций |
Честный счёт выглядит так. Кроссплатформа экономит там, где интерфейс — это формы, списки и карточки, и почти не экономит там, где он завязан на камеру, карты, системные шторки и виджеты: каждый «почти нативный» элемент придётся допиливать, а допиливание в чужом фреймворке дороже написания с нуля в родном. Обратная сторона: две нативные команды — это не только двойная стоимость, но и расхождение продукта во времени, когда фича выходит на одной платформе раньше, метрики рассинхронизируются, а гипотеза проверяется на половине аудитории.
Про найм редко проговаривают вслух ещё одно: нативная команда собирается дольше и стоит дороже, но её люди взаимозаменяемы с рынком, а команда на нишевом стеке набирается быстрее, но уход двух ключевых людей останавливает проект. Это не аргумент «за» или «против», а строка в оценке рисков, которую стоит записать явно, как и любое архитектурное решение (см. Архитектурные решения). Подробное сравнение стеков — в статье Кроссплатформа.
Доступность как часть UI
Мобильные ОС дают скринридеры из коробки: VoiceOver на iOS, TalkBack на Android. Приложение из стандартных компонентов с подписями доступно почти бесплатно; приложение из кастомных вью без семантики недоступно совсем.
// Карточка целиком — один элемент для TalkBack, а не пять разрозненных кусков текста.
Row(
modifier = Modifier
.clickable(onClick = onOpen)
.semantics(mergeDescendants = true) {
contentDescription = "${message.author}, ${message.preview}, ${message.timeAgo}"
}
) { /* ... */ }
Минимальный чек-лист: у каждой иконки есть подпись; порядок чтения совпадает с визуальным; кастомный элемент объявляет роль и состояние; целевая зона не меньше нормы; контраст текста проходит 4.5:1; интерфейс переживает 200% шрифта; жест продублирован действием. Расширенный разбор — в треке Доступность, а проверка на реальных устройствах — в статьях Тестирование мобильного и Мобильное и кросс-платформенное тестирование.
Типичные ошибки
- Экран без выхода: модалка без крестика, перехваченный
back, кастомный навбар без свайпа — пользователь закрывает приложение целиком. - Таб, который «пушит» экран поверх других табов. Вкладки обязаны иметь независимые стеки.
- Диплинк без стека: «назад» с открытого по ссылке экрана выбрасывает из приложения.
- Навигационное состояние только в памяти: после смерти процесса человек оказывается на стартовом экране, потеряв путь и черновики.
- Свайп как единственный способ действия или свой жест в системной полосе: функция не найдена большинством, недоступна скринридеру и проигрывает системе.
- Фиксированные высоты под текст: ломаются на первом же увеличенном шрифте.
- Ветвление по «типу устройства» вместо ширины окна: разваливается в сплит-скрине и на складных.
- Спиннер вместо состояний: нет разницы между «пусто», «нет сети» и «ошибка сервера» — человек не знает, что делать.
- Копирование чужой идиомы: FAB на iOS, центрированный заголовок на Android, хлебные крошки из веба.
- Тяжёлая работа в теле строки списка и анимации мимо системных настроек: гарантированный jank и жалобы на «укачивает».
Мини-итог
Мобильный интерфейс определяется не размером экрана, а физикой пальца, прерываемостью сессии и правами системы на ваш процесс. Цели нажатия — не меньше 44 pt / 48 dp, управление — в зоне большого пальца, отступы — из системных insets, высоты — ограничены снизу. Навигация является сериализуемой структурой данных: отсюда дешёвые диплинки, восстановление после смерти процесса и тестируемость переходов. Жест — соревнование распознавателей, поэтому свайпы требуют порогов, проверки доминирующей оси, уважения к системным полосам и обязательной видимой альтернативы. Экран показывает пять состояний плюс шестое, мобильное, — устаревшие данные без сети. Адаптация делается по ширине окна, а не по типу устройства. И наконец, платформенные идиомы — не вкусовщина: именно на них кроссплатформенный подход теряет часть обещанной экономии, и это стоит считать заранее, а не объяснять постфактум.
Источники
- Apple. Human Interface Guidelines — навигация, жесты, размеры целей, платформенные паттерны.
- Google. Material Design 3 — компоненты, motion-токены, адаптивные раскладки.
- Android Developers: type-safe navigation, window size classes, canonical layouts, predictive back.
- Apple. NavigationStack и Universal Links.
- Flutter. Taps, drags, and other gestures — устройство арены жестов; роутеры go_router и React Navigation.
- W3C. WCAG 2.2 — Target Size; Steven Hoober. How Do Users Really Hold Mobile Devices? — исследование хвата и зон досягаемости.
- Fitts, P. M. «The information capacity of the human motor system in controlling the amplitude of movement», Journal of Experimental Psychology, 1954 — исходная работа о законе Фиттса.
Что дальше
Архитектура мобильного приложения: слои, состояние, MVVM/MVI, DI — как разложить экран на слои, где держать состояние навигации и данных, чтобы оно переживало поворот и смерть процесса, и чем MVI отличается от MVVM на практике.