Введение

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

Во Flutter есть три библиотеки, предоставляющие основные виджеты:

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

Мы будем рассматривать в основном компоненты из widgets и material библиотек. У большинства material виджетов существуют аналоги из cupertino, с ними вы можете познакомиться самостоятельно.

Фундамент

MaterialApp

Вы часто можете встретить MaterialApp в качестве виджета, передающегося в функцию runApp. Этот виджет закладывает основу приложения. Он управляет навигацией, темизацией, локализацией приложения и прочей базовой функциональностью. Ниже вы можете посмотреть на пример использования этого виджета:


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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.dark,
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      debugShowCheckedModeBanner: false,      
      home: MyWidget(),
      builder: (context, child) {
        if (!kDebugMode) {
          return child ?? const SizedBox.shrink();
        }
        return Column(
          children: [
            if (kDebugMode)
              const Text('Debugging'),
            Expanded(child: child ?? const SizedBox.shrink()),
          ]
        );
      },
    );
  }
}

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Hello, World!',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
    );
  }
}

В данном примере при помощи MaterialApp мы задали следующую конфигурацию приложения:

  • Задали светлую и темную тему с использованием стилей из Material 3 (актуальной версии Material Design).
  • Включили использование темной темы всегда, вместо синхронизации с темой телефона.
  • Выключили отображение дебаг-подписи в углу экрана. Помимо параметра debugShowCheckedModeBanner есть и другие debug-параметры, которые могут быть полезные при разработке, с ними вы можете ознакомиться в документации.
  • Определили параметр builder — он позволяет добавить какую-то обертку над основными виджетами. Позже этот параметр будет применен в параграфе 1.7.1. для обработки ошибок.

Несмотря на название, MaterialApp адаптирует внешний вид приложения в зависимости от того, на какой ОС запущено приложение. Например поведение скролла и анимация переключения страниц будут отличаться на Android и iOS.

Помимо MaterialApp существует виджет CupertinoApp. Он предоставляет такую же функциональность, но повторяет поведение нативных iOS приложений, независимо от того, на какой платформе запускается приложение.

Стоит отметить, что и MaterialApp, и CupertinoApp под капотом используют WidgetsApp, в котором разработчики Flutter инкапсулировали общую логику. И если вы захотите получить больше контроля над различными параметрами приложения, вы и сами можете использовать WidgetsApp.

Если вы посмотрите на исходный код виджетов, описанных выше, вы увидите, что они являются композицией множества других виджетов, каждый из которых имеет свою ответственность. Например за навигацию отвечают виджеты Navigator и Router, за тему AnimatedTheme и так далее.

Scaffold

Scaffold представляет собой страницу приложения и реализует основную структуру экрана. В простейшем виде в Scaffold можно указать единственный параметр body — виджет содержимого страницы. Но у него есть и другие параметры.


import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(
      theme: ThemeData.light(useMaterial3: true),
      home: const ScaffoldExample(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('App Bar title'),
      ),
      body: const Center(
        child: Text('Content'),
      ),
      bottomNavigationBar: BottomAppBar(
        shape: const CircularNotchedRectangle(),
        child: Container(height: 50.0),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.bolt),
        shape: CircleBorder(),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}

Благодаря Scaffold мы смогли использовать виджеты AppBar, BottomAppBar, FloatingActionButton, и при этом нам не пришлось заботиться об их размещении на экране. Scaffold значительно упрощает использование базовых элементов интерфейса, и позволяет быстро реализовать интерфейс, близкий к стандартам Material Design.

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


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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.dark,
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      debugShowCheckedModeBanner: false,      
      home: MyWidget(),
      builder: (context, child) {
        if (!kDebugMode) {
          return child ?? const SizedBox.shrink();
        }
        return Column(
          children: [
            if (kDebugMode)
              const Text('Debugging'),
            Expanded(child: child ?? const SizedBox.shrink()),
          ]
        );
      },
    );
  }
}

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Hello, World!',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
    );
  }
}

Вы можете заметить, что текстовый виджет со словом “Debugging” оказался красного цвета с желтым подчеркиванием, хотя мы не задавали у него такой стиль. Дело в том, что MaterialApp добавляет такой стиль текста по умолчанию. Чтобы этого избежать, выше по дереву над текстовым виджетом необходим виджет Material — он переопределяет стиль текста по умолчанию на корректный (без желтого подчеркивания). Scaffold как раз содержит Material, благодаря чему текст “Hello, World” отображается корректно.

По аналогии с MaterialApp, у Scaffold тоже есть аналог в Cupertino-стиле — CupertinoPageScaffold. Он используется реже, так как не дает такой гибкости.

Содержимое

Text

Виджет Text позволяет отобразить текстовое содержимое с определенным стилем.


import 'package:flutter/material.dart';

const loremIpsum =
    'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';

class TextExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Text(
      loremIpsum,
      textAlign: TextAlign.center,
      overflow: TextOverflow.ellipsis,
      maxLines: 2,
      style: TextStyle(
        color: Colors.teal,
        fontSize: 15,
        fontWeight: FontWeight.bold,
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 200,
            child: TextExample(),
          ),
        ),
      ),
    );
  }
}

Мы использовали некоторые параметры:

  • textAlign — расположение текста внутри виджета;
  • overflow — определяет поведение виджета, если размер текста больше размеров виджета;
  • maxLines — максимальное количество строк, которое может вместить виджет;
  • style — объект, задающий стиль текста.

Остановимся немного подробнее на стиле текста. Как мы выяснили в прошлом пункте, Material отвечает за добавление стиля текста по умолчанию. Информацию о нужном стиле Material берет из темы и передает ее тексту при помощи механизма InheritedWidget.

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


import 'package:flutter/material.dart';

const loremIpsum =
    'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';

class TextExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTextStyle.merge(
      maxLines: 4,
      style: const TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.bold,
      ),
      child: const Text(
        loremIpsum,
        textAlign: TextAlign.center,
        overflow: TextOverflow.ellipsis,
        style: TextStyle(
          color: Colors.red,
        ),
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 200,
            child: TextExample(),
          ),
        ),
      ),
    );
  }
}

В данном примере используется статичный метод DefaultTextStyle.merge. Он объединяет переданный ему стиль с родительским дефолтным стилем. Аналогично при передаче параметра style в Text различные свойства объединяются.

Также существует расширенная версия текстового виджета — RichText, он позволяет показывать форматированный текст, то есть использовать разные стили внутри одного виджета и даже например обрабатывать нажатия, имитируя ссылки. Подробнее с RichText вы можете познакомиться в документации.

Icon

Icon позволяет отображать векторные иконки. У них можно задавать размер, цвет, и некоторые другие параметры:


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

class IconExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: <Widget>[
        Icon(Icons.alarm),
        Icon(
          Icons.favorite,
          color: Colors.pink,
          size: 36.0,
          semanticLabel: 'Text to announce in accessibility modes',
        ),
        Icon(
          CupertinoIcons.battery_25,
          color: Colors.green,
          size: 24.0,
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: IconExample(),
        ),
      ),
    );
  }
}

Главный вопрос – откуда брать иконки и как создавать свои. В material и cupertino библиотеках уже есть довольно обширные наборы, которые находятся в классах Icons и CupertinoIcons. Чтобы использовать эти классы в вашем проекте, необходимо добавить в pubspec.yaml их подключение:


# ...
dependencies:
  cupertino_icons: ^1.0.0
# ...
flutter:
  uses-material-design: true

Чтобы добавить свои иконки, недостаточно добавить их просто как ассет. Ваш собственный набор иконок должен быть создан как шрифт, и уже файл шрифта нужно будет добавить в качестве ассета. Для преобразования иконок в шрифт можно воспользоваться сайтом https://www.fluttericon.com/.

Image

Image используется для отображения изображений в форматах JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP и WBMP. В основном конструкторе есть обязательный параметр image, имеющий тип ImageProvider — он должен предоставить непосредственно изображение, изолировав виджет от источника.

Для удобства работы у Image есть несколько именованных конструкторов, которые создают нужный ImageProvider. Чаще всего используются конструкторы Image.asset (для изображений из ассетов) и Image.network (для изображений из интернета).


import 'package:flutter/material.dart';

class ImageExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Image(
          image: NetworkImage('https://placekitten.com/640/360'),
          height: 200,
          fit: BoxFit.contain,
        ),
        const SizedBox(height: 20),
        Image.network(
          'bad url',
          loadingBuilder: (context, child, loadingProgress) {
            if (loadingProgress == null) {
              return child;
            }
            return const Text('Loading');
          },
          errorBuilder: (context, _, __) => const Text('Error'),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ImageExample(),
      ),
    );
  }
}

Image позволяет задавать различные параметры, в том числе определить отображение виджета при загрузке изображений или на случай ошибки. Стоит упомянуть параметр fit типа BoxFit: он позволяет настроить то, как изображение будет размещаться внутри виджета — будет ли оно растянуто или сохранит пропорции и т.д. Различные значения BoxFit с примерами разобраны в официальной документации.

По умолчанию Image.network не умеет кэшировать изображения, что обычно необходимо в приложениях. Например без кэширования изображения могут повторно загружаться при пролистывании списков, когда элемент уходит с экрана и возвращается. Чтобы решить эту проблему, можно использовать различные пакеты, например сами разработчики Flutter рекомендуют использовать cached_network_image.

GestureDetector, InkWell

Тяжело представить приложение, с которым не может взаимодействовать пользователь, например нажатием на какие-то элементы. Самый простой виджет для обработки жестов — GestureDetector. Он содержит множество методов, позволяя обрабатывать жесты от простого нажатия до сложных свайпов.


import 'package:flutter/material.dart';

class GestureExample extends StatefulWidget {
  @override
  State<GestureExample> createState() => _GestureExampleState();
}

class _GestureExampleState extends State<GestureExample> {
  String? _lastEvent;
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() => _lastEvent = 'onTap');
      },
      onDoubleTap: () {
        setState(() => _lastEvent = 'onDoubleTap');
      },
      onLongPress: () {
        setState(() => _lastEvent = 'onLongPress');
      },
      child: Container(
        width: 200,
        height: 200,
        color: Colors.blue,
        child: Center(
          child: Text(_lastEvent == null ? 'Tap me' : 'Last Event: $_lastEvent'),
        )
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: GestureExample(),
      ),
    );
  }
}

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

Если вам кажется, что кликабельная область слишком мала, то первой идеей будет увеличить размер дочернего элемента у GestureDetector, например обернуть его в Padding:


import 'package:flutter/material.dart';

class GestureDetectorExample extends StatefulWidget {
  const GestureDetectorExample({super.key});

  @override
  State<GestureDetectorExample> createState() => _GestureDetectorExampleState();
}

class _GestureDetectorExampleState extends State<GestureDetectorExample> {
  bool _lightIsOn = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Icon(
            Icons.lightbulb_outline,
            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
            size: 60,
          ),
        ),
        GestureDetector(
          onTap: () {
            setState(() {
              _lightIsOn = !_lightIsOn;
            });
          },
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: GestureDetectorExample(),
      ),
    );
  }
}

Но если вы также попробуете запустить этот пример на устройстве, то увидите, что ничего не изменилось, область нажатия осталась той же самой. Дело в том, что GestureDetector по умолчанию игнорирует прозрачные области элементов. Чтобы поменять это поведение, необходимо воспользоваться параметром HitTestBehavior behavior. В нашем случае подойдет значение HitTestBehavior.opaque:


import 'package:flutter/material.dart';

class GestureDetectorExample extends StatefulWidget {
  const GestureDetectorExample({super.key});

  @override
  State<GestureDetectorExample> createState() => _GestureDetectorExampleState();
}

class _GestureDetectorExampleState extends State<GestureDetectorExample> {
  bool _lightIsOn = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Icon(
            Icons.lightbulb_outline,
            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
            size: 60,
          ),
        ),
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            setState(() {
              _lightIsOn = !_lightIsOn;
            });
          },
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: GestureDetectorExample(),
      ),
    );
  }
}

InkWell представляет собой аналог GestureDetector, но помимо обработки жестов он предоставляет стандартную анимацию в стиле Material Design. Такая анимация называется “Ripple”. Чтобы анимация была видна, InkWell должен быть обернут в виджет Material.


import 'package:flutter/material.dart';

class GestureDetectorExample extends StatefulWidget {
  const GestureDetectorExample({super.key});

  @override
  State<GestureDetectorExample> createState() => _GestureDetectorExampleState();
}

class _GestureDetectorExampleState extends State<GestureDetectorExample> {
  bool _lightIsOn = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Icon(
            Icons.lightbulb_outline,
            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
            size: 60,
          ),
        ),
        Material(
          child: InkWell(
            onTap: () {
              setState(() {
                _lightIsOn = !_lightIsOn;
              });
            },
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
            ),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: GestureDetectorExample(),
      ),
    );
  }
}

TextField, Button

Хотя GestureDetector дает хорошие низкоуровневые возможности по обработке различных жестов, во Flutter существует множество стандартных кнопок, как в Material-, так и в Cupertino-стилях. Тоже самое относится и к текстовым полям.


import 'package:flutter/material.dart';

class FieldAndButtonExample extends StatefulWidget {
  const FieldAndButtonExample({super.key});

  @override
  State<FieldAndButtonExample> createState() => _FieldAndButtonExampleState();
}

class _FieldAndButtonExampleState extends State<FieldAndButtonExample> {
  TextEditingController? _loginController;
  TextEditingController? _passwordController;

  @override
  void initState() {
    super.initState();
    _loginController = TextEditingController();
    _passwordController = TextEditingController();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          SizedBox(
            width: 250,
            child: TextField(
              controller: _loginController,
              decoration: const InputDecoration(
                labelText: 'Login',
              ),
            ),
          ),
          const SizedBox(height: 32),
          SizedBox(
            width: 250,
            child: TextField(
              controller: _passwordController,
              obscureText: true,
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
                labelText: 'Password',
              ),
            ),
          ),
          const SizedBox(height: 32),
          ElevatedButton(
            onPressed: () {
              print(_loginController?.text);
              print(_passwordController?.text);
            },
            child: const Text('Sign in'),
          ),
          const SizedBox(height: 32),
          TextButton(
            onPressed: () {},
            child: const Text('Recover passwrod'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _loginController?.dispose();
    _passwordController?.dispose();
    super.dispose();
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: FieldAndButtonExample(),
      ),
    );
  }
}

Подробно эти элементы интерфейса будут рассмотрены в параграфе 1.5.

SnackBar

SnackBar — способ показать пользователю неблокирующие сообщения поверх остального интерфейса. Чтобы стало понятнее, давайте посмотрим на пример:


import 'package:flutter/material.dart';

class SnackbarExample extends StatefulWidget {
  const SnackbarExample({super.key});

  @override
  State<SnackbarExample> createState() => _SnackbarExampleState();
}

class _SnackbarExampleState extends State<SnackbarExample> {
  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          ElevatedButton(
            onPressed: () {
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Row(
                    children: [
                      Icon(Icons.bolt),
                      Text('Hello from SnackBar!'),
                    ],
                  ),
                  backgroundColor: Colors.indigo,
                  duration: Duration(seconds: 5),
                  showCloseIcon: true,
                ),
              );
            },
            child: const Text('Show SnackBar!'),
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: SnackbarExample(),
      ),
    );
  }
}

Взаимодействие со снекбаром происходит при помощи ScaffoldMessenger.of(context), это как раз ещё одна из обязанностей Scaffold. При помощи параметров снекбара можно изменять различные параметры отображения и поведения.

Расположение

Column, Row, Stack

Виджеты Column и Row служат для расположения дочерних виджетов друг за другом вертикально или горизонтально соответственно.


import 'package:flutter/material.dart';

class ColumnRowExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('Привет!'),
        Image.network(
          'https://placekitten.com/640/360',
          height: 200,
          fit: BoxFit.cover,
        ),
        const Row(children: [
          Text('Текст внутри Row'),
          Icon(Icons.favorite),
          Text('Текст внутри Row'),
        ]),
        const Text('Текст после Row'),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ColumnRowExample(),
      ),
    );
  }
}

Column и Row работают по одинаковой логике, разница лишь в том, какая используется ось, так что параметры мы рассмотрим на примере Column.

  • mainAxisAlignment регулирует то, каким образом дочерние элементы будут расположены по основной (в случае Column — вертикальной) оси. Например параметр MainAxisAlignment.start расположит элементы в начале колонки, а MainAxisAlignment.spaceBetween распределит свободное пространство между элементами.
  • mainAxisSize задаёт то, сколько места виджет займёт по основной оси. MainAxisSize.min обозначает, что колонка займет минимально возможное расстояние по вертикальной оси, а MainAxisSize.max заставит колонку занять все доступное место.
  • crossAxisAlignment позволяет задать то, сколько места по второстепенной (в случае Column горизонтальной) оси будут занимать дочерние виджеты. Например при помощи CrossAxisAlignment.end можно прижать виджеты к правой части колонки, а с помощью CrossAxisAlignment.stretch заставить их растянуться по всей ширине колонки.

Можно сказать, что на Column и Row строится большая часть вёрстки, так что их комбинация с разными параметрами позволяет делать действительно сложный UI. На примере ниже вы можете попробовать применить разные значения свойств и увидеть, как меняется отображение элементов.


import 'package:flutter/material.dart';

class ColumnRowExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Container(
            width: 100,
            height: 100,
            color: Colors.red,
          ),
          const Row(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Icon(Icons.bolt),
              Icon(Icons.favorite),
              Icon(Icons.audiotrack),
            ],
          ),
          Container(
            width: 100,
            height: 100,
            color: Colors.yellow,
          ),
          const Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              Icon(Icons.bolt),
              Icon(Icons.favorite),
              Icon(Icons.audiotrack),
            ],
          ),
          Container(
            width: 100,
            height: 100,
            color: Colors.green,
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ColumnRowExample(),
      ),
    );
  }
}

Stack тоже помогает располагать виджеты, но не относительно друг друга, а относительно самого стека. При помощи него может быть удобно реализовывать наложение виджетов:


import 'package:flutter/material.dart';

class StackExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          Container(
            width: 100,
            height: 100,
            color: Colors.blue,
            
          ),
          Positioned(
            top: -10,
            right: -10,
            child: Container(
              width: 24,
              height: 24,
              decoration: const BoxDecoration(
                shape: BoxShape.circle,
                color: Colors.red,
              ),
              child: const Center(child: Text('+1')),
            ),
          )
        ]
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: StackExample(),
      ),
    );
  }
}

В параграфе «Widgets: layout» виджеты Column, Row и Stack будут рассмотрены подробнее, там вы узнаете обо всех особенностях их поведения.

SingleChildScrollView

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


import 'package:flutter/material.dart';

class SingleChildScrollViewExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: List.generate(200, (index) => Text('Item $index')),
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: SingleChildScrollViewExample(),
      ),
    );
  }
}

У виджета можно задать следующие параметры:

  • scrollDirection (Axis.vertical/Axis.horizontal) — направление прокрутки: горизонтальное или вертикальное.
  • reverse — прямое или обратное направление прокрутки. Обратное направление может быть полезно например для реализации списка сообщений, когда мы хотим отобразить сразу конец списка.
  • padding — отступы внутри списка. В случае задания этого параметра, отступы будут находится внутри прокручиваемой области, то есть поведение будет отличаться от ситуации, когда мы бы обернули SingleChildScrollView в Padding.
  • physics позволяет задать различную физику скролла, например как на iOS, либо выключить скролл вообще.
  • clipBehavior - как будет обрезаться содержимое, выходящее за границы виджета.

ListView

ListView — это виджет для отображения прокручиваемых списков, и с первого взгляда он похож на SingleChildScrollView, но давайте посмотрим на примеры использования:


import 'package:flutter/material.dart';

class ListViewExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 16),
      itemCount: 200,
      itemBuilder: (context, index) => Text('Item $index'),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ListViewExample(),
      ),
    );
  }
}

Но помимо другого API (возможности сразу передавать список элементов или использовать builder), ListView намного лучше оптимизирован для работы с большим количеством элементов, чем SingleChildScrollView.

Он отрисовывает только ту часть элементов, которая видна на экране, и несколько элементов в обе стороны скролла (чтобы не было задержек при скролле). В примере ниже используется ListView.builder, в котором логируются вызовы itemBuilder.


import 'package:flutter/material.dart';

class ListViewExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 16),
      itemCount: 1000,
      itemBuilder: (context, index) { 
        print('build item $index');
        return Text('Item $index');
      }
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ListViewExample(),
      ),
    );
  }
}

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


import 'package:flutter/material.dart';

class SingleChildScrollViewExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: List.generate(1000, (index) {
          print('build item $index');
          return Text('Item $index');
        }),
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: SingleChildScrollViewExample(),
      ),
    );
  }
}

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


import 'package:flutter/material.dart';

class ListViewSeparatedExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      padding: const EdgeInsets.symmetric(vertical: 16),
      itemCount: 200,
      itemBuilder: (context, index) => Container(
        color: Colors.green,
        child: Text('Item $index'),
      ),
      separatorBuilder: (context, index) => const SizedBox(height: 16),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ListViewSeparatedExample(),
      ),
    );
  }
}

Наконец, рассмотрим параметр shrinkWrap — он заставляет ListView рассчитать свой размер по основной оси. В случае shrinkWrap: false (по умолчанию) ListView занимает все доступное пространство по основной оси. В случае же shrinkWrap: true, он занимает ровно столько места, сколько занимает содержимое.

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

SizedBox

SizedBox — простой виджет, позволяющий ограничить размеры дочернего виджета.


import 'package:flutter/material.dart';

class SizedBoxExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Column(
      children: [
        SizedBox(
          height: 100,
          child: ColoredBox(
            color: Colors.green,
            child: Text('Текст в SizedBox высотой 100'),
          ),
        ),
        SizedBox(
          height: 200,
          child: ColoredBox(
            color: Colors.red,
            child: Text('Текст в SizedBox высотой 200'),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: SizedBoxExample(),
      ),
    );
  }
}

Часто можно встретить использование SizedBox в качестве разделителя в списках (так как ему необязательно передавать child). И также встречается использование конструкции const SizedBox.shrink() в качестве «пустого виджета» (например, когда метод должен вернуть виджет, но мы не хотим что-либо отображать).

Padding

Padding — простой виджет, который помогает добавить отступы вокруг его дочернего элемента. Сами отступы задаются параметром типа EdgeInsets, у которого есть множество именованных конструкторов:


import 'package:flutter/material.dart';

class PaddingExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.all(16),
          child: ColoredBox(
            color: Colors.green,
            child: Text('Текст с отступами'),
          ),
        ),
        Padding(
          padding: EdgeInsets.symmetric(vertical: 24),
          child: Icon(
            Icons.bolt,
          ),
        ),
        Padding(
          padding: EdgeInsets.only(left: 16),
          child: Icon(
            Icons.favorite,
          ),
        ),
        Icon(
          Icons.alarm,
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: PaddingExample(),
      ),
    );
  }
}

SafeArea

SafeArea отвечает за добавление отступов, равных размерам системных элементов интерфейса, таких как статус бар или нижняя навигационная панель. В DartPad SafeArea не даст эффекта, потому что в нем нет системных элементов. Чтобы обойти это, мы сэмулируем отступ, добавив в дерево виджет MediaQuery:


import 'package:flutter/material.dart';

class SafeAreaExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Container(
        color: Colors.cyan,
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MediaQuery(
        data: const MediaQueryData(
          padding: EdgeInsets.only(
            top: 48,
            bottom: 24,
          ),
        ),
        child: Scaffold(
          body: SafeAreaExample(),
        ),
      ),
    );
  }
}

SafeArea использует MediaQuery.of(context) для получения информации о необходимых размерах отступов. А появляется этот виджет в дереве благодаря WidgetsApp.

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

По умолчанию SafeArea добавит отступы со всех сторон, но бывают ситуации, когда отступ нужен только с определенных сторон, и SafeArea позволяет настроить это при помощи параметров top, bottom, left, right. Например если вы хотите отрисовать заголовок, вам не нужно добавлять к нему отступ снизу. Или если вы хотите показать кнопку внизу страницы, вам будет мешать верхний отступ.


import 'package:flutter/material.dart';

class SafeAreaExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: SafeArea(
            bottom: false,
            child: Container(
              color: Colors.indigo,
              child: const Center(
                child: Text('Only top SafeArea'),
              ),
            ),
          ),
        ),
        Expanded(
          child: SafeArea(
            top: false,
            child: Container(
              color: Colors.purple,
              child: const Center(
                child: Text('Only bottom SafeArea'),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MediaQuery(
        data: const MediaQueryData(
          padding: EdgeInsets.only(
            top: 48,
            bottom: 24,
          ),
        ),
        child: Scaffold(
          body: SafeAreaExample(),
        ),
      ),
    );
  }
}

Также если необходимо, вы и сами можете воспользоваться MediaQuer.ofPadding(context), чтобы получить отступы в виде EdgeInsets и самим передать их в какой-то виджет.

Декорация

Container

Container позволяет декорировать ваш контент, например добавить фон или тень.


import 'package:flutter/material.dart';

class ContainerExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200,
      padding: const EdgeInsets.all(16),
      margin: const EdgeInsets.all(24),
      alignment: Alignment.center,
      transform: Matrix4.rotationZ(0.1),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(40),
        border: Border.all(
          width: 8,
          color: Colors.purple,
        ),
        boxShadow: const [
          BoxShadow(
            blurRadius: 16,
            spreadRadius: 16,
            color: Colors.black54,
          ),
        ],
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment(0.8, 1),
          colors: <Color>[
            Color(0xff1f005c),
            Color(0xff5b0060),
            Color(0xff870160),
            Color(0xffac255e),
            Color(0xffca485c),
            Color(0xffe16b5c),
            Color(0xfff39060),
            Color(0xffffb56b),
          ],
          tileMode: TileMode.mirror,
        ),
      ),
      child: Text(
        'Hello, I am Container!',
        style: Theme.of(context)
            .textTheme
            .headlineSmall!
            .copyWith(color: Colors.white),
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MediaQuery(
        data: const MediaQueryData(
          padding: EdgeInsets.only(
            top: 48,
            bottom: 24,
          ),
        ),
        child: Scaffold(
          body: ContainerExample(),
        ),
      ),
    );
  }
}

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

В параграфе «Widgets: layout» вы подробно узнаете о том, каким образом происходит лэйаут контейнера.

Card

Card является разновидностью контейнера, с заданным стилем в виде карточки из Material Design.


import 'package:flutter/material.dart';

class CardExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Card(
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () {},
            child: const SizedBox(
              width: 300,
              height: 100,
              child: Center(child: Text('Elevated Card (tappable)')),
            ),
          ),
        ),
        Card(
          elevation: 0,
          color: Theme.of(context).colorScheme.surfaceVariant,
          child: const SizedBox(
            width: 300,
            height: 100,
            child: Center(child: Text('Filled Card')),
          ),
        ),
        Card(
          elevation: 0,
          shape: RoundedRectangleBorder(
            side: BorderSide(
              color: Theme.of(context).colorScheme.outline,
            ),
            borderRadius: const BorderRadius.all(Radius.circular(12)),
          ),
          child: const SizedBox(
            width: 300,
            height: 100,
            child: Center(child: Text('Outlined Card')),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(useMaterial3: true),
      home: MediaQuery(
        data: const MediaQueryData(
          padding: EdgeInsets.only(
            top: 48,
            bottom: 24,
          ),
        ),
        child: Scaffold(
          body: CardExample(),
        ),
      ),
    );
  }
}

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

Заключение

Теперь, когда вы познакомились со основными стандартными виджетами, вы можете композировать их, чтобы получать более сложные компоненты. Не забывайте использовать каталог виджетов, так как библиотека Flutter обширна и позволяет делать очень многое «из коробки».

Также рекомендуем ознакомиться с серией роликов Widget Of The Week, где команда Flutter в формате минутных роликов рассказывает о полезных виджетах.

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

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.7. Widgets: basics, stless, stful, inherited
Следующий параграф2.9. Widgets: keys