superlistapp / super_sliver_list

Drop-in replacement for SliverList and ListView that can handle large amount of items with variable extents and reliably jump / animate to any item.
https://superlistapp.github.io/super_sliver_list/
MIT License
277 stars 15 forks source link

[feat. request] Avoid rebuilding entire List when a single new item is added #52

Closed iosephmagno closed 5 months ago

iosephmagno commented 5 months ago

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.

Screenshot_2024-03-27-12-02-38-415_dev henryleunghk flutter_native_text_input_example

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?

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 size can 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<String> items = List<String>.generate(31, (i) => '$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.insert(0, 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, int index) {
                // LOGs section
                debugPrint("SliverList item $index rebulding");
                debugPrint("items index  ${items[index]}");
                final item = Item(name:'Item: ${items[index]})');
                // End log
                debugPrint("item ${item}");
                return Container(
                  alignment: Alignment.center,
                  color: Colors.blue[200 + int.parse(items[index]) % 4 * 100],
                  height: 100 + int.parse(items[index]) % 4 * 20.0,

                  // Option 1: we use a StatefulItem + Item + Equatable
                  // To try avoiding the rebuild of entire list when adding
                  // a new item
                  child: StatefulItem(item: item),

                  // Uncomment this to test Option 2
                  // child: Item(name:'Item: ${items[index]}'),

                );
              },
              childCount: items.length,
            ),
          ),
        ],
      ),
    );
  }
}

// Option 1: Use StatefulItem + Item + Equatable
// Shouldn't this inform flutter that widget hasn't changed
// and rebuild is not required ?
class StatefulItem extends StatefulWidget {
  final Item item;
  const StatefulItem({Key? key, required this.item}) : super(key: key);

  @override
  State<StatefulItem> createState() => _StatefulItemState();
}

class _StatefulItemState extends State<StatefulItem> with AutomaticKeepAliveClientMixin  {

  @override
  Widget build(BuildContext context) {
    return Text(
      widget.item.name,
    );
  }

  @override
  bool get wantKeepAlive => true;

}

// Item extends Equatable
class Item extends Equatable {
  final String name;

  const Item({required this.name});

  @override
  List<Object?> get props => [name];
}

/*
// Option 2: Simple Stateful item

class Item extends StatefulWidget {
  final String name;
  const Item({Key? key, required this.name}) : super(key: key);

  @override
  State<Item> createState() => _ItemState();
}

class _ItemState extends State<Item> with AutomaticKeepAliveClientMixin  {
  @override
  Widget build(BuildContext context) {
    return Text(
      widget.name,
    );
  }
  @override
  bool get wantKeepAlive => true;
}
*/
knopp commented 5 months ago

If you want to preserve state during reordering you should implement findChildIndexCallback on SliverChildBuilderDelegate.

iosephmagno commented 5 months ago

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});
}
knopp commented 5 months ago

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.

iosephmagno commented 5 months ago

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?

Screenshot 2024-03-27 at 19 20 32

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});
}
knopp commented 5 months ago

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).

iosephmagno commented 5 months ago

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?

knopp commented 5 months ago

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});
}
iosephmagno commented 5 months ago

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();
  }