Many of you know every single Widgets has a constructor parameter called key
.
Key
is one of the fundamental mechanisms in Flutter to manage Widgets, but do you know everything about Key
, especially GlobalKey
?
Key
tends to be described as an object that uniquely identifies a Widget, but once it comes to GlobalKey
, it's not all about that.
In this article, I'm going to describe what I know about GlobalKey
as much as possible, especially focusing on how the result of rebuilding will change when we use GlobalKey
.
Key
in general
Let's start by diving into framework.dart
to see how key
works when rebuilding.
First, key
in general is used to compare oldWidget
and newWidget
. The typical usage is canUpdate
static method below.
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType &&
oldWidget.key == newWidget.key;
}
This method is called to determine "how much" the widget is changed between the previous and current build in Element.updateChild
method. So let's take a look at the brief process of Element.updateChild
method as well.
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
if (hasSameSuperclass && child.widget == newWidget) {
// case 1: if the widget is the same object, typically by const constructor
// nothing happens, meaning children are not rebuilt at all.
newChild = child;
} else if (hasSameSuperclass &&
Widget.canUpdate(child.widget, newWidget)) {
// case 2: if canUpdate returns true, Element is reused but children are rebuilt.
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
child.update(newWidget);
newChild = child;
} else {
// case 3: if canUpdate returns false, old Element is deactivated and new Element is created.
deactivateChild(child);
newChild = inflateWidget(newWidget, newSlot);
}
return newChild;
}
I've omitted most of the code for brevity leaving the major process of the method. I also added comments to describe the 3 cases of "how much" the widget is changed between the previous and current build. I'll explain the 3 cases in detail below.
Case 1: Widget is the same object
The widgets at the same position before and after rebuilding are the same object. This is the case typically when the widget is created by const
constructor.
const Text('Hello, World!');
In this case, existing Element
is reused and that's all. Children are not rebuilt at all in this case, so we can say it's the most efficient and performant case.
Case 2: canUpdate returns true
If canUpdate
returns true, existing Element
is reused but children are rebuilt. This is the second most efficient case because no Element
is created and initialized, meaning State
or RenderObject
is also reused and no additional calculation is needed unless the configuration of the widget is changed.
So, when canUpdate
returns true? Let's take a look at the implementation of canUpdate
method again.
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType &&
oldWidget.key == newWidget.key;
}
There are 2 comparisons, runtimeType
and key
.
runtimeType
is the type of the widget, such as Text
, Container
, Button
, etc. key
is the Key
object which is the main target of this article.
For example, the widget coded below would return true.
Container(
width: 100,
height: 100,
color: Colors.red,
);
Because runtimeType
is always the same, Container
, and key
is also the same, null
, canUpdate
returns true.
build()
method is called again and again over time, which means the Container
object is recreated at every rebuild. However, as long as canUpdate
returns true
, it costs less than we tend to suspect because other components, such as Element
, State
, RenderObject
, etc. are reused.
Case 3: canUpdate returns false
So what if canUpdate
returns false?
This would be the most heavy case because the old Element
is deactivated and new Element
is created, meaning State
or RenderObject
are also created and initialized.
For example, the widget coded below would return false
.
Container(
key: UniqueKey(),
width: 100,
height: 100,
color: Colors.red,
);
The code above always pass UniqueKey()
as the key parameter, the comparison of oldWidget.key == newWidget.key
always returns false because UniqueKey()
is only equal to itself.
test('test UniqueKey comparison', () {
expect(UniqueKey() == UniqueKey(), isFalse);
final uniqueKey = UniqueKey();
expect(uniqueKey == uniqueKey, isTrue);
});
On the other hand, the code below would return true.
Container(
key: ValueKey('key'),
width: 100,
height: 100,
color: Colors.red,
);
This is because ==
operator of ValueKey
is overridden to compare the value of the keys, which is 'key'
in this case.
test('test ValueKey comparison', () {
expect('key' == 'key', isTrue);
expect(ValueKey('key') == ValueKey('key'), isTrue);
});
As we have seen above, how ==
is overridden is crucial to understand the behavior around this. So let's go ahead and take a look at the variation of Key
and how each ==
behaves next.
Keys and ==
implementations
We have 5 major implementations of Key
in Flutter, which are:
UniqueKey
ValueKey
ObjectKey
GlobalKey
GlobalObjectKey
Here, UniqueKey
, ValueKey
, and ObjectKey
are the subclasses of LocalKey
, while GlobalKey
and GlobalObjectKey
are categorized as GlobalKey
.
We will discuss the difference between LocalKey
and GlobalKey
later, but for now, let's focus on how ==
is implemented in each Key
.
UniqueKey
UniqueKey
is the simplest implementation of Key
. There are no special overrides in it like below.
class UniqueKey extends LocalKey {
UniqueKey();
@override
String toString() => '[#${shortHash(this)}]';
}
This means UniqueKey
is just a simple class without any specific logic or internal states. So, UniqueKey()
is always equal to itself.
test('test UniqueKey comparison', () {
expect(UniqueKey() == UniqueKey(), isFalse);
final uniqueKey = UniqueKey();
expect(uniqueKey == uniqueKey, isTrue);
});
ValueKey
ValueKey
preserves a given value
and compares it with another ValueKey
's value
.
class ValueKey<T> extends LocalKey {
const ValueKey(this.value);
final T value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ValueKey<T> && other.value == value;
}
}
Because ==
of ValueKey
evaluates ==
of the value
inside, ValueKey
is equal to another ValueKey
as long as the value
is the same.
test('test ValueKey comparison', () {
expect(ValueKey('key') == ValueKey('key'), isTrue);
expect(ValueKey('key') == ValueKey('key2'), isFalse);
});
ObjectKey
ObjectKey
is similar to ValueKey
but it compares the value
with ==
operator.
class ObjectKey extends LocalKey {
const ObjectKey(this.value);
final Object? value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ObjectKey && identical(other.value, value);
}
}
While ValueKey
compares the value
with ==
operator, ObjectKey
compares the value
with identical
function. In other words, ObjectKey
compares the object reference of the value
.
test('local key test', () {
expect(
ObjectKey(Point(1, 1)) == ObjectKey(Point(1, 1)),
isFalse, // false because the object reference is different
);
expect(
ObjectKey(const Point(1, 1)) == const ObjectKey(Point(1, 1)),
isTrue, // true because const refers the same object reference
);
expect(ObjectKey('key') == ObjectKey('key'), isTrue);
});
GlobalKey
==
implemented in GlobalKey
is the same as UniqueKey
's ==
. It doesn't override ==
, meaning GlobalKey
object is always equal to itself.
test('test GlobalKey comparison', () {
expect(GlobalKey() == GlobalKey(), isFalse);
final globalKey = GlobalKey();
expect(globalKey == globalKey, isTrue);
});
GlobalObjectKey
GlobalObjectKey
is GlobalKey
version of ObjectKey
. It compares the value
with identical
function. In other words, GlobalObjectKey
compares the object reference to the value
.
class GlobalObjectKey<T extends State<StatefulWidget>> extends GlobalKey<T> {
const GlobalObjectKey(this.value) : super.constructor();
final Object value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is GlobalObjectKey<T> && identical(other.value, value);
}
test('test GlobalObjectKey comparison', () {
expect(
GlobalObjectKey(Point(1, 1)) == GlobalObjectKey(Point(1, 1)),
isFalse, // false because the object reference is different
);
expect(
GlobalObjectKey(const Point(1, 1)) == const GlobalObjectKey(Point(1, 1)),
isTrue, // true because const refers the same object reference
);
});
GlobalKey
Finally, we are at the starting point of the main discussion of this article, GlobalKey
.
While Key
is used to compare oldWidget
and newWidget
in canUpdate
method, as we discussed before, GlobalKey
is used in a lot more places. You can find multiple if (key is GlobalKey)
statements in framework.dart
.
The two most important operations in this article would be below.
void mount(Element? parent, Object? newSlot) {
if (key is GlobalKey) {
owner!._registerGlobalKey(key, this);
}
}
Element inflateWidget(Widget newWidget, Object? newSlot) {
if (key is GlobalKey) {
final Element? newChild = _retakeInactiveElement(key, newWidget);
if (newChild != null) {
The first one registers Element
to BuildOwner
with the key of GlobalKey
, and then the second one tries to retake the inactive Element
using GlobalKey
when a new Element
should be created.
As we can see here, Element
which is created by a widget with GlobalKey
is registered to BuildOwner
so that it can be referenced or reused later.
The most typical usage of this mechanism would be to refer the State
of a widget.
final state = _globalKey.currentState;
Or retrieving the RenderObject
of a widget to determine the size of the widget.
final renderObject = _globalKey.currentContext?.findRenderObject();
One thing to note is that .currentState
or .currentContext
doesn't literally return the State
or BuildContext
preserved by the GlobalKey
.
GlobalKey
is an immutable object in the end, and it doesn't preserve any object other than the one we passed to the constructor. Then, what happens when we call .currentState
or .currentContext
?
We will find it's so simple when we take a look at the implementation of .currentState
or .currentContext
.
Element? get _currentElement => WidgetsBinding.instance.buildOwner!._globalKeyRegistry[this];
BuildContext? get currentContext => _currentElement;
T? get currentState => switch (_currentElement) {
StatefulElement(:final T state) => state,
_ => null,
};
Both the getter of currentContext
and currentState
call the getter of _currentElement
internally to retrieve the relevant Element
from the BuildOwner
. This can be functioned thanks to WidgetsBinding.instance
is a singleton object, which preserves the reference of BuildOwner
, meaning they can be accessed from anywhere.
In other words, .currentState
or .currentContext
just defines the relevant way to retrieve the registered Element
as a key-value pair with GlobalKey
.
In short, GlobalKey
is a "key" to retrieve the registered Element
from the BuildOwner
. However, "retrieving the registered Element
" is also used in the Flutter framework to optimize the construction of the widget tree. Let's deep dive into it next.
Deactivating and Retaking Element
s
Once the widget moves to another position or depth on the widget tree, or just removed from the tree, the Element
usually will be deactivated by deactivateChild()
method as we saw before, and those Element will be recognized as one of the _inactiveElements
by BuildOwner
.
void deactivateChild(Element child) {
child._parent = null;
child.detachRenderObject();
owner!._inactiveElements.add(child);
}
Although the deactivated Element
will usually be unmounted and disposed accordingly after the current frame, they have another chance to be "retaken" before the next frame comes; that is _retakeInactiveElement()
method.
Element? _retakeInactiveElement(GlobalKey key, Widget newWidget) {
final Element? element = key._currentElement;
final Element? parent = element._parent;
if (parent != null) {
parent.forgetChild(element);
parent.deactivateChild(element);
}
owner!._inactiveElements.remove(element);
return element;
}
When a new Element
is required in rebuilding phase, the Flutter framework first tries to find the deactivated Element
to be reused if GlobalKey
is given.
Once the corresponding Element
of GlobalKey
is found, the Element
forgets its previous parent and reused as a child of the new parent. It is no longer an "inactive" Element
anymore, so it won't be unmounted or disposed after the current frame.
This happens when the depth or position of the widget is changed on rebuilding like below.
final child = SomeWidget(key: GlobalObjectKey('key'));
return _isHorizontal ? Column(children: [child]) : Row(children: [child]);
In this case, if _isHorizontal
is true, the child
will be at the subtree of Column
, while the child will be at the subtree of Row
if _isHorizontal
is false.
If the key
is not given to SomeWidget
, the Flutter framework doesn't recognize the child
under the Column
and the one under the Row
as the same object, so its corresponding Element
will be unmounted and disposed at any rebuilding.
However, if the GlobalKey
is given to SomeWidget
, the Flutter framework first registers the corresponding Element
to BuildOwner
with the key of GlobalKey
, and then, tries to retake it even when the position of SomeWidget
is changed, which means the State
or RenderObject
can be kept alive even when _isHorizontal
is changed.
This mechanism is also described in the Tree surgery section of Inside Flutter. This let the framework efficiently update the UI.
Conclusion
So far, we have discussed how Key
is used for comparing oldWidget
and newWidget
in the Flutter framework, and how GlobalKey
lets the framework efficiently reuse the Element
even when its position or depth is changed.
GlobalKey
doesn’t only differentiate widgets but also helps keep Element
and its corresponding State
or RenderObject
alive in a BuildOwner
singleton object until a certain frame finishes.
My package, animated_to, also leverages this mechanism to determine the update of widgets' positions. I'll hopefully write this in detail in another article.
Though Key
is one of the most core concepts in the Flutter framework, it tends to be described with abstract words. I hope this article helps you to understand what exactly happens in the Flutter framework, and how we can leverage GlobalKey
in our app development.