Fixes issue, ReorderableListView destroys children even if their key-type wasn't changed#64855
Conversation
…type wasn't changed
|
|
||
| @override | ||
| int get hashCode => super.hashCode; | ||
| } |
There was a problem hiding this comment.
The problem with this approach is it'll fail if there's two ReorderableListViews with items that have the same local keys. I think the solution is that _GlobalObjectValueKey should have two values, one is the key we're proxying to, and the other is the _ReorderableListContentState to which they belong. We can probably rename it to _ReorderableListChildGlobalKey or something, with arguments final Key subkey and final _ReorderableListContentState state or something like that, to make it clearer what is going on.
There was a problem hiding this comment.
@Hixie
That's a great idea! Thanks for your comment. I will apply it immediately.
There was a problem hiding this comment.
@Hixie Add _ReorderableListContentState as a value of the key can not solve the situation where multiple items use the same local key, they have the same _ReorderableListContentState, which will lead to Multiple widgets used the same GlobalKey, such as:
@override
Widget build(BuildContext context) {
return ReorderableListView(
children: <Widget>[ InnerTest(key: ValueKey(0)), InnerTest(key: ValueKey(0)) ],
onReorder: (_, __) {},
);
}
The reason why there is no problem here is its wrong implementation of int get hashCode => identityHashCode(subKey);
Probably we could add the index into the values together to solve this problem.
I think we should add this to the unit test case.
There was a problem hiding this comment.
@xu-baolin Two children can never have the same key, so the current behavior is correct
There was a problem hiding this comment.
See: https://api.flutter.dev/flutter/foundation/Key-class.html
"Keys must be unique amongst the Elements with the same parent."
There was a problem hiding this comment.
@PixelToast However, this change not an equivalent modification, and change the behavior of fault tolerance.
Can we consider producing globalkey only through state and index,so that we don't have to force developers to provide the widget with a key.
There was a problem hiding this comment.
@xu-baolin No problem!
This PR is for fixing a specific bug and not extending functionality though, you are welcome to open a new issue to discuss any changes to functionality that would be nice to have.
|
Nice work on the tests! |
…balKey, Add subKey and state property
|
@Hixie When ReorderableListView was first built, the order could be changed, but an error occurred after pressing the refresh button. After a bug occurs, the order cannot be changed and the state is initialized. I would like to ask your advice. example codeimport 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(title: 'ReorderableListView issue demo',),
);
}
}
class MyHomePage extends StatefulWidget {
final String title;
MyHomePage({Key key, this.title}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<int> data = List.generate(10, (index) => index);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [_buildRefreshBtn()],
),
body: Center(
child: ReorderableListView(
children: _buildTiles(),
onReorder: (_, __) {},
)
),
);
}
Widget _buildRefreshBtn() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: IconButton(
icon: Icon(Icons.refresh),
onPressed: (){setState(() {});}
),
);
}
List<Widget> _buildTiles() {
List<Widget> tiles = data.map((int val) {
return MyListTile(
key: ValueKey(val),
data: val,
title: "tile $val",
);
}).toList();
return tiles;
}
}
class MyListTile extends StatefulWidget {
final int data;
final String title;
MyListTile({this.data, this.title, Key key}): super(key: key);
@override
_MyListTileState createState() => _MyListTileState();
}
class _MyListTileState extends State<MyListTile> {
Stream<int> _msStream;
int _curMs;
@override
void initState() {
super.initState();
print("init: ${widget.title}");
_curMs = 0;
_msStream = Stream.periodic(const Duration(milliseconds: 100), (ms) => ms);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 30.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("${widget.title}"),
StreamBuilder<int>(
stream: _msStream,
initialData: _curMs,
builder: (_, AsyncSnapshot<int> snapshot) {
if (!snapshot.hasData) return Center(child: CircularProgressIndicator());
String ms = "${snapshot.data} ms";
return Text(ms);
}
)
],
),
);
}
void dispose(){
print("dispose: ${widget.title}");
super.dispose();
}
}logLaunching lib/main.dart on iPhone SE (2nd generation) in debug mode...
Running Xcode build...
Xcode build done. 9.4s
Waiting for iPhone SE (2nd generation) to report its views...
Debug service listening on ws://127.0.0.1:50703/IbMyf_8lHco=/ws
Syncing files to device iPhone SE (2nd generation)...
flutter: init: tile 0
flutter: init: tile 1
flutter: init: tile 2
flutter: init: tile 3
flutter: init: tile 4
flutter: init: tile 5
flutter: init: tile 6
flutter: init: tile 7
flutter: init: tile 8
flutter: init: tile 9
════════ Exception caught by gesture ═══════════════════════════════════════════════════════════════
The following NoSuchMethodError was thrown while handling a gesture:
The getter 'size' was called on null.
Receiver: null
Tried calling: size
When the exception was thrown, this was the stack:
#0 Object.noSuchMethod (dart:core-patch/object_patch.dart:51:5)
#1 _ReorderableListContentState._wrap.onDragStarted.<anonymous closure> (package:flutter/src/material/reorderable_list.dart:379:66)
#2 State.setState (package:flutter/src/widgets/framework.dart:1244:30)
#3 _ReorderableListContentState._wrap.onDragStarted (package:flutter/src/material/reorderable_list.dart:373:7)
#4 _DraggableState._startDrag (package:flutter/src/widgets/drag_target.dart:425:27)
...
Handler: "onStart"
Recognizer: DelayedMultiDragGestureRecognizer#96287
════════════════════════════════════════════════════════════════════════════════════════════════════
flutter: init: tile 0
flutter: dispose: tile 0
════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
'package:flutter/src/widgets/framework.dart': Failed assertion: line 175 pos 16: 'element.widget.runtimeType != _registry[this].widget.runtimeType': is not true.
════════════════════════════════════════════════════════════════════════════════════════════════════
════════ Exception caught by gesture ═══════════════════════════════════════════════════════════════
The following NoSuchMethodError was thrown while handling a gesture:
The getter 'size' was called on null.
Receiver: null
Tried calling: size
When the exception was thrown, this was the stack:
#0 Object.noSuchMethod (dart:core-patch/object_patch.dart:51:5)
#1 _ReorderableListContentState._wrap.onDragStarted.<anonymous closure> (package:flutter/src/material/reorderable_list.dart:379:66)
#2 State.setState (package:flutter/src/widgets/framework.dart:1244:30)
#3 _ReorderableListContentState._wrap.onDragStarted (package:flutter/src/material/reorderable_list.dart:373:7)
#4 _DraggableState._startDrag (package:flutter/src/widgets/drag_target.dart:425:27)
...
Handler: "onStart"
Recognizer: DelayedMultiDragGestureRecognizer#b9436
════════════════════════════════════════════════════════════════════════════════════════════════════
flutter: init: tile 2
flutter: dispose: tile 2
════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
'package:flutter/src/widgets/framework.dart': Failed assertion: line 175 pos 16: 'element.widget.runtimeType != _registry[this].widget.runtimeType': is not true.
════════════════════════════════════════════════════════════════════════════════════════════════════
════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
Duplicate GlobalKey detected in widget tree.
════════════════════════════════════════════════════════════════════════════════════════════════════
|
If the exception occurs during build then you're probably ending up with this widget in an inconsistent state. The solution is to make the code not throw the first exception (it wasn't clear to me if the first exception you're seeing is from the framework/this widget or if it's from user code). |
|
@Hixie |
|
@Hixie In the example, each time Here's how I did it. final List<Key> keyValues = List.generate(10, (index) => ValueKey(index));List<Widget> _buildTiles() {
List<Widget> tiles = data.map((int val) {
return MyListTile(
key: keyValues[val],
data: val,
title: "tile $val",
);
}).toList();
return tiles;
}No other problems occurred after I tested it. I would be grateful if you could review my PR again. |
…reorderable_list_view
…reorderable_list_view
| /// The difference with GlobalObjectKey is that it uses [==] instead of [identical] | ||
| /// of the objects used to generate widgets. | ||
| @optionalTypeArgs | ||
| class _ReorderableListViewChildGlobalKey<T extends State<StatefulWidget>> extends GlobalObjectKey<T> { |
There was a problem hiding this comment.
you don't need to make this generic, since you don't use the T in the implementation
There was a problem hiding this comment.
Thank you! I updated the code.
|
Patch looks good, we should get a second reviewer just to double check, but otherwise I think it's good. Which example was it you said had problems? |
|
Here is the example code. I agree. It's a good idea to find a second reviewer and check the code. example codeimport 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(title: 'ReorderableListView issue demo',),
);
}
}
class MyHomePage extends StatefulWidget {
final String title;
MyHomePage({Key key, this.title}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<int> data = List.generate(10, (index) => index);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [_buildRefreshBtn()],
),
body: Center(
child: ReorderableListView(
children: _buildTiles(),
onReorder: (_, __) {},
)
),
);
}
Widget _buildRefreshBtn() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: IconButton(
icon: Icon(Icons.refresh),
onPressed: (){setState(() {});}
),
);
}
List<Widget> _buildTiles() {
List<Widget> tiles = data.map((int val) {
return MyListTile(
key: ValueKey(val),
data: val,
title: "tile $val",
);
}).toList();
return tiles;
}
}
class MyListTile extends StatefulWidget {
final int data;
final String title;
MyListTile({this.data, this.title, Key key}): super(key: key);
@override
_MyListTileState createState() => _MyListTileState();
}
class _MyListTileState extends State<MyListTile> {
Stream<int> _msStream;
int _curMs;
@override
void initState() {
super.initState();
print("init: ${widget.title}");
_curMs = 0;
_msStream = Stream.periodic(const Duration(milliseconds: 100), (ms) => ms);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 30.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("${widget.title}"),
StreamBuilder<int>(
stream: _msStream,
initialData: _curMs,
builder: (_, AsyncSnapshot<int> snapshot) {
if (!snapshot.hasData) return Center(child: CircularProgressIndicator());
String ms = "${snapshot.data} ms";
return Text(ms);
}
)
],
),
);
}
void dispose(){
print("dispose: ${widget.title}");
super.dispose();
}
} |
|
|
||
| @override | ||
| int get hashCode => super.hashCode; | ||
| } |
There was a problem hiding this comment.
@Hixie Add _ReorderableListContentState as a value of the key can not solve the situation where multiple items use the same local key, they have the same _ReorderableListContentState, which will lead to Multiple widgets used the same GlobalKey, such as:
@override
Widget build(BuildContext context) {
return ReorderableListView(
children: <Widget>[ InnerTest(key: ValueKey(0)), InnerTest(key: ValueKey(0)) ],
onReorder: (_, __) {},
);
}
The reason why there is no problem here is its wrong implementation of int get hashCode => identityHashCode(subKey);
Probably we could add the index into the values together to solve this problem.
I think we should add this to the unit test case.
| } | ||
|
|
||
| @override | ||
| int get hashCode => identityHashCode(subKey); |
There was a problem hiding this comment.
If [operator ==] is overridden to use the object state instead,
the hash code must also be changed to represent that state.
In other words, Hash codes must be the same for objects that are equal to each other according to [operator ==]
There was a problem hiding this comment.
How about this way?
@override
int get hashCode => hashValues(subKey.hashCode, identityHashCode(state));There was a problem hiding this comment.
The new hash seems to work fine :)
There was a problem hiding this comment.
@override
int get hashCode => hashValues(subKey, state, index);There was a problem hiding this comment.
Do we need an index? ValueKey already has a value for the index.
| /// particular type to identify itself. | ||
| /// | ||
| /// The difference with GlobalObjectKey is that it uses [==] instead of [identical] | ||
| /// of the objects used to generate widgets. |
There was a problem hiding this comment.
I don't know much about the hash in the key. Would you explain with an example?
There was a problem hiding this comment.
Also see
/**
* The equality operator.
*
* The default behavior for all [Object]s is to return true if and
* only if `this` and [other] are the same object.
*
* Override this method to specify a different equality relation on
* a class. The overriding method must still be an equivalence relation.
* That is, it must be:
*
* * Total: It must return a boolean for all arguments. It should never throw
* or return `null`.
*
* * Reflexive: For all objects `o`, `o == o` must be true.
*
* * Symmetric: For all objects `o1` and `o2`, `o1 == o2` and `o2 == o1` must
* either both be true, or both be false.
*
* * Transitive: For all objects `o1`, `o2`, and `o3`, if `o1 == o2` and
* `o2 == o3` are true, then `o1 == o3` must be true.
*
* The method should also be consistent over time,
* so whether two objects are equal should only change
* if at least one of the objects was modified.
*
* If a subclass overrides the equality operator it should override
* the [hashCode] method as well to maintain consistency.
*/
external bool operator ==(Object other);
/**
* The hash code for this object.
*
* A hash code is a single integer which represents the state of the object
* that affects [operator ==] comparisons.
*
* All objects have hash codes.
* The default hash code represents only the identity of the object,
* the same way as the default [operator ==] implementation only considers objects
* equal if they are identical (see [identityHashCode]).
*
* If [operator ==] is overridden to use the object state instead,
* the hash code must also be changed to represent that state.
*
* Hash codes must be the same for objects that are equal to each other
* according to [operator ==].
* The hash code of an object should only change if the object changes
* in a way that affects equality.
* There are no further requirements for the hash codes.
* They need not be consistent between executions of the same program
* and there are no distribution guarantees.
*
* Objects that are not equal are allowed to have the same hash code,
* it is even technically allowed that all instances have the same hash code,
* but if clashes happen too often, it may reduce the efficiency of hash-based
* data structures like [HashSet] or [HashMap].
*
* If a subclass overrides [hashCode], it should override the
* [operator ==] operator as well to maintain consistency.
*/
external int get hashCode;
There was a problem hiding this comment.
Thank you very much 👍
There was a problem hiding this comment.
@xu-baolin @haeseoklee
This repo uses the triple slash for documentation comments, see the style guide: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#use-for-public-quality-private-documentation
There was a problem hiding this comment.
@xu-baolin @PixelToast
It looks better to modify it with //. Thank you for your guidance.
chunhtai
left a comment
There was a problem hiding this comment.
Change looks reasonable. Added a comment about test
| expect(findState(const Key('A')).checked, true); | ||
| }); | ||
|
|
||
| testWidgets('Preserves children states when rebuilt', (WidgetTester tester) async { |
There was a problem hiding this comment.
Is this the same test as previous one?
There was a problem hiding this comment.
Yes. One is for''vertical mode" and the other is for "horizontal mode"
There was a problem hiding this comment.
I see, but then you will need to set scrollDirection to vertical in this test, right?
There was a problem hiding this comment.
That's right. I'll apply it right away. 👍
|
@chunhtai |
I think we need to set the scrollDirection to the test, I also replied to the comment |
|
@chunhtai |
|
I'm looking forward to this. Which Flutter version will contain this fix? |
Description
GlobalObjectKey used in the child of ReorderableListView does not work well for key comparison.
I solved the problem by assigning a new Key called GlobalObjectValueKey.
It compares values within Objects rather than using identical.
example code
Here is a simplified reproduction written by @Hixie
example code2
Related Issues
Fixes #58364
Tests
I added the following tests:
348475c
I referred to the code in the issue written by PixelToast. Thanks to @PixelToast.
This test can check whether the child key has changed when the widget is rebuilt.
Checklist
Before you create this PR, confirm that it meets all requirements listed below by checking the relevant checkboxes (
[x]). This will ensure a smooth and quick review process.///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Did any tests fail when you ran them? Please read Handling breaking changes.