2.10. Виджеты: StatefulWidget
Автор: Владимир Сорокин
В предыдущей статье мы начали рассматривать виджеты во Flutter: поговорили о том, что это такое, зачем нужно и обсудили два класса виджетов — StatelessWidget и InheritedWidget.
Первый — виджет без состояния, позволяющий выделить часть UI в отдельный класс. Второй — виджет, позволяющий передать данные вниз по дереву виджетов.
В этой статье мы разберём ещё один виджет — StatefulWidget.
StatefulWidget
StatelessWidget — хороший инструмент для декомпозиции UI. Однако приложения содержат большое количество изменяющихся данных, которые образуют состояние.
StatefulWidget — виджет, имеющий изменяемое состояние.
Создать StatefulWidget можно, используя сниппет stful. Либо, если у вас уже есть StatelessWidget и вы хотите превратить его в Stateful, это можно быстро сделать при помощи хоткея:
- установите курсор на имени класса вашего
StatelessWidget; - нажмите сочетание клавиш:
- DartPad/AndroidStudio/IDEA:
Alt + Enterна Windows,Option + Enterна macOS; - VS Code:
Ctrl + .на Windows иCmd + .на macOS;
- DartPad/AndroidStudio/IDEA:
- выберите в появившейся подсказке
Convert to StatefulWidget.
Можете попробовать здесь:
import 'package:flutter/material.dart';
class DummyWidget extends StatelessWidget {
final String text;
const DummyWidget({required this.text, super.key});
@override
Widget build(BuildContext context) {
return Text(text);
}
}
void main() {
runApp(
const MaterialApp(
home: Scaffold(
body: Center(child: DummyWidget(text: 'Hello, world')),
),
),
);
}
Результат выполнения
Посмотрим, что получилось:
import 'package:flutter/widgets.dart';
class DummyWidget extends StatefulWidget {
final String text;
const DummyWidget({
required this.text,
super.key,
});
@override
State<DummyWidget> createState() => _DummyWidgetState();
}
class _DummyWidgetState extends State<DummyWidget> {
@override
Widget build(BuildContext context) {
return Text(widget.text);
}
}
Теперь виджет унаследован от StatefulWidget, и в нём появилась реализация метода createState. Flutter вызывает этот метод для создания объекта State.
Ниже в коде располагается сам класс состояния. Реализация build-метода перемещается в него.
Иммутабельные свойства, которые мы заполняем через конструктор, так и остались в самом виджете. Но теперь для доступа к ним из build-метода нужно использовать геттер widget.
Изменяем состояние
В первом примере использование StatefulWidget не оправдывает себя, так как состояние пустое. Давайте посмотрим на другой пример. Реализуем лампочку с выключателем.
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: Bulb(),
),
);
}
class Bulb extends StatefulWidget {
const Bulb({
super.key,
});
@override
State<Bulb> createState() => _BulbState();
}
class _BulbState extends State<Bulb> {
bool _isLightOn = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _isLightOn ? Colors.white : Colors.black,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lightbulb,
size: 32,
color: _isLightOn ? Colors.yellow : Colors.grey,
),
const SizedBox(height: 32),
ElevatedButton(
child: Text(_isLightOn ? 'Выключить свет' : 'Включить свет'),
onPressed: () {
setState(() {
_isLightOn = !_isLightOn;
});
},
),
],
),
),
);
}
}
Результат выполнения
Разберём код состояния _BulbState. В зависимости от значения поля _isLightOn выбираем нужный цвет фона, цвет иконки и текст на кнопке. Изменяется состояние при помощи обработчика нажатия onPressed у виджета ElevatedButton.
При нажатии на кнопку происходит следующее:
- Вызываем метод
setStateдля изменения состояния. - Меняем значение поля
_isLightOnна противоположное. - В
_BulbStateвызывается build-метод. Возвращаем новые виджеты, используя новое значение_isLightOn. Фреймворк встраивает новые виджеты на экран, и на экране виден актуальный UI.
setState — метод, определённый у State, который уведомляет фреймворк о том, что State изменился и его нужно перерисовать.
Так, основная идея декларативного UI во Flutter заключается в том, что для изменения UI нужно создавать новые экземпляры виджетов, а не изменять имеющиеся.
Advanced setState
Теперь рассмотрим пример посложнее. Реализуем экран для ввода пин-кода. Такие часто встречаются, например, в банковских приложениях.
import 'package:flutter/material.dart';
void main() {
runApp(ThemedApp());
}
class ThemedApp extends StatefulWidget {
@override
State<ThemedApp> createState() => _ThemedAppState();
}
class _ThemedAppState extends State<ThemedApp> {
bool _isDarkTheme = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: _isDarkTheme ? ThemeMode.dark : ThemeMode.light,
home: Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildThemeSwitcher(context),
const SizedBox(height: 48),
_PinCode(),
],
),
),
);
}
Widget _buildThemeSwitcher(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_isDarkTheme ? 'Dark' : 'Light',
style: const TextStyle(fontSize: 18),
),
const Spacer(),
const Icon(Icons.sunny),
Switch(
value: _isDarkTheme,
onChanged: (bool value) {
setState(() {
_isDarkTheme = value;
});
},
),
const Icon(Icons.nightlight),
],
),
);
}
}
class _PinCode extends StatefulWidget {
@override
State<_PinCode> createState() => _PinCodeState();
}
class _PinCodeState extends State<_PinCode> {
String _enteredPin = '';
_PinStatus _status = _PinStatus.regular;
static const _correctPin = '1234';
@override
Widget build(BuildContext context) {
final String text;
final Color? textColor;
switch (_status) {
case _PinStatus.regular:
text = List.generate(_enteredPin.length, (_) => "*").join(' ');
textColor = null;
break;
case _PinStatus.correct:
text = 'Correct pin!';
textColor = Colors.green.shade800;
break;
case _PinStatus.wrong:
text = 'Wrong pin =(';
textColor = Colors.red.shade600;
break;
}
return Column(
children: [
Text(
text,
style: TextStyle(
fontSize: 20,
color: textColor,
),
),
const SizedBox(height: 48),
SizedBox(
width: 300,
child: GridView.count(
shrinkWrap: true,
crossAxisCount: 3,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
children: List.generate(9, (index) {
final value = index + 1;
return GestureDetector(
onTap: _status == _PinStatus.regular
? () => _onTapNumber(value)
: null,
child: Container(
alignment: Alignment.center,
color: Colors.grey.shade500,
child: Text(
value.toString(),
style: const TextStyle(fontSize: 18),
),
),
);
}),
),
),
],
);
}
void _onTapNumber(int value) {
setState(() {
_enteredPin += value.toString();
if (_enteredPin.length == _correctPin.length) {
final isCorrectPin = _enteredPin == _correctPin;
_status = isCorrectPin ? _PinStatus.correct : _PinStatus.wrong;
_resetStateAfterDelay();
}
});
}
void _resetStateAfterDelay() {
Future.delayed(const Duration(seconds: 5), () {
if (mounted) {
setState(() {
_enteredPin = '';
_status = _PinStatus.regular;
});
}
});
}
}
enum _PinStatus {
regular,
correct,
wrong,
}
Результат выполнения
Мы реализовали два StatefulWidget — ThemedApp и _PinCode.
Виджет ThemedApp похож на виджет Bulb из предыдущего примера, но теперь мы используем виджет Switch для изменения состояния. А состояние влияет на то, какой режим темы (светлый или тёмный) будет использоваться виджетом MaterialApp.
Второй StatefulWidget _PinCode отвечает за ввод пин-кода. Его State содержит поле _enteredPin, где в виде строки хранится введённый на данный момент пин-код, и _status, в котором в виде enum находится то, в каком состоянии сейчас должен быть экран (ввод, правильный/неправильный пин).
Когда пользователь нажимает на кнопки c цифрами, значения полей в State меняются и при помощи вызова setState виджет перерисовывается.
Обратите внимание, что setState инициирует перерисовки именно у того виджета, у которого он был вызван. То есть при setState у _PinCodeState не будет вызван build-метод у _ThemedApp. Вы можете сами проверить это, добавив логирование функцией print в build-методы. За счёт разделения виджетов можно оптимизировать количество и глубину перерисовок.
Также посмотрим на метод _resetStateAfterDelay. Он изменяет состояние спустя пять секунд после ввода пин-кода. За это время виджет может удалиться из дерева. Такое бывает, например, если в приложении произойдет навигация на предыдущий экран или состояние родительского изменится так, что он не вернёт ваш виджет из build-метода. В таком случае вызов setState выбросит ошибку. Чтобы этого не произошло, нужно выполнить при помощи геттера mounted проверку, что виджет по-прежнему прикреплен к дереву.
Изменение полей состояния через функцию, передаваемую в setState, может дать ощущение, что Flutter что-то знает о полях нашего состояния. На самом деле это не так. Независимо от того, обновите вы что-то внутри setState или нет, на следующем кадре будет вызван build-метод. Можно найти подтверждение этому в исходном коде метода setState. Два следующих варианта кода будут работать идентично:
void _updateState() {
setState(() {
_someField = 'Some value';
});
}
void _updateState() {
_someField = 'Some value';
setState(() {});
}
Но всё же во Flutter принят подход с обновлением полей внутри setState, так что не следует пренебрегать этим.
Иммутабельность виджетов
Во Flutter любой виджет обязан быть иммутабельным, то есть у него не должно быть изменяемых полей.
Вокруг этого принципа во многом построена внутренняя работа фреймворка, в том числе это помогает оптимизировать построение UI. Нарушать данный принцип нельзя, иначе вы получите непредвиденное поведение.
Именно поэтому StatefulWidget разделен на две части — сам виджет, содержащий только иммутабельные поля, и состояние, которое может изменяться.
Немного об Element
На самом деле Flutter практически не работает с деревом виджетов. Это абстракция для удобства разработчиков. На основе дерева виджетов Flutter строит дерево элементов (Element Tree), и уже с ним идёт основная работа. Элемент — представление виджета в дереве элементов, они отвечают за жизненный цикл UI и его эффективное обновление.
Когда обновляется состояние, и мы отдаём новые виджеты, Flutter сопоставляет их с имеющимся деревом элементов и добавляет/удаляет/обновляет элементы в нём.
Выше мы говорили, что BuildContext — интерфейс для доступа к Element. Рассмотрим подробнее, как это работает:
- мы создаём виджет;
- Flutter вызывает у этого виджета метод
createElement; - полученный элемент помещается в дерево элементов;
- у виджета (или у
Stateв случаеStatefulWidget) вызывается методbuild, куда передаётсяElementв качестве параметра.
Таким образом, мы имеем доступ к дереву элементов. Благодаря интерфейсу BuildContext нам доступны только определённые методы и поля. В этой статье мы ещё рассмотрим, как именно можно использовать возможности, которые даёт BuildContext.
Помимо дерева элементов, существует RenderObject Tree. Оно создаётся на основе дерева элементов и отвечает непосредственно за отрисовку и позиционирование элементов UI.
Element и RenderObject — довольно сложная тема, её мы рассмотрим в одной из следующих статей.
Примечание
На данном этапе важно понять, что благодаря такому подходу достигается эффективная работа UI — пересоздаются только легковесные Widget, а тяжелые Element и RenderObject переиспользуются, если это возможно.
Кстати, метод setState работает как раз с Element. State вызывает метод markNeedsBuild у Element, с которым они связаны. Для простоты чтения из кода убраны assert, так как они выполняются только в debug-режиме:
@protected
void setState(VoidCallback fn) {
// ... asserts ...
final Object? result = fn() as dynamic;
// ... asserts ...
_element!.markNeedsBuild();
}
А Element уже меняет у себя значение свойства _dirty на true и добавляет себя в список «грязных» элементов. Под «грязными» подразумеваются элементы, которые нужно перерисовать на следующем кадре.
void markNeedsBuild() {
if (_lifecycleState != _ElementLifecycle.active) {
return;
}
// ... asserts ...
if (dirty) {
return;
}
_dirty = true;
owner!.scheduleBuildFor(this);
}
Жизненный цикл StatefulWidget
А теперь вернёмся к StatefulWidget, а точнее к его жизненному циклу.
StatelessWidget обладает совсем простым жизненным циклом. По сути у него есть только конструктор и build-метод. Его нельзя изменить, можно только создать новый виджет.
Со StatefulWidget ситуация сложнее. У State есть множество методов, которые можно реализовать для реакции на изменение состояния жизненного цикла.
Давайте посмотрим на общую схему работы StatefulWidget, а затем подробно разберём каждое состояние.
createState
createState — метод, определённый в StatefulWidget.
Когда вызывается
Вызывается, когда виджет встраивается в дерево, чтобы создать State. Может быть вызван несколько раз, например если используете один и тот же экземпляр StatefulWidget в разных местах либо если отсоединяете и снова присоединяете виджет к дереву.
Использование
createState обязательно нужно реализовать, и он всегда выглядит одинаково:
class SomeWidget extends StatefulWidget {
@override
State<SomeWidget> createState() => _SomeWidgetState();
}
initState
Когда вызывается
initState вызывается фреймворком после того, как State привязывается к Element. Гарантируется, что он вызывается только один раз для экземпляра State.
Использование
В initState обычно выполняется инициализация контроллеров либо полей, которые зависят от данных из widget или context.
Необходимо вызывать родительскую реализацию super.initState первой операцией.
late int _someField;
late TextEditingController _messageController;
@override
void initState() {
super.initState();
_someField = widget.someField;
_loginController = TextEditingController();
// ...
}
Что нельзя делать
Несмотря на то, что из initState уже доступен context, нельзя использовать context.dependOnInheritedWidgetOfExactType, а соответственно, и методы SomeInheritedWidget.of(context).
didChangeDependencies
Когда вызывается
didChangeDependencies вызывается фреймворком при изменении InheritedWidget, от которых зависит State (через context.dependOnInheritedWidgetOfExactType). Метод InheritedWidget.updateShouldNotify как раз нужен, чтобы определить, в каких случаях будут уведомлены зависимые виджеты.
Также этот метод будет вызван сразу после initState. И в отличие от предыдущего метода в didChangeDependencies уже можно использовать InheritedWidget.
Использование
В didChangeDependencies нужно выполнять работу, которая должна происходить при изменении InheritedWidget. Например, вы можете увидеть, что значение в InheritedWidget изменилось, и поменять какие-то значения в State или вызвать какой-то метод.
@override
void didChangeDependencies() {
// число будет выведено в консоль при добавлении виджета в дерево и каждый раз при обновлении InheritedNumber.
print(InheritedNumber.of(context).number);
}
build
Когда вызывается
build вызывается для получения конфигурации виджетов. Гарантируется, что build вызывается уже после того, как были вызваны setState и didChangeDependencies. Этот метод может вызываться фреймворком множество раз — например:
- после вызова
setState; - при изменении
InheritedWidget, от которых зависитState; - при hot reload;
- при изменении конфигурации
StatefulWidget.
Использование
Необходимо вернуть виджет, как мы уже делали в предыдущих примерах этой статьи.
@override
Widget build(BuildContext context) {
return Placeholder();
}
Что нельзя делать
Из build нельзя вызывать setState. Если вы попытаетесь так делать, то получите следующую ошибку:
setState() or markNeedsBuild() called during build.
Вообще лучше избегать ситуаций, когда виджет обновляется из build-метода, но иногда это необходимо.
К примеру, при реализации виджетов со сложной логикой отображения. Для таких случаев существует метод addPostFrameCallback, который вызовет переданную ему функцию после текущего кадра:
@override
Widget build(BuildContext context) {
if (someCondition) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
// ...
});
});
}
return Placeholder();
}
Но будьте осторожны, чтобы не получить циклический rebuild. Поэтому в примере выше addPostFrameCallback вызывается только по некоторому условию.
Также из build-метода не рекомендуется вызывать какие-либо методы инициализации или логирования событий в аналитику, так как build может вызываться довольно часто и непредсказуемо. Лучше использовать для этого слой бизнес-логики или другие методы жизненного цикла.
didUpdateWidget
Когда вызывается
didUpdateWidget вызывается фреймворком, когда изменяется конфигурация виджета. Как это происходит:
- Имеется родительский виджет
ParentWidgetи дочерний StatefulWidgetChildWidget. - У родительского
ParentWidgetпроисходит rebuild, например из-за вызоваsetState. - Из build-метода
ParentWidgetснова возвращаетсяChildWidget. - Flutter понимает, что виджет того же типа находится в том же месте и переиспользует имеющиеся
ElementиState. - У
_ChildWidgetStateвызываетсяdidUpdateWidget, куда передаётся старый экземплярChildWidget. - Внутри реализации можно выполнить какую-то работу. К примеру, можно сравнить значения полей в текущей конфигурации виджета через геттер
widgetи в старой конфигурации через параметрoldWidgetи выполнить какие-то действия. Как пример — запустить анимацию. - После у
_ChildWidgetStateбудет вызван build-метод.
didUpdateWidget будет вызван каждый раз, когда изменяется экземпляр виджета, привязанный к State, независимо от того, меняются ли в нём какие-то параметры.
Использование
Можно выполнить работу, зависящую от конфигурации виджета. Например, вы можете анимированно изменить состояние виджета.
@override
void didUpdateWidget(covariant ChildWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled != oldWidget.enabled) {
_animationController.animateTo(widget.enabled ? 1 : 0);
}
}
dispose
Когда вызывается
dispose вызывается, когда виджет навсегда убирается из дерева, если фреймворк уверен, что текущий экземпляр State больше никогда не будет переиспользован и присоединён к элементу. Гарантируется, что dispose будет вызван только один раз после всех остальных методов жизненного цикла.
Использование
Можно воспринимать dispose как обратное действие к initState и использовать этот метод для освобождения ресурсов: для отмены подписок, закрытия различных Controller и так далее.
@override
void dispose() {
_someTextController.dispose();
_someSubscription?.cancel();
// Прочие действия по освобождению ресурсов
super.dispose();
}
Что нельзя делать
Так как виджет уже отсоединён от дерева, в dispose нельзя выполнять работу с BuildContext.
mounted
После создания State связывается с Element. У State есть геттер mounted, который позволяет определить, связан ли State в данный момент с Element. Когда виджет убирается из дерева виджетов, State отсоединяется от Element.
Геттер mounted бывает полезен, если мы хотим асинхронно вызватьsetState. Дело в том, что вызов setState, когда State уже отсоединен, ведёт к ошибке. В коде это будет выглядеть следующим образом:
// ... Some Stateful Widget ...
Button(
onTap: () {
Future.delayed(const Duration(seconds: 5), () {
if (mounted) {
setState(() {
// ...
});
}
});
},
),
Вот мы и разобрали все основные виджеты, а StatefulWidget — с пристрастием. Советуем пройти квиз, чтобы закрепить знания. А в следующей статье мы рассмотрим виджеты, которые входят в состав стандартных UI библиотек Flutter.