Custom Channel List

This recipe shows how to build a channel list with a fully custom item layout using the low-level StreamChannelListController and a standard ListView.

When to use this approach

Use a custom channel list when the itemBuilder parameter on StreamChannelListView is not flexible enough for your design. The controller handles all data fetching, pagination, and real-time updates, while you own the list rendering.

Implementation

class CustomChannelListPage extends StatefulWidget {
  const CustomChannelListPage({super.key, required this.client});

  final StreamChatClient client;

  @override
  State<CustomChannelListPage> createState() => _CustomChannelListPageState();
}

class _CustomChannelListPageState extends State<CustomChannelListPage> {
  late final StreamChannelListController _controller;

  @override
  void initState() {
    super.initState();
    _controller = StreamChannelListController(
      client: widget.client,
      filter: Filter.in_(
        'members',
        [StreamChat.of(context).currentUser!.id],
      ),
      channelStateSort: const [SortOption<ChannelState>.desc('last_message_at')],
      limit: 20,
    );
    _controller.doInitialLoad();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Messages')),
      body: PagedValueListenableBuilder<int, Channel>(
        valueListenable: _controller,
        builder: (context, value, child) {
          return value.when(
            loading: () => const Center(child: CircularProgressIndicator()),
            error: (error) => Center(child: Text(error.message)),
            data: (channels, nextPageKey, error) {
              return ListView.builder(
                itemCount: channels.length + (nextPageKey != null ? 1 : 0),
                itemBuilder: (context, index) {
                  if (index == channels.length) {
                    _controller.fetchNextPage();
                    return const Center(child: CircularProgressIndicator());
                  }
                  final channel = channels[index];
                  return _ChannelListItem(
                    channel: channel,
                    onTap: () => Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (_) => StreamChannel(
                          channel: channel,
                          child: const ChannelPage(),
                        ),
                      ),
                    ),
                  );
                },
              );
            },
          );
        },
      ),
    );
  }
}

class _ChannelListItem extends StatelessWidget {
  const _ChannelListItem({required this.channel, required this.onTap});

  final Channel channel;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final lastMessage = channel.state?.messages.last;
    return ListTile(
      leading: StreamChannelAvatar(channel: channel),
      title: StreamChannelName(channel: channel),
      subtitle: lastMessage != null ? Text(lastMessage.text ?? '') : null,
      trailing: channel.state?.unreadCount != null &&
              channel.state!.unreadCount > 0
          ? Badge(label: Text('${channel.state!.unreadCount}'))
          : null,
      onTap: onTap,
    );
  }
}

Key components

ComponentPurpose
StreamChannelListControllerFetches, paginates, and streams channel updates
PagedValueListenableBuilderRebuilds the list when the controller emits new data
StreamChannelAvatarRenders the channel's avatar (1:1 or group)
StreamChannelNameRenders the channel's display name