Последнее обновление:
Ключи во Flutter

Для чего и как использовать ключи во Флаттер

Flutter

Вступление

Во Flutter есть много разных виджетов, но если вы посмотрите на свойства любого из них, вы, скорее всего, найдете параметр key (ключ).

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

В этой статье я попытаюсь объяснить концепцию ключей, их различные типы, где и как их правильно использовать.

Что такое ключи?

Если мы посмотрим на определение слова, Key написанное в официальной документации Flutter, оно гласит:

Ключ - это идентификатор для виджетов, элементов и семантических узлов.

Это означает, что Flutter различает виджеты и место их размещения в дереве виджетов по ключам. Но это еще не все.

Ключи сохраняют, состояние (state) когда вы перемещаетесь по дереву виджетов.

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

Сейчас давайте разберем разные типы ключей один за другим.

UniqueKey

UniqueKey используется для уникальной идентификации каждого виджета вашего приложения.

UniqueKey также сохраняет состояние при перемещении виджетов в дереве виджетов.

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

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

Это также полезно, когда уникальный идентификатор не определен в вашей коллекции БД, чтобы однозначно идентифицировать весь список элементов. Вы можете использовать, UniqueKey который назначит уникальный ключ этому конкретному виджету.

Разберем один пример:

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

class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
List<Widget> emojis = [
  GetEmoji(emoji: "😎"),
  GetEmoji(emoji: "🤠")
];

swapEmoji() {
  setState(() {
    emojis.insert(1, emojis.removeAt(0));
  });
}

@override
Widget build(BuildContext context) {
  return Scaffold(
      body: SizedBox.expand(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: emojis,
        ),
        SizedBox(
          height: 20,
        ),
        ElevatedButton(
          onPressed: swapEmoji,
          child: Text("Swap"),
        )
      ],
    ),
  ));
}
}
class GetEmoji extends StatelessWidget {
GetEmoji({required this.emoji});
String emoji;
@override
Widget build(BuildContext context) {
  return Text(
    emoji,
    style: TextStyle(
      fontSize: 100,
    ),
  );
}
}

Как и следовало ожидать, смайлы поменяются местами, когда мы нажмем кнопку.

smiles
Смайлы переключаются

Но проблема возникнет, если мы попытаемся преобразовать Stateless виджет в Stateful виджет и сохранить значение в state.

class GetEmoji extends StatefulWidget {
GetEmoji({required this.emg});
String emg;
@override
_GetEmojiState createState() => _GetEmojiState();
}
class _GetEmojiState extends State<GetEmoji> {
late String emoji;
@override
void initState() {
  super.initState();
  emoji = widget.emg;
}

@override
Widget build(BuildContext context) {
  return Text(
    emoji,
    style: TextStyle(
      fontSize: 100,
    ),
  );
}
}
smiles not work
Смайлы не переключаются

Это произошло потому, что под капотом Flutter различает виджеты по типу (runtimeType) и по ключу.

В Stateful в нашем списке два виджета для смайлов. Когда мы меняем позиции смайликов, нажав на кнопку, Flutter видит в дереве виджетов и дереве элементов два виджета с одинаковым типом.

Если вы не знаете что такое дерево элементов (ElementTree): ElementTree содержит информацию только о типе каждого виджета и ссылку на дочерние элементы. Вы можете рассматривать ElementTree как скелет вашего приложения. Другими словами, дерево элементов показывает структуру вашего приложения.

tree
Состояние начальное

После нажатия Flutter проверит типы виджетов.

tree 2
Состояние после нажатия кнопки

Когда я нажал на кнопку, Flutter обходит ElementTree, проверяет тип 😎 Text Element из ElementTree и видит, что он такой же , как 🤠 Text Widget, и поэтому ничего обновлять не будет.

Но... но если мы назовем эти два Stateful виджета по-разному. Тогда не будет проблем. Потому что обоих будут разные идентификаторы / ключи.

Чтобы обновить виджеты, которые имеют тот же тип внутри списка, мы должны присвоить UniqueKey всем виджетам.

class GetEmoji extends StatefulWidget {
GetEmoji({required this.emg, required Key key}) : super(key: key);
String emg;
@override
_GetEmojiState createState() => _GetEmojiState();
}
List<Widget> emojis = [
  GetEmoji(
    emg: "😎",
    key: UniqueKey(),
  ),
  GetEmoji(
    emg: "🤠",
    key: UniqueKey(),
  ),
];
smiles work
Все снова работает

Что же произошло, - когда флаттер попытался сравнить типы виджетов они оказались одинаковыми. Но когда он начал сопоставлять ключи, они оказались разными. Флаттер понял, что это разные виджеты, изменил ссылки в дереве элементов и обновил приложение.

tree update
Обновление дерева виджетов
keys not matched
Ключи не совпали
Change references
Обновление  дерева виджетов
Swap of Elements in ElementTree
Замена элементов в дереве элементов

Куда ставить ключи

Заметьте одну вещь: если вам нужно добавить ключи в ваше приложение, вы должны добавлять их выше поерева виджетов. В противном случае вы получите странные результаты.

Объясню на примере:

Здесь я использовал предыдущий пример. Просто добавил цвет фона, который генерируется случайным образом.

Пока все работает:

smiles work
Пока все работает

Теперь давайте обернем наши GetEmoji виджеты в Container.

List<Widget> emojis = [
  Container(
    child: GetEmoji(
      emg: "😎",
      key: UniqueKey(),
    ),
  ),
  Container(
    child: GetEmoji(
      emg: "🤠",
      key: UniqueKey(),
    ),
  ),
];

А теперь давайте снова запустим приложение.

smiles with colors
Вроде работает, но не корректно

Виджеты меняются местами, это нормально, но новый цвет генерируется снова и снова при нажатии кнопки.

Фактически, это не только новый цвет, который генерируется снова и снова, но также и новый Text Widget который генерируется снова и снова в дереве виджетов, мы не можем этого видеть, потому что мы используем только два статических эмодзи.

Но, мы уже присвоили ключ виджету, правильно? Да, и проблема не в ключах, а в расположении самих ключей.

Так, что же происходит?

Вот структура дерева виджетов и элементов:

new tree
Дерево виджетов и элементов

Когда мы выполняем операцию обмена, алгоритм сопоставления элементов и виджетов Flutter просматривает только один уровень в дереве за раз. На первом уровне дочерних элементов с элементами Padding все совпадает правильно.

На втором уровне Flutter замечает, что ключ 😎 Container Element не соответствует ключу виджета, поэтому он деактивирует этот 😎 Container Element, разрывая эти соединения.

tree with keys
В этом примере мы используем локальные ключи. Это означает, что при сопоставлении виджетов с элементами, Flutter ищет ключевые совпадения только на определенном уровне дерева.

Поскольку он не может найти на этом уровне Container Element с этим значением ключа, он создает новый и инициализирует новое состояние, в данном случае создавая виджет со случайным цветом фона.

tree upd
Решить эту проблему можно добавив key в Padding виджет.

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

ValueKey :

Ключ, который использует любое указанное нами value для идентификации.

ValueKey полезен, если мы хотим сохранить состояние виджетов с отслеживанием состояния, когда они перемещаются по дереву виджетов.

Рассмотрим приведенный ниже код, где есть 2 Textfield виджета. И мы хотим удалить последний Textfield из дерева виджетов.

bool showFavouriteFramework= true;
//...
Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          if (showFavouriteFramework)
            TextField(
              decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: "Favourite Framework"),
            ),
          TextField(
            decoration: InputDecoration(
                border: OutlineInputBorder(),
                labelText: "Favourite Language"),
          ),
          SizedBox(height: 10),
          ElevatedButton(
            onPressed: () {
              setState(() {
                showFavouriteFramework = false;
              });
            },
            child: Text("Remove Favourite Framework field"),
          )
        ],
),
Пример
Нажимаем кнопку Remove Favourite Framework field.

Пример 2
Если вы заметили, мы получили Flutter в текстовом поле "Любимый язык" вместо Dart.

Примечание: это произойдет только в том случае, если вы используете несколько виджетов с отслеживанием состояния одного типа.

Мы должны предоставить этим виджетам уникальные значения, которые помогут флаттеру понять, что они разные.

Мы можем предоставить уникальные значения с помощью ValueKey.

TextField(
              key: ValueKey("Framework"),
              decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: "Favourite Framework"
       ),
),
TextField(
            key: ValueKey("Language"),
            decoration: InputDecoration(
                border: OutlineInputBorder(),
                labelText: "Favourite Language"
       ),
),
Пример 3
Теперь, как мы видим, поле "Favorite Language" сохранило свое реальное значение, как и ожидалось.

В этом примере Flutter сначала проверит типы этих двух виджетов, одинаковы они или нет. Затем он проверит ключи, и обнаружив, что они разные Flutter обновит состояние и ссылки соответственно.

С ValueKey можно использовать любой тип уникальных значений: String, int, double, Objects и т.д.

Но все виджеты должны иметь уникальные значения. Это следует иметь в виду. Иначе ничего не получится.

Одна важная вещь, когда у нас есть список виджетов внутри Listview, Column или Row, постарайтесь не использовать в ключах значения index, поступающие из списка.

ObjectKey:

Ключ, который использует ссылку определенного типа для идентификации.

Пример:

Я создал Список объектов SuperHero из класса SuperHero.

late List<SuperHero> superHeroList;

@override
void initState() {
  superHeroList = [
    SuperHero(movie: "Iron Man", name: "Tony Stark"),
    SuperHero(movie: "Hulk", name: "Bruce Banner"),
    SuperHero(movie: "Thor:Ragnarok ", name: "Thor"),
  ];
  super.initState();
}
Scaffold(
    floatingActionButton: FloatingActionButton(
      onPressed: () {
        setState(() {
          superHeroList.insert(0, superHeroList.removeAt(1));
        });
      },
      child: Icon(Icons.swap_calls),
    ),
    body: Center(
      child: Column(
        children: superHeroList
            .map<Widget>((hero) => HeroWidget(hero: hero))
            .toList(),
      ),
    ),
);

Эта программа поменяет местами первые два элемента в superHeroList.

Но если мы попытаемся поменять местами эти два элемента, что-то пойдет не так: вы увидите, что текст элемента меняется местами, а цвет - нет. А он тоже должен измениться, правда? Потому что цвет также связан с этим элементом списка.

Пример 4
Как мы обсуждали выше,  флаттер не может различить виджеты одинаковых типов.

Тогда какое решение?

Вы можете подумать, что мы можем использовать ValueKey, верно? И да, вы правы, мы можем использовать ValueKey для различения виджетов в списке. Но есть один нюанс. Посмотрим, что будет, если мы используем ValueKey.

Center(
      child: Column(
        children: superHeroList
            .map<Widget>(
              (hero) => HeroWidget(
                key: ValueKey(hero),
                hero: hero,
              ),
            )
            .toList(),
      ),
),

И мы получаем ожидаемый результат. Теперь предметы меняются местами вместе с цветом.

Пример 5
Но, что если теперь усложним и добавим в наш список Object.

superHeroList = [
    SuperHero(movie: "Iron Man", name: "Tony Stark"),
    SuperHero(movie: "Iron Man", name: "Tony Stark"),
    SuperHero(movie: "Hulk", name: "Bruce Banner"),
    SuperHero(movie: "Thor:Ragnarok ", name: "Thor"),
];

Тогда на выходе получим:

Пример 6
И Flutter выдаст ошибку, примерно такую:

Ошибка
И это правильно, потому что в объяснении работы ValueKey мы видели, что виджет идентифицируется по его значению, когда мы используем ValueKey.

В данном случае мы добавили два одинаковых объекта с одинаковым значением. Вот почему Flutter выдает ошибку: " Эй, я нашел повторяющиеся ключи".

В таких случаях мы должны использовать ObjectKey.

Как следует из определения ObjectKey, этот ключ будет различать элементы на основе ссылок.

Давайте попробуем добавить ObjectKey:

HeroWidget(
                key: ObjectKey(hero),
                hero: hero,
),

И как только мы добавим, ObjectKey мы сможем увидеть результат. Теперь все работает нормально.

Пример 7

PageStorageKey

PageStorageKey в основном используется для сохранения позиции прокрутки прокручиваемых виджетов, таких как ListView или GridView и т.д.

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

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

Scaffold(
    body: ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Text(
            "Item : $index",
            style: TextStyle(fontSize: 22),
          ),
        );
      },
    ),
);

Здесь я создал простой Listview:

Простой список

А теперь остановим список на середине и перейдем на другую вкладку.

Пример 7

Видите? Позиция списка не сохранилась.

Давайте решим эту проблему добавив PageStorageKey в ListView.

ListView.builder(
      key: PageStorageKey<String>("listViewKey"),
      itemCount: 100,
      itemBuilder: (context, index) => ListTile(
        title: Text(
          'List item ${index + 1}',
          style: TextStyle(fontSize: 24),
        ),
      ),
);
Пример 8

Теперь все отлично работает!

Это все, что вам нужно сделать, чтобы сохранить место прокрутки.

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

Пример 9

Чтобы исправить это, нам нужно обернуть в PageStorage родительский виджет дерева виджетов. В нашем случае мы можем обернуть в него Scaffold.

 final globalBucket = PageStorageBucket(); '''Don't declare it inside any class. Declare it on global level.'''
Widget build(BuildContext context) {
  return PageStorage(
    bucket: globalBucket,
    child: Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        backgroundColor: Theme.of(context).primaryColor,
        selectedItemColor: Colors.white,
        unselectedItemColor: Colors.white70,
        currentIndex: index,
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.list),
            title: Text('ListView'),
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            title: Text('Blah blah'),
          ),
        ],
        onTap: (int index) => setState(() => this.index = index),
      ),
      appBar: AppBar(),
      body: buildPages(),
    ),
  );
}

Теперь работает:

Пример 10

GlobalKey

Это наиболее часто используемый ключ во Flutter по сравнению с указанными выше ключами.

GlobalKey может быть использован для изменения родительских виджетов в любом месте вашего приложения без потери состояния.

Его можно использовать для доступа к информации о другом виджете, когда мы находимся в совершенно другом месте дерева виджетов.

Один из вариантов использования - GlobalKey это проверка Form или отображение Snackbar в приложении и т.д.

Возьмем пример:

final _counterState = GlobalKey<_CounterState>();  //Declaring the GlobalKey of CounterState
Scaffold(
      appBar: AppBar(),
      body: SizedBox.expand(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Counter(
              key: _counterState,
            ),
          ],
        ),
      ),
);
class Counter extends StatefulWidget {
const Counter({
  Key? key,
}) : super(key: key);

@override
_CounterState createState() => _CounterState();
}
class _CounterState extends State<Counter> {
late int count;

@override
void initState() {
  super.initState();
  count = 0;
}

@override
Widget build(BuildContext context) {
  return Column(
    children: <Widget>[
      Text(
        count.toString(),
        style: TextStyle(fontSize: 30),
      ),
      ElevatedButton(
          onPressed: () {
            setState(() {
              count++;
            });
          },
          child: Text("Add"))
    ],
  );
}
}

Результат:

Пример 11

Теперь мы можем получить доступ к count значению CounterWidget на любой странице, передав GlobalKey.

class SecondPage extends StatefulWidget {
final GlobalKey<_CounterState> counterKey;
SecondPage(this.counterKey);
@override
_SecondPageState createState() => _SecondPageState();
}
class _SecondPageState extends State<SecondPage> {
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(),
    body: Center(
      child: Row(
        children: <Widget>[
          IconButton(
            icon: Icon(Icons.add),
            onPressed: () {
              setState(() {
                widget.counterKey.currentState!.count++; //here
                print(widget.counterKey.currentState!.count);
              });
            },
          ),
          Text(
            widget.counterKey.currentState!.count.toString(),
            style: TextStyle(fontSize: 50),
          ),
        ],
      ),
    ),
  );
}
}
Пример 12

Итак, как мы видим, что GlobalKey можно использовать для доступа к информации о другом виджете, когда мы находимся в совершенно другом месте дерева виджетов.

Вот и все, что вам нужно знать о ключах.

 

Комментарии