2.10. Виджеты: StatefulWidget

Автор: Владимир Сорокин

В предыдущей статье мы начали рассматривать виджеты во Flutter: поговорили о том, что это такое, зачем нужно и обсудили два класса виджетов — StatelessWidget и InheritedWidget.

Первый — виджет без состояния, позволяющий выделить часть UI в отдельный класс. Второй — виджет, позволяющий передать данные вниз по дереву виджетов.

В этой статье мы разберём ещё один виджет — StatefulWidget.

StatefulWidget

StatelessWidget — хороший инструмент для декомпозиции UI. Однако приложения содержат большое количество изменяющихся данных, которые образуют состояние.

StatefulWidget — виджет, имеющий изменяемое состояние.

Создать StatefulWidget можно, используя сниппет stful. Либо, если у вас уже есть StatelessWidget и вы хотите превратить его в Stateful, это можно быстро сделать при помощи хоткея:

  1. установите курсор на имени класса вашего StatelessWidget;
  2. нажмите сочетание клавиш:
    1. DartPad/AndroidStudio/IDEA: Alt + Enter на Windows, Option + Enter на macOS;
    2. VS Code: Ctrl + . на Windows и Cmd + . на macOS;
  3. выберите в появившейся подсказке 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')),
      ),
    ),
  );
}

Результат выполнения

Результат выполнения примера 1

Посмотрим, что получилось:

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;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 2

Разберём код состояния _BulbState. В зависимости от значения поля _isLightOn выбираем нужный цвет фона, цвет иконки и текст на кнопке. Изменяется состояние при помощи обработчика нажатия onPressed у виджета ElevatedButton.

При нажатии на кнопку происходит следующее:

  1. Вызываем метод setState для изменения состояния.
  2. Меняем значение поля _isLightOn на противоположное.
  3. В _BulbState вызывается build-метод. Возвращаем новые виджеты, используя новое значение _isLightOn. Фреймворк встраивает новые виджеты на экран, и на экране виден актуальный UI.

setState — метод, определённый у State, который уведомляет фреймворк о том, что State изменился и его нужно перерисовать.

Так, основная идея декларативного UI во Flutter заключается в том, что для изменения UI нужно создавать новые экземпляры виджетов, а не изменять имеющиеся.

fluttern

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,
}

Результат выполнения

Результат выполнения примера 3

Мы реализовали два 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. Рассмотрим подробнее, как это работает:

  1. мы создаём виджет;
  2. Flutter вызывает у этого виджета метод createElement;
  3. полученный элемент помещается в дерево элементов;
  4. у виджета (или у State в случае StatefulWidget) вызывается метод build, куда передаётся Element в качестве параметра.

Таким образом, мы имеем доступ к дереву элементов. Благодаря интерфейсу BuildContext нам доступны только определённые методы и поля. В этой статье мы ещё рассмотрим, как именно можно использовать возможности, которые даёт BuildContext.

Помимо дерева элементов, существует RenderObject Tree. Оно создаётся на основе дерева элементов и отвечает непосредственно за отрисовку и позиционирование элементов UI.

Связь деревьев Widget, Element и RenderObject

fluttern

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, а затем подробно разберём каждое состояние.

fluttern

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 и дочерний StatefulWidget ChildWidget.
  • У родительского 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.