Введение
В предыдущем параграфе вы познакомились с концепцией виджетов и теперь понимаете, что они из себя представляют и как с ними работать. В этом параграфе мы рассмотрим виджеты, входящие в состав стандартных библиотек Flutter.
Во Flutter есть три библиотеки, предоставляющие основные виджеты:
- Widgets library — общие виджеты, внешний вид которых не привязан к конкретной платформе. Например, текстовые виджеты, виджеты для позиционирования, отображения списков, прочие
- Material library — виджеты, стилизованные под гайдлайны Material Design от Google.
- Cupertino library — виджеты, стилизованные под гайдлайны Apple.
Помимо виджетов, представленных в этом параграфе, существует ещё множество других, с ними можно ознакомиться через каталог виджетов из официальной документацией.
Мы будем рассматривать в основном компоненты из 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.