diff --git a/README.md b/README.md index b160503..ef7047a 100644 --- a/README.md +++ b/README.md @@ -45,21 +45,21 @@ import 'package:grouped_list/grouped_list.dart'; ``` ### Parameters: -| Name | Description | Required | Default value | -|----|----|----|----| -|`elements`| A list of the data you want to display in the list | required | - | -|`groupBy` |Function which maps an element to its grouped value | required | - | -|`itemBuilder` / `indexedItemBuilder` / `interdependentItemBuilder`| Function which returns an Widget which defines the item. `indexedItemBuilder` provides the current index as well. `interdependentItemBuilder` provides the previous and next items as well.`indexedItemBuilder` is preferred over `interdependentItemBuilder` and `interdependentItemBuilder` is preferred over`itemBuilder` | yes, either of them | - | -|`groupSeparatorBuilder` / `groupHeaderBuilder`| Function which returns a Widget which defines the group headers. While `groupSeparatorBuilder` gets the `groupBy`-value as parameter `groupHeaderBuilder` gets the whole element. If both are defined `groupHeaderBuilder` is preferred| yes, either of them | - | -|`groupStickyHeaderBuilder` | Function which returns a Widget which defines the sticky group header, when `useStickyGroupSeparators` is `true`. If not defined `groupSeparatorBuilder` or `groupHeaderBuilder` will be used as described above. | no | - | -|`useStickyGroupSeparators` | When set to true the group header of the current visible group will stick on top | no | `false` | -|`floatingHeader` | Whether the sticky group header float over the list or occupy it's own space | no | `false` | -|`stickyHeaderBackgroundColor` | Defines the background color of the sticky header. Will only be used if `useStickyGroupSeparators` is used | no | `Color(0xffF7F7F7)` | -|`separator` | A Widget which defines a separator between items inside a group | no | no separator | -| `groupComparator` | Can be used to define a custom sorting for the groups. Otherwise the natural sorting order is used | no | - | -| `itemComparator` | Can be used to define a custom sorting for the elements inside each group. Otherwise the natural sorting order is used | no | - | -| `order` | Change to `GroupedListOrder.DESC` to reverse the group sorting | no | `GroupedListOrder.ASC` | -| `footer` | Widget at the bottom of the list | no | - | +| Name | Description | Required | Default value | +|----------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----|----| +| `elements` | A list of the data you want to display in the list | required | - | +| `groupBy` | Function which maps an element to its grouped value | required | - | +| `itemBuilder` / `indexedItemBuilder` / `interdependentItemBuilder` / `grouItemBuilder` | Function which returns an Widget which defines the item.
* `indexedItemBuilder` provides the current index as well.
* `interdependentItemBuilder` provides the previous and next items as well.
* `groupItemBuilder` provides information if the item is the first or last element inside a group.
`indexedItemBuilder` is preferred over `interdependentItemBuilder` and `interdependentItemBuilder` is preferred over`itemBuilder`. | yes, either of them | - | +| `groupSeparatorBuilder` / `groupHeaderBuilder` | Function which returns a Widget which defines the group headers. While `groupSeparatorBuilder` gets the `groupBy`-value as parameter `groupHeaderBuilder` gets the whole element. If both are defined `groupHeaderBuilder` is preferred | yes, either of them | - | +| `groupStickyHeaderBuilder` | Function which returns a Widget which defines the sticky group header, when `useStickyGroupSeparators` is `true`. If not defined `groupSeparatorBuilder` or `groupHeaderBuilder` will be used as described above. | no | - | +| `useStickyGroupSeparators` | When set to true the group header of the current visible group will stick on top | no | `false` | +| `floatingHeader` | Whether the sticky group header float over the list or occupy it's own space | no | `false` | +| `stickyHeaderBackgroundColor` | Defines the background color of the sticky header. Will only be used if `useStickyGroupSeparators` is used | no | `Color(0xffF7F7F7)` | +| `separator` | A Widget which defines a separator between items inside a group | no | no separator | +| `groupComparator` | Can be used to define a custom sorting for the groups. Otherwise the natural sorting order is used | no | - | +| `itemComparator` | Can be used to define a custom sorting for the elements inside each group. Otherwise the natural sorting order is used | no | - | +| `order` | Change to `GroupedListOrder.DESC` to reverse the group sorting | no | `GroupedListOrder.ASC` | +| `footer` | Widget at the bottom of the list | no | - | **Also the fields from `ListView.builder` can be used.** diff --git a/example/lib/chat_example.dart b/example/lib/chat_example.dart index f74996b..353d93e 100644 --- a/example/lib/chat_example.dart +++ b/example/lib/chat_example.dart @@ -11,100 +11,90 @@ void main() => runApp(const MyApp()); DateTime initialReferenceDate = DateTime(2023, 6, 24, 9, 05); DateTime _currentReferenceDate = initialReferenceDate; -DateTime getCurrentReferenceDate(Duration duration){ +DateTime getCurrentReferenceDate(Duration duration) { _currentReferenceDate = _currentReferenceDate.subtract(duration); return _currentReferenceDate; } String remoteUserName = 'Michael Jackson'; -Color remoteUserColor = - Colors.blueAccent; - //Colors.primaries[Random().nextInt(Colors.primaries.length)]; +Color remoteUserColor = Colors.blueAccent; +//Colors.primaries[Random().nextInt(Colors.primaries.length)]; Duration _scrollDelay = const Duration(seconds: 0); -List get getOlderMessages =>[ - for (int position = 0; position < 3; position++) - ...[ - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(days: 3, seconds: 40)), - message: 'Yeah sure I have send them per mail', - isLocalUser: true - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(seconds: 8)), - message: 'I dont understand the math questions :(', - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 2)), - message: 'Can you send me the homework for tomorrow please?' - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 1)), - message: 'Of course what do you need?', - isLocalUser: true - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 10)), - message: 'Hey whats up? Can you help me real quick?' - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(days: 5, minutes: 2)), - message: 'Okay see you then :)' - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 6)), - message: 'Lets meet at 8 o clock', - isLocalUser: true - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 2)), - message: 'Yes of course when do we want to meet' - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 3)), - message: 'Hey you do you wanna go to the cinema?', - isLocalUser: true - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 10)), - message: 'I am fine too' - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 10)), - message: 'Fine and what about you?', - isLocalUser: true - ), - Message( - username: remoteUserName, - userColor: remoteUserColor, - date: getCurrentReferenceDate(const Duration(minutes: 10)), - message: 'Hello how are you?' - ), - ]..sort((a,b) => a.compareTo(b)) -]; +List get getOlderMessages => [ + for (int position = 0; position < 3; position++) + ...[ + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: + getCurrentReferenceDate(const Duration(days: 3, seconds: 40)), + message: 'Yeah sure I have send them per mail', + isLocalUser: true), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(seconds: 8)), + message: 'I dont understand the math questions :(', + ), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 2)), + message: 'Can you send me the homework for tomorrow please?'), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 1)), + message: 'Of course what do you need?', + isLocalUser: true), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 10)), + message: 'Hey whats up? Can you help me real quick?'), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: + getCurrentReferenceDate(const Duration(days: 5, minutes: 2)), + message: 'Okay see you then :)'), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 6)), + message: 'Lets meet at 8 o clock', + isLocalUser: true), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 2)), + message: 'Yes of course when do we want to meet'), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 3)), + message: 'Hey you do you wanna go to the cinema?', + isLocalUser: true), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 10)), + message: 'I am fine too'), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 10)), + message: 'Fine and what about you?', + isLocalUser: true), + Message( + username: remoteUserName, + userColor: remoteUserColor, + date: getCurrentReferenceDate(const Duration(minutes: 10)), + message: 'Hello how are you?'), + ]..sort((a, b) => a.compareTo(b)) + ]; class MyApp extends StatefulWidget { const MyApp({Key? key}) : super(key: key); @@ -114,18 +104,15 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final List _messages = getOlderMessages; @override void initState() { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp - ]); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); super.initState(); } - void _removeInputTextFocus(){ + void _removeInputTextFocus() { FocusScope.of(context).requestFocus(FocusNode()); } @@ -141,7 +128,6 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { - ThemeData currentTheme = Theme.of(context); ThemeData remoteUserTheme = currentTheme; ThemeData localUserTheme = currentTheme.copyWith( @@ -151,50 +137,41 @@ class _MyAppState extends State { ), cardTheme: currentTheme.cardTheme.copyWith( color: Colors.blue, - ) - ); + )); const BoxDecoration chatBackgroundDecoration = BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFe3edff), - Color(0xFFcad8fd) - ] - ) - ); + gradient: + LinearGradient(colors: [Color(0xFFe3edff), Color(0xFFcad8fd)])); return MaterialApp( debugShowCheckedModeBanner: false, title: 'Grouped List Chat Example', theme: ThemeData( - primarySwatch: Colors.blue, - canvasColor: Colors.transparent - ), + primarySwatch: Colors.blue, canvasColor: Colors.transparent), home: Scaffold( appBar: AppBar( title: const Text('Grouped List Chat Example'), ), body: Builder( - builder: (context) => - Container( - decoration: chatBackgroundDecoration, - child: Column( - children: [ - Expanded( - child: GestureDetector( - onTap: _removeInputTextFocus, - child: ChatTimeline( - messages: _messages, - localUserTheme: localUserTheme, - remoteUserTheme: remoteUserTheme, - onPageTopScrollFunction: _onPageTopScrollFunction, - ), + builder: (context) => Container( + decoration: chatBackgroundDecoration, + child: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: _removeInputTextFocus, + child: ChatTimeline( + messages: _messages, + localUserTheme: localUserTheme, + remoteUserTheme: remoteUserTheme, + onPageTopScrollFunction: _onPageTopScrollFunction, ), ), - const FakeMessageTextField() - ], - ), + ), + const FakeMessageTextField() + ], ), + ), ), ), ); @@ -235,11 +212,10 @@ class _ChatTimelineState extends State { if (widget.onPageTopScrollFunction == null) return; if (!(_scrollCompleter?.isCompleted ?? true)) return; - double - screenSize = MediaQuery.of(context).size.height, - scrollLimit = _scrollController.position.maxScrollExtent, - missingScroll = scrollLimit - screenSize, - scrollLimitActivation = scrollLimit - missingScroll * 0.05; + double screenSize = MediaQuery.of(context).size.height, + scrollLimit = _scrollController.position.maxScrollExtent, + missingScroll = scrollLimit - screenSize, + scrollLimitActivation = scrollLimit - missingScroll * 0.05; if (_scrollController.position.pixels < scrollLimitActivation) return; if (!(_scrollCompleter?.isCompleted ?? true)) return; @@ -269,25 +245,23 @@ class _ChatTimelineState extends State { element.date.month, element.date.day, ), - groupHeaderBuilder: (element) => - GroupHeaderDate(date: element.date), + groupHeaderBuilder: (element) => GroupHeaderDate(date: element.date), interdependentItemBuilder: ( - context, - Message? previousElement, - Message currentElement, - Message? nextElement, + context, + Message? previousElement, + Message currentElement, + Message? nextElement, ) => Theme( - data: currentElement.isLocalUser - ? widget.localUserTheme - : widget.remoteUserTheme, - child: MessageBox( - context: context, - previousElement: previousElement, - currentElement: currentElement, - nextElement: nextElement - ), - ), + data: currentElement.isLocalUser + ? widget.localUserTheme + : widget.remoteUserTheme, + child: MessageBox( + context: context, + previousElement: previousElement, + currentElement: currentElement, + nextElement: nextElement), + ), ), ); } @@ -301,55 +275,40 @@ class FakeMessageTextField extends StatelessWidget { return Padding( padding: const EdgeInsets.all(10.0), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9 - ), + constraints: + BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.9), child: Theme( data: Theme.of(context).copyWith( - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0 - ), - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(24.0), - ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(24.0)), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2 - ) - ), - errorBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(24.0)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.error, - width: 2 - ) - ), - ) - ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(24.0), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(24.0)), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, width: 2)), + errorBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(24.0)), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, width: 2)), + )), child: TextField( minLines: 1, maxLines: 8, decoration: InputDecoration( prefixIcon: IconButton( onPressed: () => null, - icon: Icon( - Icons.camera_alt_outlined, - color: Theme.of(context).primaryColor - ), + icon: Icon(Icons.camera_alt_outlined, + color: Theme.of(context).primaryColor), ), suffixIcon: IconButton( onPressed: () => null, - icon: Icon( - Icons.send, - color: Theme.of(context).primaryColor - ), + icon: Icon(Icons.send, color: Theme.of(context).primaryColor), ), ), ), @@ -359,14 +318,10 @@ class FakeMessageTextField extends StatelessWidget { } } - class GroupHeaderDate extends StatelessWidget { final DateTime date; - const GroupHeaderDate({ - required this.date, - super.key - }); + const GroupHeaderDate({required this.date, super.key}); @override Widget build(BuildContext context) { @@ -380,16 +335,15 @@ class GroupHeaderDate extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(8.0)), ), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0 - ), + padding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Text( DateFormat.yMMMd().format(date), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white - ), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.white), ), ), ), @@ -398,7 +352,6 @@ class GroupHeaderDate extends StatelessWidget { } } - class MessageBox extends StatelessWidget { const MessageBox({ super.key, @@ -415,26 +368,16 @@ class MessageBox extends StatelessWidget { @override Widget build(BuildContext context) { - bool - displayUserName = true, - displayAvatar = true; + bool displayUserName = true, displayAvatar = true; - if (currentElement.isLocalUser){ + if (currentElement.isLocalUser) { displayUserName = false; - } else - - if (nextElement == null){ + } else if (nextElement == null) { displayUserName = true; - } else - - if ( - DateUtils.dateOnly(currentElement.date) != - DateUtils.dateOnly(nextElement!.date) - ){ + } else if (DateUtils.dateOnly(currentElement.date) != + DateUtils.dateOnly(nextElement!.date)) { displayUserName = true; - } else - - if (!nextElement!.isLocalUser){ + } else if (!nextElement!.isLocalUser) { displayUserName = false; } @@ -453,14 +396,10 @@ class MessageBox extends StatelessWidget { child: CircleAvatar( radius: 16, backgroundColor: currentElement.userColor, - child: const Icon( - Icons.person, - color: Colors.white - ), + child: const Icon(Icons.person, color: Colors.white), ), ), - if (!displayAvatar) - const SizedBox(width: 40), + if (!displayAvatar) const SizedBox(width: 40), Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: currentElement.isLocalUser @@ -472,9 +411,10 @@ class MessageBox extends StatelessWidget { padding: const EdgeInsets.only(left: 10.0, top: 10), child: Text( '${currentElement.username}:', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: currentElement.userColor - ), + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: currentElement.userColor), ), ), SizedBox( @@ -487,32 +427,30 @@ class MessageBox extends StatelessWidget { elevation: 4.0, shadowColor: Colors.black45, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - bottomLeft: const Radius.circular(18.0), - topRight: const Radius.circular(18.0), - topLeft: Radius.circular( - currentElement.isLocalUser ? 18.0 : 0 - ), - bottomRight: Radius.circular( - currentElement.isLocalUser ? 0 : 18.0 - ), - ) - ), - margin: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), + borderRadius: BorderRadius.only( + bottomLeft: const Radius.circular(18.0), + topRight: const Radius.circular(18.0), + topLeft: + Radius.circular(currentElement.isLocalUser ? 18.0 : 0), + bottomRight: + Radius.circular(currentElement.isLocalUser ? 0 : 18.0), + )), + margin: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 4.0), child: Stack( children: [ Padding( - padding: const EdgeInsets.fromLTRB(20.0, 20.0, 20.0, 24.0), + padding: + const EdgeInsets.fromLTRB(20.0, 20.0, 20.0, 24.0), child: Text(currentElement.message), ), Positioned( - bottom: 4, - right: 8, - child: Text( - DateFormat.Hm().format(currentElement.date), - style: Theme.of(context).textTheme.bodySmall, - ) - ) + bottom: 4, + right: 8, + child: Text( + DateFormat.Hm().format(currentElement.date), + style: Theme.of(context).textTheme.bodySmall, + )) ], ), ), @@ -532,13 +470,12 @@ class Message implements Comparable { String message; bool isLocalUser; - Message({ - required this.username, - required this.userColor, - required this.date, - required this.message, - this.isLocalUser = false - }); + Message( + {required this.username, + required this.userColor, + required this.date, + required this.message, + this.isLocalUser = false}); @override int compareTo(other) { diff --git a/example/lib/section_example.dart b/example/lib/section_example.dart new file mode 100644 index 0000000..06cb158 --- /dev/null +++ b/example/lib/section_example.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:grouped_list/grouped_list.dart'; + +void main() => runApp(const MyApp()); + +List _elements = [ + {'name': 'John', 'group': 'Team A'}, + {'name': 'Will', 'group': 'Team B'}, + {'name': 'Beth', 'group': 'Team A'}, + {'name': 'Miranda', 'group': 'Team B'}, + {'name': 'Mike', 'group': 'Team C'}, + {'name': 'Danny', 'group': 'Team C'}, +]; + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: Scaffold( + appBar: AppBar( + title: const Text('Grouped List View Example'), + ), + body: _createGroupedListView(), + ), + ); + } + + _createGroupedListView() { + return GroupedListView( + elements: _elements, + groupBy: (element) => element['group'], + groupComparator: (value1, value2) => value2.compareTo(value1), + itemComparator: (item1, item2) => item1['name'].compareTo(item2['name']), + order: GroupedListOrder.DESC, + useStickyGroupSeparators: true, + groupSeparatorBuilder: (String value) => Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + value, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + itemBuilder: (c, element) { + return Card( + elevation: 8.0, + margin: const EdgeInsets.symmetric(horizontal: 10.0), + child: SizedBox( + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), + leading: const Icon(Icons.account_circle), + title: Text(element['name']), + trailing: const Icon(Icons.arrow_forward), + ), + ), + ); + }, + groupItemBuilder: (c, element, groupStart, groupEnd) { + ShapeBorder? shapeBorder; + if (groupStart) { + shapeBorder = const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), topRight: Radius.circular(15))); + } else if (groupEnd) { + shapeBorder = const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(15), + bottomRight: Radius.circular(15))); + } + return Card( + elevation: 8.0, + margin: const EdgeInsets.symmetric(horizontal: 10.0), + shape: shapeBorder, + child: SizedBox( + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), + leading: const Icon(Icons.account_circle), + title: Text(element['name']), + trailing: const Icon(Icons.arrow_forward), + ), + ), + ); + }, + ); + } +} diff --git a/lib/grouped_list.dart b/lib/grouped_list.dart index 7b69671..182947a 100644 --- a/lib/grouped_list.dart +++ b/lib/grouped_list.dart @@ -67,6 +67,15 @@ class GroupedListView extends StatefulWidget { final Widget Function(BuildContext context, T? previousElement, T currentElement, T? nextElement)? interdependentItemBuilder; + /// Called to build children for the list with additional information about + /// whether the item is at the start or end of a group. + /// + /// The [groupStart] parameter is `true` if the item is the first in its group. + /// The [groupEnd] parameter is `true` if the item is the last in its group. + final Widget Function( + BuildContext context, T element, bool groupStart, bool groupEnd)? + groupItemBuilder; + /// Called to build children for the list with /// 0 <= element, index < elements.length final Widget Function(BuildContext context, T element, int index)? @@ -222,6 +231,7 @@ class GroupedListView extends StatefulWidget { this.groupStickyHeaderBuilder, this.emptyPlaceholder, this.itemBuilder, + this.groupItemBuilder, this.indexedItemBuilder, this.interdependentItemBuilder, this.itemComparator, @@ -251,7 +261,8 @@ class GroupedListView extends StatefulWidget { this.footer, }) : assert(itemBuilder != null || indexedItemBuilder != null || - interdependentItemBuilder != null), + interdependentItemBuilder != null || + groupItemBuilder != null), assert(groupSeparatorBuilder != null || groupHeaderBuilder != null); @override @@ -301,6 +312,7 @@ class _GroupedListViewState extends State> { _sortedElements = _sortElements(); var hiddenIndex = widget.reverse ? _sortedElements.length * 2 - 1 : 0; var isSeparator = widget.reverse ? (int i) => i.isOdd : (int i) => i.isEven; + isValidIndex(int i) => i >= 0 && i < _sortedElements.length; _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { _scrollListener(); @@ -324,16 +336,22 @@ class _GroupedListViewState extends State> { child: _buildGroupSeparator(_sortedElements[actualIndex]), ); } + var curr = widget.groupBy(_sortedElements[actualIndex]); + var preIndex = actualIndex + (widget.reverse ? 1 : -1); + var prev = isValidIndex(preIndex) + ? widget.groupBy(_sortedElements[preIndex]) + : null; + var nextIndex = actualIndex + (widget.reverse ? -1 : 1); + var next = isValidIndex(nextIndex) + ? widget.groupBy(_sortedElements[nextIndex]) + : null; if (isSeparator(index)) { - var curr = widget.groupBy(_sortedElements[actualIndex]); - var prev = widget - .groupBy(_sortedElements[actualIndex + (widget.reverse ? 1 : -1)]); if (prev != curr) { return _buildGroupSeparator(_sortedElements[actualIndex]); } return widget.separator; } - return _buildItem(context, actualIndex); + return _buildItem(context, actualIndex, prev != curr, curr != next); } return Stack( @@ -379,20 +397,35 @@ class _GroupedListViewState extends State> { /// Returns the widget for element positioned at [index]. The widget is /// retrieved either by [widget.indexedItemBuilder], [widget.itemBuilder] /// or [widget.interdependentItemBuilder]. - Widget _buildItem(context, int index) => KeyedSubtree( + Widget _buildItem(context, int index, bool groupStart, bool groupEnd) => + KeyedSubtree( key: _keys.putIfAbsent('$index', () => GlobalKey()), - child: widget.indexedItemBuilder != null - ? widget.indexedItemBuilder!(context, _sortedElements[index], index) - : widget.interdependentItemBuilder != null - ? widget.interdependentItemBuilder!( + child: widget.groupItemBuilder != null + ? widget.groupItemBuilder!( + context, + _sortedElements[index], + groupStart, + groupEnd, + ) + : widget.indexedItemBuilder != null + ? widget.indexedItemBuilder!( context, - index > 0 ? _sortedElements[index - 1] : null, _sortedElements[index], - index + 1 < _sortedElements.length - ? _sortedElements[index + 1] - : null, + index, ) - : widget.itemBuilder!(context, _sortedElements[index]), + : widget.interdependentItemBuilder != null + ? widget.interdependentItemBuilder!( + context, + index > 0 ? _sortedElements[index - 1] : null, + _sortedElements[index], + index + 1 < _sortedElements.length + ? _sortedElements[index + 1] + : null, + ) + : widget.itemBuilder!( + context, + _sortedElements[index], + ), ); /// This scroll listener is added to the lists controller if