Build an iMessage Clone with Stream’s Flutter Chat SDK

Sacha A.
Nash R.
Sacha A. & Nash R.
Published February 26, 2021 Updated June 14, 2021

In this tutorial, we’ll build a functional clone of iMessage using Stream Chat Flutter SDK. Building a chat in your app at scale is not an easy task; but in this tutorial, you’ll get a chat experience up and running in roughly 20 minutes! You can follow this tutorial without a deep knowledge of Flutter, a basic understanding of the layout system, Row and Column, will do.

There are three ways to use our SDKs. Depending on how much abstraction you want.

Since we are trying to reproduce the look and feel of an app, in this tutorial we'll use the way that give us the most degrees of liberty for customization: stream_chat_flutter_core. Also, it is also worth to note that to stay consistent with stream_chat_flutter package we'll keep the naming convention the same for widgets.

If you get lost during this tutorial, you can check:

The result of our application will look similar to the following screenshots:

Screenshots of the iMessage Clone with Stream's Flutter SDK

Let’s get started! 🚀

Prerequisites

To proceed with this tutorial, you'll need the following:

Install the sdk in pubspec.yaml:

yaml
1
2
dependencies: stream_chat_flutter: ^1.3.0-beta

Don't forget to tap in your terminal flutter packages getto download the depedencies. But usually, if you use vscode they will be downloaded on save or when you run your code.

Let's first build the static views then sprinkle 🪄 some Stream Flutter SDK magic here and there.

Shia Labeouf Magic

We'll first build the list view listing all conversations then the detailed view of those conversations.

ChannelPreview

Let's start with showing up a preview of the conversations
In each item of the list we need to display three things:

  • the contact that sent the message, including its avatar and name
  • a preview of the message. If there is a media, show a little emoji indicating if it is an image or a video (yes our product support all of that 😉)
  • the day of the week and the hour at which the message was received. If it was more than a week ago we'll have to change the format for a nicer UX.

Since iMessage is an iPhone app we'll use Cupertino Widgets, which are high-fidelity widgets for current iOS design system.

Make it interactive

To make our ChannelPreview widget interactive, we use GestureDetector, among other things this widget is used for handling taps.

import 'package:flutter/cupertino.dart';
import 'package:imessage/channel_image.dart';
import 'package:imessage/channel_name_text.dart';
import 'package:imessage/utils.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart' show Channel;

import 'utils.dart';

class ChannelPreview extends StatelessWidget {
  final VoidCallback onTap; // onTap is a callback in our widget that we'll be used later on for navigation purposes
  final Channel channel;

  const ChannelPreview({
    Key key,
    @required this.onTap,
    @required this.channel,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final lastMessage =
        channel.state.messages.isNotEmpty ? channel.state.messages.last : null; // if the message has attachments

    final prefix = lastMessage?.attachments != null
        ? lastMessage?.attachments
            ?.map((e) {
              if (e.type == 'image') { // and the attachment is of type image
                return '📷 '; // we prefix the message with a camera emoji
              } else if (e.type == 'video') {
                return '🎬 '; // or clap emoji if it is a video
              }
              return null;
            })
            ?.where((e) => e != null)
            ?.join(' ')
        : '';
    return GestureDetector(
      onTap: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        constraints: BoxConstraints.tightFor(
          height: 90,
        ),
        child: Padding(
          padding: const EdgeInsets.symmetric(
            horizontal: 8.0,
          ),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding:
                    const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
                child: ChannelImage(
                  channel: channel,
                  size: 50,
                ),
              ),
              Expanded(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 8.0),
                          child: ChannelNameText(
                            channel: channel,
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 8.0),
                          child: Row(
                            children: [
                              Text(
                                isSameWeek(channel.lastMessageAt)
                                    ? formatDateSameWeek(channel.lastMessageAt)
                                    : formatDate(channel.lastMessageAt),
                                style: TextStyle(
                                  fontSize: 15,
                                  color: CupertinoColors.systemGrey,
                                ),
                              ),
                              Icon(
                                CupertinoIcons.right_chevron,
                                color: CupertinoColors.systemGrey3,
                              ),
                            ],
                          ),
                        )
                      ],
                    ),
                    Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text(
                        '$prefix${lastMessage?.text ?? ''}',
                        style: TextStyle(
                          fontWeight: FontWeight.normal,
                          color: CupertinoColors.systemGrey,
                          fontSize: 16,
                        ),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    Divider(),
                  ],
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

Formating dates 📅

To format the date we'll use the intl package is usually used to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. Let's define these utility functions in utils.dart

import 'package:intl/intl.dart';

String formatDate(DateTime date) { // 21/01/20 3:31pm
  final dateFormat = DateFormat.yMd().add_jm();
  return dateFormat.format(date);
}

String formatDateSameWeek(DateTime date) {
  DateFormat dateFormat;
  if (date.day == DateTime.now().day) {
    dateFormat = DateFormat('hh:mm a');// 3:31pm
  } else {
    dateFormat = DateFormat('EEEE, hh:mm a'); // Wednesday, 3:31pm
  }
  return dateFormat.format(date);
}

String formatDateMessage(DateTime date) {
  final dateFormat = DateFormat('EEE. MMM. d ' 'yy' '  hh:mm a'); // Wed. jan. 20 2021 3:31pm
}
  return dateFormat.format(date);
}

bool isSameWeek(DateTime timestamp) =>
    DateTime.now().difference(timestamp).inDays < 7;

ChannelListView 📺

To build our listview we'll use a Sliver widget. A sliver is just a portion of a scrollable area. Let's call our widget ChannelListView because in Stream, since we have different use cases such as livestream, customer support etc conversations happen in channels.

import 'package:flutter/cupertino.dart';
import 'package:imessage/channel_preview.dart';
import 'package:imessage/message_page.dart';
import 'package:animations/animations.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show Channel, StreamChannel;

class ChannelListView extends StatelessWidget {
  const ChannelListView({Key key, @required this.channels}) : super(key: key);
  final List<Channel> channels;
  @override
  Widget build(BuildContext context) {
    channels.removeWhere((channel) => channel.lastMessageAt == null);
    return SliverList(
      delegate: SliverChildBuilderDelegate(
        (
          BuildContext context,
          int index,
        ) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 4.0),
            child: ChannelPreview(
              channel: channels[index],
              onTap: () { // remember our onTap callback? Here it is
                Navigator.push( // We use Navigator.push to navigate to our MessagePage
                  context,
                  PageRouteBuilder(
                    pageBuilder: (_, __, ___) => StreamChannel( // So that we can do StreamChannel.of(context) in MessagePage
                      channel: channels[index],
                      child: MessagePage(),
                    ),
                    transitionsBuilder: (
                      _,
                      animation,
                      secondaryAnimation,
                      child,
                    ) =>
                        SharedAxisTransition( // fancy transition
                      child: child,
                      animation: animation,
                      secondaryAnimation: secondaryAnimation,
                      transitionType: SharedAxisTransitionType.horizontal,
                    ),
                  ),
                );
              },
            ),
          );
        },
        childCount: channels.length,
      ),
    );
  }
}

ChatLoader ⌛

Let's add a nice AppBar and wrap this in a widget that we call ChatLoader don't worry about channels parameters we'll explain later where does it come from.

class ChatLoader extends StatelessWidget {
  const ChatLoader({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
        child:  CustomScrollView(
                        slivers: [
                          ChannelPageAppBar(),
                          SliverPadding(
                            sliver: ChannelListView(channels: channels),
                            padding: const EdgeInsets.only(top: 16),
                          )
                        ],
                      )
    );
  }
}

MessagePage 📄

Now that we have our listview displaying a preview of ours conversations. We need to navigate to individual items. Let's call those items that hold each conversation MessagePage, since it displays messages. We'll need a navigation bar to display the contact with (avatar and name) whom we are having the discussion with and display the list of the messages. Let's call it MessageListView. Again, forget about messages parameter here. We'll see how we can add them with our Chat SDK.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
import 'package:imessage/channel_image.dart';
import 'package:imessage/channel_name_text.dart';
import 'package:imessage/message_list_view.dart';

class MessagePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Column(
          children: [
            ChannelImage(size: 25, channel:channel),//contact's avatar
            ChannelNameText(channel:channel), //contact's name or phone number
          ],
        ),
      ), //ChannelHeader
      child: MessageListView(messages: messages)//The List of messages
    );
  }
}

MessageListView

In MessageListView we'll group messages by day like in the real app and change the color and display based on whether this is a message we sent or received. We'll need to "draw" a chat bubble. We'll also need an input to send those messages and attach medias to them.

Group Messages by Date

To group messages by day we use the function groupBy to group the elements in values by the value returned by key, from the collection package.

import 'package:collection/collection.dart';

  final entries = groupBy(messages.reversed,
            (Message message) => message.createdAt.toString().substring(0, 10))
        .entries
        .toList();

End Result

Then it's a matter of getting the date using entries[index].key and the list of messages entries[index].value and wrap this in a ListView builder as follow.

At the end, this is what the MessageListView widget will look like:

import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:imessage/message_header.dart';
import 'package:imessage/message_input.dart';
import 'package:imessage/message_widget.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show Message, StreamChatCore;

class MessageListView extends StatelessWidget {
  const MessageListView({Key key, this.messages}) : super(key: key);
  final List<Message> messages;

  @override
  Widget build(BuildContext context) {
    final entries = groupBy(messages,
            (Message message) => message.createdAt.toString().substring(0, 10))
        .entries
        .toList();
    return Column(
      children: [
        Expanded(
          child: SizedBox(
              height: MediaQuery.of(context).size.height * 0.9,
              child: Align(
                alignment: FractionalOffset.topCenter,
                child: ListView.builder(
                    reverse: true,
                    itemCount: entries.length,
                    itemBuilder: (context, index) {
                      return Column(
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 8.0),
                            child: MessageHeader(
                                rawTimeStamp: entries[index].key), //date
                          ),
                          ...entries[index]
                              .value //messages
                              .map((message) {
                                return MessageWidget(
                                  alignment: isReceived(message, context)
                                      ? Alignment.centerLeft
                                      : Alignment.topRight,
                                  color: isReceived(message, context)
                                      ? CupertinoColors.systemGrey5
                                      : CupertinoColors.systemBlue,
                                  messageColor: isReceived(message, context)
                                      ? CupertinoColors.black
                                      : CupertinoColors.white,
                                  message: message,
                                );
                              })
                              .toList()
                              .reversed,
                        ],
                      );
                    }),
              )),
        ),
        MessageInput()
      ],
    );
  }

  bool isReceived(Message message, BuildContext context) {
    final currentUserId = StreamChatCore.of(context).user.id;
    return message.user.id == currentUserId;
  }

  bool isSameDay(Message message) =>
      message.createdAt.day == DateTime.now().day;
}

MessageWidget

In our MessageWidget we want to check attachment type type, if it is an image or video, and display it accordingly

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:imessage/cutom_painter.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show Message;

class MessageWidget extends StatelessWidget {
  final Alignment alignment;
  final Message message;
  final Color color;
  final Color messageColor;

  const MessageWidget(
      {Key key,
      @required this.alignment,
      @required this.message,
      @required this.color,
      @required this.messageColor})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (message.attachments?.isNotEmpty == true &&
        message.attachments.first.type == "image") {
      return MessageImage(
          color: color, message: message, messageColor: messageColor);
    } else {
      return MessageText(
          alignment: alignment,
          color: color,
          message: message,
          messageColor: messageColor);
    }
  }
}

class MessageImage extends StatelessWidget {
  const MessageImage({
    Key key,
    @required this.color,
    @required this.message,
    @required this.messageColor,
  }) : super(key: key);

  final Color color;
  final Message message;
  final Color messageColor;

  @override
  Widget build(BuildContext context) {
    if (message.text != null) {
      return Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            ClipRRect(
              borderRadius: BorderRadius.circular(20),
              child: Container(
                color: color,
                child: Column(
                  children: [
                    if (message.attachments.first.file != null)
                      Image.memory(
                        message.attachments.first.file.bytes,
                        fit: BoxFit.cover,
                      )
                    else
                      CachedNetworkImage(
                        imageUrl: message.attachments.first.thumbUrl ??
                            message.attachments.first.imageUrl ??
                            message.attachments.first.assetUrl,
                      ),
                    if (message.attachments.first?.title != null)
                      Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Text(message.attachments.first.title,
                            style: TextStyle(color: messageColor)),
                      ),
                    message.attachments.first.pretext != null
                        ? Text(message.attachments.first.pretext)
                        : Container()
                  ],
                ),
              ),
            )
          ],
        ),
      );
    } else {
      return ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: Container(
            color: color,
            child: CachedNetworkImage(
              imageUrl: message.attachments.first.thumbUrl,
            )),
      );
    }
  }
}

class MessageText extends StatelessWidget {
  const MessageText({
    Key key,
    @required this.alignment,
    @required this.color,
    @required this.message,
    @required this.messageColor,
  }) : super(key: key);

  final Alignment alignment;
  final Color color;
  final Message message;
  final Color messageColor;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Align(
        alignment:
            alignment, //Change this to Alignment.topRight or Alignment.topLeft
        child: CustomPaint(
          painter: ChatBubble(color: color, alignment: alignment),
          child: Container(
            margin: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Container(
                  constraints: BoxConstraints(
                      maxWidth: MediaQuery.of(context).size.width * 0.65),
                  child: Padding(
                    padding: const EdgeInsets.all(4.0),
                    child: Text(
                      message.text,
                      style: TextStyle(color: messageColor),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

ChatBubble 💬

To draw our bubble we use a CustomPainter, which is a widget used to draw custom shapes and paths. The api surface is a bit like html canvas (if you are familiar with it). Let's simply call this widget ChatBubblethat takes into parameters the color and the alignment. We'll display the bubble differently according to alignment.

class ChatBubble extends CustomPainter {
  final Color color;
  final Alignment alignment;

  ChatBubble({
    @required this.color,
    this.alignment,
  });

  final _radius = 10.0;
  final _x = 10.0;

  @override
  void paint(Canvas canvas, Size size) {
    if (alignment == Alignment.topRight) {
      canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            0,
            0,
            size.width - 8,
            size.height,
            bottomLeft: Radius.circular(_radius),
            topRight: Radius.circular(_radius),
            topLeft: Radius.circular(_radius),
          ),
          Paint()
            ..color = color
            ..style = PaintingStyle.fill);
      var path = Path();
      path.moveTo(size.width - _x, size.height - 20);
      path.lineTo(size.width - _x, size.height);
      path.lineTo(size.width, size.height);
      canvas.clipPath(path);
      canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            size.width - _x,
            0.0,
            size.width,
            size.height,
            topRight: Radius.circular(_radius),
          ),
          Paint()
            ..color = color
            ..style = PaintingStyle.fill);
    } else {
      canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            _x,
            0,
            size.width,
            size.height,
            bottomRight: Radius.circular(_radius),
            topRight: Radius.circular(_radius),
            topLeft: Radius.circular(_radius),
          ),
          Paint()
            ..color = color
            ..style = PaintingStyle.fill);
      var path = Path();
      path.moveTo(0, size.height);
      path.lineTo(_x, size.height);
      path.lineTo(_x, size.height - 20);
      canvas.clipPath(path);
      canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            0,
            0.0,
            _x,
            size.height,
            topRight: Radius.circular(_radius),
          ),
          Paint()
            ..color = color
            ..style = PaintingStyle.fill);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

Message Input

In our view now that we have displayed the messages we need to send a message. To do so, let's call our widgetMessageInput. We use the ImagePicker plugin to take a photo from the gallery and uploading it along with our message.

import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:image_picker/image_picker.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show Attachment, AttachmentFile, Message, MultipartFile, StreamChannel;

class MessageInput extends StatefulWidget {
  const MessageInput({
    Key key,
  }) : super(key: key);

  @override
  _MessageInputState createState() => _MessageInputState();
}

class _MessageInputState extends State<MessageInput> {
  final textController = TextEditingController();
  File _image;
  final picker = ImagePicker();

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: FractionalOffset.bottomCenter,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 32.0),
        child: Row(
          children: [
            GestureDetector(
              onTap: () async {
                final pickedFile =
                    await picker.getImage(source: ImageSource.gallery);
                final bytes = await File(pickedFile.path).readAsBytes();
                final channel = StreamChannel.of(context).channel;
                final message =
                    Message(text: textController.value.text, attachments: [
                  Attachment(
                    type: 'image',
                    file: AttachmentFile(bytes: bytes, path: pickedFile.path),
                  ),
                ]);
                await channel.sendMessage(message);
              },
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Icon(
                  CupertinoIcons.camera_fill,
                  color: CupertinoColors.systemGrey,
                  size: 35,
                ),
              ),
            ),
            Expanded(
              child: CupertinoTextField(
                controller: textController,
                onSubmitted: (input) async {
                  await sendMessage(context, input);
                },
                placeholder: 'Text Message',
                prefix: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(
                        "") //trick to add padding around  placeholder iMessage text
                    ),
                suffix: GestureDetector(
                  onTap: () async {
                    if (textController.value.text.isNotEmpty) {
                      await sendMessage(context, textController.value.text);
                      textController.clear();
                    }
                  },
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Icon(CupertinoIcons.arrow_up_circle_fill,
                        color: CupertinoColors.activeGreen, size: 35),
                  ),
                ),
                decoration: BoxDecoration(
                    border: Border.all(
                      color: CupertinoColors.systemGrey,
                    ),
                    borderRadius: BorderRadius.all(Radius.circular(35))),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> sendMessage(BuildContext context, String input) async {
    final streamChannel = StreamChannel.of(context);
    await streamChannel.channel.sendMessage(Message(text: input));
  }
}

Spicing it up with Stream Chat SDK 🌶️

Now that everything is setup. "How do we make an actual chat?" You may ask.

Well that's simple, let's initialize our SDK and runApp to run our top widget IMessage that we haven't defined yet, until now.

For this to work you'll need an api key, and an user token that you can get in your dashboard.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final client = StreamChatClient('b67pax5b2wdq', logLevel: Level.INFO); //we want to see debug 
                                                                         //logs in our terminal
  await client.connectUser(
    User(
      id: 'cool-shadow-7',
      extraData: {
        'image':
            'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow',
      },
    ),
    'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo',
  );

  runApp(IMessage(client: client));
}

class IMessage extends StatelessWidget {
  final StreamChatClient client;
  IMessage({@required this.client});
  @override
  Widget build(BuildContext context) {
    initializeDateFormatting('en_US', null);//we need this for dateFormat utils
    return CupertinoApp(
      title: 'Flutter Demo',
      theme: CupertinoThemeData(brightness: Brightness.light),
      home: StreamChatCore(client: client, child: ChatLoader()),
    );
  }
}

ChannelListCore

Remember when we set up our ChatLoader ? ChannelListView was taking the parameter channels but we did'nt explained where does it come from. Now is the time to add the missing piece. Let me present you, your two new best friends: ChannelsBlocand ChannelListCore. ChannelListCore exposes builders to let you customize how to handle errors, loading progress, but most importantly it exposes List<Channel>. It also has options for filters, sorting, and pagination.

Our new ChatLoader we'll look like this:

import 'package:flutter/cupertino.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show
        Channel,
        ChannelListController,
        ChannelListCore,
        ChannelsBloc,
        LazyLoadScrollView,
        Level,
        PaginationParams,
        SortOption,
        StreamChatCore,

import 'package:imessage/channel_list_view.dart';

import 'package:imessage/channel_page_appbar.dart';


class ChatLoader extends StatelessWidget {
  ChatLoader({
    Key key,
  }) : super(key: key);

  final channelListController = ChannelListController();

  @override
  Widget build(BuildContext context) {
    final user = StreamChatCore.of(context).user;
    return CupertinoPageScaffold(
        child: ChannelsBloc(
            child: ChannelListCore(
                channelListController: channelListController,
                filter: {
                  'members': {
                    r'$in': [user.id],
                  },
                  'type': {
                    r'$eq': 'messaging',
                  },
                },
                sort: [SortOption('last_message_at')],
                pagination: PaginationParams(
                  limit: 20,
                ),
                emptyBuilder: (BuildContext context) {
                  return Center(
                    child: Text('Looks like you are not in any channels'),
                  );
                },
                loadingBuilder: (BuildContext context) {
                  return Center(
                    child: SizedBox(
                      height: 100.0,
                      width: 100.0,
                      child: CupertinoActivityIndicator(),
                    ),
                  );
                },
                errorBuilder: (BuildContext context, dynamic error) {
                  return Center(
                    child: Text(
                        'Oh no, something went wrong. Please check your config.'),
                  );
                },
                listBuilder: (
                  BuildContext context,
                  List<Channel> channels,
                ) =>
                    LazyLoadScrollView(
                      onEndOfPage: () async {
                        channelListController.paginateData();
                      },
                      child: CustomScrollView(
                        slivers: [
                          CupertinoSliverRefreshControl(onRefresh: () async {
                            channelListController.loadData();
                          }),
                          ChannelPageAppBar(),
                          SliverPadding(
                            sliver: ChannelListView(channels: channels),
                            padding: const EdgeInsets.only(top: 16),
                          )
                        ],
                      ),
                    ))));
  }
}

LazyLoadScrollView 🦥

LazyLoadScrollView, is a wrapper around a Scrollable which triggers onEndOfPage/onStartOfPage when the Scrollable reaches to the start or end of the view extent. It exposes callbacks like onRefresh which comes handy in our case with controller.loadData() and controller.paginateData()

MessageListCore

Same patterns go for MessagePage, thanks to MessageListCore you can have access to different builders, including the one exposing List<Message>:

import 'package:flutter/cupertino.dart';
import 'package:imessage/message_list_view.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    show
        LazyLoadScrollView,
        MessageListController,
        MessageListCore,
        StreamChannel,
        StreamChatCore;

import 'package:imessage/channel_image.dart';
import 'package:imessage/channel_name_text.dart';

class MessagePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final streamChannel = StreamChannel.of(context);

    var messageListController = MessageListController();
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Column(
          children: [
            ChannelImage(
              size: 25,
              channel: streamChannel.channel,
            ),
            ChannelNameText(
              size: 16,
              channel: streamChannel.channel,
            ),
          ],
        ),
      ), //ChannelHeader
      child: StreamChatCore(
          client: streamChannel.channel.client,
          child: MessageListCore(
              messageListController: messageListController,
              loadingBuilder: (context) {
                return Center(
                  child: CupertinoActivityIndicator(),
                );
              },
              errorWidgetBuilder: (context, err) {
                return Center(
                  child: Text('Error'),
                );
              },
              emptyBuilder: (context) {
                return Center(
                  child: Text('Nothing here...'),
                );
              },
              messageListBuilder: (context, messages) => LazyLoadScrollView(
                    onStartOfPage: () async {
                      messageListController.paginateData();
                    },
                    child: MessageListView(
                      messages: messages,
                    ),
                  ))),
    );
  }
}

Congratulations! 👏

This concludes part one of our tutorial on building a iMessage clone using Stream’s Flutter Chat SDK. I hope you found this tutorial useful, and I look forward to hearing your feedback.

In a next article – which will be published later – we will cover how to implement a feature such as Search to search through messages.

Happy coding!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->