Skip to content

Commit 0d32ca2

Browse files
Allow key reparenting between slots in SlottedMultiChildRenderObjectWidgetMixin (#106977)
1 parent 060adf0 commit 0d32ca2

File tree

2 files changed

+166
-24
lines changed

2 files changed

+166
-24
lines changed

packages/flutter/lib/src/widgets/slotted_render_object_widget.dart

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,24 @@ mixin SlottedContainerRenderObjectMixin<S> on RenderBox {
182182
adoptChild(child);
183183
}
184184
}
185+
186+
void _moveChild(RenderBox child, S slot, S oldSlot) {
187+
assert(slot != oldSlot);
188+
final RenderBox? oldChild = _slotToChild[oldSlot];
189+
if (oldChild == child) {
190+
_setChild(null, oldSlot);
191+
}
192+
_setChild(child, slot);
193+
}
185194
}
186195

187196
/// Element used by the [SlottedMultiChildRenderObjectWidgetMixin].
188197
class SlottedRenderObjectElement<S> extends RenderObjectElement {
189198
/// Creates an element that uses the given widget as its configuration.
190199
SlottedRenderObjectElement(SlottedMultiChildRenderObjectWidgetMixin<S> super.widget);
191200

192-
final Map<S, Element> _slotToChild = <S, Element>{};
201+
Map<S, Element> _slotToChild = <S, Element>{};
202+
Map<Key, Element> _keyedChildren = <Key, Element>{};
193203

194204
@override
195205
SlottedContainerRenderObjectMixin<S> get renderObject => super.renderObject as SlottedContainerRenderObjectMixin<S>;
@@ -231,21 +241,73 @@ class SlottedRenderObjectElement<S> extends RenderObjectElement {
231241
}(), '${widget.runtimeType}.slots must not change.');
232242
assert(slottedMultiChildRenderObjectWidgetMixin.slots.toSet().length == slottedMultiChildRenderObjectWidgetMixin.slots.length, 'slots must be unique');
233243

244+
final Map<Key, Element> oldKeyedElements = _keyedChildren;
245+
_keyedChildren = <Key, Element>{};
246+
final Map<S, Element> oldSlotToChild = _slotToChild;
247+
_slotToChild = <S, Element>{};
248+
249+
Map<Key, List<Element>>? debugDuplicateKeys;
250+
234251
for (final S slot in slottedMultiChildRenderObjectWidgetMixin.slots) {
235-
_updateChild(slottedMultiChildRenderObjectWidgetMixin.childForSlot(slot), slot);
252+
final Widget? widget = slottedMultiChildRenderObjectWidgetMixin.childForSlot(slot);
253+
final Key? newWidgetKey = widget?.key;
254+
255+
final Element? oldSlotChild = oldSlotToChild[slot];
256+
final Element? oldKeyChild = oldKeyedElements[newWidgetKey];
257+
258+
// Try to find the slot for the correct Element that `widget` should update.
259+
// If key matching fails, resort to `oldSlotChild` from the same slot.
260+
final Element? fromElement;
261+
if (oldKeyChild != null) {
262+
fromElement = oldSlotToChild.remove(oldKeyChild.slot as S);
263+
} else if (oldSlotChild?.widget.key == null) {
264+
fromElement = oldSlotToChild.remove(slot);
265+
} else {
266+
// The only case we can't use `oldSlotChild` is when its widget has a key.
267+
assert(oldSlotChild!.widget.key != newWidgetKey);
268+
fromElement = null;
269+
}
270+
final Element? newChild = updateChild(fromElement, widget, slot);
271+
272+
if (newChild != null) {
273+
_slotToChild[slot] = newChild;
274+
275+
if (newWidgetKey != null) {
276+
assert(() {
277+
final Element? existingElement = _keyedChildren[newWidgetKey];
278+
if (existingElement != null) {
279+
(debugDuplicateKeys ??= <Key, List<Element>>{})
280+
.putIfAbsent(newWidgetKey, () => <Element>[existingElement])
281+
.add(newChild);
282+
}
283+
return true;
284+
}());
285+
_keyedChildren[newWidgetKey] = newChild;
286+
}
287+
}
236288
}
289+
oldSlotToChild.values.forEach(deactivateChild);
290+
assert(_debugDuplicateKeys(debugDuplicateKeys));
291+
assert(_keyedChildren.values.every(_slotToChild.values.contains), '_keyedChildren ${_keyedChildren.values} should be a subset of ${_slotToChild.values}');
237292
}
238293

239-
void _updateChild(Widget? widget, S slot) {
240-
final Element? oldChild = _slotToChild[slot];
241-
assert(oldChild == null || oldChild.slot == slot); // Reason why [moveRenderObjectChild] is not reachable.
242-
final Element? newChild = updateChild(oldChild, widget, slot);
243-
if (oldChild != null) {
244-
_slotToChild.remove(slot);
294+
bool _debugDuplicateKeys(Map<Key, List<Element>>? debugDuplicateKeys) {
295+
if (debugDuplicateKeys == null) {
296+
return true;
245297
}
246-
if (newChild != null) {
247-
_slotToChild[slot] = newChild;
298+
for (final MapEntry<Key, List<Element>> duplicateKey in debugDuplicateKeys.entries) {
299+
throw FlutterError.fromParts(<DiagnosticsNode>[
300+
ErrorSummary('Multiple widgets used the same key in ${widget.runtimeType}.'),
301+
ErrorDescription(
302+
'The key ${duplicateKey.key} was used by multiple widgets. The parents of those widgets were:\n'
303+
),
304+
for (final Element element in duplicateKey.value) ErrorDescription(' - $element\n'),
305+
ErrorDescription(
306+
'A key can only be specified on one widget at a time in the same parent widget.',
307+
),
308+
]);
248309
}
310+
return true;
249311
}
250312

251313
@override
@@ -256,14 +318,14 @@ class SlottedRenderObjectElement<S> extends RenderObjectElement {
256318

257319
@override
258320
void removeRenderObjectChild(RenderBox child, S slot) {
259-
assert(renderObject._slotToChild[slot] == child);
260-
renderObject._setChild(null, slot);
261-
assert(renderObject._slotToChild[slot] == null);
321+
if (renderObject._slotToChild[slot] == child) {
322+
renderObject._setChild(null, slot);
323+
assert(renderObject._slotToChild[slot] == null);
324+
}
262325
}
263326

264327
@override
265-
void moveRenderObjectChild(RenderBox child, Object? oldSlot, Object? newSlot) {
266-
// Existing elements are never moved to a new slot, see assert in [_updateChild].
267-
assert(false, 'not reachable');
328+
void moveRenderObjectChild(RenderBox child, S oldSlot, S newSlot) {
329+
renderObject._moveChild(child, newSlot, oldSlot);
268330
}
269331
}

packages/flutter/test/widgets/slotted_render_object_widget_test.dart

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,72 @@ void main() {
139139
expect(_RenderTest().publicNameForSlot(slot), slot.toString());
140140
});
141141

142+
testWidgets('key reparenting', (WidgetTester tester) async {
143+
const Widget widget1 = SizedBox(key: ValueKey<String>('smol'), height: 10, width: 10);
144+
const Widget widget2 = SizedBox(key: ValueKey<String>('big'), height: 100, width: 100);
145+
const Widget nullWidget = SizedBox(key: ValueKey<String>('null'), height: 50, width: 50);
146+
147+
await tester.pumpWidget(buildWidget(topLeft: widget1, bottomRight: widget2, nullSlot: nullWidget));
148+
final _RenderDiagonal renderObject = tester.renderObject(find.byType(_Diagonal));
149+
expect(renderObject._topLeft!.size, const Size(10, 10));
150+
expect(renderObject._bottomRight!.size, const Size(100, 100));
151+
expect(renderObject._nullSlot!.size, const Size(50, 50));
152+
153+
final Element widget1Element = tester.element(find.byWidget(widget1));
154+
final Element widget2Element = tester.element(find.byWidget(widget2));
155+
final Element nullWidgetElement = tester.element(find.byWidget(nullWidget));
156+
157+
// Swapping 1 and 2.
158+
await tester.pumpWidget(buildWidget(topLeft: widget2, bottomRight: widget1, nullSlot: nullWidget));
159+
expect(renderObject._topLeft!.size, const Size(100, 100));
160+
expect(renderObject._bottomRight!.size, const Size(10, 10));
161+
expect(renderObject._nullSlot!.size, const Size(50, 50));
162+
expect(widget1Element, same(tester.element(find.byWidget(widget1))));
163+
expect(widget2Element, same(tester.element(find.byWidget(widget2))));
164+
expect(nullWidgetElement, same(tester.element(find.byWidget(nullWidget))));
165+
166+
// Shifting slots
167+
await tester.pumpWidget(buildWidget(topLeft: nullWidget, bottomRight: widget2, nullSlot: widget1));
168+
expect(renderObject._topLeft!.size, const Size(50, 50));
169+
expect(renderObject._bottomRight!.size, const Size(100, 100));
170+
expect(renderObject._nullSlot!.size, const Size(10, 10));
171+
expect(widget1Element, same(tester.element(find.byWidget(widget1))));
172+
expect(widget2Element, same(tester.element(find.byWidget(widget2))));
173+
expect(nullWidgetElement, same(tester.element(find.byWidget(nullWidget))));
174+
175+
// Moving + Deleting.
176+
await tester.pumpWidget(buildWidget(bottomRight: widget2));
177+
expect(renderObject._topLeft, null);
178+
expect(renderObject._bottomRight!.size, const Size(100, 100));
179+
expect(renderObject._nullSlot, null);
180+
expect(widget1Element.debugIsDefunct, isTrue);
181+
expect(nullWidgetElement.debugIsDefunct, isTrue);
182+
expect(widget2Element, same(tester.element(find.byWidget(widget2))));
183+
184+
// Moving.
185+
await tester.pumpWidget(buildWidget(topLeft: widget2));
186+
expect(renderObject._topLeft!.size, const Size(100, 100));
187+
expect(renderObject._bottomRight, null);
188+
expect(widget2Element, same(tester.element(find.byWidget(widget2))));
189+
});
190+
191+
testWidgets('duplicated key error message', (WidgetTester tester) async {
192+
const Widget widget1 = SizedBox(key: ValueKey<String>('widget 1'), height: 10, width: 10);
193+
const Widget widget2 = SizedBox(key: ValueKey<String>('widget 1'), height: 100, width: 100);
194+
const Widget widget3 = SizedBox(key: ValueKey<String>('widget 1'), height: 50, width: 50);
195+
196+
await tester.pumpWidget(buildWidget(topLeft: widget1, bottomRight: widget2, nullSlot: widget3));
197+
198+
expect((tester.takeException() as FlutterError).toString(), equalsIgnoringHashCodes(
199+
'Multiple widgets used the same key in _Diagonal.\n'
200+
"The key [<'widget 1'>] was used by multiple widgets. The parents of those widgets were:\n"
201+
" - SizedBox-[<'widget 1'>](width: 50.0, height: 50.0, renderObject: RenderConstrainedBox#00000 NEEDS-LAYOUT NEEDS-PAINT)\n"
202+
" - SizedBox-[<'widget 1'>](width: 10.0, height: 10.0, renderObject: RenderConstrainedBox#00000 NEEDS-LAYOUT NEEDS-PAINT)\n"
203+
" - SizedBox-[<'widget 1'>](width: 100.0, height: 100.0, renderObject: RenderConstrainedBox#a4685 NEEDS-LAYOUT NEEDS-PAINT)\n"
204+
'A key can only be specified on one widget at a time in the same parent widget.'
205+
));
206+
});
207+
142208
testWidgets('debugDescribeChildren', (WidgetTester tester) async {
143209
await tester.pumpWidget(buildWidget(
144210
topLeft: const SizedBox(
@@ -178,14 +244,15 @@ _RenderDiagonal#00000 relayoutBoundary=up1
178244
});
179245
}
180246

181-
Widget buildWidget({Widget? topLeft, Widget? bottomRight}) {
247+
Widget buildWidget({Widget? topLeft, Widget? bottomRight, Widget? nullSlot}) {
182248
return Directionality(
183249
textDirection: TextDirection.ltr,
184250
child: Align(
185251
alignment: Alignment.topLeft,
186252
child: _Diagonal(
187253
topLeft: topLeft,
188254
bottomRight: bottomRight,
255+
nullSlot: nullSlot,
189256
),
190257
),
191258
);
@@ -196,21 +263,25 @@ enum _DiagonalSlot {
196263
bottomRight,
197264
}
198265

199-
class _Diagonal extends RenderObjectWidget with SlottedMultiChildRenderObjectWidgetMixin<_DiagonalSlot> {
266+
class _Diagonal extends RenderObjectWidget with SlottedMultiChildRenderObjectWidgetMixin<_DiagonalSlot?> {
200267
const _Diagonal({
201268
this.topLeft,
202269
this.bottomRight,
270+
this.nullSlot,
203271
});
204272

205273
final Widget? topLeft;
206274
final Widget? bottomRight;
275+
final Widget? nullSlot;
207276

208277
@override
209-
Iterable<_DiagonalSlot> get slots => _DiagonalSlot.values;
278+
Iterable<_DiagonalSlot?> get slots => <_DiagonalSlot?>[null, ..._DiagonalSlot.values];
210279

211280
@override
212-
Widget? childForSlot(_DiagonalSlot slot) {
281+
Widget? childForSlot(_DiagonalSlot? slot) {
213282
switch (slot) {
283+
case null:
284+
return nullSlot;
214285
case _DiagonalSlot.topLeft:
215286
return topLeft;
216287
case _DiagonalSlot.bottomRight:
@@ -219,16 +290,17 @@ class _Diagonal extends RenderObjectWidget with SlottedMultiChildRenderObjectWid
219290
}
220291

221292
@override
222-
SlottedContainerRenderObjectMixin<_DiagonalSlot> createRenderObject(
293+
SlottedContainerRenderObjectMixin<_DiagonalSlot?> createRenderObject(
223294
BuildContext context,
224295
) {
225296
return _RenderDiagonal();
226297
}
227298
}
228299

229-
class _RenderDiagonal extends RenderBox with SlottedContainerRenderObjectMixin<_DiagonalSlot> {
300+
class _RenderDiagonal extends RenderBox with SlottedContainerRenderObjectMixin<_DiagonalSlot?> {
230301
RenderBox? get _topLeft => childForSlot(_DiagonalSlot.topLeft);
231302
RenderBox? get _bottomRight => childForSlot(_DiagonalSlot.bottomRight);
303+
RenderBox? get _nullSlot => childForSlot(null);
232304

233305
@override
234306
void performLayout() {
@@ -251,9 +323,17 @@ class _RenderDiagonal extends RenderBox with SlottedContainerRenderObjectMixin<_
251323
bottomRightSize = _bottomRight!.size;
252324
}
253325

326+
Size nullSlotSize = Size.zero;
327+
final RenderBox? nullSlot = _nullSlot;
328+
if (nullSlot != null) {
329+
nullSlot.layout(childConstraints, parentUsesSize: true);
330+
_positionChild(nullSlot, Offset.zero);
331+
nullSlotSize = nullSlot.size;
332+
}
333+
254334
size = constraints.constrain(Size(
255-
topLeftSize.width + bottomRightSize.width,
256-
topLeftSize.height + bottomRightSize.height,
335+
topLeftSize.width + bottomRightSize.width + nullSlotSize.width,
336+
topLeftSize.height + bottomRightSize.height + nullSlotSize.height,
257337
));
258338
}
259339

0 commit comments

Comments
 (0)