Мобильная разработка Мобильный UI и навигация: паттерны, жесты, адаптация под экраны
0%

Мобильный UI и навигация: паттерны, жесты, адаптация под экраны

Мобильный 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 выглядит чужеродно и уводит функции в невидимую зону.

Две платформенные детали ломают наивные реализации.

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 хранит состояние навигации бесплатно, а в мобильном приложении вы обязаны хранить и восстанавливать его сами.

Диплинки, холодный старт и восстановление стека

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

  1. Верификация домена обязательна. Без apple-app-site-association для Universal Links и assetlinks.json для App Links ссылка уйдёт в браузер. Кастомная схема myapp:// работает, но зарегистрировать её может кто угодно — это средство отладки, а не доставки.
  2. Синтетический back-стек. Открывая глубокий экран, восстановите путь к нему: на Android для этого есть TaskStackBuilder, в современных роутерах — просто список маршрутов.
  3. Парсинг до авторизации. Сначала распарсить и сохранить намерение, потом авторизовать, потом применить. Обратный порядок теряет диплинк после логина — классический баг воронки.
  4. Идемпотентность. Двойной тап по ссылке не должен порождать два экрана; общая теория — в статье Идемпотентность и доставка.

Отдельная тема — восстановление после смерти процесса. Система убивает фоновое приложение и через час возвращает пользователя «в то же место»: 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.

Адаптивные раскладки по ширине окна: compact, medium, expanded и складное устройство

// Единая точка принятия решения о раскладке.
@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% шрифта; жест продублирован действием. Расширенный разбор — в треке Доступность, а проверка на реальных устройствах — в статьях Тестирование мобильного и Мобильное и кросс-платформенное тестирование.

Типичные ошибки

  1. Экран без выхода: модалка без крестика, перехваченный back, кастомный навбар без свайпа — пользователь закрывает приложение целиком.
  2. Таб, который «пушит» экран поверх других табов. Вкладки обязаны иметь независимые стеки.
  3. Диплинк без стека: «назад» с открытого по ссылке экрана выбрасывает из приложения.
  4. Навигационное состояние только в памяти: после смерти процесса человек оказывается на стартовом экране, потеряв путь и черновики.
  5. Свайп как единственный способ действия или свой жест в системной полосе: функция не найдена большинством, недоступна скринридеру и проигрывает системе.
  6. Фиксированные высоты под текст: ломаются на первом же увеличенном шрифте.
  7. Ветвление по «типу устройства» вместо ширины окна: разваливается в сплит-скрине и на складных.
  8. Спиннер вместо состояний: нет разницы между «пусто», «нет сети» и «ошибка сервера» — человек не знает, что делать.
  9. Копирование чужой идиомы: FAB на iOS, центрированный заголовок на Android, хлебные крошки из веба.
  10. Тяжёлая работа в теле строки списка и анимации мимо системных настроек: гарантированный jank и жалобы на «укачивает».

Мини-итог

Мобильный интерфейс определяется не размером экрана, а физикой пальца, прерываемостью сессии и правами системы на ваш процесс. Цели нажатия — не меньше 44 pt / 48 dp, управление — в зоне большого пальца, отступы — из системных insets, высоты — ограничены снизу. Навигация является сериализуемой структурой данных: отсюда дешёвые диплинки, восстановление после смерти процесса и тестируемость переходов. Жест — соревнование распознавателей, поэтому свайпы требуют порогов, проверки доминирующей оси, уважения к системным полосам и обязательной видимой альтернативы. Экран показывает пять состояний плюс шестое, мобильное, — устаревшие данные без сети. Адаптация делается по ширине окна, а не по типу устройства. И наконец, платформенные идиомы — не вкусовщина: именно на них кроссплатформенный подход теряет часть обещанной экономии, и это стоит считать заранее, а не объяснять постфактум.

Источники

Что дальше

Архитектура мобильного приложения: слои, состояние, MVVM/MVI, DI — как разложить экран на слои, где держать состояние навигации и данных, чтобы оно переживало поворот и смерть процесса, и чем MVI отличается от MVVM на практике.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов