Closed iosephmagno closed 5 months ago
If you want to preserve state during reordering you should implement findChildIndexCallback on SliverChildBuilderDelegate
.
Thx @knopp.
I quick coded it but findChildIndexCallback
is never being fired.
Might it be an issue related to SuperList? Can you please quick run it?
import 'package:flutter/material.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:equatable/equatable.dart';
/// A sample code to showcase a common use case by which
/// List might be increased dynamically (by adding a new element)
/// and we should find a way to avoid rebuilding entire list elements.
void main() => runApp(const CustomScrollViewExampleApp());
class CustomScrollViewExampleApp extends StatelessWidget {
const CustomScrollViewExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CustomScrollViewExample(),
);
}
}
class CustomScrollViewExample extends StatefulWidget {
const CustomScrollViewExample({super.key});
@override
State<CustomScrollViewExample> createState() =>
_CustomScrollViewExampleState();
}
class _CustomScrollViewExampleState extends State<CustomScrollViewExample> {
ListController listController = ListController();
ScrollController get scrollController => ScrollController();
// Populate the list with 30 items
List<Item> items = List<Item>.generate(3, (i) => Item(name:'Message $i', id:'$i'));
/// Scroll List to the bottom
void _scrollToBottom() {
listController.animateToItem(index: 0, duration: (estimatedDistance) {
return const Duration(milliseconds: 300);
}, curve: (estimatedDistance) {
return Curves.easeIn;
}, scrollController: scrollController, alignment: 1.0,);
}
@override
Widget build(BuildContext context) {
const Key centerKey = ValueKey<String>('bottom-sliver-list');
return Scaffold(
appBar: AppBar(
title: const Text('Press on the plus to add items above and below'),
leading: IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(Item(name: "Message ${items.length.toString()}", id: items.length.toString()));
});
_scrollToBottom();
},
),
),
body: CustomScrollView(
controller: scrollController,
center: centerKey,
cacheExtent: 0,
reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
semanticChildCount: items.length,
slivers: <Widget>[
SuperSliverList(
key: centerKey,
listController: listController,
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[items.length -1 - index];
// LOGS section
debugPrint("SliverList item $index rebulding");
debugPrint("items index ${items[index]}");
debugPrint("item ${item}");
return Container(
alignment: Alignment.center,
color: Colors.blue[200 + int.parse(items[index].id) % 4 * 100],
height: 100 + int.parse(items[index].id) % 4 * 20.0,
child: StatelesItem(
key: ValueKey(item.id),
item: item
),
);
},
childCount: items.length,
findChildIndexCallback: (key) {
debugPrint("findChildIndexCallback invoked $key");
final valueKey = key as ValueKey<String>;
final val = items.indexWhere((i) => i.id == valueKey.value);
return items.length - 1 - val;
},
),
),
],
),
);
}
}
class StatelesItem extends StatelessWidget {
final Item item;
const StatelesItem({Key? key, required this.item}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
item.name,
);
}
}
class Item {
final String name;
final String id;
const Item({required this.name, required this.id});
}
findChildIndexCallback
is not invoked in your case because the top level widget returned from builder doesn't have a key. The build method will always be invoked but that doesn't really mean anything. The important thing is to preserve state. Modified example:
import 'package:flutter/material.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
/// A sample code to showcase a common use case by which
/// List might be increased dynamically (by adding a new element)
/// and we should find a way to avoid rebuilding entire list elements.
void main() => runApp(const CustomScrollViewExampleApp());
class CustomScrollViewExampleApp extends StatelessWidget {
const CustomScrollViewExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CustomScrollViewExample(),
);
}
}
class CustomScrollViewExample extends StatefulWidget {
const CustomScrollViewExample({super.key});
@override
State<CustomScrollViewExample> createState() =>
_CustomScrollViewExampleState();
}
class _CustomScrollViewExampleState extends State<CustomScrollViewExample> {
ListController listController = ListController();
ScrollController get scrollController => ScrollController();
// Populate the list with 30 items
List<Item> items =
List<Item>.generate(3, (i) => Item(name: 'Message $i', id: '$i'));
/// Scroll List to the bottom
void _scrollToBottom() {
listController.animateToItem(
index: 0,
duration: (estimatedDistance) {
return const Duration(milliseconds: 300);
},
curve: (estimatedDistance) {
return Curves.easeIn;
},
scrollController: scrollController,
alignment: 1.0,
);
}
@override
Widget build(BuildContext context) {
const Key centerKey = ValueKey<String>('bottom-sliver-list');
return Scaffold(
appBar: AppBar(
title: const Text('Press on the plus to add items above and below'),
leading: IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(Item(
name: "Message ${items.length.toString()}",
id: items.length.toString()));
});
_scrollToBottom();
},
),
),
body: CustomScrollView(
controller: scrollController,
center: centerKey,
cacheExtent: 0,
reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
semanticChildCount: items.length,
slivers: <Widget>[
SuperSliverList(
key: centerKey,
listController: listController,
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[items.length - 1 - index];
return StatefulItem(
item: item,
key: ValueKey(item.id),
);
},
childCount: items.length,
findChildIndexCallback: (key) {
debugPrint("findChildIndexCallback invoked $key");
final valueKey = key as ValueKey<String>;
final val = items.indexWhere((i) => i.id == valueKey.value);
return items.length - 1 - val;
},
),
),
],
),
);
}
}
class StatefulItem extends StatefulWidget {
const StatefulItem({
super.key,
required this.item,
});
final Item item;
@override
State<StatefulItem> createState() => _StatefulItemState();
}
class _StatefulItemState extends State<StatefulItem> {
@override
void initState() {
super.initState();
debugPrint("StatefulItem ${widget.item.id} initState");
}
@override
Widget build(BuildContext context) {
return Container(
key: ValueKey(widget.item.id),
alignment: Alignment.center,
color: Colors.blue[200 + int.parse(widget.item.id) % 4 * 100],
height: 100 + int.parse(widget.item.id) % 4 * 20.0,
child: StatelesItem(key: ValueKey(widget.item.id), item: widget.item),
);
}
}
class StatelesItem extends StatelessWidget {
final Item item;
const StatelesItem({Key? key, required this.item}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
item.name,
);
}
}
class Item {
final String name;
final String id;
const Item({required this.name, required this.id});
}
As you add items you will see in the log that initState
is only invoked on the newly added items.
Thx! Funny part, I did code the stateful version but forgot to declare key. However, I noticed that build() is still called for all items. Am I missing something here?
Here is your last code + extra log.
import 'package:flutter/material.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
/// A sample code to showcase a common use case by which
/// List might be increased dynamically (by adding a new element)
/// and we should find a way to avoid rebuilding entire list elements.
void main() => runApp(const CustomScrollViewExampleApp());
class CustomScrollViewExampleApp extends StatelessWidget {
const CustomScrollViewExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CustomScrollViewExample(),
);
}
}
class CustomScrollViewExample extends StatefulWidget {
const CustomScrollViewExample({super.key});
@override
State<CustomScrollViewExample> createState() =>
_CustomScrollViewExampleState();
}
class _CustomScrollViewExampleState extends State<CustomScrollViewExample> {
ListController listController = ListController();
ScrollController get scrollController => ScrollController();
// Populate the list with 30 items
List<Item> items =
List<Item>.generate(3, (i) => Item(name: 'Message $i', id: '$i'));
/// Scroll List to the bottom
void _scrollToBottom() {
listController.animateToItem(
index: 0,
duration: (estimatedDistance) {
return const Duration(milliseconds: 300);
},
curve: (estimatedDistance) {
return Curves.easeIn;
},
scrollController: scrollController,
alignment: 1.0,
);
}
@override
Widget build(BuildContext context) {
const Key centerKey = ValueKey<String>('bottom-sliver-list');
return Scaffold(
appBar: AppBar(
title: const Text('Press on the plus to add items above and below'),
leading: IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(Item(
name: "Message ${items.length.toString()}",
id: items.length.toString()));
});
_scrollToBottom();
},
),
),
body: CustomScrollView(
controller: scrollController,
center: centerKey,
cacheExtent: 0,
reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
semanticChildCount: items.length,
slivers: <Widget>[
SuperSliverList(
key: centerKey,
listController: listController,
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[items.length - 1 - index];
return StatefulItem(
item: item,
key: ValueKey(item.id),
);
},
childCount: items.length,
findChildIndexCallback: (key) {
debugPrint("findChildIndexCallback invoked $key");
final valueKey = key as ValueKey<String>;
final val = items.indexWhere((i) => i.id == valueKey.value);
return items.length - 1 - val;
},
),
),
],
),
);
}
}
class StatefulItem extends StatefulWidget {
const StatefulItem({
super.key,
required this.item,
});
final Item item;
@override
State<StatefulItem> createState() => _StatefulItemState();
}
class _StatefulItemState extends State<StatefulItem> {
@override
void initState() {
super.initState();
debugPrint("StatefulItem ${widget.item.id} initState");
}
@override
Widget build(BuildContext context) {
debugPrint("StatefulItem ${widget.item.id} rebuild");
return Container(
key: ValueKey(widget.item.id),
alignment: Alignment.center,
color: Colors.blue[200 + int.parse(widget.item.id) % 4 * 100],
height: 100 + int.parse(widget.item.id) % 4 * 20.0,
child: StatelesItem(key: ValueKey(widget.item.id), item: widget.item),
);
}
}
class StatelesItem extends StatelessWidget {
final Item item;
const StatelesItem({Key? key, required this.item}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
item.name,
);
}
}
class Item {
final String name;
final String id;
const Item({required this.name, required this.id});
}
That is expected. SliverMultiBoxAdaptorElement
(used internally by both SuperSliverList and SliverList) will update all active items on rebuild, meaning it will call the delegate build method, which creates new widget instance, which then updates the stateful element with the new widget instance.
As long as you don't change the widget hierarchy during rebuild it should be very cheap (because the element tree where the actual state is stays the same). You could remove the rebuild by caching the actual widget instance returned from builder but I'd profile first to see if that makes any difference (probably doesn't).
As long as you don't change the widget hierarchy during rebuild it should be very cheap (because the element tree where the actual state is stays the same). You could remove the rebuild by caching the actual widget instance returned from builder but I'd profile first to see if that makes any difference (probably doesn't).
Can you showcase me this other trick with the sample code above?
The code below reuses widget instance which prevents the rebuild.
import 'package:flutter/material.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
/// A sample code to showcase a common use case by which
/// List might be increased dynamically (by adding a new element)
/// and we should find a way to avoid rebuilding entire list elements.
void main() => runApp(const CustomScrollViewExampleApp());
class CustomScrollViewExampleApp extends StatelessWidget {
const CustomScrollViewExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CustomScrollViewExample(),
);
}
}
class CustomScrollViewExample extends StatefulWidget {
const CustomScrollViewExample({super.key});
@override
State<CustomScrollViewExample> createState() =>
_CustomScrollViewExampleState();
}
class _CustomScrollViewExampleState extends State<CustomScrollViewExample> {
ListController listController = ListController();
ScrollController get scrollController => ScrollController();
// Populate the list with 30 items
List<Item> items =
List<Item>.generate(3, (i) => Item(name: 'Message $i', id: '$i'));
final widgets = <String, Widget>{};
/// Scroll List to the bottom
void _scrollToBottom() {
listController.animateToItem(
index: 0,
duration: (estimatedDistance) {
return const Duration(milliseconds: 300);
},
curve: (estimatedDistance) {
return Curves.easeIn;
},
scrollController: scrollController,
alignment: 1.0,
);
}
@override
Widget build(BuildContext context) {
const Key centerKey = ValueKey<String>('bottom-sliver-list');
return Scaffold(
appBar: AppBar(
title: const Text('Press on the plus to add items above and below'),
leading: IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(Item(
name: "Message ${items.length.toString()}",
id: items.length.toString()));
});
_scrollToBottom();
},
),
),
body: CustomScrollView(
controller: scrollController,
center: centerKey,
cacheExtent: 0,
reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
semanticChildCount: items.length,
slivers: <Widget>[
SuperSliverList(
key: centerKey,
listController: listController,
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[items.length - 1 - index];
final res = widgets.putIfAbsent(
item.id,
() => StatefulItem(
item: item,
key: ValueKey(item.id),
),
);
return res;
},
childCount: items.length,
findChildIndexCallback: (key) {
// debugPrint("findChildIndexCallback invoked $key");
final valueKey = key as ValueKey<String>;
final val = items.indexWhere((i) => i.id == valueKey.value);
return items.length - 1 - val;
},
),
),
],
),
);
}
}
class StatefulItem extends StatefulWidget {
const StatefulItem({
super.key,
required this.item,
});
final Item item;
@override
State<StatefulItem> createState() => _StatefulItemState();
}
class _StatefulItemState extends State<StatefulItem> {
@override
void initState() {
super.initState();
debugPrint("StatefulItem ${widget.item.id} initState");
}
@override
Widget build(BuildContext context) {
print('Rebuilding ${widget.item.id}');
return Container(
key: ValueKey(widget.item.id),
alignment: Alignment.center,
color: Colors.blue[200 + int.parse(widget.item.id) % 4 * 100],
height: 100 + int.parse(widget.item.id) % 4 * 20.0,
child: StatelesItem(key: ValueKey(widget.item.id), item: widget.item),
);
}
}
class StatelesItem extends StatelessWidget {
final Item item;
const StatelesItem({Key? key, required this.item}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
item.name,
);
}
}
class Item {
final String name;
final String id;
const Item({required this.name, required this.id});
}
Thx for the help, it works perfectly.
For those who may read this thread, I just remind below that cache must be cleared and/or specific items removed when state is edited.
In a real case scenario, it would be something like that
static final messageListWidgets = <String, Widget>{};
/// Remove a widget from messageWidgets
/// Invoke this if widget state gets updated and widget must be rebuilt.
static void removeFromMessageListWidgets(String messageId) {
messageListWidgets.removeWhere((key, value) =>
(value as MessageBubble).key == ValueKey(messageId));
}
/// Clear all widgets from cache
/// This is required when user leaves the screen or app is paused.
static void clearMessageListWidgtes() {
messageListWidgets.clear();
}
Hello @knopp thx for this amazing plugin. We tested it for a while and eventually adopted it in our product.
We are thinking about how to address a specific use case by which an item is added to the List after the List has been rendered and we should avoid to rebuild entire List's elements.
This use case is common with chat screen and userfeed, where the List is reversed. Please have a look at below sample.
We add 30 elements to the List with
reverse: true
. User can tap the + icon to add the new element (item 31) which will be placed at the bottom of the list.The problem is that all the elements of the List get rebuilt (coz index changed) and we should avoid this behaviour. Is there a way to achieve this requirement by changing our code or adding a new feature to SuperList? Can we maybe instruct SuperList to rebuild "only the new added element" in this very event?