SizedBox, Padding и ColoredBox делают по одной вещи. Container служит виджетом-обёрткой: он объединяет размер, padding, margin, декорацию и трансформацию и сам строит из этих однозадачных виджетов нужную цепочку.
**SizedBox**: фиксированный размер или отступ между виджетами; SizedBox.expand() заполняет всё место, SizedBox.shrink() не занимает ничего
**Padding: пустое место вокруг ребёнка, и только
ColoredBox**: простой цвет фона; DecoratedBox добавляет рамку, скругление, тень или градиент
**Container**: когда нужно несколько свойств сразу; его margin задаёт отступ снаружи декорации, а padding внутри неё. У обычных виджетов margin нет, их оборачивают в Padding
Почему лучше маленькие виджеты: у Container нет const-конструктора, а const SizedBox(height: 8) создаётся один раз и пропускается при перестройке
FlutterJunior
Базовые виджеты
Основные виджеты макета: Container, Row, Column, Stack, ListView
Закрыто вопросов
0 / 24
- 0Повторить
- 9Часто спрашивают
Вопросы
В debug-сборке сработает assert в конструкторе: «Cannot provide both a color and a decoration». color служит лишь сокращением для decoration: BoxDecoration(color: ...), поэтому цвет нужно перенести внутрь BoxDecoration.
Почему это ошибка: получилось бы два фона, нарисованных друг поверх друга, и фреймворк не берётся угадывать, какой из них вы имели в виду
Следующий сюрприз: borderRadius скругляет фон, но не ребёнка: картинка внутри останется с острыми углами, пока вы не добавите в Container параметр clipBehavior: Clip.antiAlias
Главной осью называют направление, в котором выкладываются дети (горизонталь у Row, вертикаль у Column); поперечная ось перпендикулярна ей. mainAxisAlignment расставляет детей вдоль главной оси, а crossAxisAlignment расставляет их поперёк.
**mainAxisAlignment**: start, center, end, spaceBetween, spaceAround, spaceEvenly; эффект виден, только когда осталось свободное место
**crossAxisAlignment**: по умолчанию center; stretch растягивает каждого ребёнка на всю поперечную ось; baseline выравнивает текст по базовой линии и требует textBaseline: TextBaseline.alphabetic, иначе сработает assert
**mainAxisSize**: max (по умолчанию) занимает всё место, которое разрешил родитель, min сжимается по детям, например Column внутри диалога
**spacing**: с Flutter 3.27 фиксированный зазор между детьми вместо SizedBox между каждой парой
Учитывает направление текста: в Row start означает «слева» для языков слева направо и «справа» для арабского или иврита
Expanded представляет собой Flexible с fit: FlexFit.tight: ребёнок обязан заполнить ровно выделенную ему долю свободного места. Обычный Flexible позволяет ребёнку остаться меньше своей доли.
Оба работают только как прямой ребёнок Row, Column или Flex; в любом другом месте Flutter сообщит «Incorrect use of ParentDataWidget».
**flex**: по умолчанию 1; свободное место делится пропорционально flex соседей, так что flex: 2 получает вдвое больше, чем flex: 1
Что выбрать: Expanded для панели, которая должна заполнить ряд, Flexible для ребёнка, который держит свой естественный размер и сжимается, только когда ряд заполнен
**Spacer**: всего лишь Expanded вокруг пустого SizedBox.shrink(); он отталкивает следующего ребёнка к дальнему краю
Все три ничего не рисуют, а меняют размер: AspectRatio подбирает размер с заданным соотношением ширины и высоты, FittedBox масштабирует ребёнка под доступное место, а ConstrainedBox добавляет min/max-ограничения поверх ограничений родителя.
**AspectRatio: видео 16:9 или квадратная заглушка под картинку; берёт наибольшую разрешённую ширину и вычисляет высоту (а если ширина не ограничена, исходит из высоты), поэтому нужна хотя бы одна ограниченная сторона
FittedBox**: масштабирует ребёнка, у которого есть свой естественный размер, например цену, которая должна остаться в одну строку на узком телефоне; fit: BoxFit.scaleDown только уменьшает. Масштабируются пиксели, поэтому текст становится мельче, а не переносится
**ConstrainedBox**: BoxConstraints(maxWidth: 480) держит форму читаемой на планшете, minHeight: 48 гарантирует размер области нажатия
Подвох: ConstrainedBox может выбрать размер только в пределах того, что разрешил родитель; при жёстком (tight) ограничении он ничего не делает, поэтому над ним ставят Center или Align
Stack рисует детей друг поверх друга, последний оказывается сверху. Он нужен всегда, когда виджеты должны перекрываться (подпись поверх фото, бейдж на иконке, лоадер поверх формы), а Row и Column так не умеют.
Непозиционированные дети: расставляются по alignment (по умолчанию верхний начальный угол); самый большой из них задаёт размер Stack
**Positioned**: закрепляет ребёнка отступами top, left, right, bottom от краёв Stack и не влияет на его размер; Positioned.fill растягивает ребёнка на весь Stack, например градиентное затемнение
**fit: StackFit.expand**: заставляет непозиционированных детей заполнить весь Stack
Обрезка: clipBehavior по умолчанию Clip.hardEdge, поэтому ребёнок, вынесенный за край отрицательным отступом, обрезается; Clip.none его дорисует, но нажатия на выступающую часть всё равно не дойдут
**IndexedStack**: показывает одного ребёнка за раз, но держит живыми всех, например страницы под нижней панелью
Wrap заполняет строку детьми и переносит остальных на новую, как слова в абзаце; у Row строка ровно одна, и лишние дети переполняют его с жёлтыми полосами. Теги, фильтры и хэштеги как раз и делают на Wrap.
Настройки: spacing между детьми в строке, runSpacing между строками, alignment и runAlignment для расстановки; direction: Axis.vertical переносит по колонкам
Длинные дети: Wrap даёт каждому ребёнку не больше ширины строки, поэтому длинный Text переносится на несколько строк, а не переполняет; maxLines и TextOverflow.ellipsis нужны, если чип должен остаться в одну строку
Без flex внутри: Expanded и Flexible в Wrap не работают ("Incorrect use of ParentDataWidget")
Не ленивый: все дети строятся сразу, поэтому несколько десятков чипов он потянет, а сотни уже переносите в прокручиваемую сетку
**spacing у Row не переносит строки**: с Flutter 3.27 Row и Column тоже принимают spacing, но новую строку они всё равно никогда не начинают
«A RenderFlex overflowed by N pixels on the right» с жёлто-чёрными полосами. Row разрешает негибкому ребёнку быть сколь угодно широким, поэтому длинный Text остаётся в одну строку и вылезает за край; оберните его в Expanded и добавьте maxLines: 1 с TextOverflow.ellipsis.
**Почему помогает Expanded**: он отдаёт Text ровно ту ширину, что осталась после иконок, и текст обрезается или переносится, а не вылезает
**Flexible вместо него: когда короткое имя не должно растягиваться и отталкивать стрелку к дальнему краю
Полосы есть только в debug: в release-сборке вёрстка сломана так же, просто без предупреждения
Та же ошибка в Column**: обычно это клавиатура, уменьшившая экран; там лечит SingleChildScrollView, а не Expanded
Другие варианты: Wrap, когда дети должны переноситься на новую строку, FittedBox, когда содержимое должно уменьшаться
SafeArea добавляет ребёнку отступы, чтобы тот не залезал под вырез, статус-бар, скруглённые углы и индикатор «домой». Эти отступы он берёт из MediaQuery.padding и передаёт вниз MediaQuery уже без них, поэтому вложенный SafeArea ничего не добавляет.
**Вокруг body, а не вокруг Scaffold**: AppBar сам учитывает статус-бар; если обернуть весь Scaffold, панель сдвинется вниз, а над ней останется пустая полоса
По краям: top: false, bottom: false и так далее, когда отступ нужен не со всех сторон; minimum: задаёт нижнюю границу отступа, даже если устройство не сообщает никакого inset
Списки: часто их лучше не оборачивать, чтобы контент прокручивался под статус-баром, а снизу добавить отступ под индикатор; SliverSafeArea делает то же самое внутри CustomScrollView
ListView.builder создаёт строки по требованию, когда они въезжают в экран, поэтому десять тысяч элементов стоят примерно один экран. ListView(children:) создаёт все дочерние виджеты сразу, а Column в SingleChildScrollView ещё и строит и раскладывает их все.
Как работает ленивое построение: viewport просит у builder только индексы в видимой области плюс cacheExtent (по умолчанию 250 пикселей с каждой стороны); строки, уехавшие далеко, уничтожаются
**ListView(children:): подходит для короткого фиксированного списка вроде экрана настроек; виджеты уже созданы, хотя раскладываются только видимые
ListView.separated**: тот же builder плюс separatorBuilder для разделителей между строками
**itemExtent / prototypeItem: сообщают списку высоту каждой строки, и он может прыгать по позициям и рассчитывать скроллбар, не раскладывая строки
SingleChildScrollView**: для формы или страницы из разнородных блоков, которая чуть выше экрана; никогда для списка, пришедшего из данных
Они прячут виджет на разных уровнях: Visibility(visible: false) убирает его из дерева, Opacity(opacity: 0) оставляет его разложенным и кликабельным, но невидимым, а Offstage держит его живым, но не занимает места, ничего не рисует и не принимает нажатия.
**Visibility**: по умолчанию ребёнок заменяется пустым SizedBox, и его State теряется; флаги maintainState, maintainSize (или Visibility.maintain) сохраняют состояние и место
**Opacity**: ребёнок по-прежнему занимает место и получает нажатия; если это не нужно, добавьте IgnorePointer. Значения между 0 и 1 стоят дополнительного прохода композитинга, поэтому для появления и исчезновения берите AnimatedOpacity или FadeTransition
**Offstage: ребёнок сохраняет состояние, и его анимации продолжают тикать, что тратит CPU; применяют, чтобы держать виджет «прогретым» или измерить его до показа
Самый простой вариант**: if (show) const PromoBanner() прямо в списке детей; ровно это Visibility и делает по умолчанию
Кнопки Material 3 различаются акцентом, и выбирают их по важности действия: FilledButton берут для главного действия, OutlinedButton для второстепенного, TextButton для самого тихого.
**FilledButton**: главное действие вроде «Оплатить» или «Зарегистрироваться», в идеале одно на экран; FilledButton.tonal подходит для второго важного действия, которое не должно с ним спорить
**OutlinedButton: второстепенное действие вроде «Отмена» рядом с залитой кнопкой
TextButton: слабый акцент: «Забыли пароль?», действия внутри карточки или диалога
ElevatedButton: бывшая основная кнопка Material 2; в M3 её оставляют для кнопки поверх пёстрого контента, которой нужна тень
IconButton**: только иконка; .filled, .filledTonal и .outlined дают те же уровни акцента, а у каждой кнопки есть конструктор .icon для иконки с подписью
Неактивное состояние: передайте onPressed: null; кнопка станет серой и перестанет реагировать, отдельного флага enabled нет
InkWell рисует рябь на ближайшем предке Material, а цвет Container рисуется поверх этого Material и закрывает всплеск. Используйте Ink(color: ...): тогда цвет рисуется на Material, под всплеском.
Сначала проверьте: возможно, готовый виджет уже умеет это: у ListTile есть onTap, у кнопок и ActionChip есть onPressed, с правильной обратной связью и доступностью из коробки
**Откуда берётся Material**: его даёт Scaffold, Card или сам Material; если Material выше нет, InkWell бросает «No Material widget found»
Частая ошибка: нажатия на пустой padding теряются, потому что попадание засчитывается только по нарисованному; задайте у GestureDetector behavior: HitTestBehavior.opaque
У Image есть именованный конструктор под каждый источник: Image.asset, Image.network, Image.memory, Image.file. Image.network является виджетом; внутри он использует NetworkImage, то есть ImageProvider, который скачивает и декодирует картинку и нужен там, где API ждёт провайдер, а не виджет.
Ассеты: перечислите файлы или папку в flutter: assets: в pubspec.yaml; варианты из папок 2.0x/ и 3.0x/ выбираются по плотности экрана
Где нужны провайдеры: CircleAvatar(backgroundImage: NetworkImage(url)), DecorationImage(image: AssetImage(...)), precacheImage(...)
Сеть: показывайте что-то во время загрузки и при ошибке через loadingBuilder и errorBuilder; Image.network кэширует только в памяти, поэтому для дискового кэша обычно подключают cached_network_image
**fit**: BoxFit.cover заполняет область и обрезает лишнее, BoxFit.contain показывает картинку целиком
Ограничения: для SVG нужен пакет flutter_svg; Image.file использует dart:io и не работает в вебе
Вызовите ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: ...)). ScaffoldMessenger стоит выше всех маршрутов (его создаёт MaterialApp) и владеет очередью снекбаров, поэтому сообщение переживает переход на другой экран и показывается на текущем Scaffold.
История: раньше снекбары показывали через Scaffold.of(context).showSnackBar, что привязывало их к одному экрану; этот метод объявили устаревшим в пользу ScaffoldMessenger, а потом удалили
Очередь: три вызова покажут три снекбара по очереди; если важен только последний, сначала вызовите hideCurrentSnackBar()
Действие и стиль: SnackBarAction(label: 'Undo', onPressed: ...); behavior: SnackBarBehavior.floating приподнимает его над нижним краем
Нет контекста под рукой: передайте в MaterialApp scaffoldMessengerKey и вызывайте key.currentState?.showSnackBar(...)
**После await**: проверьте context.mounted, прежде чем использовать контекст
Оберните экран в DefaultTabController(length: n), поставьте TabBar в AppBar.bottom, а TabBarView положите в body. Контроллер синхронизирует между ними выбранную вкладку и положение свайпа.
Количество должно совпадать: length, число tabs и число детей TabBarView; при расхождении будет исключение
**Свой TabController**: когда вкладки переключаются из кода или нужно реагировать на смену: создайте его в State с SingleTickerProviderStateMixin, передайте vsync: this и не забудьте dispose
Без своего контроллера: DefaultTabController.of(context) ниже по дереву даёт index и animateTo(...)
Состояние вкладок: TabBarView построен на PageView, поэтому вкладка, с которой вы ушли, уничтожается и теряет позицию прокрутки и State; держите данные выше вкладок или используйте AutomaticKeepAliveClientMixin в странице
Нижняя навигация устроена иначе: это NavigationBar плюс IndexedStack, а не TabBarView
Каждый родитель передаёт ребёнку минимальные и максимальные ограничения, ребёнок выбирает размер в их пределах, а родитель решает, где его поставить. При жёстком (tight) ограничении (min == max) допустим ровно один размер, и width: 300 молча обрезается до него.
Кто даёт жёсткие ограничения: SizedBox.expand, Positioned.fill, Expanded вдоль главной оси, crossAxisAlignment: stretch поперёк неё и корень приложения (экран навязывает ему свой размер). Body у Scaffold получает свободное ограничение (от 0 до размера экрана), поэтому width: 300 там работает
Как вернуть 300: оберните ребёнка в Center или Align: они передают свободные (loose) ограничения (min = 0), и меньший размер становится допустим
**double.infinity**: значит «настолько большой, насколько разрешено»; при ограниченных размерах это работает, но внутри Row ширина не ограничена, и Flutter бросает «BoxConstraints forces an infinite width»
Как отлаживать: выведите ограничения через LayoutBuilder; если размер игнорируется, перестаньте править ребёнка и идите вверх по дереву до родителя, который делает ограничение жёстким
Column даёт детям неограниченную высоту, а ListView старается стать настолько высоким, насколько позволяют ограничения, то есть бесконечным. Flutter останавливается с ошибкой «Vertical viewport was given unbounded height».
**Expanded: обычное решение: список занимает место, оставшееся после других детей, и остаётся ленивым
SizedBox(height: ...)**: когда список представляет собой полосу известной высоты; так же горизонтальный список встраивают в Column
**shrinkWrap: true + NeverScrollableScrollPhysics(): только для короткого списка внутри внешней прокрутки; он раскладывает все элементы сразу, и ленивость пропадает
CustomScrollView**: правильный ответ на «шапка плюс длинный список»: SliverToBoxAdapter и SliverList, одна прокрутка на обоих
Частая ошибка: обернуть Column в SingleChildScrollView не поможет: высота по-прежнему не ограничена
Размер плитки задаёт делегат сетки, а содержимое права голоса не имеет: при crossAxisCount ширина плитки = (доступная ширина − отступы) ÷ число колонок, а высота = ширина ÷ childAspectRatio. На узком телефоне плитка становится ниже, а текст остаётся того же размера и вылезает.
**mainAxisExtent: высота в пикселях, которая перекрывает соотношение сторон; честное решение, когда дизайн задаёт высоту плитки
SliverGridDelegateWithMaxCrossAxisExtent: число колонок зависит от ширины экрана, а не зашито как 2
Ленивая или нет**: GridView.builder строит плитки по требованию; GridView.count и GridView.extent принимают готовый список детей, что нормально для десятка плиток
Пусть плитка выживает: maxLines и TextOverflow.ellipsis у текста и Spacer, который заберёт лишнюю высоту
Частая ошибка: подбирать childAspectRatio до магического 0.72, пока полосы не исчезнут на вашем телефоне; на самом деле нужен был mainAxisExtent
Scaffold.of(context) ищет вверх от context, а context, переданный в HomePage.build, принадлежит HomePage, который стоит выше созданного им же Scaffold. Дайте кнопке контекст ниже Scaffold через Builder или вынесите кнопку в отдельный виджет.
**Что делает Builder**: виджет, единственная задача которого состоит в том, чтобы вызвать вашу функцию со своим BuildContext, на уровень глубже в дереве
Вынести виджет: более чистое решение в реальном коде: у отдельного виджета MenuButton контекст уже внутри Scaffold
**GlobalKey<ScaffoldState>: работает откуда угодно, но глобальный ключ на каждый экран тяжелее и его легко использовать неправильно
Та же ловушка в других местах**: Theme.of, Navigator.of или любой поиск в том же виджете, который сам создаёт этот Theme или Navigator; ScaffoldMessenger.of(context) здесь работает только потому, что MaterialApp поставил messenger выше
Часто это вообще не нужно: AppBar в Scaffold с drawer сам показывает кнопку меню, которая его открывает
body: _pages[_index] при каждом переключении кладёт в тот же слот другой виджет, поэтому Flutter размонтирует старую страницу и уничтожает её State с позицией скролла и загруженными данными. Нужен IndexedStack(index: _index, children: _pages): тогда все страницы остаются смонтированными.
Почему так происходит: если в слот приходит виджет другого типа, элемент не обновляется, а заменяется: у FeedPage вызывается dispose(), а при возврате снова initState(), который заново запускает загрузку. const не спасает: он экономит перестройки, а не State
**Как это чинит IndexedStack**: он держит в дереве всех детей, но рисует и пропускает касания только к ребёнку с индексом index, поэтому скрытые страницы сохраняют State, позицию скролла и запросы в процессе
Цена: все страницы строятся при старте и остаются в памяти; для тяжёлых вкладок стоит строить страницу при первом заходе, а до этого показывать пустую заглушку. В go_router то же самое для каждой ветки делает StatefulShellRoute.indexedStack
Не исправление: PageStorageKey на списке возвращает только позицию скролла; страница всё равно создаётся заново и снова загружает данные
Как поймать: виджет-тест: прокрутить ленту, нажать другую вкладку и вернуться, проверить, что смещение то же и загрузка была одна
Неявная (implicit) анимация сама анимирует свойство, когда меняется его значение. Явная (explicit) даёт вам AnimationController, который вы сами запускаете, останавливаете, повторяете и разворачиваете. Начинайте с неявных; контроллер берите, только когда у анимации своя жизнь.
Неявные: AnimatedContainer, AnimatedOpacity, AnimatedAlign, AnimatedSwitcher: меняете значение в setState, задаёте duration и curve, и виджет плавно приходит к нему. Ни контроллера, ни dispose
Явные: AnimationController с vsync: this плюс Tween; нужны для зацикленной анимации, анимации от жеста или нескольких анимаций по очереди от одного контроллера. Всегда вызывайте dispose
Область перестройки: FadeTransition, ScaleTransition или AnimatedBuilder перестраивают только анимируемое поддерево; слушатель, который вызывает setState, перестраивает весь виджет каждый кадр
CustomPainter даёт Canvas, на котором рисуют то, что не выразить виджетами: графики, кольца прогресса, подписи от руки, нестандартные фигуры. Вы наследуетесь от него, реализуете paint(canvas, size) и shouldRepaint(oldDelegate) и передаёте его в виджет CustomPaint.
Координаты: size задаёт выделенную вам область, (0, 0) соответствует её левому верхнему углу; рисуйте относительно size, а не фиксированных экранных чисел
API Canvas: drawLine, drawCircle, drawRect, drawArc, drawPath, drawImage; для текста нужен TextPainter, который сначала раскладывают
**Paint**: цвет, strokeWidth, style (fill или stroke), шейдеры для градиентов
**shouldRepaint**: возвращайте true, только если изменилось что-то, влияющее на рисунок; постоянный true перерисовывает при каждой перестройке родителя
Анимация: передайте анимацию в super(repaint: animation), тогда painter перерисовывается на каждом тике без перестройки виджетов, и оберните CustomPaint в RepaintBoundary
Когда не надо: если BoxDecoration, ClipRRect или готовый пакет графиков уже это умеют, оставайтесь на виджетах
shouldRepaint всегда возвращает false, поэтому, когда родитель перестраивается с новым painter, рендер-объект оставляет старую картинку и кольцо застывает. Нужно возвращать true, когда изменились данные, от которых зависит рисунок: oldDelegate.progress != progress.
Почему так происходит: CustomPaint переиспользует тот же RenderCustomPaint; получив новый painter, он планирует перерисовку, только если сменился тип painter или shouldRepaint(oldDelegate) вернул true. Перестройка виджета ещё не означает перерисовку
Почему баг выглядит плавающим: новый painter всё равно сохраняется, поэтому кольцо перескакивает на текущее значение, как только что-то другое перерисовывает тот же слой, например рябь от нажатия на соседней кнопке; за RepaintBoundary оно не сдвинется никогда
Исправление: сравнивать каждое поле, которое читает paint (progress, цвет, толщину линии); поле, забытое в сравнении, возвращает баг для этого поля. => true тоже работает, но перерисовывает при каждой перестройке родителя
Для анимаций: передайте Animation в super(repaint: animation) и читайте её value в paint: painter перерисовывается на каждом тике без перестройки виджетов
Как поймать: линтер это не находит; находит виджет-тест или golden-тест, который подаёт второе значение и проверяет нарисованный результат