Merge pull request #493 from GetStream/feat/message-list-header-footer

feat: add support for messageListView header/footer
This commit is contained in:
Salvatore Giordano
2021-06-24 17:12:11 +02:00
committed by GitHub
2 changed files with 122 additions and 76 deletions
@@ -1,5 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -148,6 +148,8 @@ class MessageListView extends StatefulWidget {
this.messageHighlightColor, this.messageHighlightColor,
this.onShowMessage, this.onShowMessage,
this.showConnectionStateTile = false, this.showConnectionStateTile = false,
this.headerBuilder,
this.footerBuilder,
this.loadingBuilder, this.loadingBuilder,
this.emptyBuilder, this.emptyBuilder,
this.systemMessageBuilder, this.systemMessageBuilder,
@@ -236,6 +238,12 @@ class MessageListView extends StatefulWidget {
/// Function called when messages are fetched /// Function called when messages are fetched
final Widget Function(BuildContext, List<Message>)? messageListBuilder; final Widget Function(BuildContext, List<Message>)? messageListBuilder;
/// Function used to build a header widget
final WidgetBuilder? headerBuilder;
/// Function used to build a footer widget
final WidgetBuilder? footerBuilder;
/// Function used to build a loading widget /// Function used to build a loading widget
final WidgetBuilder? loadingBuilder; final WidgetBuilder? loadingBuilder;
@@ -388,6 +396,12 @@ class _MessageListViewState extends State<MessageListView> {
_messageListLength = newMessagesListLength; _messageListLength = newMessagesListLength;
final itemCount = messages.length + // total messages
2 + // top + bottom loading indicator
2 + // header + footer
1 // parent message
;
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@@ -449,17 +463,51 @@ class _MessageListViewState extends State<MessageListView> {
itemScrollController: _scrollController, itemScrollController: _scrollController,
reverse: true, reverse: true,
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
itemCount: itemCount: itemCount,
messages.length + 2 + (_isThreadConversation ? 1 : 0),
// Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages)
// eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)|
// ParentMessage -> 7 (count-1)
// Separator(ThreadSeparator) -> 6 (count-2)
// Header -> 6 (count-2)
// Separator(Header -> 8??T -> 0||52) -> 5 (count-3)
// TopLoader -> 5 (count-3)
// Separator(0) -> 4 (count-4)
// Message -> 4 (count-4)
// Separator(2||8) -> 3 (count-5)
// Message -> 3 (count-5)
// Separator(2||8) -> 2 (count-6)
// Message -> 2 (count-6)
// Separator(0) -> 1 (count-7)
// BottomLoader -> 1 (count-7)
// Separator(Footer -> 8??30) -> 0 (count-8)
// Footer -> 0 (count-8)
separatorBuilder: (context, i) { separatorBuilder: (context, i) {
if (i == messages.length) return const Offstage(); if (i == itemCount - 2) {
if (i == 0) return const SizedBox(height: 30); if (widget.parentMessage == null) {
if (i == messages.length + 1) { return const Offstage();
}
return _buildThreadSeparator(); return _buildThreadSeparator();
} }
if (i == itemCount - 3) {
if (widget.headerBuilder == null) {
if (_isThreadConversation) return const Offstage();
return const SizedBox(height: 52);
}
return const SizedBox(height: 8);
}
if (i == 0) {
if (widget.footerBuilder == null) {
return const SizedBox(height: 30);
}
return const SizedBox(height: 8);
}
final message = messages[i]; if (i == 1 || i == itemCount - 4) return const Offstage();
final nextMessage = messages[i - 1];
final message = messages[i - 1];
final nextMessage = messages[i - 2];
if (!Jiffy(message.createdAt.toLocal()).isSame( if (!Jiffy(message.createdAt.toLocal()).isSame(
nextMessage.createdAt.toLocal(), nextMessage.createdAt.toLocal(),
Units.DAY, Units.DAY,
@@ -495,45 +543,61 @@ class _MessageListViewState extends State<MessageListView> {
return const SizedBox(height: 2); return const SizedBox(height: 2);
}, },
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == messages.length + 2) { if (i == itemCount - 1) {
if (widget.parentMessageBuilder != null) { if (widget.parentMessage == null) return const Offstage();
return widget.parentMessageBuilder!( return widget.parentMessageBuilder?.call(
context, context,
widget.parentMessage, widget.parentMessage,
); ) ??
} else { buildParentMessage(widget.parentMessage!);
return buildParentMessage(widget.parentMessage!);
}
} }
if (i == messages.length + 1) {
if (i == itemCount - 2) {
return widget.headerBuilder?.call(context) ??
const Offstage();
}
if (i == itemCount - 3) {
return _buildLoadingIndicator( return _buildLoadingIndicator(
streamChannel!, streamChannel!,
QueryDirection.top, QueryDirection.top,
); );
} }
if (i == 0) {
if (i == 1) {
return _buildLoadingIndicator( return _buildLoadingIndicator(
streamChannel!, streamChannel!,
QueryDirection.bottom, QueryDirection.bottom,
); );
} }
final message = messages[i - 1];
if (i == 0) {
return widget.footerBuilder?.call(context) ??
const Offstage();
}
final topMessageIndex = itemCount -
4; // 7 -> parent // 6 -> header // 5 -> loader
const bottomMessageIndex = 2; // 1 -> loader // 0 -> footer
final message = messages[i - 2];
Widget messageWidget; Widget messageWidget;
if (i == 1) { if (i == topMessageIndex) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel!,
);
} else if (i == messages.length - 1) {
messageWidget = _buildTopMessage( messageWidget = _buildTopMessage(
context, context,
message, message,
messages, messages,
streamChannel, streamChannel,
i,
);
} else if (i == bottomMessageIndex) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel!,
i,
); );
} else { } else {
if (widget.messageBuilder != null) { if (widget.messageBuilder != null) {
@@ -561,7 +625,8 @@ class _MessageListViewState extends State<MessageListView> {
}, },
), ),
if (widget.showScrollToBottom) _buildScrollToBottom(), if (widget.showScrollToBottom) _buildScrollToBottom(),
if (widget.showFloatingDateDivider) _buildFloatingDateDivider(), if (widget.showFloatingDateDivider)
_buildFloatingDateDivider(itemCount),
], ],
); );
} }
@@ -579,7 +644,6 @@ class _MessageListViewState extends State<MessageListView> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
// ignore: lines_longer_than_80_chars
'$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: _streamTheme.channelTheme.channelHeaderTheme.subtitle, style: _streamTheme.channelTheme.channelHeaderTheme.subtitle,
@@ -588,42 +652,34 @@ class _MessageListViewState extends State<MessageListView> {
); );
} }
Positioned _buildFloatingDateDivider() => Positioned( Positioned _buildFloatingDateDivider(int itemCount) => Positioned(
top: 20, top: 20,
child: BetterStreamBuilder<Iterable<ItemPosition>>( child: BetterStreamBuilder<Iterable<ItemPosition>>(
initialData: _itemPositionListener.itemPositions.value, initialData: _itemPositionListener.itemPositions.value,
stream: _itemPositionStream, stream: _itemPositionStream,
comparator: (a, b) { comparator: (a, b) {
if (a == null) { if (a == null || b == null) {
return false; return false;
} }
final aTop = _getTopElement(a)?.index; final aTop = _getTopElementIndex(a);
final bTop = _getTopElement(b)?.index; final bTop = _getTopElementIndex(b);
return aTop == bTop; return aTop == bTop;
}, },
builder: (context, values) { builder: (context, values) {
final items = _itemPositionListener.itemPositions.value; if (values.isEmpty || messages.isEmpty) {
if (items.isEmpty || messages.isEmpty) { return const Offstage();
return const SizedBox();
} }
var index = _getTopElement(values)?.index; final index = _getTopElementIndex(values);
if (index == null || index > messages.length) { if (index == null || index <= 2 || index >= itemCount - 3) {
return const SizedBox(); return const Offstage();
}
if (index == messages.length) {
index = max(index - 1, 0);
} }
final message = messages[index - 2];
return widget.dateDividerBuilder != null return widget.dateDividerBuilder != null
? widget.dateDividerBuilder!( ? widget.dateDividerBuilder!(message.createdAt.toLocal())
messages[index].createdAt.toLocal(), : DateDivider(dateTime: message.createdAt.toLocal());
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
}, },
), ),
); );
@@ -632,16 +688,13 @@ class _MessageListViewState extends State<MessageListView> {
StreamChannelState? channel, QueryDirection direction) => StreamChannelState? channel, QueryDirection direction) =>
_messageListController.paginateData!(direction: direction); _messageListController.paginateData!(direction: direction);
ItemPosition? _getTopElement(Iterable<ItemPosition> values) { int? _getTopElementIndex(Iterable<ItemPosition> values) {
final inView = final inView = values.where((position) => position.itemLeadingEdge < 1);
values.where((ItemPosition position) => position.itemLeadingEdge < 0.9); if (inView.isEmpty) return null;
return inView
if (inView.isEmpty) { .reduce((max, position) =>
return null; position.itemLeadingEdge > max.itemLeadingEdge ? position : max)
} .index;
return inView.reduce((ItemPosition max, ItemPosition position) =>
position.itemLeadingEdge > max.itemLeadingEdge ? position : max);
} }
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>( Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
@@ -737,6 +790,7 @@ class _MessageListViewState extends State<MessageListView> {
Message message, Message message,
List<Message> messages, List<Message> messages,
StreamChannelState? streamChannel, StreamChannelState? streamChannel,
int index,
) { ) {
Widget messageWidget; Widget messageWidget;
if (widget.messageBuilder != null) { if (widget.messageBuilder != null) {
@@ -754,7 +808,7 @@ class _MessageListViewState extends State<MessageListView> {
), ),
); );
} else { } else {
messageWidget = buildMessage(message, messages, messages.length - 1); messageWidget = buildMessage(message, messages, index);
} }
return messageWidget; return messageWidget;
} }
@@ -764,6 +818,7 @@ class _MessageListViewState extends State<MessageListView> {
Message message, Message message,
List<Message> messages, List<Message> messages,
StreamChannelState streamChannel, StreamChannelState streamChannel,
int index,
) { ) {
Widget messageWidget; Widget messageWidget;
if (widget.messageBuilder != null) { if (widget.messageBuilder != null) {
@@ -781,7 +836,7 @@ class _MessageListViewState extends State<MessageListView> {
), ),
); );
} else { } else {
messageWidget = buildMessage(message, messages, 0); messageWidget = buildMessage(message, messages, index);
} }
return VisibilityDetector( return VisibilityDetector(
@@ -894,7 +949,7 @@ class _MessageListViewState extends State<MessageListView> {
final userId = StreamChat.of(context).user!.id; final userId = StreamChat.of(context).user!.id;
final isMyMessage = message.user!.id == userId; final isMyMessage = message.user!.id == userId;
final nextMessage = index - 2 >= 0 ? messages[index - 2] : null; final nextMessage = index - 3 >= 0 ? messages[index - 3] : null;
final isNextUserSame = final isNextUserSame =
nextMessage != null && message.user!.id == nextMessage.user!.id; nextMessage != null && message.user!.id == nextMessage.user!.id;
@@ -1240,15 +1295,7 @@ class _LoadingIndicator extends StatelessWidget {
), ),
), ),
builder: (context, data) { builder: (context, data) {
if (!data) { if (!data) return const Offstage();
if (!isThreadConversation && direction == QueryDirection.top) {
return const SizedBox(
height: 52,
width: double.infinity,
);
}
return const Offstage();
}
return const Center( return const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
@@ -25,7 +25,7 @@ class BetterStreamBuilder<T> extends StatefulWidget {
final T initialData; final T initialData;
/// Comparator used to check if the new data is different than the last one /// Comparator used to check if the new data is different than the last one
final bool Function(T?, T)? comparator; final bool Function(T?, T?)? comparator;
/// Builder that builds based on the new snapshot /// Builder that builds based on the new snapshot
final Widget Function(BuildContext context, T data) builder; final Widget Function(BuildContext context, T data) builder;
@@ -96,9 +96,8 @@ class _BetterStreamBuilderState<T> extends State<BetterStreamBuilder<T>> {
void _onEvent(T event) { void _onEvent(T event) {
_lastError = null; _lastError = null;
final isEqual = widget.comparator != null final isEqual =
? widget.comparator!(_lastEvent, event) widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent;
: event == _lastEvent;
if (!isEqual) { if (!isEqual) {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});