Responsive UI перестраивает тот же контент под доступное место, adaptive UI меняет поведение приложения под устройство, способ ввода и платформу. Сетка, которая меняет число колонок, относится к responsive, а замена нижней панели на navigation rail к adaptive.
Responsive: LayoutBuilder, Flexible, Wrap и сетка с maxCrossAxisExtent меняют размеры и расположение тех же виджетов
Adaptive: разная навигация для разных размеров окна, hover и правый клик на десктопе, горячие клавиши, Switch.adaptive на iOS
Ориентируйтесь на доступное место, а не на устройство: iPad в Split View или приложение на складном телефоне может быть любого размера, поэтому спрашивайте, сколько есть места, а не что это за устройство
FlutterMiddle
Адаптивный интерфейс
Брейкпоинты, LayoutBuilder, NavigationRail, складные экраны, ориентация, SafeArea, масштаб текста
Закрыто вопросов
0 / 20
- 0Повторить
- 5Часто спрашивают
Вопросы
MediaQuery описывает всё окно (размер, отступы, масштаб текста, настройки пользователя), а LayoutBuilder отдаёт ограничения, которые родитель реально передал этому виджету. Переиспользуемый виджет выбирает раскладку по LayoutBuilder; факты об окне вроде клавиатуры берутся из MediaQuery.
Окно одно, места разные: карточка в боковой панели шириной 320 px и карточка на всё окно шириной 1280 px читают одинаковый MediaQuery.sizeOf, но LayoutBuilder сообщит maxWidth 320 и 1280
Экран или компонент: «показывать navigation rail от 600 dp» решает экран, и размер окна тут подходит; «картинка помещается рядом с текстом от 480 px» решает карточка
Когда он вызывается: builder работает во время layout и заново при каждом изменении ограничений, поэтому никакого setState внутри
Неограниченные оси: внутри вертикального скролла maxHeight равен double.infinity; проверьте hasBoundedHeight, прежде чем считать от него
Берите window size classes из Material 3: compact до 600 dp, medium 600–839, expanded 840–1199, large 1200–1599, extra-large от 1600. Переводите ширину в enum в одной функции и ветвитесь по нему один раз, наверху экрана.
Единицы: размеры во Flutter уже в логических пикселях (dp); никогда не умножайте их на devicePixelRatio
Что обычно получает каждый класс: compact: одна колонка и NavigationBar; medium: NavigationRail, иногда две панели; expanded и выше: список и деталь рядом
Один switch на экран: виджеты ниже принимают параметры и сами ширину не сравнивают
Типичная ошибка: if (width > 600), скопированный в десять файлов: числа расходятся, и каждый файл по-своему решает, что происходит ровно на 600
Поворот просто даёт новый размер окна: ничего не пересоздаётся, зависящие от размера виджеты перестраиваются. Ориентацию читают через MediaQuery.orientationOf или OrientationBuilder, блокируют через SystemChrome.setPreferredOrientations, но раскладку выбирают по месту, а не по ориентации.
**MediaQuery.orientationOf(context): только сравнивает ширину и высоту окна; датчики тут ни при чём
OrientationBuilder: сравнивает ограничения от родителя, поэтому коробка 300 × 600 сообщит portrait даже на планшете в landscape
Ловушка**: телефон набок даёт примерно 780 × 360 dp: landscape, но для двух панелей слишком низко. Для «это планшет?» используйте shortestSide >= 600, а для раскладки ограничения по обеим осям
Блокировка: вызовите setPreferredOrientations в main после WidgetsFlutterBinding.ensureInitialized() и объявите те же ориентации в Info.plist и манифесте, чтобы совпадал экран запуска
На больших экранах блокировка служит лишь подсказкой: Android 16 игнорирует её на больших экранах для приложений с target API 36, а окна многозадачности на iPad бывают любой формы
Размеры во Flutter уже в логических пикселях, и 16 dp на любом телефоне примерно одного физического размера; масштабирование по ширине лишь показывает на большом экране тот же макет, только крупнее. Команды берут фиксированные dp, гибкие виджеты и брейкпоинты, чтобы большой экран вмещал больше.
Что он делает: при designSize: Size(375, 812) .w умножает на ширину / 375, .h на высоту / 812, а .sp тоже на коэффициент по ширине (с minTextAdapt на меньший из двух)
Планшет получает увеличенный телефон: на iPad шириной 834 dp кнопка 343.w занимает 763 dp, а текст 16.sp становится 36 dp, хотя пользователь ждал вторую колонку
Поворот и ресайз: в ландшафте (812 × 375) 48.h сжимается до 22 dp, меньше тап-таргета в 48 dp, а 16.sp вырастает до 35; split screen или окно на десктопе пересчитывают весь UI при каждом перетаскивании
Он конфликтует с размером шрифта пользователя: системный text scaler применяется поверх .sp, поэтому 200% на том же iPad делают основной текст около 71 px, а блоки с высотой в .h не растут, и текст в них переполняется
Вместо этого: отступы и типографика в обычных dp из темы, Expanded, Flexible и AspectRatio там, где пропорция действительно и есть дизайн, maxWidth для контента и брейкпоинты, которые меняют раскладку
Где это оправдано: приложение только для телефонов с заблокированной портретной ориентацией, нарисованное под один фиксированный фрейм, например киоск или меню игры
MediaQuery.of(context) подписывает виджет на все поля MediaQueryData, а клавиатура меняет viewInsets на каждом кадре своего появления. Поэтому перестраивается каждый виджет, вызвавший of, даже если ему нужен был только size; MediaQuery.sizeOf(context) зависит только от размера.
Почему работают геттеры по аспектам: MediaQuery является InheritedModel; каждый геттер …Of регистрирует зависимость от одного поля, и перестройку вызывает только изменение этого поля
Геттеры: sizeOf, paddingOf, viewPaddingOf, viewInsetsOf, textScalerOf, orientationOf, platformBrightnessOf, disableAnimationsOf
Где это бьёт больнее всего: MediaQuery.of в build уровня экрана перестраивает весь экран на каждом кадре анимации клавиатуры, а также при повороте, смене темы системы или масштаба текста
**Читайте в build**: не кэшируйте размер в initState или в поле late final; поворот, split screen и ресайз окна меняют его в любой момент
SafeArea читает MediaQuery.padding, а это viewPadding минус viewInsets, поэтому когда клавиатура закрывает home indicator, нижний отступ становится нулём. Это не баг: панель теперь лежит прямо на клавиатуре, и отступ под home indicator там не нужен.
**viewInsets**: то, что закрывает клавиатура; viewInsets.bottom даёт её высоту
**viewPadding: то, что закрывает неподвижный системный UI: вырез, статус-бар, home indicator; клавиатура его не меняет
padding**: viewPadding минус viewInsets, но не меньше нуля; именно его применяет SafeArea
**Scaffold уже всё сделал**: при resizeToAvoidBottomInset: true по умолчанию он уменьшает body на высоту клавиатуры и убирает этот отступ из MediaQuery для body; если прибавить viewInsetsOf(context).bottom, прочитанный выше Scaffold, высота клавиатуры учтётся дважды и появится пустая полоса
**maintainBottomViewPadding: true**: для раскладок, которые сами обрабатывают клавиатуру: SafeArea сохраняет viewPadding.bottom, и гибкие дети не прыгают при открытии клавиатуры
Android 15 и новее: приложения с target API 35 рисуются edge-to-edge, поэтому навигационная панель так же попадает в viewPadding.bottom
Шапка занимает 40% высоты окна, но MediaQuery.sizeOf не меняется, когда открывается клавиатура, а тело Scaffold сжимается на её высоту. На телефоне высотой 812 px шапка в 325 px и форма в 200 px уже не помещаются в оставшиеся примерно 370 px, и Column переполняется.
Окно или тело: sizeOf описывает всё окно, включая статус-бар, AppBar и область под клавиатурой; при resizeToAvoidBottomInset: true по умолчанию Scaffold раскладывает тело над клавиатурой, и видно это только по ограничениям тела
Исправление: LayoutBuilder отдаёт реальный maxHeight тела: на телефоне 375 × 812 с клавиатурой 336 px это около 370, шапка становится около 148 px, и форма помещается
**Не resizeToAvoidBottomInset: false: полоса пропадает, потому что тело больше не сжимается, но форма теперь оказывается под клавиатурой
Когда не помещается сама форма**: на маленьком телефоне или при 200% тексте оберните экран в SingleChildScrollView; внутри него maxHeight бесконечен, поэтому шапке тогда нужна фиксированная высота
Как поймать: виджет-тест, который выставляет tester.view.devicePixelRatio = 1 и затем tester.view.viewInsets = const FakeViewPadding(bottom: 336) (инсеты в физических пикселях), падает на этом экране: тестовый биндинг считает переполнение RenderFlex ошибкой
MediaQuery.textScalerOf(context) возвращает TextScaler, и scaler.scale(14) даёт размер, которым реально нарисуется шрифт в 14 px. Чините раскладку так, чтобы она росла вместе с текстом; ограничение масштаба остаётся крайней мерой для единственного элемента интерфейса, который не может перестроиться.
Это scaler, а не число, потому что Android 14 и новее масштабирует текст нелинейно (крупный текст растёт меньше мелкого), поэтому textScaleFactor объявлен устаревшим.
Что ломается первым: фиксированная height у коробки с текстом, Row с текстом без Flexible, подписи с maxLines: 1
Чините раскладкой: пусть высота берётся из контента, используйте Wrap вместо Row, ставьте иконку и подпись друг под другом, когда scale(14) становится большим
Ограничивайте локально: MediaQuery.withClampedTextScaling(maxScaleFactor: 1.3, child: …) только вокруг самодельной нижней панели или бейджа; Material NavigationBar уже сам ограничивает свои подписи до 1.3
Типичная ошибка: ограничить или отключить масштаб в корне приложения: это перекрывает настройку доступности и нарушает WCAG 1.4.4, который требует, чтобы текст мог вырасти до 200%
**Никакого FittedBox вокруг текста**: он снова уменьшает глифы и незаметно отменяет настройку пользователя
Четыре колонки по 238 px: число колонок считается как (1000 / (240 + 16)).ceil() = 4, ширина как (1000 − 3 × 16) / 4. В панели шириной 400 px тот же код даст две колонки по 192 px, и сетка перестраивается от собственной ширины, без списка брейкпоинтов.
Читает свой вьюпорт: а не окно, поэтому одна и та же сетка работает и на весь экран, и внутри панели
Высота следует за шириной: с childAspectRatio: 0.75 карточка шириной 238 px получает около 317 px высоты, а карточка шириной 192 px только 256; контент, который помещается в одной, переполняет другую, хуже всего при тексте 200%. mainAxisExtent: 300 вместо этого фиксирует высоту
«Max» лишь приблизителен: отступ входит в делитель, поэтому на некоторых ширинах карточка выходит на несколько пикселей шире maxCrossAxisExtent
Когда фиксированное число колонок правильно: когда число и есть дизайн, как у цифровой клавиатуры или календаря на семь колонок: SliverGridDelegateWithFixedCrossAxisCount
IntrinsicHeight спрашивает всё поддерево, какой высоты оно хотело бы быть, ещё до настоящей раскладки. Это лишний проход по поддереву на каждый layout, а вложенные intrinsic-виджеты умножают проходы: документация Flutter предупреждает, что стоимость может стать O(N²) от глубины дерева.
Обычная раскладка делает один проход: ограничения идут вниз, размеры вверх, каждый ребёнок раскладывается один раз. Intrinsic-виджеты ломают это, чтобы ответить на вопрос «кто из детей самый высокий?» раньше, чем у кого-то появился размер.
Один раз это нормально, а на каждую строку нет: три карточки в шапке не стоят ничего измеримого; та же обёртка в itemBuilder платит лишний проход за каждую строку при каждой раскладке
Способы дешевле: дайте ряду ограниченную высоту (itemExtent у списка или фиксированный SizedBox) и используйте CrossAxisAlignment.stretch, либо mainAxisExtent в сетке, либо Table, который сам подгоняет строку под самую высокую ячейку
Измерить можно не всё: ListView или LayoutBuilder внутри него падают, потому что вьюпорт и layout-колбэк не умеют отвечать на запросы intrinsic-размеров
Горизонтальный ListView даёт детям неограниченную ширину, поэтому c.maxWidth равен double.infinity, проверка >= 240 всегда выбирает Row, а Expanded внутри Row с неограниченной шириной бросает «RenderFlex children have non-zero flex but incoming width constraints are unbounded».
**Что сообщает LayoutBuilder: ограничения от родителя, а не место на экране; скролл оставляет свою ось прокрутки неограниченной, так что там брейкпоинт 240 сравнивается с бесконечностью
Почему в сетке всё работает**: ячейка сетки задаёт жёсткую ширину, поэтому maxWidth совпадает с реальной шириной ячейки, и достижимы обе ветки
Исправление: ширину карточки решает лента через SizedBox(width: 280), и брейкпоинт карточки снова получает настоящее число
Защита в самой карточке: переиспользуемый виджет может проверять c.hasBoundedWidth и откатываться к компактной раскладке вместо того, чтобы верить maxWidth; то же касается maxHeight в вертикальном списке
Как поймать: ошибка называет Row и указывает на неограниченные ограничения; виджет-тест, который кладёт карточку в горизонтальный ListView, ловит это до релиза, где на месте ленты был бы серый блок ошибки
Храните выбранную вкладку в роутере, а не в навигационной панели, и меняйте только панель вокруг одного shell: NavigationBar в compact, NavigationRail от 600 dp. У StatefulShellRoute из go_router shell несёт GlobalKey, поэтому при переносе между раскладками стек каждой вкладки сохраняется.
Один роутер: StatefulShellRoute.indexedStack даёт каждой вкладке свой Navigator и передаёт в builder StatefulNavigationShell; его currentIndex и goBranch управляют той панелью, которая сейчас показана
Почему состояние выживает: shell остаётся одним и тем же виджетом с ключом в обеих ветках, поэтому Flutter переносит его элемент, а не создаёт заново, а индекс вкладки живёт в роуте, а не в виджете
Один список пунктов: превращайте его и в NavigationDestination, и в NavigationRailDestination, чтобы индекс 2 означал одну и ту же вкладку в любой навигации
Какая навигация где: панель до 600 dp, rail с 600 до 839, расширенный rail или постоянный NavigationDrawer от 840
Типичная ошибка: int _tab в StatefulWidget внутри одной ветки раскладки или самодельный IndexedStack без ключа: переход через брейкпоинт уничтожает и то и другое
Конструкторы .adaptive покрывают короткий список контролов, которые на iOS и macOS рисуют своего Cupertino-двойника. Переходы между экранами, физика скролла и выделение текста уже следуют платформе; app bar, шиты, пикеры и контекстные меню вы ветвите сами.
Что покрыто: Switch.adaptive, Checkbox.adaptive, Radio.adaptive, Slider.adaptive, CircularProgressIndicator.adaptive, RefreshIndicator.adaptive, AlertDialog.adaptive вместе с showAdaptiveDialog и иконки вроде Icons.adaptive.share
Бесплатно, без кода: переход между экранами по умолчанию, пружинящая физика скролла, тулбар выделения текста и скроллбар следуют Theme.of(context).platform
Ветвить придётся вам: AppBar или CupertinoNavigationBar, bottom sheet или showCupertinoModalPopup, пикеры дат, контекстные меню, хаптика и кнопки диалога (CupertinoDialogAction на платформах Apple)
**Ветвитесь по Theme.of(context).platform**: а не по Platform.isIOS: это работает в web, а тест может задать ThemeData(platform: TargetPlatform.iOS) и проверить оба вида
Платформа не размер: .adaptive ничего не знает о ширине; iPad получит Cupertino-контролы, но планшетная раскладка ему всё равно нужна
Всё, что не было нужно пальцу: подсветку при наведении и курсор-указатель, меню по правому клику, видимое кольцо фокуса с разумным порядком Tab и скролл, который можно тащить мышью там, где пользователь этого ждёт.
Hover и курсор: InkWell и Material-кнопки уже подсвечиваются при наведении; на своих виджетах MouseRegion даёт onEnter, onExit и cursor: SystemMouseCursors.click
Правый клик: onSecondaryTapUp открывает MenuAnchor в точке указателя; оставьте long press для тача, чтобы оба вели в одно меню
Фокус: Tab должен доходить до каждого контрола; FocusableActionDetector даёт своему контролу фокус, hover и шорткаты в одном виджете, а FocusTraversalGroup чинит порядок
Перетаскивание мышью: ScrollBehavior.dragDevices по умолчанию не включает PointerDeviceKind.mouse, поэтому карусель на PageView нельзя потащить мышью, пока вы его не добавите
Плотность и размер: Material уже использует компактную VisualDensity на десктопе; всё равно держите зоны нажатия в 48 dp, потому что у многих ноутбуков сенсорный экран
Сделайте выбранное письмо состоянием, а не запушенным роутом. Держите selectedId выше проверки ширины и выводите навигацию из него: две панели на широком экране, декларативный список страниц Navigator на узком. Тогда поворот просто строит другую ветку, и снимать невидимое нечего.
Баг появляется из-за Navigator.push для детали: когда раскладка расширяется, этот роут остаётся в стеке под двухпанельным видом, и «назад» снимает то, чего пользователь не видит.
Поднимите состояние: String? selectedId живёт в State экрана или в вашем стейт-менеджере, выше проверки ширины
Широкий экран: Row из списка и детали, которые читают один и тот же id; роута для детали нет вообще
Узкий экран: Navigator(pages: […]), где страница детали есть только if (selectedId != null); onDidRemovePage сбрасывает id, когда пользователь уходит назад
«Назад» на широком экране: PopScope(canPop: selectedId == null) превращает первое «назад» в «закрыть письмо», а не в выход из входящих
Вложенный навигатор: системное «назад» на Android сначала приходит в корневой навигатор; оберните внутренний в NavigatorPopHandler, чтобы pop дошёл до него
С go_router: правило то же: /inbox/42 и есть выбор, а его builder на широком экране рисует двухпанельный вид, а не пушит страницу детали
При пересечении 840 WideOrders заменяется на NarrowOrders. Flutter переиспользует элемент, только если совпадают runtimeType и ключ, поэтому старое поддерево размонтируется, а каждый State и контроллер в нём уничтожается, включая выбор и позицию скролла.
Это снос, а не перераскладка: новая ветка создаёт свежие объекты State, которые начинают с начальных значений
Поднимите то, что сделал пользователь: выбор, фильтры и черновой текст должны жить в State или контроллере выше LayoutBuilder и передаваться вниз параметрами
Позиция скролла: дайте списку один и тот же PageStorageKey в обеих раскладках, и он восстановит смещение из PageStorage роута; поднятый ScrollController тоже подойдёт, но использовать его одновременно может только один скроллируемый виджет
**Сохранить живой State**: чтобы не потерять State самого списка (фокус, идущие анимации), дайте ему GlobalKey: Flutter перенесёт элемент в том же кадре. PageStorageKey лишь восстанавливает смещение скролла в новом элементе
MediaQuery.displayFeaturesOf(context) перечисляет сгибы, шарниры и вырезы в координатах окна. Split screen и раскрытие устройства приходят как обычное изменение размера: виджеты остаются живыми и получают новые ограничения, так что раскладка, читающая размер в build, просто подстраивается.
**DisplayFeature**: прямоугольник bounds, type (fold, hinge, cutout) и state (postureFlat, postureHalfOpened)
Сгиб и шарнир: сгиб нулевой ширины работает как складка, через которую можно рисовать; шарнир с реальной шириной создаёт физический зазор, поэтому делите панели по bounds.left и bounds.right
Поза «на столе»: полураскрытое устройство с горизонтальным сгибом: контент на верхней половине, управление на нижней
**DisplayFeatureSubScreen**: помещает ребёнка в один из подэкранов; showDialog уже его использует, поэтому диалоги обходят шарнир бесплатно
Типичная ошибка: кэшировать ширину в поле late final; окно в split screen меняет размер при каждом перетаскивании разделителя
Разделите задачу. На вопрос «Раскладывается ли вообще?» отвечает обычная проверка в widget-тесте, которую можно бесплатно прогнать по всем ширинам и масштабам текста; на вопрос «выглядит ли правильно?» отвечает golden, и таких хватит нескольких.
Задайте поверхность: tester.view.physicalSize и tester.view.devicePixelRatio для размера, tester.platformDispatcher.textScaleFactorTestValue для масштаба текста; сбрасывайте оба через addTearDown
Прогоните матрицу: переполнение RenderFlex считается ошибкой фреймворка, и тестовый binding валит на ней тест, поэтому цикл обходится без файлов с картинками; tester.takeException() нужен только чтобы проверить ожидаемую ошибку
Проверяйте расположение: find.byType(NavigationRail) даёт findsOneWidget на 1024 и findsNothing на 390
Мало golden-тестов: по одному на компонент, а не на брейкпоинт: отрисуйте его на трёх ширинах в одной колонке и снимите одну картинку
Слепое пятно: текст с многоточием не бросает ошибку; где обрезка важна, проверяйте didExceedMaxLines у RenderParagraph этого текста
Ни то, ни другое: делите по слоям. Контентные виджеты никогда не спрашивают ширину окна, а одна тонкая раскладка на каждый window size class расставляет их, поэтому поведение пишется один раз, а отличается только расположение.
Почему два очевидных ответа не работают
Одно дерево с условиями: каждый if (isWide) удваивает число состояний файла, и они перемножаются по всему экрану
Три дерева: вместе с расположением копируется поведение, и баг, исправленный в телефонной раскладке, остаётся в планшетной
Слои
Назовите ширины один раз: одна функция переводит ширину в класс размера
Один switch на экран: раскладку выбирает один виджет; всё ниже принимает параметры и рисуется при любом размере
Состояние выше switch: иначе ресайз его выбросит
Компоненты подстраиваются сами: карточка, меняющая форму, использует свой LayoutBuilder независимо от класса экрана
Ширина и платформа
Две независимые оси: по ширине ветвитесь ради раскладки, а по Theme.of(context).platform ради конвенций платформы; iPad сочетает iOS и широкий экран, а узкое окно десктопа сочетает macOS и compact
Тесты повторяют слои: экраны прогоняйте widget-тестами по классам размеров; контентным виджетам настройка размера вообще не нужна