2.12 Виджеты: идентификатор key

В предыдущем параграфе мы рассмотрели виджеты стандартной библиотеки. Наверняка вы обратили внимание, что в некоторых случаях мы передавали в конструктор родителького класса параметр key:

1class SnackbarExample extends StatefulWidget {
2  const SnackbarExample({super.key});
3  
4}

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

Что такое Key

Key — это уникальный идентификатор виджета. Он используется при перестроении дерева виджетов, чтобы отличать друг от друга одинаковые по типу виджеты.

Ключи необходимо использовать, когда у одного родителя (например, Column или Row) несколько дочерних виджетов одного типа, и у этих виджетов есть состояние. Если мы поменяем такие виджеты местами или удалим один из них, фреймворк не сможет отличить один виджет от другого, так как у них одинаковый тип.

Давайте рассмотрим наглядный пример: у нас есть Column, в котором находятся два Stateful-виджета MyStatefulWidget, и мы удаляем один из них.

fluttern

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

fluttern

Пример использования ключей

Давайте рассмотрим следующий пример. У нас есть виджет ColorBlock — это StatefulWidget, который представляет собой цветной Container. При инициализации для Container задаётся переданный извне цвет. По тапу на ColorBlock цвет меняется по определённой логике, а именно — параметры цвета red/green /blue инкрементируются.

Пример (здесь и далее мы скрыли под катом часть объёмных примеров для удобства чтения)
1class ColorBlock extends StatefulWidget {
2  final Color color;
3
4  const ColorBlock({
5    required this.color,
6    Key? key,
7  }) : super(key: key);
8
9  @override
10  State<ColorBlock> createState() => _ColorBlockState();
11}
12
13class _ColorBlockState extends State<ColorBlock> {
14  late Color color;
15
16  @override
17  void initState() {
18    super.initState();
19    color = widget.color;
20  }
21
22  void incrementColor() {
23    setState(() {
24      color = Color.fromARGB(
25        color.alpha,
26        (color.red + 4) % 256,
27        (color.green + 4) % 256,
28        (color.blue + 4) % 256,
29      );
30    });
31  }
32
33  @override
34  Widget build(BuildContext context) {
35    return GestureDetector(
36      onTap: incrementColor,
37      child: Container(
38        color: color,
39        width: 100.0,
40        height: 100.0,
41      ),
42    );
43  }
44}

Также у нас есть виджет MyHomePage — это StatefulWidget, который представляет собой экран. На экране располагается список виджетов ColorBlock, а также есть виджет FloatingActionButton, по тапу на который наш список виджетов ColorBlock разворачивается.

Ещё пример
1class MyHomePage extends StatefulWidget {
2  const MyHomePage({Key? key, required this.title}) : super(key: key);
3
4  final String title;
5
6  @override
7  State<MyHomePage> createState() => _MyHomePageState();
8}
9
10class _MyHomePageState extends State<MyHomePage> {
11  var _colorBlocks = <Widget>[];
12
13  @override
14  void initState() {
15    super.initState();
16    final random = Random().nextInt(Colors.primaries.length - 2);
17    _colorBlocks = [
18      ColorBlock(
19        color: Colors.primaries[random],
20      ),
21      ColorBlock(
22        color: Colors.primaries[random + 1],
23      ),
24      ColorBlock(
25        color: Colors.primaries[random + 2],
26      ),
27    ];
28  }
29
30  @override
31  Widget build(BuildContext context) {
32    return Scaffold(
33      appBar: AppBar(
34        title: Text(widget.title),
35      ),
36      body: Column(children: _colorBlocks),
37      floatingActionButton: FloatingActionButton(
38        child: const Icon(Icons.swap_vert),
39        onPressed: () {
40          setState(() {
41            _colorBlocks = _colorBlocks.reversed.toList();
42          });
43        },
44      ),
45    );
46  }
47}

Запустив приложение, мы увидим следующее:

Когда мы нажимаем на виджет ColorBlock, цвет ожидаемо меняется, а если нажать на виджет FloatingActionButton, ничего не происходит, хотя мы меняем последовательность ColorBlock и вызываем setState.

👉 Это происходит потому, что фреймворк не может отличить виджеты ColorBlock друг от друга и определить, что их последовательность поменялась.

Давайте воспользуемся ключами и поправим данный пример. При создании виджетов ColorBlock будем передавать туда уникальный Key. В классе Key есть factory-конструктор, который принимает String и создаёт ключ.

1_colorBlocks = [
2  ColorBlock(
3    color: Colors.primaries[random],
4    key: Key(Colors.primaries[random].toString()),
5  ),
6  ColorBlock(
7    color: Colors.primaries[random + 1],
8    key: Key(Colors.primaries[random + 1].toString()),
9  ),
10  ColorBlock(
11    color: Colors.primaries[random + 2],
12    key: Key(Colors.primaries[random + 2].toString()),
13  ),
14];

Теперь, когда мы нажимаем на FloatingActionButton, всё работает как ожидается:

Может возникнуть ощущение, что важно добавлять ключи к StatefulWidget или к первому StatefulWidget среди дочерних виджетов, но это не так. Давайте обернём наши ColorBlock в Padding.

Вероятно, вы ожидаете, что к нашим ColorBlock просто добавится Padding, а в остальном поведение не изменится.

Пример
1_colorBlocks = [
2  Padding(
3    padding: const EdgeInsets.all(8.0),
4    child: ColorBlock(
5      color: Colors.primaries[random],
6      key: Key(Colors.primaries[random].toString()),
7    ),
8  ),
9  Padding(
10    padding: const EdgeInsets.all(8.0),
11    child: ColorBlock(
12      color: Colors.primaries[random + 1],
13      key: Key(Colors.primaries[random + 1].toString()),
14    ),
15  ),
16  Padding(
17    padding: const EdgeInsets.all(8.0),
18    child: ColorBlock(
19      color: Colors.primaries[random + 2],
20      key: Key(Colors.primaries[random + 2].toString()),
21    ),
22  ),
23];

Однако, когда мы нажимаем на виджет FloatingActionButton, все наши ColorBlock вместе с Padding меняются местами, но состояние ColorBlock теряется.

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

Пример
1_colorBlocks = [
2  Padding(
3    key: Key(Colors.primaries[random].toString()),
4    padding: const EdgeInsets.all(8.0),
5    child: ColorBlock(
6      color: Colors.primaries[random],
7    ),
8  ),
9  Padding(
10    key: Key(Colors.primaries[random + 1].toString()),
11    padding: const EdgeInsets.all(8.0),
12    child: ColorBlock(
13      color: Colors.primaries[random + 1],
14    ),
15  ),
16  Padding(
17    key: Key(Colors.primaries[random + 2].toString()),
18    padding: const EdgeInsets.all(8.0),
19    child: ColorBlock(
20      color: Colors.primaries[random + 2],
21    ),
22  ),
23];

И вот что у нас получилось:

Типы ключей

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

Есть множество ключей, и они делятся на две группы: LocalKey и GlobalKey, выглядит это следующим образом:

fluttern

  • Key — это абстрактный класс, общий предок для всех ключей, у него есть factoryконструктор, который создаёт ValueKey<String>;
  • LocalKey — это абстрактный класс, наследуется от Key, общий предок для всех локальных ключей. Особенность локальных ключей в том, что они должны быть уникальны для всех виджетов с одним родителем;
  • ValueKey**<T>** — локальный ключ, уникальность которого определяется по значению объекта T, это значит, что для корректной работы ключа необходимо, чтобы были переопределены hashCode и оператор сравнения == у класса T;
  • ObjectKey — локальный ключ, уникальность которого определяется не по значению объекта, а по ссылке на него;
  • UniqueKey — уникальный локальный ключ, который равен только самому себе.

UniqueKey может быть использован, когда нам нужно на каждое перестроение дерева виджетов пересоздавать поддерево, либо когда у нас нет какого-то конкретного value, с которым мы можем ассоциировать виджеты.

В примере выше мы фактически использовали ValueKey (именно он создаётся с помощью factory-конструктора Key), но могли использовать любой из локальных ключей, в том числе UniqueKey. То, какой из ключей использовать, зависит от задачи и данных, которые у нас есть.

Давайте рассмотрим пример использования UniqueKey, для этого изменим наш пример следующим образом:

  • вместо ColorBlock напишем RandomColorBlock, который будет случайным образом определять свой цвет в initState и больше не будет его менять;
  • в initState виджета MyHomePage будем создавать список из RandomColorBlock;
  • по тапу на FloatingActionButton вместо разворота списка будем его обновлять и вызывать setState.

Поскольку сама структура дерева виджетов у нас меняться не будет, передадим UniqueKey в наши RandomColorBlock при создании — благодаря этому фреймворк поймёт, что это другие RandomColorBlock.

Пример
1class RandomColorBlock extends StatefulWidget {
2  const RandomColorBlock({Key? key}) : super(key: key);
3
4  @override
5  State<RandomColorBlock> createState() => _RandomColorBlockState();
6}
7
8class _RandomColorBlockState extends State<RandomColorBlock> {
9  late Color color;
10
11  @override
12  void initState() {
13    super.initState();
14    final random = Random();
15    color = Color.fromARGB(
16      255,
17      random.nextInt(256),
18      random.nextInt(256),
19      random.nextInt(256),
20    );
21  }
22
23  @override
24  Widget build(BuildContext context) {
25    return Container(
26      color: color,
27      width: 100.0,
28      height: 100.0,
29    );
30  }
31}
32
33class MyHomePage extends StatefulWidget {
34  const MyHomePage({Key? key, required this.title}) : super(key: key);
35
36  final String title;
37
38  @override
39  State<MyHomePage> createState() => _MyHomePageState();
40}
41
42class _MyHomePageState extends State<MyHomePage> {
43  var _colorBlocks = <Widget>[];
44
45  @override
46  void initState() {
47    super.initState();
48    _generateRandomColorBlocks();
49  }
50
51  void _generateRandomColorBlocks() {
52    setState(() {
53      _colorBlocks = List.generate(
54        3,
55        (_) => RandomColorBlock(key: UniqueKey()),
56      );
57    });
58  }
59
60  @override
61  Widget build(BuildContext context) {
62    return Scaffold(
63      appBar: AppBar(title: Text(widget.title)),
64      body: Column(children: _colorBlocks),
65      floatingActionButton: FloatingActionButton(
66        onPressed: _generateRandomColorBlocks,
67        child: const Icon(Icons.swap_vert),
68      ),
69    );
70  }
71}

Вот как это выглядит:

GlobalKey — это абстрактный класс, наследуется от Key, общий предок для всех глобальных ключей. Особенность глобальных ключей в том, что они должны быть уникальны для всего приложения.

LabeledGlobalKey — своего рода глобальный аналог UniqueKey, уникален и равен сам себе. У ключа есть debug label, который никак не влияет на его уникальность и используется только для целей дебага.

GlobalObjectKey — своего рода глобальный аналог ObjectKey, равенство / уникальность определяются по инстансу Object (то есть фактически по ссылке в памяти).

Глобальные ключи могут использоваться в двух целях:

  • переместить виджет и его State из одной части дерева виджетов в совершенно другое место в дереве виджетов (иными словами, поменять родителя виджета), при этом при перемещении виджет с нашим ключом не должен покидать дерево виджетов и не должен быть использован в двух разных местах одновременно;
  • получить доступ к State/BuildContext StatefulWidget из любой точки приложения.

Используем GlobalKey

Представим, что в примере со списком из ColorBlock вёрстка в portrait- и landscape-ориентации сильно различается: в landscape добавляется дополнительный Padding к Column.

Пример
1@override
2Widget build(BuildContext context) {
3  return OrientationBuilder(builder: (context, orientation) {
4    return Scaffold(
5     appBar: AppBar(
6        title: Text(widget.title),
7      ),
8      body: orientation == Orientation.portrait
9          ? Column(
10              children: _colorBlocks,
11            )
12          : Padding(
13              padding: const EdgeInsets.all(8.0),
14              child: Column(
15                children: _colorBlocks,
16              ),
17            ),
18      floatingActionButton: FloatingActionButton(
19        child: const Icon(Icons.swap_vert),
20        onPressed: () {
21          setState(() {
22            _colorBlocks = _colorBlocks.reversed.toList();
23          });
24        },
25      ),
26    );
27  });
28}

Выглядит это так:

В таком случае, поскольку при повороте экрана у виджета Scaffold меняется виджет body, у нас будет пересоздаваться всё поддерево body. Чтобы решить эту проблему, мы можем воспользоваться глобальным ключом. Давайте добавим его к Column и посмотрим, что произойдёт.

Пример
1class _MyHomePageState extends State<MyHomePage> {
2  final _columnKey = GlobalKey();
3
4  @override
5Widget build(BuildContext context) {
6  return OrientationBuilder(builder: (context, orientation) {
7    return Scaffold(
8     appBar: AppBar(
9        title: Text(widget.title),
10      ),
11      body: orientation == Orientation.portrait
12          ? Column(
13              key: _columnKey,
14              children: _colorBlocks,
15            )
16          : Padding(
17              padding: const EdgeInsets.all(8.0),
18              child: Column(
19                key: _columnKey,
20                children: _colorBlocks,
21              ),
22            ),
23      floatingActionButton: FloatingActionButton(
24        child: const Icon(Icons.swap_vert),
25        onPressed: () {
26          setState(() {
27            _colorBlocks = _colorBlocks.reversed.toList();
28            });
29          },
30        ),
31      );
32    });
33  }
34}

Вот что произойдёт:

Как видите, GlobalKey позволяет поменять родителя Column и смонтировать его в любом другом месте без потери состояния. Происходит это за один фрейм, и Column не уходит из дерева виджетов.

Также необходимо следить за тем, чтобы в один фрейм один и тот же глобальный ключ не был использован более чем в одном виджете. Иначе мы получим следующую ошибку: Duplicate GlobalKey detected in widget tree.

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

В примере выше мы могли вместо добавления виджета Padding в landscape добавить его в обеих ориентациях и только менять значение параметра padding. Так и стоит поступать, если есть такая возможность.

Пример
1@override
2Widget build(BuildContext context) {
3  return OrientationBuilder(builder: (context, orientation) {
4    return Scaffold(
5     appBar: AppBar(
6        title: Text(widget.title),
7      ),
8      body: Padding(
9        padding: orientation == Orientation.portrait 
10     ? EdgeInsets.zero
11     : const EdgeInsets.all(8.0),
12        child: Column(
13          children: _colorBlocks,
14        ),
15      ),
16      floatingActionButton: FloatingActionButton(
17        child: const Icon(Icons.swap_vert),
18        onPressed: () {
19          setState(() {
20            _colorBlocks = _colorBlocks.reversed.toList();
21          });
22        },
23      ),
24    );
25  });
26}

Теперь давайте рассмотрим пример с получением доступа к State. На нашем экране сейчас есть три ColorBlock и FloatingActionButton, который их разворачивает. Давайте воспользуемся GlobalKey и поменяем логику следующим образом: будем по тапу на FloatingActionButton инкрементировать все ColorBlock, то есть вызывать incrementColor у каждого ColorBlock. Для этого нужно внести следующие изменения:

  • в каждый ColorBlock передать GlobalKey<_ColorBlockState>;
  • по тапу на FloatingActionButton пробегаться по всем ключам, получать доступ к State через key.currentState и вызывать incrementColor.
Пример
1class _MyHomePageState extends State<MyHomePage> {
2  final _colorBlockKeys = <GlobalKey<_ColorBlockState>>[];
3}
1@override
2void initState() {
3  super.initState();
4  final random = Random().nextInt(Colors.primaries.length - 2);
5  _colorBlocks = [
6    ColorBlock(
7      key: GlobalKey<_ColorBlockState>(),
8      color: Colors.primaries[random],
9    ),
10    ColorBlock(
11      key: GlobalKey<_ColorBlockState>(),
12      color: Colors.primaries[random + 1],
13    ),
14    ColorBlock(
15      key: GlobalKey<_ColorBlockState>(),
16      color: Colors.primaries[random + 2],
17    ),
18  ];
19  for (final colorBlock in _colorBlocks) {
20    final key = colorBlock.key;
21    if (key is GlobalKey<_ColorBlockState>) {
22      _colorBlockKeys.add(key);
23    }
24  }
25}
1floatingActionButton: FloatingActionButton(
2  child: const Icon(Icons.swap_vert),
3  onPressed: () {
4    for (final key in _colorBlockKeys) {
5      key.currentState?.incrementColor();
6    }
7  },
8),

Вот что получится:

Таким образом GlobalKey позволяет получить доступ к State из любой точки приложения.

Этим также не стоит злоупотреблять — управлять состоянием виджетов лучше с помощью отдельных классов с бизнес-логикой / презентационной логикой. Подробнее про управление состоянием мы расскажем в следующих параграфах.

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


Итак, в этом параграфе мы рассмотрели, что такое ключи, какие они бывают, когда, зачем и, главное, как их использовать.

Чтобы закрепить новые знания, советуем посмотреть видео по теме от команды Flutter, а ещё — пройти квиз.

И переходите к следующему параграфу — в нём мы подробно разберём лейаут во Flutter: процесс при котором определяются размеры и позиции виджетов на экране.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Предыдущий параграф2.11. Виджеты: стандартные библиотеки
Следующий параграф2.13. Виджеты: лейаут