Введение
Работая с виджетами во Flutter, вы, наверное, уже заметили, что у всех виджетов есть параметр Key
, или так называемый ключ. В этом параграфе мы рассмотрим, что такое ключи, когда, зачем и как их стоит использовать.
Что такое Key
Key
— это уникальный идентификатор виджета, который используется при перестроении дерева виджетов для того, чтобы отличать друг от друга одинаковые по типу виджеты.
Ключи необходимо использовать, когда у одного родителя (например, Column
или Row
) несколько дочерних виджетов одного типа и у этих виджетов есть состояние. Если мы поменяем такие виджеты местами или удалим один из них, фреймворк не сможет отличить один виджет от другого, так как у них одинаковый тип.
Давайте рассмотрим наглядный пример: у нас есть Column
, в котором находятся два StatefulWidget
виджета MyStatefulWidget
, и мы удаляем один из них.
Какой из виджетов мы удалили? В данном случае фреймворк будет ориентироваться на позицию виджетов внутри children
и удалит второй, но, если мы хотим удалить первый, нужно, чтобы фреймворк их мог различить. Именно для этого используются ключи.
Пример использования ключей
Давайте рассмотрим следующий пример. У нас есть виджет ColorBlock
— это StatefulWidget
, который представляет собой цветной Container
. При инициализации для Container
задаётся переданный извне цвет. По тапу на ColorBlock
цвет меняется по определённой логике, а именно — параметры цвета red
/green
/blue
инкрементируются:
class ColorBlock extends StatefulWidget {
final Color color;
const ColorBlock({
required this.color,
Key? key,
}) : super(key: key);
@override
State<ColorBlock> createState() => _ColorBlockState();
}
class _ColorBlockState extends State<ColorBlock> {
late Color color;
@override
void initState() {
super.initState();
color = widget.color;
}
void incrementColor() {
setState(() {
color = Color.fromARGB(
color.alpha,
(color.red + 4) % 256,
(color.green + 4) % 256,
(color.blue + 4) % 256,
);
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: incrementColor,
child: Container(
color: color,
width: 100.0,
height: 100.0,
),
);
}
}
Также у нас есть виджет MyHomePage
— это StatefulWidget
, который представляет собой экран. На экране располагается список виджетов ColorBlock
, а также есть виджет FloatingActionButton
, по тапу на который наш список виджетов ColorBlock
разворачивается:
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var _colorBlocks = <Widget>[];
@override
void initState() {
super.initState();
final random = Random().nextInt(Colors.primaries.length - 2);
_colorBlocks = [
ColorBlock(
color: Colors.primaries[random],
),
ColorBlock(
color: Colors.primaries[random + 1],
),
ColorBlock(
color: Colors.primaries[random + 2],
),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(children: _colorBlocks),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
_colorBlocks = _colorBlocks.reversed.toList();
});
},
),
);
}
}
Запустив приложение, мы увидим следующее:
Когда мы нажимаем на виджет ColorBlock
, цвет ожидаемо меняется, а если нажать на виджет FloatingActionButton
, ничего не происходит, хотя мы меняем последовательность ColorBlock
и вызываем setState
. Это происходит потому, что фреймворк не может отличить виджеты ColorBlock
друг от друга и определить, что их последовательность поменялась.
Давайте воспользуемся ключами и поправим данный пример. При создании виджетов ColorBlock
будем передавать туда уникальный Key
. В классе Key
есть factory
-конструктор, который принимает String
и создаёт ключ.
_colorBlocks = [
ColorBlock(
color: Colors.primaries[random],
key: Key(Colors.primaries[random].toString()),
),
ColorBlock(
color: Colors.primaries[random + 1],
key: Key(Colors.primaries[random + 1].toString()),
),
ColorBlock(
color: Colors.primaries[random + 2],
key: Key(Colors.primaries[random + 2].toString()),
),
];
Теперь, когда мы нажимаем на FloatingActionButton
, всё работает как ожидается:
Может возникнуть ощущение, что важно добавлять ключи к StatefulWidget
или к первому StatefulWidget
среди дочерних виджетов, но это не так. Давайте обернём наши ColorBlock
в Padding
. Вероятно, вы ожидаете, что к нашим ColorBlock
просто добавится Padding
, а в остальном поведение не изменится:
_colorBlocks = [
Padding(
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random],
key: Key(Colors.primaries[random].toString()),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random + 1],
key: Key(Colors.primaries[random + 1].toString()),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random + 2],
key: Key(Colors.primaries[random + 2].toString()),
),
),
];
Однако, когда мы нажимаем на виджет FloatingActionButton
, все наши ColorBlock
вместе с Padding
меняются местами, но состояние ColorBlock
теряется. Всё дело в том, что при перестановке Padding
фреймворк переиспользует те же самые Padding
, для него ничего не меняется, но дочерний виджет у каждого Padding
меняет ключ, и поэтому State
создаётся заново. Это легко исправить, указав ключ у самого Padding
:
_colorBlocks = [
Padding(
key: Key(Colors.primaries[random].toString()),
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random],
),
),
Padding(
key: Key(Colors.primaries[random + 1].toString()),
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random + 1],
),
),
Padding(
key: Key(Colors.primaries[random + 2].toString()),
padding: const EdgeInsets.all(8.0),
child: ColorBlock(
color: Colors.primaries[random + 2],
),
),
];
Типы ключей
Итак, мы разобрались, для чего нужны ключи, давайте теперь рассмотрим, какие они бывают. Есть множество ключей, и они делятся на две группы: LocalKey
и GlobalKey
, выглядит это следующим образом:
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
.
class RandomColorBlock extends StatefulWidget {
const RandomColorBlock({Key? key}) : super(key: key);
@override
State<RandomColorBlock> createState() => _RandomColorBlockState();
}
class _RandomColorBlockState extends State<RandomColorBlock> {
late Color color;
@override
void initState() {
super.initState();
final random = Random();
color = Color.fromARGB(
255,
random.nextInt(256),
random.nextInt(256),
random.nextInt(256),
);
}
@override
Widget build(BuildContext context) {
return Container(
color: color,
width: 100.0,
height: 100.0,
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var _colorBlocks = <Widget>[];
@override
void initState() {
super.initState();
_generateRandomColorBlocks();
}
void _generateRandomColorBlocks() {
setState(() {
_colorBlocks = List.generate(
3,
(_) => RandomColorBlock(key: UniqueKey()),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Column(children: _colorBlocks),
floatingActionButton: FloatingActionButton(
onPressed: _generateRandomColorBlocks,
child: const Icon(Icons.swap_vert),
),
);
}
}
GlobalKey — это абстрактный класс, наследуется от Key
, общий предок для всех глобальных ключей. Особенность глобальных ключей в том, что они должны быть уникальны для всего приложения.
LabeledGlobalKey — своего рода глобальный аналог UniqueKey
, уникален и равен сам себе. У ключа есть debug label
, который никак не влияет на его уникальность и используется только для целей дебага.
GlobalObjectKey — своего рода глобальный аналог ObjectKey
, равенство / уникальность определяются по инстансу Object
(то есть фактически по ссылке в памяти).
Глобальные ключи могут использоваться в двух целях:
- переместить виджет и его
State
из одной части дерева виджетов в совершенно другое место в дереве виджетов (иными словами, поменять родителя виджета), при этом при перемещении виджет с нашим ключом не должен покидать дерево виджетов и не должен быть использован в двух разных местах одновременно; - получить доступ к
State
/BuildContext
StatefulWidget
из любой точки приложения.
Используем GlobalKey
Представьте, что в примере со списком из ColorBlock
вёрстка в portrait- и landscape-ориентации сильно различается: в landscape добавляется дополнительный Padding
к Column
.
@override
Widget build(BuildContext context) {
return OrientationBuilder(builder: (context, orientation) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: orientation == Orientation.portrait
? Column(
children: _colorBlocks,
)
: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: _colorBlocks,
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
_colorBlocks = _colorBlocks.reversed.toList();
});
},
),
);
});
}
В таком случае, поскольку при повороте экрана у виджета Scaffold
меняется виджет body
, у нас будет пересоздаваться всё поддерево body
. Чтобы решить эту проблему, мы можем воспользоваться глобальным ключом. Давайте добавим его к Column
и посмотрим, что произойдёт.
class _MyHomePageState extends State<MyHomePage> {
final _columnKey = GlobalKey();
@override
Widget build(BuildContext context) {
return OrientationBuilder(builder: (context, orientation) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: orientation == Orientation.portrait
? Column(
key: _columnKey,
children: _colorBlocks,
)
: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
key: _columnKey,
children: _colorBlocks,
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
_colorBlocks = _colorBlocks.reversed.toList();
});
},
),
);
});
}
Как видите, GlobalKey
позволяет поменять родителя Column
и смонтировать его в любом другом месте без потери состояния. Происходит это за один фрейм, и Column
не уходит из дерева виджетов.
Также необходимо следить за тем, чтобы в один фрейм один и тот же глобальный ключ не был использован более чем в одном виджете. Иначе мы получим следующую ошибку: Duplicate GlobalKey detected in widget tree
.
Очень важно понимать, что использование глобальных ключей для сохранения состояния и перемещения виджета не самая дешёвая операция, поэтому не рекомендуется без необходимости использовать для этого глобальные ключи. По возможности желательно выносить состояние из виджета в слой презентационной логики или в инхеритед-виджеты.
В примере выше мы могли вместо добавления виджета Padding
в landscape добавить его в обеих ориентациях и только менять значение параметра padding
. Так и стоит поступать, если есть такая возможность.
@override
Widget build(BuildContext context) {
return OrientationBuilder(builder: (context, orientation) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: orientation == Orientation.portrait
? EdgeInsets.zero
: const EdgeInsets.all(8.0),
child: Column(
children: _colorBlocks,
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
_colorBlocks = _colorBlocks.reversed.toList();
});
},
),
);
});
}
Теперь давайте рассмотрим пример с получением доступа к State
. На нашем экране сейчас есть три ColorBlock
и FloatingActionButton
, который их разворачивает. Давайте воспользуемся GlobalKey
и поменяем логику следующим образом: будем по тапу на FloatingActionButton
инкрементировать все ColorBlock
, то есть вызывать incrementColor
у каждого ColorBlock
. Для этого нужно внести следующие изменения:
- в каждый
ColorBlock
передатьGlobalKey<_ColorBlockState>
; - по тапу на
FloatingActionButton
пробегаться по всем ключам, получать доступ кState
черезkey.currentState
и вызыватьincrementColor
.
class _MyHomePageState extends State<MyHomePage> {
final _colorBlockKeys = <GlobalKey<_ColorBlockState>>[];
}
@override
void initState() {
super.initState();
final random = Random().nextInt(Colors.primaries.length - 2);
_colorBlocks = [
ColorBlock(
key: GlobalKey<_ColorBlockState>(),
color: Colors.primaries[random],
),
ColorBlock(
key: GlobalKey<_ColorBlockState>(),
color: Colors.primaries[random + 1],
),
ColorBlock(
key: GlobalKey<_ColorBlockState>(),
color: Colors.primaries[random + 2],
),
];
for (final colorBlock in _colorBlocks) {
final key = colorBlock.key;
if (key is GlobalKey<_ColorBlockState>) {
_colorBlockKeys.add(key);
}
}
}
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.swap_vert),
onPressed: () {
for (final key in _colorBlockKeys) {
key.currentState?.incrementColor();
}
},
),
Таким образом GlobalKey
позволяет получить доступ к State
из любой точки приложения.
Этим также не стоит злоупотреблять — управлять состоянием виджетов лучше с помощью отдельных классов с бизнес-логикой / презентационной логикой. Подробнее о State Management и различных подходах к управлению состояниями мы расскажем в следующих параграфах.
Тем не менее бывают кейсы, когда глобальные ключи удобны. Например, для навигации. Об этом мы расскажем подробнее в параграфе.
Итак, в этом параграфе мы рассмотрели, что такое ключи, какие они бывают, когда, зачем и, главное, как их использовать.
Чтобы закрепить новые знания, советуем посмотреть видео по теме от команды Flutter.