Implementing KakaoTalk Chat List View in Flutter
I am creating a new app containing a chat feature using Flutter. I made input box in the previous article.
It’s the time to create a chat list.
Background
Widgets don’t have style to set background. So we should use Container and decoration. However there is a special Sliver widget to achieve it.
DecoratedSliver supports decoration.
DecoratedSliver(
decoration: const BoxDecoration(color: Color(0xFF2c2c2e)),
sliver: StreamBuilder(...
This widget can decorate only one sliver by one. We need to wrap all slivers to give background or should use Stack to lay background.
Chat Balloon
The KakaoTalk Chat Balloon seemed created rounded rectangle and balloon tail image. First I will fill message boxes with background color.
return Card(
color: Colors.yellow,
The balloon tail should be overlayed on the chat box as the send button. So we should wrap chat box with Stack to put the tail together.
return Stack(
children: [
Card(
color: Colors.yellow,
child: Row(mainAxisSize: MainAxisSize.min, children: [
...
]),
),
Positioned(
top: 4,
right: 0,
child: Container(
height: 20,
width: 20,
decoration: const BoxDecoration(color: Colors.red),
))
],
);
When I wrapped the box with Stack, the chat boxes were minimized and away from the tail box.
To solve this problem we should reorder Stack and Row.
return Row(
children: [
Stack(
children: [
Card(
color: Colors.yellow,
child: Column(
children: [...],
),
),
Positioned(...),
],
)
],
);
Now is the time to draw the tail. I will just simple Triangle. How to draw path in Flutter? We can clip widgets with ClipPath.
ClipPath(
clipper: ...
child: ...
)
To clip by the shape we want, we should implement custom Clipper.
class BalloonTailClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
path.lineTo(0, size.height / 2);
path.lineTo(size.width, 0);
path.lineTo(0, size.height);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return false;
}
}
Then we can create a positioned tail.
Positioned(
top: 4,
right: 0,
child: ClipPath(
clipper: BalloonTailClipper(),
child: Container(
height: 10,
width: 10,
decoration: const BoxDecoration(color: Colors.red),
),
)),
Lastly we need to give some breath spacing.
Card(
color: Colors.yellow,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
Chat Balloon is perfect!!
My Chat
When we are using chat app, my chat is aligned to right.
It is very easy to align the items in row.
return Row(
mainAxisAlignment: MainAxisAlignment.end,
Other Chat
However other chats is more complex, they should present their profile. Because you should know who sent the message.
Let make it one by one.
isMine
First we should check if the message is mine. Maybe we can do it with user id. Actually I received the chat list via iterable type, so it is not useful change isMine of each chat.
To determine if is mine in generating rows, I defined isMine
property.
class Message extends Identifiable {
bool isMine = false;
Message(
{...,
this.isMine = false,
And appended property setting code in the load
method of the provider.
Future<Iterable<Message>> load(String chatId) async {
...
return snapshot.docs.map((doc) {
final message = MessageFactory.fromRemote(doc);
message.isMine = userService.isMyId(message.authorId);
return message;
});
}
Now I can determine whether the message is mine.
Time
My chat should present not only message, but time to let us when I sent the message.
Align Bottom
To make time label, I inserted a Text
widget into list MessageListItemView.
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Column(
children: [
Text(
"09:00 AM",
style: TextStyle(color: Colors.white),
),
],
),
Stack(...
However the new text was place on center of item.
Placing the row item bottom is very easy. We used mainAxisAlignment
to align chat ballon to right. It is horizontal, left to right. There is one more alignment
property crossAxisAlignment
. It is namedcross
, so we can align top to bottom using it.
return Row(
...,
crossAxisAlignment: CrossAxisAlignment.end,
Format
Next we need to put real date
by message. So I replace ‘09:00 AM” with createdAt
.
Text(
item.createdAt?.toString() ?? "",
style: ...,
),
This is very long. How can we formate date in Dart? There is DateFormat class to solve it. DateFormat
provides built-in shortcut like this.
DateFormat.yMMMd().format(DateTime.now())
To present am or pm, we need format a
, but there is no shortcut containing it. So we should make our own format.
DateFormat("hh:mm a").format(item.createdAt)
However we should present 오후 3:20
not 3:20 PM
. The format doesn’t mention about localization. But actually, jm
format printed am/pm.
DateFormat.jm().format(item.createdAt)
Locale
According to the guide, jm uses default locale us
. So I am not sure it will be printed korean in korean devices. So I had to test manually.
DateFormat.jm("ko_KR").format(item.createdAt!)
But this crashed my app, because we should call initializeDateFormatting before calling functions related to localization.
Therefore I called this function with with at least one locale.
initializeDateFormatting();
Even thought I solved the issue, I couldn’t see the korean time. Maybe it is problem I didn’t set korean locale yet?.
We should use Intl to get locales.
Others’ Chat Position
Most chat apps present others’ chat left side, while our messages are presented right.
Their chat balloon are different to ours.
Left Tail
Their tails are on left side.
So we should add option to Chat Ballon Tail whether to draw the tail left or right side.
enum BalloonTailDirection {
left, right
}
class BalloonTailClipper extends CustomClipper<Path> {
final BalloonTailDirection direction;
BalloonTailClipper(this.direction) : super();
@override
Path getClip(Size size) {
final path = Path();
if (direction == BalloonTailDirection.right) {
path.lineTo(0, size.height / 2);
path.lineTo(size.width, 0);
path.lineTo(0, size.height);
} else {
path.lineTo(0, 0);
path.lineTo(size.width, size.height);
path.lineTo(size.width, size.height / 2);
}
Of course adjusting the position of tail is also required.
Widget build(BuildContext context) {
final item = widget.item;
final BalloonTailDirection direction =
item.isMine ? BalloonTailDirection.right : BalloonTailDirection.left;
...
Positioned(
top: 4,
right: direction == BalloonTailDirection.right ? 0 : null,
left: direction == BalloonTailDirection.left ? 0 : null,
child: ClipPath(
clipper: BalloonTailClipper(direction),
Balloon Color
The color of balloon for other chats are different to mine. So we have to change the color base on whether it is mine.
const darkBallonColor = Color.fromRGBO(58, 58, 60, 1);
...
Stack(
children: [
Card(
color: isMine ? Colors.yellow : darkBallonColor,
child: Padding(
...,
child: Column(
children: [
...,
Text(
item.content ?? '',
style: TextStyle(
color: isMine ? Colors.black : Colors.white),
),
],
),
),
),
Positioned(
...,
child: ClipPath(
clipper: BalloonTailClipper(direction),
child: Container(
...,
decoration: BoxDecoration(
color: isMine ? Colors.yellow : darkBallonColor),
),
)),
Alignment
My chat is aligned to right end, while others should be in the left side.
Now the chat rows align to right(end).
return Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
children: [
Text(...),
],
),
Stack(...)
],
);
So we can align to left by specifying start
instead of end
.
mainAxisAlignment: MainAxisAlignment.start,
Time
The time must be replaced on right of the chat. We can use if Row
widget have the property like reverse
. But there is no way like that. So we should reversed children.
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [...]
.reversed.map((e) => e).toList(),
);
The reverse is not conditional, so we should create reversedIf
.
extension ListExtension<E> on List<E> {
Iterable<E> reversedIf(bool condition) {
if (!condition) {
return this;
}
return this.reversed.toList();
}
}
and apply to children.
return Row(
...,
children: [
...
].reversedIf(!isMine).toList(),
);
Nickname
Others’ chat balloon contain nickname
. We should move it out of balloon.
First, I wrapped balloon Stack
with the Column
.
return Row(
...,
children: [
Column(
children: [
Text(...),
],
),
Column(
children: [
Stack(
and I also moved nickname to the Column
.
Column(
children: [
Text(item.authorName,
style: const TextStyle(color: Color.fromRGBO(155, 155, 156, 1))),
Stack(
Alignment is also required. However textAlign
was not working.
Text(item.authorName,
textAlign: TextAlign.left,
While the Text’s size fits to its content,
we should move Text
itself.
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.authorName,
Profile Image
Last task for other chat is to present the profile image.
The user would have a profile image, but now I didn’t make feature to upload it. So I will use constant asset.
Lets add Image widget.
return Row(
...,
children: [
Column(...),
Column(...),
Image.asset('assets/images/logo.png')
This will present large image like this, because there no constraint to limit the size.
There might be various ways to solve this, however, I will specifying width
and height
.
The sizing issue is solved, another is occurred. The image is place at bottom not at top. This is caused by aligning createdAt
Text. Therefore I tried to handle it with Column
.
Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Image.asset(...
As we saw above in creating createdAt
, it couldn’t solve the problem. I couldn’t find any solution using Row
and Column
from internet.
So I replaced the image with empty space.
Column(...),
if (!isMine) const SizedBox(width: 40.0),]
And wrapped the Column
with Stack
to overlay the profile image.
Stack(
children: [
Column(...),
Positioned(
top: 0,
left: -20,
child: Image.asset('assets/images/logo.png',
width: 40, height: 40))
However I couldn’t see the image if it is placed on out of Stack.
So I disabled the clipping of Stack
and I could see the profile image.
Stack(
clipBehavior: Clip.none,
...
if(isMine) Positioned(
top: 0,
left: -40 - 8,
child: Image.asset(...)
Of course the profile image should be rounded. The chat balloon is implemented using Card
. If the corner radius of the image is different to the chat, we can use ClipRRect
to clip widget with a round rect shape.
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.asset('assets/images/logo.png',
width: 40, height: 40)))
Auto Scroll
Typically chat screens would be automatically scrolled to bottom after sending a message or receiving new messages.
Maybe you would consider reversing the list. Imagine we entered to new chat room, we can’t see previous messages and first message is at top not bottom. Therefore we can’t use reverse
option.
There is no property to make the list view scroll automatically. So we have to scroll to bottom manually whenever we need.
How to control scrolling programmatically? There is ScrollController and we can attach it to CustomScrollView
.
final ScrollController _scrollController = ScrollController();
...
body: CustomScrollView(
controller: _scrollController,
Chat Count
There is no native event to detect of appending chat into list. So we should remember chat count.
int _previousMessageCount = 0;
int get previousMessageCount => _previousMessageCount;
updateMessageCount(int value) {
_previousMessageCount = value;
}
With the stored previous count, we can now determine when we should scroll in StreamBuilder
.
sliver: StreamBuilder(
stream: viewModel.messages,
builder: (context, snapshot) {
final newMessages = snapshot.data ?? [];
final newMessageCount = newMessages.length;
if (newMessageCount > viewModel.previousMessageCount) {
// scroll
viewModel.updateMessageCount(newMessageCount);
}
Scroll
We made controller earlier. How to scroll using ScrollController?
I thought the controller would have scrollToEnd
like iOS, but there is no such a method.
Instead we should use animateTo
. In iOS animation is provided by animated
parameter, but Flutter has a separated method. I created my own scrollToEnd
method.
scrollToEnd() {
_scrollController.animateTo(double.infinity,
duration: const Duration(microseconds: 500),
curve: Curves.easeInOut);
}
First parameter is position, so how can we specify end position of the scroll? infinity
is possible?? This approach will crash our app.
So which value is available? We can get end position from the scroller.
final endPosition = _scrollController.position.maxScrollExtent;
_scrollController.animateTo(endPosition,
Now we can see the scroll automation.
However something is strange, it seemed like scrolling to the second chat from last, not the last one.
This problem might be caused by Bottom Navigation Bar, fortunately we can solve it by adding extra into the position. It is a little weird way, however it works.
_scrollController.animateTo(endPosition * 2 // + ??
Another solution is to give delay before scrolling.
await Future.delayed(const Duration(milliseconds: 200));
final endPosition = _scrollController.position.maxScrollExtent;
Closing Keyboard
If our device is small, the keyboard will make the screen very narrow. Therefore we should allow the users to hide the keyboard. In practice users would expect tapping the screen can hide the keyboard.
So do we have to detect the tap gesture first? Thanks to Google, TextField
has onTapOutside
event handler. It can detect tap gesture without another Gesture widget.
TextField(
...,
onTapOutside: (event) {
// tap detected
}
I am already using TextEditingController, so I expected there is any method to unfocus, but there is not such a method.
Fortunately FocusManager can help us and it has primaryFocus
property to know which widget has focus currently.
hideKeyboard() {
FocusManager.instance.primaryFocus?.unfocus();
}
...
onTapOutside: (event) {
hideKeyboard();
},
Section
Probably you might see the date above the chat list to know when the chats were delivered.
We can create Section above items in iOS, but Flutter’s Sliver don’t support the feature. So we have to use external library.
Someone created MultiSliver
to create such a section. We can get the sliver by installing sliver_tools package.
sliver_tools: ^0.2.12
I created MessageSectionView
following the guide.
class MessageSectionView extends MultiSliver {
final MessageSection section;
MessageSectionView({Key? key, required this.section}) : super(key:key,
pushPinnedChildren: true,
children: [
SliverPinnedHeader(child: Text('${section.date}')),
MessageListView(list: section.messages)
]);
}
The MessageSection would be like this.
class MessageSection extends Identifiable {
Iterable<Message> messages;
MessageSection(
{required this.messages,
super.id});
To use the section class, we need to make sections from entire messages to pass to SectionView.
final class MessageSectionFactory {
static List<MessageSection> fromMessages(Iterable<Message> messages)
I created also a transformer to know in which section the message should be contained.
String transformMessageToSectionId(Message message) {
return DateFormat.yMMMMEEEEd("ko_KR").format(message.createdAt!);
}
If the section is changed, we will create a new section.
for (final message in messages) {
final sectionId = transformMessageToSectionId(message);
if (section.id != sectionId) {
sectionMessages = [];
section = MessageSection(messages: sectionMessages, id: sectionId);
sections.add(section);
}
sectionMessages.add(message);
}
However the service is based on Message
, so we should appended another version of section.
@override
Future<Iterable<MessageSection>> load(String chatId) async {
debugPrint("Load messages. chatId $chatId");
final newMessages = await _databaseService.load(chatId);
return MessageSectionFactory.fromMessages(newMessages);
}
While the view model loads entire sections instead of messages,
final messageSectionStreamController =
StreamController<Iterable<MessageSection>>.broadcast();
loadMessageList() async {
final chatId = party!.chatId;
final loadedSections = await _messageSectionLoadService.load(chatId);
_storedMessageSections = List.from(loadedSections);
messageStreamController.add(_storedMessageSections);
}
the stream have to load messages. Since the stream receive new messages from database, we need to modify current section or create new section with them.
startListeningMessages() async {
debugPrint("Start Listening New Messages");
_messageLoadService.streamMessages(chatId).listen((newMessages) {
debugPrint("New Messages");
final newSections = MessageSectionFactory.fromMessages(newMessages);
_storedMessageSections.addAll(newSections);
messageSectionStreamController.add(_storedMessageSections);
});
}
Sectioned List View
Last task is to replace the list view with the sectioned. I will make SectionedMessageListView
with MessageSectionView
we created before.
So I cloned MessageListView
and rename it as Sectioned…
class SectionedMessageListView extends StatefulWidget {
...
then I replaced the item type of list with MessageSection
final Iterable<MessageSection> list;
Now the time to use MessageSectionView. The child generated by Sliver Builder is MessageListItemView,
return SliverList.builder(
itemBuilder: (context, index) {
var item = widget.list.elementAt(index);
return GestureDetector(
child: MessageListItemView(item: item),
I will swap the child with it.
child: MessageSectionView(section: item),
Finally I can use SectionedMessageListView
in the screen.
sliver: StreamBuilder(
stream: viewModel.messages,
builder: (context, snapshot) {
...
return SectionedMessageListView(list: newSections);
And I met this error
So I first moved out StreamBuilder
from DecoratedSliver
, but it couldn’t solve the problem.
child: Consumer<PartyHomeScreenModel>(
builder: (context, viewModel, child) => Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(...),
StreamBuilder(
Next I attempted test whether at least one section is working.
return MessageSectionView(section: newSections.first);
I guessed it might be impossible to solve MultiSliver
with SliverList
.builder. I googled about the issue, but I couldn’t find anything. So I have to attach MultiSlivers to the slivers.
I initially attempted to merge slivers by generating section views with StreamBuilder
However, StreamBuilder can only Widget, it doesn’t support List<Widget>.
slivers: [...]
+ StreamBuilder(
stream: viewModel.messages,
builder: (context, snapshot) {
return newSections
.map((section) => MessageSectionView(section: section))
}
)
Therefore I considered it is possible to use nested MultiSliver
slivers: [...,
StreamBuilder(
stream: viewModel.messages,
builder: (context, snapshot) {
return MultiSliver(
children: newSections
.map((section) => MessageSectionView(
section: section))
.toList());
}
)
and it works.
As we are seeing, the background is cleared. So I decorated StreamBuilder again.
DecoratedSliver(
decoration:
const BoxDecoration(color: Color(0xFF2c2c2e)),
sliver: StreamBuilder(...
I didn’t give the color to the section header yet. So I had to do to see it clearly.
Now I can see the section header, but I also wanted to position it to center.
class MessageSectionView extends MultiSliver {
...
SliverPinnedHeader(
child: Center(
child: Text(
'${section.id}',
style: const TextStyle(color: Colors.grey),
),
)),
App will be crashed If you wrap SliverPinnedHeader
with Center.
This is finaly result!
If you found this post helpful, please give it a round of applause 👏. Explore more Flutter-related content in my other posts.
For additional insights and updates, check out my LinkedIn profile. Thank you for your support!
Troubleshootings
Locale data has not been initialized, call initializeDateFormatting.
initializeDateFormatting("ko_KR");
Failed assertion: ‘overscroll.isFinite’: is not true.
final endPosition = _scrollController.position.maxScrollExtent;
maxScrollExtent excepting bottomNavigationBar
maxScrollExtent * 2
or delay scrolling
A RenderPointerListener expected a child of type RenderBox but received a child of type RenderMultiSliver.
MultiSliver can’t be used in List Builder. create each by generating list
return MultiSliver(
children: newSections
.map((section) => MessageSectionView(
section: section))
.toList());
References
Flutter: Format a Date with locale using Dart
Using Intl APIs to format date
prafullkumar77.medium.com
https://jake-seo-dev.tistory.com/667https://jake-seo-dev.tistory.com/667