Введение

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

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

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

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

Фундамент

MaterialApp

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

1
2import 'package:flutter/foundation.dart';
3import 'package:flutter/material.dart';
4
5void main() {
6  runApp(MyApp());
7}
8
9class MyApp extends StatelessWidget {
10  @override
11  Widget build(BuildContext context) {
12    return MaterialApp(
13      themeMode: ThemeMode.dark,
14      theme: ThemeData.light(useMaterial3: true),
15      darkTheme: ThemeData.dark(useMaterial3: true),
16      debugShowCheckedModeBanner: false,      
17      home: MyWidget(),
18      builder: (context, child) {
19        if (!kDebugMode) {
20          return child ?? const SizedBox.shrink();
21        }
22        return Column(
23          children: [
24            if (kDebugMode)
25              const Text('Debugging'),
26            Expanded(child: child ?? const SizedBox.shrink()),
27          ]
28        );
29      },
30    );
31  }
32}
33
34class MyWidget extends StatelessWidget {
35  @override
36  Widget build(BuildContext context) {
37    return Scaffold(
38      body: Center(
39        child: Text(
40          'Hello, World!',
41          style: Theme.of(context).textTheme.headlineMedium,
42        ),
43      ),
44    );
45  }
46}
47

В данном примере при помощи 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 — виджет содержимого страницы. Но у него есть и другие параметры.

1
2import 'package:flutter/material.dart';
3
4void main() {
5  runApp(MaterialApp(
6      theme: ThemeData.light(useMaterial3: true),
7      home: const ScaffoldExample(),
8    ),
9  );
10}
11
12class ScaffoldExample extends StatelessWidget {
13  const ScaffoldExample({super.key});
14
15  @override
16  Widget build(BuildContext context) {
17    return Scaffold(
18      appBar: AppBar(
19        title: const Text('App Bar title'),
20      ),
21      body: const Center(
22        child: Text('Content'),
23      ),
24      bottomNavigationBar: BottomAppBar(
25        shape: const CircularNotchedRectangle(),
26        child: Container(height: 50.0),
27      ),
28      floatingActionButton: FloatingActionButton(
29        onPressed: () {},
30        child: const Icon(Icons.bolt),
31        shape: CircleBorder(),
32      ),
33      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
34    );
35  }
36}
37

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

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

1
2import 'package:flutter/foundation.dart';
3import 'package:flutter/material.dart';
4
5void main() {
6  runApp(MyApp());
7}
8
9class MyApp extends StatelessWidget {
10  @override
11  Widget build(BuildContext context) {
12    return MaterialApp(
13      themeMode: ThemeMode.dark,
14      theme: ThemeData.light(useMaterial3: true),
15      darkTheme: ThemeData.dark(useMaterial3: true),
16      debugShowCheckedModeBanner: false,      
17      home: MyWidget(),
18      builder: (context, child) {
19        if (!kDebugMode) {
20          return child ?? const SizedBox.shrink();
21        }
22        return Column(
23          children: [
24            if (kDebugMode)
25              const Text('Debugging'),
26            Expanded(child: child ?? const SizedBox.shrink()),
27          ]
28        );
29      },
30    );
31  }
32}
33
34class MyWidget extends StatelessWidget {
35  @override
36  Widget build(BuildContext context) {
37    return Scaffold(
38      body: Center(
39        child: Text(
40          'Hello, World!',
41          style: Theme.of(context).textTheme.headlineMedium,
42        ),
43      ),
44    );
45  }
46}
47

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

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

Содержимое

Text

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

1
2import 'package:flutter/material.dart';
3
4const loremIpsum =
5    '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.';
6
7class TextExample extends StatelessWidget {
8  @override
9  Widget build(BuildContext context) {
10    return const Text(
11      loremIpsum,
12      textAlign: TextAlign.center,
13      overflow: TextOverflow.ellipsis,
14      maxLines: 2,
15      style: TextStyle(
16        color: Colors.teal,
17        fontSize: 15,
18        fontWeight: FontWeight.bold,
19      ),
20    );
21  }
22}
23
24void main() {
25  runApp(MyApp());
26}
27
28class MyApp extends StatelessWidget {
29  @override
30  Widget build(BuildContext context) {
31    return MaterialApp(
32      home: Scaffold(
33        body: Center(
34          child: SizedBox(
35            width: 200,
36            child: TextExample(),
37          ),
38        ),
39      ),
40    );
41  }
42}
43

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

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

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

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

1
2import 'package:flutter/material.dart';
3
4const loremIpsum =
5    '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.';
6
7class TextExample extends StatelessWidget {
8  @override
9  Widget build(BuildContext context) {
10    return DefaultTextStyle.merge(
11      maxLines: 4,
12      style: const TextStyle(
13        fontSize: 24,
14        fontWeight: FontWeight.bold,
15      ),
16      child: const Text(
17        loremIpsum,
18        textAlign: TextAlign.center,
19        overflow: TextOverflow.ellipsis,
20        style: TextStyle(
21          color: Colors.red,
22        ),
23      ),
24    );
25  }
26}
27
28void main() {
29  runApp(MyApp());
30}
31
32class MyApp extends StatelessWidget {
33  @override
34  Widget build(BuildContext context) {
35    return MaterialApp(
36      home: Scaffold(
37        body: Center(
38          child: SizedBox(
39            width: 200,
40            child: TextExample(),
41          ),
42        ),
43      ),
44    );
45  }
46}
47

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

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

Icon

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

1
2import 'package:flutter/material.dart';
3import 'package:flutter/cupertino.dart';
4
5class IconExample extends StatelessWidget {
6  @override
7  Widget build(BuildContext context) {
8    return const Row(
9      mainAxisAlignment: MainAxisAlignment.spaceAround,
10      children: <Widget>[
11        Icon(Icons.alarm),
12        Icon(
13          Icons.favorite,
14          color: Colors.pink,
15          size: 36.0,
16          semanticLabel: 'Text to announce in accessibility modes',
17        ),
18        Icon(
19          CupertinoIcons.battery_25,
20          color: Colors.green,
21          size: 24.0,
22        ),
23      ],
24    );
25  }
26}
27
28void main() {
29  runApp(MyApp());
30}
31
32class MyApp extends StatelessWidget {
33  @override
34  Widget build(BuildContext context) {
35    return MaterialApp(
36      home: Scaffold(
37        body: Center(
38          child: IconExample(),
39        ),
40      ),
41    );
42  }
43}
44

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

1
2# ...
3dependencies:
4  cupertino_icons: ^1.0.0
5# ...
6flutter:
7  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 (для изображений из интернета).

1
2import 'package:flutter/material.dart';
3
4class ImageExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Column(
8      children: [
9        const Image(
10          image: NetworkImage('https://placekitten.com/640/360'),
11          height: 200,
12          fit: BoxFit.contain,
13        ),
14        const SizedBox(height: 20),
15        Image.network(
16          'bad url',
17          loadingBuilder: (context, child, loadingProgress) {
18            if (loadingProgress == null) {
19              return child;
20            }
21            return const Text('Loading');
22          },
23          errorBuilder: (context, _, __) => const Text('Error'),
24        ),
25      ],
26    );
27  }
28}
29
30void main() {
31  runApp(MyApp());
32}
33
34class MyApp extends StatelessWidget {
35  @override
36  Widget build(BuildContext context) {
37    return MaterialApp(
38      home: Scaffold(
39        body: ImageExample(),
40      ),
41    );
42  }
43}
44

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

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

GestureDetector, InkWell

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

1
2import 'package:flutter/material.dart';
3
4class GestureExample extends StatefulWidget {
5  @override
6  State&lt;GestureExample&gt; createState() => _GestureExampleState();
7}
8
9class _GestureExampleState extends State&lt;GestureExample&gt; {
10  String? _lastEvent;
11  
12  @override
13  Widget build(BuildContext context) {
14    return GestureDetector(
15      onTap: () {
16        setState(() => _lastEvent = 'onTap');
17      },
18      onDoubleTap: () {
19        setState(() => _lastEvent = 'onDoubleTap');
20      },
21      onLongPress: () {
22        setState(() => _lastEvent = 'onLongPress');
23      },
24      child: Container(
25        width: 200,
26        height: 200,
27        color: Colors.blue,
28        child: Center(
29          child: Text(_lastEvent == null ? 'Tap me' : 'Last Event: $_lastEvent'),
30        )
31      ),
32    );
33  }
34}
35
36void main() {
37  runApp(MyApp());
38}
39
40class MyApp extends StatelessWidget {
41  @override
42  Widget build(BuildContext context) {
43    return MaterialApp(
44      home: Scaffold(
45        body: GestureExample(),
46      ),
47    );
48  }
49}
50

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

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

1
2import 'package:flutter/material.dart';
3
4class GestureDetectorExample extends StatefulWidget {
5  const GestureDetectorExample({super.key});
6
7  @override
8  State&lt;GestureDetectorExample&gt; createState() => _GestureDetectorExampleState();
9}
10
11class _GestureDetectorExampleState extends State&lt;GestureDetectorExample&gt; {
12  bool _lightIsOn = false;
13
14  @override
15  Widget build(BuildContext context) {
16    return Column(
17      mainAxisAlignment: MainAxisAlignment.center,
18      children: &lt;Widget&gt;[
19        Padding(
20          padding: const EdgeInsets.all(8.0),
21          child: Icon(
22            Icons.lightbulb_outline,
23            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
24            size: 60,
25          ),
26        ),
27        GestureDetector(
28          onTap: () {
29            setState(() {
30              _lightIsOn = !_lightIsOn;
31            });
32          },
33          child: Padding(
34            padding: const EdgeInsets.all(24),
35            child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
36          ),
37        ),
38      ],
39    );
40  }
41}
42
43void main() {
44  runApp(MyApp());
45}
46
47class MyApp extends StatelessWidget {
48  @override
49  Widget build(BuildContext context) {
50    return const MaterialApp(
51      home: Scaffold(
52        body: GestureDetectorExample(),
53      ),
54    );
55  }
56}
57

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

1
2import 'package:flutter/material.dart';
3
4class GestureDetectorExample extends StatefulWidget {
5  const GestureDetectorExample({super.key});
6
7  @override
8  State&lt;GestureDetectorExample&gt; createState() => _GestureDetectorExampleState();
9}
10
11class _GestureDetectorExampleState extends State&lt;GestureDetectorExample&gt; {
12  bool _lightIsOn = false;
13
14  @override
15  Widget build(BuildContext context) {
16    return Column(
17      mainAxisAlignment: MainAxisAlignment.center,
18      children: &lt;Widget&gt;[
19        Padding(
20          padding: const EdgeInsets.all(8.0),
21          child: Icon(
22            Icons.lightbulb_outline,
23            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
24            size: 60,
25          ),
26        ),
27        GestureDetector(
28          behavior: HitTestBehavior.opaque,
29          onTap: () {
30            setState(() {
31              _lightIsOn = !_lightIsOn;
32            });
33          },
34          child: Padding(
35            padding: const EdgeInsets.all(24),
36            child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
37          ),
38        ),
39      ],
40    );
41  }
42}
43
44void main() {
45  runApp(MyApp());
46}
47
48class MyApp extends StatelessWidget {
49  @override
50  Widget build(BuildContext context) {
51    return const MaterialApp(
52      home: Scaffold(
53        body: GestureDetectorExample(),
54      ),
55    );
56  }
57}
58

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

1
2import 'package:flutter/material.dart';
3
4class GestureDetectorExample extends StatefulWidget {
5  const GestureDetectorExample({super.key});
6
7  @override
8  State&lt;GestureDetectorExample&gt; createState() => _GestureDetectorExampleState();
9}
10
11class _GestureDetectorExampleState extends State&lt;GestureDetectorExample&gt; {
12  bool _lightIsOn = false;
13
14  @override
15  Widget build(BuildContext context) {
16    return Column(
17      mainAxisAlignment: MainAxisAlignment.center,
18      children: &lt;Widget&gt;[
19        Padding(
20          padding: const EdgeInsets.all(8.0),
21          child: Icon(
22            Icons.lightbulb_outline,
23            color: _lightIsOn ? Colors.yellow.shade600 : Colors.black,
24            size: 60,
25          ),
26        ),
27        Material(
28          child: InkWell(
29            onTap: () {
30              setState(() {
31                _lightIsOn = !_lightIsOn;
32              });
33            },
34            child: Padding(
35              padding: const EdgeInsets.all(24),
36              child: Text(_lightIsOn ? 'Выключить свет' : 'Включить свет'),
37            ),
38          ),
39        ),
40      ],
41    );
42  }
43}
44
45void main() {
46  runApp(MyApp());
47}
48
49class MyApp extends StatelessWidget {
50  @override
51  Widget build(BuildContext context) {
52    return const MaterialApp(
53      home: Scaffold(
54        body: GestureDetectorExample(),
55      ),
56    );
57  }
58}
59

TextField, Button

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

1
2import 'package:flutter/material.dart';
3
4class FieldAndButtonExample extends StatefulWidget {
5  const FieldAndButtonExample({super.key});
6
7  @override
8  State&lt;FieldAndButtonExample&gt; createState() => _FieldAndButtonExampleState();
9}
10
11class _FieldAndButtonExampleState extends State&lt;FieldAndButtonExample&gt; {
12  TextEditingController? _loginController;
13  TextEditingController? _passwordController;
14
15  @override
16  void initState() {
17    super.initState();
18    _loginController = TextEditingController();
19    _passwordController = TextEditingController();
20  }
21
22  @override
23  Widget build(BuildContext context) {
24    return Container(
25      alignment: Alignment.center,
26      child: Column(
27        mainAxisAlignment: MainAxisAlignment.center,
28        children: &lt;Widget&gt;[
29          SizedBox(
30            width: 250,
31            child: TextField(
32              controller: _loginController,
33              decoration: const InputDecoration(
34                labelText: 'Login',
35              ),
36            ),
37          ),
38          const SizedBox(height: 32),
39          SizedBox(
40            width: 250,
41            child: TextField(
42              controller: _passwordController,
43              obscureText: true,
44              decoration: const InputDecoration(
45                border: OutlineInputBorder(),
46                labelText: 'Password',
47              ),
48            ),
49          ),
50          const SizedBox(height: 32),
51          ElevatedButton(
52            onPressed: () {
53              print(_loginController?.text);
54              print(_passwordController?.text);
55            },
56            child: const Text('Sign in'),
57          ),
58          const SizedBox(height: 32),
59          TextButton(
60            onPressed: () {},
61            child: const Text('Recover passwrod'),
62          ),
63        ],
64      ),
65    );
66  }
67
68  @override
69  void dispose() {
70    _loginController?.dispose();
71    _passwordController?.dispose();
72    super.dispose();
73  }
74}
75
76void main() {
77  runApp(MyApp());
78}
79
80class MyApp extends StatelessWidget {
81  @override
82  Widget build(BuildContext context) {
83    return const MaterialApp(
84      home: Scaffold(
85        body: FieldAndButtonExample(),
86      ),
87    );
88  }
89}
90

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

SnackBar

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

1
2import 'package:flutter/material.dart';
3
4class SnackbarExample extends StatefulWidget {
5  const SnackbarExample({super.key});
6
7  @override
8  State&lt;SnackbarExample&gt; createState() => _SnackbarExampleState();
9}
10
11class _SnackbarExampleState extends State&lt;SnackbarExample&gt; {
12  @override
13  Widget build(BuildContext context) {
14    return Container(
15      alignment: Alignment.center,
16      child: Column(
17        mainAxisAlignment: MainAxisAlignment.center,
18        children: &lt;Widget&gt;[
19          ElevatedButton(
20            onPressed: () {
21              ScaffoldMessenger.of(context).showSnackBar(
22                const SnackBar(
23                  content: Row(
24                    children: [
25                      Icon(Icons.bolt),
26                      Text('Hello from SnackBar!'),
27                    ],
28                  ),
29                  backgroundColor: Colors.indigo,
30                  duration: Duration(seconds: 5),
31                  showCloseIcon: true,
32                ),
33              );
34            },
35            child: const Text('Show SnackBar!'),
36          ),
37        ],
38      ),
39    );
40  }
41}
42
43void main() {
44  runApp(MyApp());
45}
46
47class MyApp extends StatelessWidget {
48  @override
49  Widget build(BuildContext context) {
50    return const MaterialApp(
51      home: Scaffold(
52        body: SnackbarExample(),
53      ),
54    );
55  }
56}
57

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

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

Column, Row, Stack

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

1
2import 'package:flutter/material.dart';
3
4class ColumnRowExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Column(
8      children: [
9        const Text('Привет!'),
10        Image.network(
11          'https://placekitten.com/640/360',
12          height: 200,
13          fit: BoxFit.cover,
14        ),
15        const Row(children: [
16          Text('Текст внутри Row'),
17          Icon(Icons.favorite),
18          Text('Текст внутри Row'),
19        ]),
20        const Text('Текст после Row'),
21      ],
22    );
23  }
24}
25
26void main() {
27  runApp(MyApp());
28}
29
30class MyApp extends StatelessWidget {
31  @override
32  Widget build(BuildContext context) {
33    return MaterialApp(
34      home: Scaffold(
35        body: ColumnRowExample(),
36      ),
37    );
38  }
39}
40

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

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

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

1
2import 'package:flutter/material.dart';
3
4class ColumnRowExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Center(
8      child: Column(
9        mainAxisSize: MainAxisSize.min,
10        crossAxisAlignment: CrossAxisAlignment.stretch,
11        children: [
12          Container(
13            width: 100,
14            height: 100,
15            color: Colors.red,
16          ),
17          const Row(
18            mainAxisAlignment: MainAxisAlignment.start,
19            children: [
20              Icon(Icons.bolt),
21              Icon(Icons.favorite),
22              Icon(Icons.audiotrack),
23            ],
24          ),
25          Container(
26            width: 100,
27            height: 100,
28            color: Colors.yellow,
29          ),
30          const Row(
31            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
32            children: [
33              Icon(Icons.bolt),
34              Icon(Icons.favorite),
35              Icon(Icons.audiotrack),
36            ],
37          ),
38          Container(
39            width: 100,
40            height: 100,
41            color: Colors.green,
42          ),
43        ],
44      ),
45    );
46  }
47}
48
49void main() {
50  runApp(MyApp());
51}
52
53class MyApp extends StatelessWidget {
54  @override
55  Widget build(BuildContext context) {
56    return MaterialApp(
57      home: Scaffold(
58        body: ColumnRowExample(),
59      ),
60    );
61  }
62}
63

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

1
2import 'package:flutter/material.dart';
3
4class StackExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Center(
8      child: Stack(
9        clipBehavior: Clip.none,
10        children: [
11          Container(
12            width: 100,
13            height: 100,
14            color: Colors.blue,
15            
16          ),
17          Positioned(
18            top: -10,
19            right: -10,
20            child: Container(
21              width: 24,
22              height: 24,
23              decoration: const BoxDecoration(
24                shape: BoxShape.circle,
25                color: Colors.red,
26              ),
27              child: const Center(child: Text('+1')),
28            ),
29          )
30        ]
31      ),
32    );
33  }
34}
35
36void main() {
37  runApp(MyApp());
38}
39
40class MyApp extends StatelessWidget {
41  @override
42  Widget build(BuildContext context) {
43    return MaterialApp(
44      home: Scaffold(
45        body: StackExample(),
46      ),
47    );
48  }
49}
50

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

SingleChildScrollView

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

1
2import 'package:flutter/material.dart';
3
4class SingleChildScrollViewExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return SingleChildScrollView(
8      child: Column(
9        crossAxisAlignment: CrossAxisAlignment.stretch,
10        children: List.generate(200, (index) => Text('Item $index')),
11      ),
12    );
13  }
14}
15
16void main() {
17  runApp(MyApp());
18}
19
20class MyApp extends StatelessWidget {
21  @override
22  Widget build(BuildContext context) {
23    return MaterialApp(
24      home: Scaffold(
25        body: SingleChildScrollViewExample(),
26      ),
27    );
28  }
29}
30

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

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

ListView

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

1
2import 'package:flutter/material.dart';
3
4class ListViewExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return ListView.builder(
8      padding: const EdgeInsets.symmetric(vertical: 16),
9      itemCount: 200,
10      itemBuilder: (context, index) => Text('Item $index'),
11    );
12  }
13}
14
15void main() {
16  runApp(MyApp());
17}
18
19class MyApp extends StatelessWidget {
20  @override
21  Widget build(BuildContext context) {
22    return MaterialApp(
23      home: Scaffold(
24        body: ListViewExample(),
25      ),
26    );
27  }
28}
29

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

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

1
2import 'package:flutter/material.dart';
3
4class ListViewExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return ListView.builder(
8      padding: const EdgeInsets.symmetric(vertical: 16),
9      itemCount: 1000,
10      itemBuilder: (context, index) { 
11        print('build item $index');
12        return Text('Item $index');
13      }
14    );
15  }
16}
17
18void main() {
19  runApp(MyApp());
20}
21
22class MyApp extends StatelessWidget {
23  @override
24  Widget build(BuildContext context) {
25    return MaterialApp(
26      home: Scaffold(
27        body: ListViewExample(),
28      ),
29    );
30  }
31}
32

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

1
2import 'package:flutter/material.dart';
3
4class SingleChildScrollViewExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return SingleChildScrollView(
8      child: Column(
9        crossAxisAlignment: CrossAxisAlignment.stretch,
10        children: List.generate(1000, (index) {
11          print('build item $index');
12          return Text('Item $index');
13        }),
14      ),
15    );
16  }
17}
18
19void main() {
20  runApp(MyApp());
21}
22
23class MyApp extends StatelessWidget {
24  @override
25  Widget build(BuildContext context) {
26    return MaterialApp(
27      home: Scaffold(
28        body: SingleChildScrollViewExample(),
29      ),
30    );
31  }
32}
33

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

1
2import 'package:flutter/material.dart';
3
4class ListViewSeparatedExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return ListView.separated(
8      padding: const EdgeInsets.symmetric(vertical: 16),
9      itemCount: 200,
10      itemBuilder: (context, index) => Container(
11        color: Colors.green,
12        child: Text('Item $index'),
13      ),
14      separatorBuilder: (context, index) => const SizedBox(height: 16),
15    );
16  }
17}
18
19void main() {
20  runApp(MyApp());
21}
22
23class MyApp extends StatelessWidget {
24  @override
25  Widget build(BuildContext context) {
26    return MaterialApp(
27      home: Scaffold(
28        body: ListViewSeparatedExample(),
29      ),
30    );
31  }
32}
33

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

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

SizedBox

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

1
2import 'package:flutter/material.dart';
3
4class SizedBoxExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return const Column(
8      children: [
9        SizedBox(
10          height: 100,
11          child: ColoredBox(
12            color: Colors.green,
13            child: Text('Текст в SizedBox высотой 100'),
14          ),
15        ),
16        SizedBox(
17          height: 200,
18          child: ColoredBox(
19            color: Colors.red,
20            child: Text('Текст в SizedBox высотой 200'),
21          ),
22        ),
23      ],
24    );
25  }
26}
27
28void main() {
29  runApp(MyApp());
30}
31
32class MyApp extends StatelessWidget {
33  @override
34  Widget build(BuildContext context) {
35    return MaterialApp(
36      home: Scaffold(
37        body: SizedBoxExample(),
38      ),
39    );
40  }
41}
42

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

Padding

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

1
2import 'package:flutter/material.dart';
3
4class PaddingExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return const Column(
8      crossAxisAlignment: CrossAxisAlignment.start,
9      children: [
10        Padding(
11          padding: EdgeInsets.all(16),
12          child: ColoredBox(
13            color: Colors.green,
14            child: Text('Текст с отступами'),
15          ),
16        ),
17        Padding(
18          padding: EdgeInsets.symmetric(vertical: 24),
19          child: Icon(
20            Icons.bolt,
21          ),
22        ),
23        Padding(
24          padding: EdgeInsets.only(left: 16),
25          child: Icon(
26            Icons.favorite,
27          ),
28        ),
29        Icon(
30          Icons.alarm,
31        ),
32      ],
33    );
34  }
35}
36
37void main() {
38  runApp(MyApp());
39}
40
41class MyApp extends StatelessWidget {
42  @override
43  Widget build(BuildContext context) {
44    return MaterialApp(
45      home: Scaffold(
46        body: PaddingExample(),
47      ),
48    );
49  }
50}
51

SafeArea

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

1
2import 'package:flutter/material.dart';
3
4class SafeAreaExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return SafeArea(
8      child: Container(
9        color: Colors.cyan,
10      ),
11    );
12  }
13}
14
15void main() {
16  runApp(MyApp());
17}
18
19class MyApp extends StatelessWidget {
20  @override
21  Widget build(BuildContext context) {
22    return MaterialApp(
23      home: MediaQuery(
24        data: const MediaQueryData(
25          padding: EdgeInsets.only(
26            top: 48,
27            bottom: 24,
28          ),
29        ),
30        child: Scaffold(
31          body: SafeAreaExample(),
32        ),
33      ),
34    );
35  }
36}
37

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

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

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

1
2import 'package:flutter/material.dart';
3
4class SafeAreaExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Column(
8      children: [
9        Expanded(
10          child: SafeArea(
11            bottom: false,
12            child: Container(
13              color: Colors.indigo,
14              child: const Center(
15                child: Text('Only top SafeArea'),
16              ),
17            ),
18          ),
19        ),
20        Expanded(
21          child: SafeArea(
22            top: false,
23            child: Container(
24              color: Colors.purple,
25              child: const Center(
26                child: Text('Only bottom SafeArea'),
27              ),
28            ),
29          ),
30        ),
31      ],
32    );
33  }
34}
35
36void main() {
37  runApp(MyApp());
38}
39
40class MyApp extends StatelessWidget {
41  @override
42  Widget build(BuildContext context) {
43    return MaterialApp(
44      home: MediaQuery(
45        data: const MediaQueryData(
46          padding: EdgeInsets.only(
47            top: 48,
48            bottom: 24,
49          ),
50        ),
51        child: Scaffold(
52          body: SafeAreaExample(),
53        ),
54      ),
55    );
56  }
57}
58

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

Декорация

Container

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

1
2import 'package:flutter/material.dart';
3
4class ContainerExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Container(
8      height: 200,
9      padding: const EdgeInsets.all(16),
10      margin: const EdgeInsets.all(24),
11      alignment: Alignment.center,
12      transform: Matrix4.rotationZ(0.1),
13      decoration: BoxDecoration(
14        borderRadius: BorderRadius.circular(40),
15        border: Border.all(
16          width: 8,
17          color: Colors.purple,
18        ),
19        boxShadow: const [
20          BoxShadow(
21            blurRadius: 16,
22            spreadRadius: 16,
23            color: Colors.black54,
24          ),
25        ],
26        gradient: const LinearGradient(
27          begin: Alignment.topLeft,
28          end: Alignment(0.8, 1),
29          colors: [
30            Color(0xff1f005c),
31            Color(0xff5b0060),
32            Color(0xff870160),
33            Color(0xffac255e),
34            Color(0xffca485c),
35            Color(0xffe16b5c),
36            Color(0xfff39060),
37            Color(0xffffb56b),
38          ],
39          tileMode: TileMode.mirror,
40        ),
41      ),
42      child: Text(
43        'Hello, I am Container!',
44        style: Theme.of(context)
45            .textTheme
46            .headlineSmall!
47            .copyWith(color: Colors.white),
48      ),
49    );
50  }
51}
52
53void main() {
54  runApp(MyApp());
55}
56
57class MyApp extends StatelessWidget {
58  @override
59  Widget build(BuildContext context) {
60    return MaterialApp(
61      home: MediaQuery(
62        data: const MediaQueryData(
63          padding: EdgeInsets.only(
64            top: 48,
65            bottom: 24,
66          ),
67        ),
68        child: Scaffold(
69          body: ContainerExample(),
70        ),
71      ),
72    );
73  }
74}
75

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

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

Card

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

1
2import 'package:flutter/material.dart';
3
4class CardExample extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    return Column(
8      children: [
9        Card(
10          clipBehavior: Clip.hardEdge,
11          child: InkWell(
12            onTap: () {},
13            child: const SizedBox(
14              width: 300,
15              height: 100,
16              child: Center(child: Text('Elevated Card (tappable)')),
17            ),
18          ),
19        ),
20        Card(
21          elevation: 0,
22          color: Theme.of(context).colorScheme.surfaceVariant,
23          child: const SizedBox(
24            width: 300,
25            height: 100,
26            child: Center(child: Text('Filled Card')),
27          ),
28        ),
29        Card(
30          elevation: 0,
31          shape: RoundedRectangleBorder(
32            side: BorderSide(
33              color: Theme.of(context).colorScheme.outline,
34            ),
35            borderRadius: const BorderRadius.all(Radius.circular(12)),
36          ),
37          child: const SizedBox(
38            width: 300,
39            height: 100,
40            child: Center(child: Text('Outlined Card')),
41          ),
42        ),
43      ],
44    );
45  }
46}
47
48void main() {
49  runApp(MyApp());
50}
51
52class MyApp extends StatelessWidget {
53  @override
54  Widget build(BuildContext context) {
55    return MaterialApp(
56      theme: ThemeData(useMaterial3: true),
57      home: MediaQuery(
58        data: const MediaQueryData(
59          padding: EdgeInsets.only(
60            top: 48,
61            bottom: 24,
62          ),
63        ),
64        child: Scaffold(
65          body: CardExample(),
66        ),
67      ),
68    );
69  }
70}
71

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

Заключение

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

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

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

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

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

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