this is code of paginating data from firestore , here i am using a library called firebase_ui_firestore ^1.5.15 from pub.dev,pagination works fine but here the issue is when i tap on the textfield it automatically scrolls up the list , here i always want the list to be bottom for chat app, but for the first time when we click to the chat page it displays the list and scroll at bottom part of list and works as expected ,but the issue only happens when we click on the textfield or send any message the list scrolls up.
is this any library issue? or logical error please help.
here my main aim the list is always at the bottom when we trying to message someone or tap on the keypad or textfield.
here below i am pasting the code of chatpage.
class _ChatAppState extends State<ChatApp> {
final TextEditingController _messageController = TextEditingController();
final ChatService _chatService = ChatService();
bool isImageSelected = false;
bool isSendingImage = false;
final ScrollController _scrollController = ScrollController();
XFile? image;
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
return Scaffold(
backgroundColor: ThemeManager.scaffoldBackgroundColor,
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
ChatScreenAppBar(
senderName: widget.name, avatarUrl: widget.profileUrl),
Expanded(child: _buildMessageList()),
TextField(
onMessageSent: (text) {
sendMessage();
_scrollToBottom();
},
onImageSelected: (selectedImage) async {}, messageController: _messageController,
),
],
),
);
}
/// send chat message.
void sendMessage()async{
try{
if(_messageController.text.isNotEmpty)
{
await _chatService.sendMessage(widget.userId,
widget.currentUserId,
_messageController.text,'recieverEmail','text','gp-01');
_messageController.clear();
}else{
log('its empty');
}
}catch(e)
{
log("send Error: ${e.toString()}");
}
}
_scrollToBottom() {
if(_scrollController.hasClients)
{
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}
Widget _buildMessageList() {
List<String> ids = [widget.currentUserId, widget.userId];
ids.sort();
String chatRoomId = ids.join("_");
return FirestoreQueryBuilder(
pageSize: 5,
query: FirebaseFirestore.instance
.collection('chat_rooms')
.doc(chatRoomId)
.collection('messages')
.orderBy('TimeStamp',descending: true),
builder: (context, snapshot, index) {
print("currrent index $index");
if (snapshot.hasError) {
return Text('Error ${snapshot.error}');
}
if (snapshot.isFetching) {
return const Center(child: CircularProgressIndicator());
}
print("firebase docs ${snapshot.docs}");
List<Message> allMessages = snapshot.docs.map((doc) {
return Message.fromFireStore(doc);
}).toList();
// Group messages by date
return GroupedListView<Message, DateTime>(
controller: _scrollController,
reverse: true,
order: GroupedListOrder.ASC,
floatingHeader: true,
elements:allMessages.toList(),
groupBy: (message) =>DateTime(
DateTime.parse(message.timeStamp.toDate().toString()).year,
DateTime.parse(message.timeStamp.toDate().toString()).month,
DateTime.parse(message.timeStamp.toDate().toString()).day,
),
itemComparator: (item1, item2) => item1.compareTo(item2),
sort: false, //
groupHeaderBuilder: (Message message) {
final formattedDate =
formatMessageDate(DateTime.parse(message.timeStamp.toDate().toString()));
return SizedBox(
height: 40.h,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
formattedDate,
style: const TextStyle(color: ThemeManager.primaryBlack),
),
),
),
);
},
itemBuilder: (context, Message message) {
// WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
int messageIndex = allMessages.indexOf(message);
final hasEndReached = snapshot.hasMore &&
messageIndex + 1 == snapshot.docs.length &&
!snapshot.isFetchingMore;
print("has reached the end: $hasEndReached");
if(hasEndReached) {
print("fetch more");
snapshot.fetchMore();
}
String messageId = snapshot.docs[messageIndex].id;
return Align(
alignment: message.receiverId == widget.userId
? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h),
child: Column(
children: [
message.receiverId == widget.userId ? Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) async {
bool shouldDelete = await Dialogs.showDeleteConfirmationDialog(context);
return shouldDelete;
},
onDismissed: (direction) async{
_chatService.deleteMessage(widget.currentUserId, widget.userId, messageId);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TextMessageContainer(message: message,
senderId: widget.currentUserId,
receiverId:widget.userId,),
SizedBox(height: 5.h),
Text(
DateFormat("hh:mm a").format(DateTime.parse(message.timeStamp.toDate().toString())),
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w400,
color: ThemeManager.secondaryBlack,
),
),
],
),
):Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TextMessageContainer(message: message,
senderId: widget.currentUserId,
receiverId:widget.userId,),
SizedBox(height: 5.h),
Text(
DateFormat("hh:mm a").format(DateTime.parse(message.timeStamp.toDate().toString())),
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w400,
color: ThemeManager.secondaryBlack,
),
),
],
),
],
),
),
);
}
);
},
);
}
String formatMessageDate(DateTime? dateTime) {
final now = DateTime.now();
final yesterday = now.subtract(const Duration(days: 1));
if (dateTime?.year == now.year &&
dateTime?.month == now.month &&
dateTime?.day == now.day) {
return "Today";
} else if (dateTime?.year == yesterday.year &&
dateTime?.month == yesterday.month &&
dateTime?.day == yesterday.day) {
return "Yesterday";
} else {
return DateFormat.yMMMd().format(dateTime!);
}
}
}
i don’t know is there any issue in this library or my code logic , i am new to this.
my main aim is the list always at bottom end like the chat app, when we tap on textfield or sending message its always need to be bottom.
chat textfield class code
class TextField extends StatefulWidget {
final Function(String)? onMessageSent;
final Function(XFile)? onImageSelected;
final FocusNode? focusNode;
final TextEditingController messageController;
const TextField({super.key, this.onMessageSent, this.onImageSelected,required this.messageController,
this.focusNode
});
@override
State<TextField> createState() => _TextFieldState();
}
class _TextFieldState extends State<TextField> {
XFile? image;
bool isAttached = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
isAttached
? Padding(
padding: EdgeInsets.symmetric(horizontal: 15.w),
child: Column(
children: [
if (isAttached) ...[
SizedBox(height: 10.h),
SizedBox(height: 12.h),
],
],
),
)
: const SizedBox.shrink(),
Container(
height: 72.h,
padding: EdgeInsets.only(left: 15.w, right: 18.w),
decoration: const BoxDecoration(
color: Colors.white,
),
child: Row(
children: [
GestureDetector(
onTap: () {
setState(() {
isAttached = !isAttached;
});
},
child: Image.asset(
"assets/images/icon.png",
width: 22.w,
height: 23.h,
color: isAttached
? Theme.primaryColor
: Theme.inactivateColor,
)),
SizedBox(width: 16.w),
Expanded(
child: TextField(
focusNode: widget.focusNode,
maxLines: null,
controller: widget.messageController,
decoration: InputDecoration(
hintStyle: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: ThemeManager.secondaryBlack),
border: InputBorder.none,
),
),
),
GestureDetector(
onTap: () {
final messageText = widget.messageController.text;
if (messageText.isNotEmpty) {
widget.onMessageSent!(messageText);
widget.messageController.clear();
}
},
child: CircleAvatar(
backgroundImage:
const AssetImage("assets/images/ellipse_gradient.png"),
radius: 22.5.r,
child: Image.asset(
"assets/images/send_icon.png",
height: 24.h,
width: 30.w,
),
)),
],
),
),
],
);
}
Widget _buildAttachOption(String text, ImageSource source) {
return GestureDetector(
onTap: () async {
},
child: Container(
padding: EdgeInsets.only(left: 15.w, top: 13, bottom: 13),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: ThemeManager.primaryWhite,
),
child: Row(
children: [
Image.asset("assets/images/images_attach.png"),
SizedBox(width: 20.w),
Text( text,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w400,
color: Theme.primaryBlack),
),
],
),
),
);
}
}
Message model class code
class Message implements Comparable<Message>{
final String? senderId;
final String? receiverId;
final String? message;
final String? messageType;
final Timestamp timeStamp;
final String? groupId;
Message({
required this.senderId,
required this.receiverId,
required this.message,
required this.messageType,
required this.groupId,
required this.timeStamp,
});
Map<String, dynamic> toMap() {
return {
'senderId': senderId,
'receiverId': receiverId,
'message': message,
'messageType': messageType,
'TimeStamp': timeStamp,
'groupId': groupId,
};
}
// Factory constructor to create a Message instance from a DocumentSnapshot
factory Message.fromFireStore(DocumentSnapshot doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
return Message(
senderId: data['senderId'],
receiverId: data['receiverId'],
message: data['message'],
messageType: data['messageType'],
timeStamp: data['TimeStamp'],
groupId: data['groupId'],
);
}
factory Message.fromMap(Map<String, dynamic> map) {
return Message(
senderId: map['senderId'] as String,
receiverId: map['receiverId'] as String,
message: map['message'] as String,
messageType: map['messageType'] as String,
timeStamp: map['TimeStamp'] as Timestamp,
groupId: map['groupId'] as String,
);
}
@override
String toString() {
return 'Message{senderId: $senderId, receiverId: $receiverId, message: $message, messageType: $messageType, timeStamp: $timeStamp, groupId: $groupId}';
}
@override
int compareTo(Message other) {
return timeStamp.compareTo(other.timeStamp);
} // converted to map
}
2
Answers
i found the solution need to remove the scrollController listener from initstate and also need to remove _scrollToBottom function ,no need to control the textfield also, the list scroll end at the with the help of groupedListview with reverse true option.
no need to use the scrollToBottom function , the GroupedListview libray's reverse true option will help to scroll the list at the bottom.
In your
build
method, you are usingWidgetsBinding.instance.addPostFrameCallback(_)
to scroll to the bottom of the list when the widget is built.That means the chat starts at the bottom (where the latest messages are), but it does not handle other interactions like message send or text field focus.
Since
_scrollToBottom
is called in multiple places already (like after sending a message), I would remove the automatic scrolling from the build method, to avoid unnecessary scrolling on every frame build.When the keyboard appears, it changes the available screen space, which can cause scrollable views to adjust their positions. In a chat application, you typically want the most recent messages to remain visible when the keyboard is opened.
Try and use the
flutter_keyboard_visibility
package to listen for keyboard visibility changes. When the keyboard appears, adjust the scroll position to make sure the latest messages stay visible.Add
flutter_keyboard_visibility
to yourpubspec.yaml
:The
Listener
would be:Make sure the
ListView
accounts for the keyboard’s presence by adjusting its padding: the last message won’t be hidden behind the keyboard.Make sure the scroll position is adjusted when the
TextField
gains focus, in case the keyboard visibility listener does not catch it in time.The OP Nns_ninteyFIve adds in the comments:
Removing the
ScrollController
listener and the_scrollToBottom
function while relying on theGroupedListView
‘s inherent behavior withreverse
set totrue
is a valid approach, especially for a chat application where the newest messages are typically shown at the bottom.By removing the listener from the
ScrollController
in theinitState
, you prevent any additional or unwanted scroll behavior that might have been introduced by manual scroll control.Not using
_scrollToBottom
means that you are relying on the default scrolling behavior of your list view, which can be more natural and less prone to unexpected behavior, especially in the context of keyboard appearance and focus changes.Setting
reverse
totrue
in yourGroupedListView
automatically handles the scrolling to show the latest messages at the bottom of the list. That is a common practice in chat UIs as it mimics the natural flow of conversation.Your
_ChatAppState
class would be something like:And in the
_buildMessageList
method, you useGroupedListView
withreverse
set totrue
:By removing the custom scroll logic, that means there is no need for a custom
ScrollController
or_scrollToBottom
method, asGroupedListView
takes care of displaying the most recent messages at the bottom.The widget structure is simplified, leading to potentially fewer issues related to scroll behavior, especially when interacting with the keyboard or sending messages.