diff --git a/CHANGELOG.md b/CHANGELOG.md index 180372d..7975903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.27.2 +- ListViewObserver + - Support `SliverPrototypeExtentList` (`prototypeItem` of `ListView` and `ReorderableListView`) by @LinXunFeng in [#158](https://github.com/fluttercandies/flutter_scrollview_observer/issues/158). + ## 1.27.1 - ObserverController - Correct the scrolling clamped by an outdated `scrollExtent` by @LinXunFeng in [#150](https://github.com/fluttercandies/flutter_scrollview_observer/issues/150). diff --git a/example/lib/common/route/router_config.dart b/example/lib/common/route/router_config.dart index 205bd34..dc4cef5 100644 --- a/example/lib/common/route/router_config.dart +++ b/example/lib/common/route/router_config.dart @@ -26,6 +26,7 @@ import 'package:scrollview_observer_example/features/listview/listview_custom_de import 'package:scrollview_observer_example/features/listview/listview_demo/listview_demo_page.dart'; import 'package:scrollview_observer_example/features/listview/listview_dynamic_offset/listview_dynamic_offset_page.dart'; import 'package:scrollview_observer_example/features/listview/listview_fixed_height_demo/listview_fixed_height_demo_page.dart'; +import 'package:scrollview_observer_example/features/listview/reorderable_listview_demo/reorderable_listview_demo_page.dart'; import 'package:scrollview_observer_example/features/listview/sliver_list_demo/sliver_list_demo_page.dart'; import 'package:scrollview_observer_example/features/nested_scrollview/nested_scrollview_demo/nested_scrollview_demo_page.dart'; import 'package:scrollview_observer_example/features/nested_scrollview/nested_scrollview_tab_bar_view_demo/page/nested_scrollview_tab_bar_view_demo_page.dart'; @@ -57,6 +58,7 @@ class MyPage { static const listViewDynamicOffset = '/list_view_dynamic_offset'; static const listViewCustom = '/list_view_custom'; static const listViewInfinite = '/list_view_infinite'; + static const listViewReorderable = '/list_view_reorderable'; static const sliverListView = '/sliver_list_view'; // GridView static const gridView = '/grid_view'; @@ -141,6 +143,10 @@ class MyRoute { path: MyPage.listViewInfinite, builder: (context, state) => const InfiniteListViewPage(), ), + GoRoute( + path: MyPage.listViewReorderable, + builder: (context, state) => const ReorderableListViewDemoPage(), + ), GoRoute( path: MyPage.sliverListView, builder: (context, state) => const SliverListViewDemoPage(), diff --git a/example/lib/features/home/home_page.dart b/example/lib/features/home/home_page.dart index 238d1b0..2695dd1 100644 --- a/example/lib/features/home/home_page.dart +++ b/example/lib/features/home/home_page.dart @@ -63,6 +63,12 @@ class HomePage extends StatelessWidget { NavigationService.push(MyPage.listViewInfinite); }, ), + ListTile( + title: const Text("ListView - Reorderable"), + onTap: () { + NavigationService.push(MyPage.listViewReorderable); + }, + ), ListTile( title: const Text("SliverListView"), onTap: () { diff --git a/example/lib/features/listview/reorderable_listview_demo/reorderable_listview_demo_page.dart b/example/lib/features/listview/reorderable_listview_demo/reorderable_listview_demo_page.dart new file mode 100644 index 0000000..f95a28e --- /dev/null +++ b/example/lib/features/listview/reorderable_listview_demo/reorderable_listview_demo_page.dart @@ -0,0 +1,167 @@ +/* + * @Author: LinXunFeng linxunfeng@yeah.net + * @Repo: https://github.com/fluttercandies/flutter_scrollview_observer + * @Date: 2026-09-13 16:00:00 + */ + +import 'package:flutter/material.dart'; +import 'package:scrollview_observer/scrollview_observer.dart'; +import 'package:scrollview_observer_example/utils/snackbar.dart'; + +class ReorderableListViewDemoPage extends StatefulWidget { + const ReorderableListViewDemoPage({Key? key}) : super(key: key); + + @override + State createState() => + _ReorderableListViewDemoPageState(); +} + +class _ReorderableListViewDemoPageState + extends State { + final items = List.generate(100, (index) => index); + + int _hitIndex = 0; + + /// Whether to use the prototypeItem of [ReorderableListView]. + bool _usePrototypeItem = false; + + ScrollController scrollController = ScrollController(); + + late ListObserverController observerController; + + @override + void initState() { + super.initState(); + + observerController = ListObserverController(controller: scrollController); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget resultWidget = _buildListView(); + resultWidget = ListViewObserver( + child: resultWidget, + controller: observerController, + onObserve: (resultModel) { + setState(() { + _hitIndex = resultModel.firstChild?.index ?? 0; + }); + }, + ); + resultWidget = Scaffold( + appBar: _buildAppBar(), + body: resultWidget, + floatingActionButton: _buildJumpButton(), + ); + return resultWidget; + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + title: const Text("ReorderableListView"), + actions: [ + _buildPrototypeItemSwitch(), + ], + ); + } + + Widget _buildPrototypeItemSwitch() { + final prototypeItemSwitch = Switch( + value: _usePrototypeItem, + onChanged: (value) => _onPrototypeItemChanged(value: value), + ); + Widget resultWidget = Row( + children: [ + const Text('prototypeItem'), + prototypeItemSwitch, + ], + ); + return resultWidget; + } + + Widget _buildJumpButton() { + Widget resultWidget = const Icon(Icons.airline_stops_outlined); + resultWidget = FloatingActionButton( + child: resultWidget, + onPressed: () => _onJumpToItem(item: 50), + ); + return resultWidget; + } + + Widget _buildListView() { + return ReorderableListView.builder( + // It must be the same as the controller of ListObserverController. + scrollController: scrollController, + prototypeItem: _usePrototypeItem ? const SizedBox(height: 80) : null, + itemBuilder: (ctx, index) { + return _buildListItemView(index: index); + }, + itemCount: items.length, + onReorderItem: (oldIndex, newIndex) => _onReorderItem( + oldIndex: oldIndex, + newIndex: newIndex, + ), + ); + } + + Widget _buildListItemView({required int index}) { + final item = items[index]; + final isHit = _hitIndex == index; + Widget resultWidget = Text( + "item -- $item, index -- $index", + style: TextStyle( + color: isHit ? Colors.white : Colors.black, + ), + ); + resultWidget = Center(child: resultWidget); + resultWidget = Container( + margin: const EdgeInsets.only(bottom: 5), + color: isHit ? Colors.red : Colors.black12, + child: resultWidget, + ); + resultWidget = SizedBox( + // The key is required by ReorderableListView. + key: ValueKey(item), + height: _usePrototypeItem || item.isEven ? 80 : 50, + child: resultWidget, + ); + return resultWidget; + } + + void _onPrototypeItemChanged({required bool value}) { + setState(() { + _usePrototypeItem = value; + }); + // The type of the sliver will be changed, so the sliver context needs to + // be re-recorded. + observerController.reattach(); + } + + void _onJumpToItem({required int item}) { + // The index of the item would be changed after reordering. + final index = items.indexOf(item); + SnackBarUtil.showSnackBar( + context: context, + text: 'Jump to item $item, index $index', + ); + observerController.jumpTo( + index: index, + isFixedHeight: _usePrototypeItem, + ); + } + + void _onReorderItem({ + required int oldIndex, + required int newIndex, + }) { + setState(() { + items.insert(newIndex, items.removeAt(oldIndex)); + }); + } +} diff --git a/lib/src/common/observer_widget.dart b/lib/src/common/observer_widget.dart index dd35c28..3bddf0e 100644 --- a/lib/src/common/observer_widget.dart +++ b/lib/src/common/observer_widget.dart @@ -83,8 +83,8 @@ class ObserverWidget testReorderableListView( + WidgetTester tester, { + bool isFixedHeight = false, + bool usePrototypeItem = false, + }) async { + final scrollController = ScrollController(); + final observerController = ListObserverController( + controller: scrollController, + ); + ListViewObserveModel? observeResult; + + Widget widget = MaterialApp( + home: ReorderableListView.builder( + scrollController: scrollController, + header: const SizedBox(height: 100), + prototypeItem: usePrototypeItem ? const SizedBox(height: 80) : null, + itemBuilder: (ctx, index) { + double height = 80; + if (!isFixedHeight) { + height = (index % 2 == 0) ? 80 : 50; + } + return SizedBox( + key: ValueKey(index), + height: height, + child: Center(child: Text("index -- $index")), + ); + }, + itemCount: 100, + // ignore: deprecated_member_use + onReorder: (oldIndex, newIndex) {}, + ), + ); + widget = ListViewObserver( + child: widget, + controller: observerController, + onObserve: (result) { + observeResult = result; + }, + ); + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + expect(observerController.sliverContexts.length, 1); + + int targeItemIndex = 30; + observerController.jumpTo( + index: targeItemIndex, + isFixedHeight: isFixedHeight, + ); + await tester.pumpAndSettle(); + await tester.pump(observerController.observeIntervalForScrolling); + expect(observeResult?.firstChild?.index, targeItemIndex); + + targeItemIndex = 60; + observerController.animateTo( + index: targeItemIndex, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + isFixedHeight: isFixedHeight, + ); + await tester.pumpAndSettle(); + await tester.pump(observerController.observeIntervalForScrolling); + expect(observeResult?.firstChild?.index, targeItemIndex); + + scrollController.dispose(); + } + + testWidgets('Dynamic height', (tester) async { + await testReorderableListView(tester); + }); + + testWidgets('Fixed height with prototypeItem', (tester) async { + await testReorderableListView( + tester, + isFixedHeight: true, + usePrototypeItem: true, + ); + }); + }); + // Regression test for https://github.com/fluttercandies/flutter_scrollview_observer/issues/123 testWidgets( 'No exception when ListViewObserverState is disposed during scrolling',