Проблема

Приложению на Flutter, которое выходит на телефоны, планшеты и десктоп, недостаточно гибкой колонки. Экрану, который хорошо читается при ширине 390 логических пикселей, при 700 нужна центрированная карточка, а при 1200 — боковая панель. Flutter даёт измерения, но не даёт структуры, в которой их применять.

Поэтому проверки расползаются по коду. Здесь MediaQuery.of(context).size.width > 600, там LayoutBuilder, тремя виджетами ниже — тернарный оператор внутри Padding. Каждая по отдельности выглядит уместно, но вместе они образуют правила вёрстки, которые негде прочитать целиком.

Типичные проблемы:

  • Значения брейкпойнтов дублируются в десятках виджетов и расходятся по мере изменения дизайна
  • LayoutBuilder сообщает ограничения родителя, тогда как нужен размер устройства
  • Вёрстка переключается на планшетный вариант, как только телефон поворачивают в альбомную ориентацию
  • В дереве виджетов невозможно увидеть логику адаптивности, не прочитав каждый дочерний виджет

Как flutter_adaptive_layout решает это

AdaptiveLayout принимает по одному билдеру на размер экрана и child. Он измеряет устройство через MediaQuery, сопоставляет результат с ScreenSize и строит только подходящий вариант — при этом child создаётся один раз и передаётся в выбранный билдер.

AdaptiveLayout(
  smallBuilder: (context, child) => child!,
  mediumBuilder: (context, child) => Center(child: child),
  largeBuilder: (context, child) => Row(
    children: [const Sidebar(), Expanded(child: child!)],
  ),
  child: const MyHomePage(),
)

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

Что умеет пакет

  • Вёрстка по размеру экранаsmallBuilder, mediumBuilder и largeBuilder, каждый необязателен: если билдера нет, рисуется child, поэтому можно описать только те размеры, которые важны
  • Свои брейкпойнты — по умолчанию 400 и 600 логических пикселей, переопределяются для отдельного виджета или для всего приложения
  • child строится один раз — при смене вёрстки контент оборачивается, а не пересобирается с нуля
  • Устойчивость к повороту — классификация читает MediaQuery.of(context).size.shortestSide, поэтому телефон остаётся маленьким экраном и в альбомной ориентации
  • Подключаемая логика классификации — реализуйте ScreenSizeQualifier, чтобы делить экраны по ширине, платформе, состоянию складного экрана или как принято в вашей дизайн-системе
  • Без зависимостей — чистый Flutter поверх MediaQuery, работает на iOS, Android, web, macOS, Windows и Linux

Как определяется размер экрана

BreakpointsQualifier по умолчанию сравнивает короткую сторону с двумя брейкпойнтами, причём включительно: значение, равное брейкпойнту, попадает в меньшую категорию.

Размер экранаУсловиеДиапазон по умолчаниюТипичное устройство
ScreenSize.smallshortestSide <= smallBreakpoint0–400 логических пикселейТелефоны
ScreenSize.mediumshortestSide <= mediumBreakpoint401–600 логических пикселейНебольшие планшеты
ScreenSize.largeбольше mediumBreakpoint601+ логических пикселейБольшие планшеты, десктоп

Брейкпойнты разрешаются в фиксированном порядке — побеждает первое заданное значение: аргументы BreakpointsQualifier, затем ближайший BreakpointsSetting выше по дереву, затем встроенные 400 и 600.

Сценарии использования

От телефона к планшету

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

Навигация «список — детали»

Список, который на телефоне открывает детальный экран отдельным маршрутом, на планшете может показывать список и детали рядом. Оба варианта объявлены в одном виджете, так что разницу между ними видно прямо в диффе.

Дизайн-системы со своими брейкпойнтами

Оберните MaterialApp в BreakpointsSetting один раз — и каждый AdaptiveLayout в дереве возьмёт числа дизайн-системы. Позже поменять их — это правка одной строки, а не поиск по всему проекту.

Окна на web и десктопе

На web и десктопе измеряется размер окна, поэтому вёрстка реагирует на изменение его размера: тот же код, что обслуживает планшет, обслуживает и уменьшенное окно браузера.

С чего начать

Установите пакет:

flutter pub add flutter_adaptive_layout

Требуется Dart >=2.18.5 <4.0.0 и Flutter 1.17+.

Импортируйте его и оберните виджет, вёрстка которого должна адаптироваться:

import 'package:flutter_adaptive_layout/flutter_adaptive_layout.dart';

AdaptiveLayout(
  largeBuilder: (context, child) => Center(
    child: SizedBox(width: 600, child: child),
  ),
  child: const ArticleView(), // на маленьких и средних экранах — как есть
)

Чтобы классифицировать экраны иначе, реализуйте ScreenSizeQualifier и передайте его в qualifier:

class WidthQualifier extends ScreenSizeQualifier {
  @override
  ScreenSize qualify(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    if (width < 600) return ScreenSize.small;
    if (width < 1024) return ScreenSize.medium;
    return ScreenSize.large;
  }
}

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

Полноценное рабочее приложение лежит в каталоге example.

LayoutBuilder сообщает ограничения, переданные родительским виджетом, — они могут быть намного меньше экрана. AdaptiveLayout классифицирует устройство или окно через MediaQuery, поэтому виджет глубоко внутри дерева всё равно знает, что работает на планшете. LayoutBuilder нужен, чтобы вписаться в доступную область; AdaptiveLayout — чтобы выбрать вёрстку под устройство.
Нет. Классификация использует MediaQuery.of(context).size.shortestSide — эта величина одинакова в портретной и ландшафтной ориентации, поэтому телефон остаётся ScreenSize.small при повороте. Если вёрстка должна зависеть от ориентации, реализуйте свой ScreenSizeQualifier, который читает size.width.
Нет. Любой билдер можно не указывать — для этого размера экрана будет использован child. Если нет ни подходящего билдера, ни child, будет выброшен UnimplementedError.
Либо передайте BreakpointsQualifier(smallBreakpoint: ..., mediumBreakpoint: ...) в конкретный AdaptiveLayout, либо оберните приложение в BreakpointsSetting, чтобы поменять их везде. Значения из конструктора важнее BreakpointsSetting, а он важнее значений по умолчанию — 400 и 600.
Да. Он зависит только от MediaQuery, поэтому работает на всех платформах Flutter. На web и десктопе измеряется размер окна, так что вёрстка реагирует на его изменение.