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
297 stars 19 forks source link

Error: "'index >= 0 && flutter: index < _extents.length': is not true." #42

Closed kylianSalomon closed 8 months ago

kylianSalomon commented 8 months ago

I got this error during build of a SuperSliverList :

flutter: ══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
flutter: The following assertion was thrown during performLayout():
flutter: 'package:super_sliver_list/src/extent_list.dart': Failed assertion: line 143 pos 12: 'index >= 0 &&
flutter: index < _extents.length': is not true.
flutter:
flutter: The relevant error-causing widget was:
flutter:   SuperSliverList
flutter:   SuperSliverList:file://.../lib/src/widgets/paginated_sliver_list_view.dart:58:12
flutter:
flutter: When the exception was thrown, this was the stack:
flutter: #2      ExtentList.[] (package:super_sliver_list/src/extent_list.dart:143:12)
flutter: #3      ExtentManager.setExtent (package:super_sliver_list/src/extent_manager.dart:32:39)
flutter: #4      RenderSuperSliverList.layoutChild (package:super_sliver_list/src/render_object.dart:298:20)
flutter: #5      RenderSuperSliverList._performLayoutInner (package:super_sliver_list/src/render_object.dart:540:24)
flutter: #6      ExtentManager.performLayout (package:super_sliver_list/src/extent_manager.dart:108:13)
flutter: #7      RenderSuperSliverList.performLayout (package:super_sliver_list/src/render_object.dart:409:20)
flutter: #8      RenderObject.layout (package:flutter/src/rendering/object.dart:2546:7)
flutter: #9      RenderSuperSliverList.layout (package:super_sliver_list/src/render_object.dart:414:11)
flutter: #10     RenderSliverEdgeInsetsPadding.performLayout (package:flutter/src/rendering/sliver_padding.dart:139:12)
flutter: #11     RenderSliverPadding.performLayout (package:flutter/src/rendering/sliver_padding.dart:361:11)
flutter: #12     RenderObject.layout (package:flutter/src/rendering/object.dart:2546:7)
flutter: #13     RenderProxySliver.performLayout (package:flutter/src/rendering/proxy_sliver.dart:54:12)
flutter: #14     RenderObject.layout (package:flutter/src/rendering/object.dart:2546:7)
flutter: #15     RenderSliverEdgeInsetsPadding.performLayout (package:flutter/src/rendering/sliver_padding.dart:139:12)
flutter: #16     RenderSliverPadding.performLayout (package:flutter/src/rendering/sliver_padding.dart:361:11)
flutter: #17     RenderObject.layout (package:flutter/src/rendering/object.dart:2546:7)
flutter: #18     RenderViewportBase.layoutChildSequence (package:flutter/src/rendering/viewport.dart:601:13)

This error happened in a search page while updating a list rendered in the SuperSliverList (the list has been updated due to search query changes). I remember having the same error with the Flutter SliverList when updating the given list but the error was something like : earliestUsefulChild !=null is not true if I remember well.

Is this a bug from the package or is it related to a bad practice dealing with sliver list ? Should I rebuild the entire SliverList widget when my list changes ?

knopp commented 8 months ago

I'd need to have a reproducible example in order to find out what is going on. It might be a bug in your code or in SuperSliverList. It's not clear from the stacktrace.

kylianSalomon commented 8 months ago

I think I managed to reproduce an interesting the error in a similar context :

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:super_sliver_list/super_sliver_list.dart';

final listProvider =
    FutureProvider.family<List<String>, String>((ref, query) async {
  await Future.delayed(const Duration(seconds: 2));

  if (query.isNotEmpty) {
    return List.generate(200, (index) => 'Query result $query $index');
  }

  return List.generate(100, (index) => 'Item $index');
});

void main() {
  runApp(const ProviderScope(child: MaterialApp(home: MainApp())));
}

class MainApp extends HookWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    final textController = useTextEditingController();

    useListenable(textController);

    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (context, hasScrolled) {
          return [
            SliverAppBar(
              pinned: true,
              title: TextField(
                controller: textController,
              ),
            )
          ];
        },
        body: CustomScrollView(
          slivers: [
            PaginatedSliverListView(
              limit: 10,
              providerListBuilder: listProvider(textController.text),
              listCount: textController.text.isEmpty ? 100 : 200,
            )
          ],
        ),
      ),
    );
  }
}

class PaginatedSliverListView<T> extends ConsumerWidget {
  const PaginatedSliverListView({
    super.key,
    required this.limit,
    required this.providerListBuilder,
    required this.listCount,
  });

  final int limit;
  final ProviderListenable<AsyncValue<List<T>>> providerListBuilder;
  final int listCount;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return SuperSliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          final itemIndexInPage = index % limit;

          final list = ref.watch(providerListBuilder);

          return list.when(
            loading: () {
              if (itemIndexInPage == 0) {
                return const Center(child: CircularProgressIndicator());
              }

              return null;
            },
            data: (list) {
              if (index == 0 && list.isEmpty) {
                return const Text('no result');
              }
              if (itemIndexInPage < list.length) {
                return ListTile(
                  title: Text(list[itemIndexInPage].toString()),
                );
              }

              return null;
            },
            error: (error, __) {
              if (index == 0) {
                return const Text('An error occurred');
              }
              return null;
            },
          );
        },
        childCount: listCount,
      ),
    );
  }
}

This example needs flutter_riverpod and flutter_hooks packages to work (sorry for those dependencies, I tried to make a quick example and I'm used to develop with this packages).

To reproduce the error you can let the first list loads, then scroll to the bottom of the page and type something in the TextField in the AppBar. You should get an error like this :

Unexpected trailing child.
'package:super_sliver_list/src/render_object.dart':
Failed assertion: line 532 pos 18: 'newChild != null'

This is not exactly the same error but I found that this is due to the return null; in the loading state where a null return in a list view should stop the list view for being built.

As a result, I'm no longer sure that those errors are related to your packages but may be the way I implemented the SuperSliverList.

knopp commented 8 months ago

You both a) specify childCount to SliverChildBuilderDelegate b) return null from the build method

The documentation of SliverChildBuilderDelegate does not specify how that should be handled, but I can imagine this can be causing problems. I'd recommend always setting childCount to actual items in the list.

The ref.watch should probably be outside of the delegate.

kylianSalomon commented 8 months ago

I used a simplified version here for testing but normally I pass to the listProvider the current page value calculated from the sliver index and a arbitrary limit. The purpose is to create a lazy list view with paginated data.

So if I understand well your previous message, I should either set the childCount, either return null when dealing with SliverChildBuilderDelegate ?

In my perspective, specifying the childCount when it is possible was a way of reducing performance cost in infinite list. But I'm maybe wrong.

knopp commented 8 months ago

If childCount is not specified, SliverMultiBoxAdaptorElement will determine child count using binary search by calling the childBuilder repeatedly seeing which indices return null. I'd not recommend using this if you have any way to actually determine child count (which you seem to do).

However when childCount is specified, as it is in your case, the build must be able to return valid child for any index between 0 and childCount-1, which is seems to be where your code fails.

If you have lazy list with paginated data, and the list loads more data when scrolled to end, then the childCount should be number of loaded items, not number of total (not loaded yet) items.

kylianSalomon commented 8 months ago

Ok I see now where I have probably made an error : instead of returning null in the loading state to give the information that I no longer want the sliverList to render any widget, I should return a SizedBox.shrink() because the SliverMultiBoxAdaptorElement expect the same amount of items regardless of the state for a given childCount.

knopp commented 8 months ago

That is not a very good solution, as you'll end up materializing elements unecessarily. The correct solution is for childCount to always reflect the exact number of actually available items.

kylianSalomon commented 8 months ago

You're right, I replace the child count value by 1 when my state is loading and I no longer get the error.

Thanks for the advice and for the package ! 💪