diff --git a/packages/flutter/lib/src/material/reorderable_list.dart b/packages/flutter/lib/src/material/reorderable_list.dart index 7e901c4d969..a2430c70043 100644 --- a/packages/flutter/lib/src/material/reorderable_list.dart +++ b/packages/flutter/lib/src/material/reorderable_list.dart @@ -4,8 +4,9 @@ import 'dart:math'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; import 'debug.dart'; import 'material.dart'; @@ -344,7 +345,11 @@ class _ReorderableListContentState extends State<_ReorderableListContent> with T // Handles up the logic for dragging and reordering items in the list. Widget _wrap(Widget toWrap, int index, BoxConstraints constraints) { assert(toWrap.key != null); - final GlobalObjectKey keyIndexGlobalKey = GlobalObjectKey(toWrap.key); + final _ScopedValueGlobalKey<_ReorderableListContentState> keyIndexGlobalKey = + _ScopedValueGlobalKey<_ReorderableListContentState>( + scope: this, + value: toWrap.key, + ); // We pass the toWrapWithGlobalKey into the Draggable so that when a list // item gets dragged, the accessibility framework can preserve the selected // state of the dragging item. @@ -574,3 +579,32 @@ class _ReorderableListContentState extends State<_ReorderableListContent> with T }); } } + +/// A [GlobalKey] that uses a scope and a value to determine equality. +/// +/// The scope is compared using [identical], while the value is compared +/// using [operator ==]. This allows a locally scoped value to be turned +/// into a globally unique key. +class _ScopedValueGlobalKey> extends GlobalKey { + const _ScopedValueGlobalKey({this.scope, this.value}) : super.constructor(); + + final Object scope; + final Object value; + + @override + int get hashCode => hashValues(identityHashCode(scope), value.hashCode); + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + final _ScopedValueGlobalKey typedOther = other; + return identical(scope, typedOther.scope) && value == typedOther.value; + } + + @override + String toString() { + return '[$runtimeType ${describeIdentity(scope)} ${describeIdentity(value)}]'; + } +} diff --git a/packages/flutter/test/material/reorderable_list_test.dart b/packages/flutter/test/material/reorderable_list_test.dart index b765d240f0c..5027d1d905e 100644 --- a/packages/flutter/test/material/reorderable_list_test.dart +++ b/packages/flutter/test/material/reorderable_list_test.dart @@ -655,6 +655,51 @@ void main() { expect(findState(const Key('A')).checked, true); }); + testWidgets('Preserves children states across reorder when keys are not identical', (WidgetTester tester) async { + _StatefulState findState(Key key) { + return find.byElementPredicate((Element element) => element.ancestorWidgetOfExactType(_Stateful)?.key == key) + .evaluate() + .first + .ancestorStateOfType(const TypeMatcher<_StatefulState>()); + } + await tester.pumpWidget(MaterialApp( + home: ReorderableListView( + children: [ + _Stateful(key: const ObjectKey('A')), + _Stateful(key: const ObjectKey('B')), + _Stateful(key: const ObjectKey('C')), + ], + onReorder: (int oldIndex, int newIndex) { }, + scrollDirection: Axis.horizontal, + ), + )); + await tester.tap(find.byKey(const ObjectKey('A'))); + await tester.pumpAndSettle(); + // Only the 'A' widget should be checked. + expect(findState(const ObjectKey('A')).checked, true); + expect(findState(const ObjectKey('B')).checked, false); + expect(findState(const ObjectKey('C')).checked, false); + + // Rebuild with distinct key objects. + await tester.pumpWidget(MaterialApp( + home: ReorderableListView( + children: [ + // Deliberately avoid the const constructor below to ensure keys are + // distinct objects. + _Stateful(key: ObjectKey('B')), // ignore:prefer_const_constructors + _Stateful(key: ObjectKey('C')), // ignore:prefer_const_constructors + _Stateful(key: ObjectKey('A')), // ignore:prefer_const_constructors + ], + onReorder: (int oldIndex, int newIndex) { }, + scrollDirection: Axis.horizontal, + ), + )); + // Only the 'A' widget should be checked. + expect(findState(const ObjectKey('B')).checked, false); + expect(findState(const ObjectKey('C')).checked, false); + expect(findState(const ObjectKey('A')).checked, true); + }); + group('Accessibility (a11y/Semantics)', () { Map getSemanticsActions(int index) { final Semantics semantics = find.ancestor(