Я Магин Максим, Flutter-разработчик агентства мобильной разработки Instadev. Поговорим о таком понятии как “адаптивная верстка”. Разберем, для чего она нужна, чем отличается от других видов верстки и какие подводные камни могут встретиться при использовании.

Каждый разработчик так или иначе сталкивался с вопросом - как сделать качественный UI, который будет хорошо себя показывать на различных устройствах. Даже среди смартфонов существует большое разнообразие размеров – одни шире, другие – длиннее. Понятное дело, что для каждого устройства уникальный код не напишешь. А что делать, если среди устройств необходимо учесть еще и планшеты, где действуют уже другие правила расположения и масштабирования элементов? Есть ли универсальное решение? Но обо всем по порядку.

Для того, чтобы понять, каким образом следует выстроить процесс формирования UI приложения, для начала необходимо разобраться, какие подходы используются в принципе.

Существует 4 основных подхода к верстке: фиксированный, резиновый, адаптивный и отзывчивый. Каждый из этих подходов имеет свои плюсы и минусы. Разберем их чуть подробнее.

Фиксированный подход

Его суть  заключается в том, чтобы задать жесткие размеры и расстояния для всех элементов, отображаемых на экране. Иными словами мы “фиксируем” положение каждого виджета на экране. Преимущество очевидно – мы всегда знаем величину того или иного используемого элемента. С другой стороны, если мы возьмем устройство, которое хоть немного отличается размером от того, на котором мы производим тесты, все наши расчеты оказываются неподходящими для него: в лучшем случае, может появиться больше пустого пространства или, наоборот, элементы сильно прижмутся друг к другу, в худшем – мы получим ошибку рендеринга. Это говорит нам о том, что в чистом виде данный подход лучше не использовать.

Резиновый подход

Его идея заключается в том, что элементы отрисовываются, исходя из размеров экрана. То есть размеры виджетов устанавливаются в процентном соотношении от рабочей области. С одной стороны, в отличие от фиксированного подхода, сохраняется тот вид экрана, который был задуман, даже если мы возьмем другое устройство. Но в то же время, если виджеты привязываются к размерам экрана, то при изменении только одного измерения (например, высоты) их пропорции могут нарушиться. А что, если мы возьмем не просто смартфон другого размера, а, например, планшет? Все те компактные элементы, которые были предназначены для небольшого экрана, внезапно станут огромными. 

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

Ниже представлен пример резиновой и фиксированной верстки соответственно:

Адаптивный и отзывчивый подходы

Подходы базируются на идее переключения между различными состояниями экрана. Но перед тем, как говорить об их использовании, нужно разобраться, как они работают и в чем между ними разница.

К пониманию я пришел не сразу, так как большое количество ресурсов и изображений, на мой взгляд, недостаточно информативны. Например:

В основе данных подходов лежит общий принцип – использование брейкпоинтов. Что это? Определим для себя, что брейкпоинт – некоторое состояние экрана, вариант его отображения, которое применяется при возникновении определенных условий в ходе изменения размеров. Чем это отличается от того же резинового подхода? Резиновый – масштабирует имеющуюся картинку, а адаптивный – подменяет две или более принципиально разных. Наглядно это выглядит следующим образом:

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

Но вернемся к понятиям адаптивности и отзывчивости. В чем же разница между ними, если в основе у обоих лежит идея брейкпоинтов? Все достаточно просто. Адаптивность подразумевает жесткое переключение между двумя и более состояниями, отзывчивость же – плавный переход между ними.

Есть хорошая фраза, которая помогает в полной мере понять эту разницу: “Адаптивность - это про платформы. Отзывчивость - про размер”. 

Это значит, что мы используем адаптивный подход в том случае, когда хотим, чтобы наше приложение работало и на смартфонах, и на планшетах, и еще где-нибудь. А отзывчивый подход мы вправе использовать и для одного устройства. Самый простой пример – поворот экрана.

Для того, чтобы реализовать адаптивный подход, используем такой виджет как LayoutBuilder. Задаем builder, который в качестве параметров принимает текущий context и constraints. И здесь уже можно оперировать значениями минимально и максимально возможных измерений для экрана (ширина и высота). Что это дает? Это позволяет определить брейкпоинты – те крайние точки, при которых UI необходимо перестроить. На текущий момент, к сожалению, нет параметра, отвечающего за устройство, на котором запущено приложение, поэтому придется использовать конкретные числовые значения измерений.

Исключение составляет случай, когда брейкпоинты предусматриваются для поворота экрана. Что происходит в этот момент? По сути, высота становится шириной, а ширина - высотой. А значит, справедливо следующее:

return constraints.maxWidth > constraints.maxHeight
              ? const LandscapeWidget()
              : const PortraitWidget();

Вся прелесть в том, что велосипед изобретать не нужно, и во flutter уже есть виджет, который предусматривает данную ситуацию – OrientationBuilder, для которого мы можем формировать UI, отталкиваясь от текущего значения orientation устройства. 

Чем он отличается от LayoutBuilder и что использовать предпочтительнее? Для ответа обратимся к реализации OrientationBuilder. Его метод build выглядит следующим образом:

Widget build(BuildContext context) {
    return LayoutBuilder(builder: _buildWithConstraints);
  }

Получается, что данный виджет является даже не наследником LayoutBuilder, а просто надстройкой над ним. И если мы обратим внимание на метод  _buildWithConstraints, то встретим условие, к которому пришли выше:

final Orientation orientation = constraints.maxWidth > constraints.maxHeight 
          ? Orientation.landscape 
          : Orientation.portrait;

Это значит, что нет категоричного ответа – что использовать лучше, потому что так или иначе мы приходим к LayoutBuilder. Другое дело, что уже предусмотрены инструменты, которые немного облегчают процесс разработки.

Рассмотрим, как данный виджет реализуется на практике:

  1. Формируем виджет для первого брейкпоинта:

class PortraitWidget extends StatelessWidget {
  const PortraitWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          Icon(
            Icons.flutter_dash,
            size: MediaQuery.of(context).size.width * 0.8,
          ),
          const SizedBox(
            height: 16,
          ),
          const Description(),
        ],
      ),
    );
  }
}
  1. Формируем виджет для второго брейкпоинта:

class LandscapeWidget extends StatelessWidget {
  const LandscapeWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.height * 0.8,
        ),
        const SizedBox(width: 16,),
        const Expanded(
          child: SingleChildScrollView(
            child: Description(),
          ),
        )
      ],
    );
  }
}
  1. Используем LayoutBuilder:

LayoutBuilder(
  builder: (context, constraints) {
      return constraints.maxWidth > constraints.maxHeight
          ? const LandscapeWidget()
          : const PortraitWidget();
  },
)

После чего получаем следующий результат:

PortraitWidget
PortraitWidget
LandscapeWidget
LandscapeWidget

Есть один интересный факт, который не был упомянут выше. При более детальном изучении данного виджета, обнаружилось, что OrientationBuilder наследуется от StatelessWidget. Возникает логичный вопрос – за счет чего тогда обновляется UI? Чтобы ответить на него, разберем подробнее, как устроен LayoutBuilder.

Обратимся к классу _LayoutBuilderElement, который отвечает за построение объекта рендеринга данного виджета. Каждый раз, как мы получаем новое значение ограничений (constraints), мы вызываем метод update. 

@override
  void update(ConstrainedLayoutBuilder<ConstraintType> newWidget) {
    assert(widget != newWidget);
    super.update(newWidget);
    assert(widget == newWidget);

    renderObject.updateCallback(_layout);
    // Force the callback to be called, even if the layout constraints are the
    // same, because the logic in the callback might have changed.
    renderObject.markNeedsBuild();
  }

Обратим внимание на последнюю строку. После изменения виджета вызывается метод markNeedsBuild для объекта рендеринга. Далее посредством рендер-биндинга запрашивается перерисовка объекта. Как это выглядит с точки зрения реализации?

Сначала обратим внимание на реализацию метода markNeedsBuild. Переменная _relayoutBoundary отвечает за объект рендеринга, который отдается на перерисовку. Если он равен null, то мы обращаемся к родителю текущего элемента (если таковой имеется) и помечаем как needsBuild уже его. Также мы поступаем, когда _relayoutBoundary не нулевой, но не является тем объектом, с которым мы работаем в данный момент. И уже в том случае, когда элемент является тем, который система собирается перерисовать, она запрашивает запуск этого процесса у ShedulerBinding – прослойки между движком и кодовой базой, которая отвечает за вызов функций и выполнение задач в фоновом режиме через определенные интервалы времени или в ответ на определенные события.

void markNeedsLayout() {
    assert(_debugCanPerformMutations);
    if (_needsLayout) {
      assert(_debugSubtreeRelayoutRootAlreadyMarkedNeedsLayout());
      return;
    }
    if (_relayoutBoundary == null) {
      _needsLayout = true;
      if (parent != null) {
        // _relayoutBoundary is cleaned by an ancestor in RenderObject.layout.
        // Conservatively mark everything dirty until it reaches the closest
        // known relayout boundary.
        markParentNeedsLayout();
      }
      return;
    }
    if (_relayoutBoundary != this) {
      markParentNeedsLayout();
    } else {
      _needsLayout = true;
      if (owner != null) {
        assert(() {
          if (debugPrintMarkNeedsLayoutStacks) {
            debugPrintStack(label: 'markNeedsLayout() called for $this');
          }
          return true;
        }());
        owner!._nodesNeedingLayout.add(this);
        owner!.requestVisualUpdate();
      }
    }
  }

Для того, чтобы добиться отзывчивости, нужно придерживаться двух основных правил:

Первое – использование гибких (flexible) виджетов, т. е. виджетов, которые занимают все предоставленное пространство. 

Второе – если нам необходимо занимать не все пространство, а только некоторую его часть, мы должны отталкиваться от размеров экрана – использовать MediaQuery данные.

Если отзывчивый подход решает часть проблем, почему бы не использовать только его? Во-первых, создание гибкого UI – процесс достаточно трудоемкий, и иногда просто выгоднее использовать подходы проще. Во-вторых, работа с flexible виджетами не всегда предсказуема. Это касается как их непосредственного отображения, так и взаимодействия с другими элементами экрана. Для примера представим реализацию скролла. Так или иначе, необходимо обозначить область, в которой он будет располагаться, а также определиться с тем, как будут выглядеть его элементы. Однако если подразумевается использование flexible виджетов, мы получаем следующую картину: скролл сам по себе – неограниченное рабочее пространство, и туда помещаются объекты неограниченных размеров. 

Также нужно понимать, что подобное определение размеров виджетов – очень приблизительно. Бывают случаи, когда оптимальнее использовать конкретные значения, а не вычислять их, используя сложные выражения.

Плюс ко всему, использование гибкого UI нагружает приложение, и если перестараться, это может привести к проблемам с производительностью.

На что стоит обратить внимание, когда речь идет об адаптивной верстке? 

Конечно, самая очевидная проблема - понимание того, как должны виджеты реагировать на изменение размеров экрана. Есть вероятность, что брейкпоинтов, определенных разработчиком, может не хватить. То есть для каких-то экранов приложение работать будет корректно, но оно также может быть запущено на неучтенных устройствах, для которых подобная реализация не подходит. 

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

Далее приведен список виджетов, которые могут хорошо помочь вам при адаптивной и отзывчивой верстке: 

  • AspectRatio

  • CustomSingleChildLayout

  • CustomMultiChildLayout

  • FittedBox

  • FractionallySizedBox

  • LayoutBuilder

  • OrientationBuilder

  • MediaQuery

  • MediaQueryData

  • Expanded

  • Flexible

  • Spacer

Рассмотрим использование некоторых из представленных виджетов на основе PortraitWidget, который был упомянут выше:

class PortraitWidget extends StatelessWidget {
  const PortraitWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          Icon(
            Icons.flutter_dash,
            size: MediaQuery.of(context).size.width * 0.8,
          ),
          const SizedBox(
            height: 16,
          ),
          const Description(),
        ],
      ),
    );
  }
}

Здесь задается размер изображения, исходя из размеров экрана, и весь контент целиком скроллится. Но что, если скролл изображения не требуется? Попробуем вынести его за пределы SingleChildScrollView:

@override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.width * 0.8,
        ),
        const SizedBox(
          height: 16,
        ),
        const SingleChildScrollView(
          child: Description(),
        ),
      ],
    );
  

Кажется логичным, и при запуске приложения мы получаем экран, который планировали увидеть. Но что произойдет, если размер описания сильно увеличится? 

Почему так происходит? Проблема в том, что SingleChildScrollView подразумевает, что в нем помещается неограниченно большой контент, который мы скроллим. В данном примере он пытается отрисоваться полностью, из-за чего выходит за пределы экрана. Поэтому следующий шаг – установить для SingleChildScrollView границы, в рамках которых он может существовать. Это можно сделать различными способами. Но один из них показался мне достаточно интересным:

Для начала используем Expanded:

@override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.width * 0.8,
        ),
        const SizedBox(
          height: 16,
        ),
        const Expanded(
          child: SingleChildScrollView(
            child: Description(),
          ),
        ),
      ],
    );
  }

hДля чего он нужен? Данный виджет говорит своим детям, чтобы они заняли все оставшееся пространство. Таким образом SingleChildScrollView получает те самые границы, которых ему не хватало для корректной отрисовки – он должен быть не более тех размеров, что родитель может предоставить. 

Чем данный метод реализации интересен? Можно подойти к его реализации иначе. Существуют такие виджеты как: Flex, который позволяет разместить внутри себя гибкие виджеты, и Flexible, который позволяет указать пропорции, по которым будет определяться соотношение его размеров по отношению к размерам других Flexible элементов. Обратим внимание на то, что Flex мы используем вместо Column.

  @override
  Widget build(BuildContext context) {
    return Flex(
      direction: Axis.vertical,
      children: [
        Flexible(
          flex: 0,
          child: Icon(
            Icons.flutter_dash,
            size: MediaQuery.of(context).size.width * 0.8,
          ),
        ),
        const SizedBox(
          height: 16.0,
        ),
        const Flexible(
          flex: 1,
          child: SingleChildScrollView(
            child: Description(),
          ),
        ),
      ],
    );
  }

Значение flex = 0 говорит о том, что дочерний элемент не является гибким и при расчетах используется его собственный, заданный размер. Соответственно, единственный оставшийся Flexible со значением flex = 1, занимает все предоставленное ему пространство. Поскольку в данном примере помимо SizedBox и виджета с текстовым описанием ничего нет, и на соотношение измерений больше ничего не влияет, можем упростить код, убрав лишнюю Flexible-обертку:

@override
  Widget build(BuildContext context) {
    return Flex(
      direction: Axis.vertical,
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.width * 0.8,
        ),
        const SizedBox(
          height: 16.0,
        ),
        const Flexible(
          flex: 1,
          child: SingleChildScrollView(
            child: Description(),
          ),
        ),
      ],
    );
  }

Но упрощения на этом на заканчиваются. Рассмотрим конструктор Flexible:

const Flexible({
    super.key,
    this.flex = 1,
    this.fit = FlexFit.loose,
    required super.child,
  });

Значение flex по умолчанию идет равное 1, значит в примере данную строку можно убрать. Однако есть еще параметр fit, который определяет, какую часть от пространства занимает виджет. Либо это loose – размер не более того, что предоставляет родитель или меньше его, либо tight  – все доступное для отрисовки место. На мой взгляд, второе значение больше подходит для данной ситуации, поэтому переопределим виджет следующим образом:

const Flexible(
          fit: FlexFit.tight,
          child: SingleChildScrollView(
            child: Description(),
          ),
        ),

Самое интересное заключается в том, что аналогичный виджет уже существует,  а значит мы можем сразу использовать его:

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

Но еще более интересным является факт, что существует виджет аналогичный Flex, с выбранным значением направления Axis.vertical, и как ни странно, это… Column!

class Column extends Flex {
  const Column({
    super.key,
    super.mainAxisAlignment,
    super.mainAxisSize,
    super.crossAxisAlignment,
    super.textDirection,
    super.verticalDirection,
    super.textBaseline,
    super.children,
  }) : super(
    direction: Axis.vertical,
  );
}

А значит, что после всех преобразований мы получаем реализацию, с которой начался разбор данного примера:

@override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.width * 0.8,
        ),
        const SizedBox(
          height: 16,
        ),
        const Expanded(
          child: SingleChildScrollView(
            child: Description(),
          ),
        ),
      ],
    );
  }

Это говорит о том, что можно найти различные способы реализации адаптивности, но не стоит забывать о том, что Flutter уже имеет инструменты, которые для этого подходят и, возможно, правильное их использование даст оптимальную комбинацию виджетов для достижения поставленной цели.

Возникшую проблему можно было решить и другими способами. Например, задать размер SingleChildScrollView через SizedBox, что характерно для фиксированного подхода, либо через FractionallySizedBox, параметры которого задаются не конкретными значениями ширины и высоты, а частью от соответствующих измерений родителя, что подходит больше для резинового.

Рассмотрим другой пример: скролл не нужен, но при этом расстояние между изображением и текстом должно быть максимально возможным (для данной реализации вернемся к ограниченному размеру сообщения). Достичь подобного эффекта поможет Spacer:

@override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          Icons.flutter_dash,
          size: MediaQuery.of(context).size.width * 0.8,
        ),
        const Spacer(),
        const Description(),
      ],
    );
  }
Демонстрация работы Spacer
Демонстрация работы Spacer

Резюмируем

К сожалению, на данный момент нет одного универсального подхода, который можно было бы постоянно использовать. Однако в данной статье были рассмотрены варианты реализации UI для различных устройств. Это позволит более грамотно подойти к формированию подхода, удовлетворяющего требованиям продукта. Более того, знакомство с адаптивным (и отзывчивым) подходом к верстке позволяет упростить процесс разработки приложений, работа с которыми подразумевается не только на смартфонах.

Подведем итог тому, для чего нужна адаптивность при создании нового продукта:

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

  • Использование адаптивного подхода в рамках одного вида устройств позволяет реализовать универсальный UI, который будет выглядеть красиво и гармонично. Другими словами – обеспечивается масштабируемость для данного приложения.

  • Адаптивность позволяет продумать и разработать дизайн не только для стандартной (портретной) ориентации экрана, но и для альбомной.

Исходный код проекта с примером реализации LayoutBuilder доступен по ссылке

Комментарии (3)


  1. PackRuble
    12.01.2024 20:51
    +3

    Это было весьма содержательно и интересно! Спасибо за материал.


  1. Dewblass
    12.01.2024 20:51

    Статья конечно великолепная, но зачем этот ваш проприетарный fluter? Когда есть GTK и QT?


    1. MaximMagin Автор
      12.01.2024 20:51

      Мне кажется, это тема для отдельной дискуссии - о преимуществах и недостатках использования flutter.

      Плюс речь идёт в большей степени о мобильной разработке, и применение gtk и qt для android и ios мне кажется сомнительным.