Kodokon kodokon.com

Адаптивность: LayoutBuilder, MediaQuery и платформы

Создавай интерфейсы, которые подстраиваются под ограничения родителя, под окно и под соглашения каждой ОС.

9 мин · 3 вопросов

Открыть этот урок в Kodokon

MediaQuery описывает логическое окно, а не физический экран: размер, padding (вырез, системная панель), viewInsets (клавиатура), textScaler... Классическая ловушка: MediaQuery.of(context) подписывает виджет на все эти свойства. Когда открывается клавиатура, viewInsets меняется, и перестраиваются все подписанные виджеты - включая те, что читают только ширину. Начиная с Flutter 3.10 MediaQueryData отдаётся через InheritedModel: точечные аксессоры MediaQuery.sizeOf(context), paddingOf, viewInsetsOf... подписывают виджет только на запрошенный аспект. Рефлекс эксперта: изгнать .of(context).size из своей кодовой базы.

DART
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;
    final columns = switch (width) {
      >= 900 => 4,
      >= 600 => 3,
      _ => 2,
    };
    return GridView.builder(
      gridDelegate:
          SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
      ),
      itemCount: 24,
      itemBuilder: (context, i) => Card(
        child: Center(child: Text('Tile $i')),
      ),
    );
  }
}
sizeOf плюс реляционные паттерны: читаемые точки перелома.

MediaQuery отвечает на вопрос «какого размера окно?»; LayoutBuilder отвечает на вопрос «сколько места даёт мне мой родитель?». Этот нюанс решающий для переиспользуемых компонентов: панель, которую иногда показывают на весь экран, а иногда в колонке шириной 320 px, должна реагировать на свои ограничения, а не на окно - иначе она решит, что она на планшете, тогда как на деле ей тесно. Полезная внутренняя деталь: builder у LayoutBuilder выполняется во время фазы вёрстки, когда ограничения уже известны; поэтому вызвать синхронный setState внутри него нельзя, фреймворк это запрещает.

DART
import 'package:flutter/material.dart';

class MasterDetail extends StatelessWidget {
  const MasterDetail({
    super.key,
    required this.list,
    required this.detail,
  });

  final Widget list;
  final Widget detail;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 720) {
          return list;
        }
        return Row(
          children: [
            SizedBox(width: 320, child: list),
            const VerticalDivider(width: 1),
            Expanded(child: detail),
          ],
        );
      },
    );
  }
}
Компонент подстраивается под то место, что ему досталось, куда бы его ни поставили.

Для различий между iOS и Android источник истины на стороне виджетов - Theme.of(context).platform: он выводится из defaultTargetPlatform, но уважает переопределения - это важно, чтобы проверить отрисовку под iOS прямо из теста или сымитировать другую платформу. Многие адаптации уже встроены во фреймворк: физика прокрутки (отскок на iOS против свечения на Android), переходы между страницами, жест возврата свайпом от края. Конструкторы .adaptive (Switch.adaptive, CircularProgressIndicator.adaptive) сами переключаются на отрисовку в стиле Cupertino. Ручные ветвления оставь для действительно расходящихся соглашений - например, для формулировок действий или расположения кнопок в диалогах.

DART
import 'package:flutter/material.dart';

class SettingsTile extends StatelessWidget {
  const SettingsTile({
    super.key,
    required this.value,
    required this.onChanged,
  });

  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final platform = Theme.of(context).platform;
    final isApple = platform == TargetPlatform.iOS ||
        platform == TargetPlatform.macOS;
    return ListTile(
      title: Text(
        isApple ? 'Settings' : 'Preferences',
      ),
      trailing: Switch.adaptive(
        value: value,
        onChanged: onChanged,
      ),
    );
  }
}
Switch.adaptive рисует переключатель Cupertino на iOS и macOS.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. В чём разница между MediaQuery.of(context) и MediaQuery.sizeOf(context)?
    • sizeOf подписывает виджет только на аспект размера: открытие клавиатуры его не перестраивает
    • sizeOf возвращает физический размер в реальных пикселях экрана
    • of устарел и будет удалён из фреймворка
  2. Когда стоит предпочесть LayoutBuilder вместо MediaQuery для адаптации вёрстки?
    • Когда решение зависит от места, выделенного родителем, а не от размера окна
    • Когда нужно узнать ориентацию устройства
    • Когда хочется избежать перестройки при смене темы
    • Никогда: они взаимозаменяемы
  3. Почему не стоит использовать Platform.isIOS в коде виджетов?
    • Он приходит из dart:io, которого нет в вебе, и игнорирует переопределения, которыми пользуются тесты и тема
    • Он заметно медленнее, чем defaultTargetPlatform
    • Он возвращает true на macOS