Start Debugging

Expanded или Flexible во Flutter: разбор FlexFit.tight и FlexFit.loose

Используйте Expanded, когда дочерний виджет должен занять свою долю Row или Column, и Flexible, когда он может быть меньше своей доли. Замеры раскладки на Flutter 3.44 показывают, куда уходит оставшееся место, почему неплотные дочерние виджеты не отдают его соседям и когда при неограниченных ограничениях работает только Flexible.

Короткий ответ: Expanded - это буквально Flexible(fit: FlexFit.tight). Используйте Expanded, когда дочерний виджет должен занять свою долю Row или Column (списки, панели, всё, у чего есть фон). Используйте Flexible (по умолчанию FlexFit.loose), когда дочерний виджет может быть меньше своей доли; классический случай - Text, который должен обрезаться, если длинный, и прилегать к содержимому, если короткий. Оба получают одинаковый максимальный размер; только Expanded принуждает дочерний виджет к этому размеру. Всё ниже измерено с помощью flutter test на Flutter 3.44.8 / Dart 3.12.2.

Путаница понятна: в самых распространённых раскладках они выглядят одинаково. ListView или Container без дочернего виджета стремится занять всё доступное место, поэтому обёртка в любой из двух виджетов даёт те же пиксели. Разница проявляется, только когда дочернему виджету комфортно быть маленьким, и именно тогда люди получают растянутый чип, иконку, прижатую к дальнему краю строки, или зазор, который не могут объяснить.

Сравнение с первого взгляда

Поведение (Flutter 3.44)ExpandedFlexible (по умолчанию)
FitFlexFit.tightFlexFit.loose
Ограничение, передаваемое по главной осиmin = max = доляmin = 0, max = доля
Дочерний виджет меньше своей долирастягивается до долисохраняет свой размер
Неиспользованная часть долинетстановится свободным местом, соседям не отдаётся
mainAxisAlignment оказывает влияниенет (свободного места не остаётся)да
Row(mainAxisSize: MainAxisSize.min) сжимаетсянет, строка занимает всю ширинуда
Внутри неограниченной главной оси (прокручиваемый виджет)всегда assertionдопустимо при mainAxisSize: MainAxisSize.min
Настраиваемый fitнетда

Если запомнить только одну строку, запомните третью: tight растягивает, loose не растягивает.

Что фреймворк на самом деле делает с двумя вариантами fit

Исходный код достаточно короткий, чтобы его процитировать. В packages/flutter/lib/src/widgets/basic.dart на 3.44.8:

// Flutter 3.44.8, widgets/basic.dart
class Flexible extends ParentDataWidget<FlexParentData> {
  const Flexible({super.key, this.flex = 1, this.fit = FlexFit.loose, required super.child});
  // ...
}

class Expanded extends Flexible {
  const Expanded({super.key, super.flex, required super.child}) : super(fit: FlexFit.tight);
}

Ни один из этих виджетов ничего не раскладывает. Это ParentDataWidget, которые записывают flex и fit в FlexParentData дочернего виджета, а RenderFlex (объект рендеринга за Row, Column и Flex) читает их во время раскладки. Поэтому оба выбрасывают одну и ту же ошибку “must be placed inside Flex widgets”, если поместить их под родителя, не являющегося flex.

RenderFlex._computeSizes в rendering/flex.dart работает в два прохода:

  1. Раскладывает каждый дочерний виджет с flex == 0 (обычные виджеты) по их естественному размеру и суммирует их размеры по главной оси.
  2. Вычисляет spacePerFlex = (maxMainSize - usedByNonFlexChildren) / totalFlex один раз, затем раскладывает каждый flex-виджет с максимумом spacePerFlex * flex. Для FlexFit.tight минимум равен этому максимуму. Для FlexFit.loose минимум равен 0.

Важна именно деталь “один раз”. Доля определяется до измерения любого гибкого дочернего виджета. Если неплотный дочерний виджет использует меньше своей доли, остаток не передаётся его соседям. Он становится свободным местом для mainAxisAlignment, что и является источником большинства вопросов “откуда этот зазор”.

Замеры: один и тот же дочерний виджет при обоих вариантах fit

Все числа ниже получены из виджет-тестов, которые фиксируют Row в блоке шириной 400 логических пикселей и считывают tester.getSize и tester.getTopLeft. Вот тестовая обвязка и первый случай:

// Flutter 3.44.8, Dart 3.12.2, flutter_test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Widget host(Widget child) => Directionality(
      textDirection: TextDirection.ltr,
      child: Align(
        alignment: Alignment.topLeft,
        child: SizedBox(width: 400, height: 100, child: child),
      ),
    );

const a = Key('a'), b = Key('b');

void main() {
  testWidgets('tight vs loose with a small child', (t) async {
    await t.pumpWidget(host(Row(children: [
      Expanded(child: SizedBox(key: a, width: 50, height: 10)),
      Flexible(child: SizedBox(key: b, width: 50, height: 10)),
    ])));
    print('Expanded=${t.getSize(find.byKey(a))} '
        'Flexible=${t.getSize(find.byKey(b))}');
  });
}

Вывод: Expanded=Size(200.0, 10.0) Flexible=Size(50.0, 10.0). Обоим дочерним виджетам предложили по 200 пикселей (400, поделённые на суммарный flex 2). SizedBox(width: 50) под Expanded был перекрыт плотным ограничением и стал шириной 200; под Flexible он сохранил свои 50. width: 50 - это просьба, а плотное ограничение от родителя всегда побеждает.

Полный набор проб, та же строка в 400 px:

Раскладка (Flutter 3.44.8)Результат
Expanded(SizedBox(w: 50)) + Flexible(SizedBox(w: 50))200 и 50
Flexible(SizedBox(w: 50)) + Flexible(SizedBox(w: 1000))50 и 200, 150 px не использованы в конце
SizedBox(w: 100) + Expanded(flex: 2) + Flexible(flex: 1, SizedBox(w: 40))200 и 40, 60 px не использованы
Flexible(fit: FlexFit.tight, SizedBox(w: 50)) + SizedBox(w: 100)300, идентично Expanded
Flexible(Container(color: ...)) + SizedBox(w: 100)300 x 100, неплотный fit ничего не изменил

Стоит усвоить вторую строку. 1000-пиксельный дочерний виджет был зажат до 200, хотя его сосед использовал только 50, потому что неиспользованные соседом 150 никогда не перераспределялись. Если нужно “маленький берёт, сколько ему нужно, а большой получает остальное”, маленький не должен быть гибким вообще: сделайте его обычным дочерним виджетом, а Expanded оставьте только для большого.

Последняя строка объясняет, почему многие делают вывод “Flexible и Expanded - одно и то же”. Container без дочернего виджета, без ширины и без выравнивания принимает наибольший размер, который позволяют его ограничения. Дайте ему неплотный максимум 300, и он выберет 300. То же верно для ListView, TextField (который заполняет свою ширину) и всего остального, что по умолчанию расширяется. С жадными дочерними виджетами fit незаметен.

Когда выбирать Expanded

Когда выбирать Flexible

// Flutter 3.44.8: label that hugs its text but ellipsizes when long
Row(
  children: [
    Flexible(
      child: Text(
        userName,
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
    ),
    const Icon(Icons.verified, size: 16),
  ],
)

Измерено в строке 400 px внутри Scaffold при userName = 'Short': под Flexible текст имеет ширину 71.25 px, а иконка стоит в x = 71.25, прямо рядом с именем. Замените на Expanded, и текст станет шириной 376 px, а иконку вытолкнет в x = 376, к дальнему краю строки. Для длинного имени обе версии ограничивают текст 376 px и показывают многоточие, так что исправление переполнения одинаково, но только Flexible сохраняет правильный вид короткого случая. Если вы здесь из-за жёлто-чёрной полосы, другие причины разобраны в руководстве по RenderFlex overflowed.

Подвох, который выбирает за вас: неограниченная главная ось

Поместите Column внутрь SingleChildScrollView (или Row внутрь горизонтального ListView), и flex получит бесконечное ограничение по главной оси. RenderFlex не может разделить бесконечность, поэтому в _computeSizes он устанавливает canFlex = false и раскладывает каждый дочерний виджет так, будто у него вообще нет flex.

Что будет дальше, зависит от fit. Отладочная проверка _debugCheckConstraints выбрасывает исключение, если у дочернего виджета ненулевой flex и либо flex использует MainAxisSize.max, либо дочерний виджет имеет FlexFit.tight:

// Flutter 3.44.8: throws in debug
SingleChildScrollView(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Expanded(child: SizedBox(height: 50)),
    ],
  ),
)
// RenderFlex children have non-zero flex but incoming height constraints are unbounded.
// Flutter 3.44.8: lays out fine, child is 50 px tall
SingleChildScrollView(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Flexible(child: SizedBox(height: 50)),
    ],
  ),
)

Оба варианта проверены в пробе: вариант с Expanded вызвал assertion, вариант с Flexible разложил дочерний виджет 10 x 50 без исключения. Это важно для переиспользуемых виджетов. Если компонент оборачивает часть себя в Expanded, его нельзя поместить в прокручиваемый столбец. Если он использует Flexible, а его столбец использует MainAxisSize.min, он деградирует мягко: родитель ограничен - он гибкий; родитель не ограничен - он принимает естественный размер. Учтите, что в неограниченном случае Flexible фактически ничего не делает, поэтому жадный дочерний виджет под ним (ListView) всё равно выбросит собственную ошибку “unbounded height”.

Мелкие подвохи и варианты

flex: 0 отключает виджет. RenderFlex проверяет flex > 0, прежде чем считать дочерний виджет гибким. Flexible(flex: 0, ...) раскладывается как обычный дочерний виджет. Spacer проверяет assertion flex > 0, чтобы вы не сделали этого случайно.

Flexible(fit: FlexFit.tight) и Expanded взаимозаменяемы. Проба дала одинаковые размеры. Предпочитайте Expanded ради читаемости; к явному fit прибегайте, только когда он вычисляется, например Flexible(fit: isCompact ? FlexFit.loose : FlexFit.tight, child: ...).

Поперечная ось не затрагивается. Оба виджета ограничивают только главную ось. Если нужно, чтобы дочерний виджет заполнял и поперечную ось, это crossAxisAlignment: CrossAxisAlignment.stretch у Row или Column, а не другой flex-виджет.

Сначала вычитается spacing. Начиная с Flutter 3.27 у Row и Column есть параметр spacing. _computeSizes заранее закладывает в использованное место spacing * (childCount - 1) перед делением, так что промежутки вычитаются до вычисления любой flex-доли.

Не вкладывайте их друг в друга. Expanded(child: Flexible(...)) выбрасывает ошибку ParentDataWidget, потому что родитель внутреннего виджета не является flex. Полный список обёрток, которые это вызывают, см. в исправлении “Expanded widgets must be placed inside Flex widgets”.

Рекомендация и контекст за ней

По умолчанию используйте Expanded. Большинство гибких дочерних виджетов в реальных приложениях - это прокручиваемые виджеты, панели или поля, которым и так нужно всё место, а плотные fit дают предсказуемые, выровненные раскладки без сюрпризов со свободным местом.

Переходите на Flexible ровно в трёх ситуациях: у дочернего виджета есть естественный размер, который нужно сохранить (текст рядом с иконкой, чип), вам нужно, чтобы mainAxisAlignment или MainAxisSize.min продолжали работать, либо вы пишете переиспользуемый виджет, который может оказаться внутри прокручиваемого виджета. А когда поймаете себя на том, что ставите Flexible на двух соседей, ожидая, что маленький отдаст место большому, остановитесь: сделайте маленький обычным дочерним виджетом, а большой Expanded, потому что RenderFlex никогда не перераспределяет неиспользованную долю неплотного дочернего виджета.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад