void main() {
final client = StreamChatClient(
'q29npdvqjr99',
logLevel: Level.OFF,
);
runApp(MyApp(client: client));
}
class MyApp extends StatelessWidget {
const MyApp({
Key? key,
required this.client,
}) : super(key: key);
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, child) {
return StreamChat(client: client, child: child);
},
home: const HomeScreen(),
);
}
}
Initialize Stream Chat in Part of the Widget Tree
If you’re creating a full-scale chat application, you probably want to have Stream Chat Flutter initialized at the top of your widget tree and the Stream user connected as soon as they open the application.
However, if you only need chat functionality in a part of your application, then it’ll be better to delay Stream Chat initialization to when it’s needed. This guide demonstrates three alternative ways for you to initialize Stream Chat Flutter for a part of your widget tree and to only connect a user when needed.
What To Keep In Mind?
Before investigating potential solutions, let’s first take a look at the relevant Stream Chat widgets and classes.
Most of the Stream Chat Flutter UI widgets rely on having a StreamChat ancestor in the widget tree. The StreamChat widget is an InheritedWidget that exposes the StreamChatClient through BuildContext. This widget also initializes the StreamChatCore widget and the StreamChatTheme.
StreamChatCore is a StatefulWidget used to react to life cycle changes and system updates. When the app goes into the background, the WebSocket connection is closed. Conversely, a new connection is initiated when the app is back in the foreground.
What is important to take note of is that a connection is only established if a user is connected.
This means that if you have not yet called client.connectUser(user, token)
, no connection will be made, and only background listeners will be registered to determine the app’s foreground state.
Option 1: Builder and Connect/Disconnect User
This option requires you to wrap your whole application with the StreamChat widget and to call connectUser
and disconnectUser
as needed.
This option is the easiest, however, it requires StreamChat to be at the top of each route and as a result, will have a slight overhead as it’ll create the above-mentioned Stream widgets that may not yet be needed.
Exposing the StreamChat Widget
First, you must expose the client and base Stream Chat widgets to the whole application.
In the above code, you:
- Create a StreamChatClient instance
- Pass the instance to the StreamChat widget
- Expose StreamChat to the whole application within the MaterialApp
builder
A few important things to note:
- The
builder
wraps the StreamChat widget for every route of our application. No matter where you are in the widget tree, you’ll be able to callStreamChat.of(context)
. - The state will only be created once for our application, as StreamChat is a StatefulWidget and the position of StreamChat in the widget tree remains the same throughout the application lifecycle.
- No connection will be made until you call
connectUser
.
Connecting and Disconnecting Users
In MaterialApp above, the home page is set to HomeScreen. This screen could look something like the following:
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: const Center(
child: Text('Home Screen'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ChatSetup()));
},
child: const Icon(
Icons.message,
),
),
);
}
}
This screen shows a floating action button that on click navigates the user to the ChatSetup. We want to connect and disconnect a Stream user only when they go to the chat setup screen.
class ChatSetup extends StatefulWidget {
const ChatSetup({
super.key,
});
@override
State<ChatSetup> createState() => _ChatSetupState();
}
class _ChatSetupState extends State<ChatSetup> {
late final Future<OwnUser> connectionFuture;
late final client = StreamChat.of(context).client;
@override
void initState() {
super.initState();
connectionFuture = client.connectUser(
User(id: 'USER_ID'),
'TOKEN',
);
}
@override
void dispose() {
client.disconnectUser();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: FutureBuilder(
future: connectionFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Center(
child: CircularProgressIndicator(),
);
default:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return const ChannelListPage();
}
}
},
),
);
}
}
Within initState
you call connectUser
; once the future for connectUser
has completed a connection to the Stream API is established, you can then display the relevant Stream Chat UI widgets.
Once the ChatScreen widget is disposed of, then disconnectUser
will be called within the dispose
method.
Caution
In this example, disconnectUser
will only be called when the ChatSetup widget is disposed.
You need to ensure that this widget (route) is completely disposed of, or you need to call disconnectUser
and connectUser
manually when navigating to relevant parts of your application.
For example, let’s say you have a button within one of the chat screens to navigate to a completely different part of your app, then you want to make sure the ChatScreen route is disposed of by forcing it to be removed:
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: ((context) => const HomeScreen()),
),
(Route<dynamic> route) => false,
);
Or let’s say you wanted to pop back to the first route:
Navigator.of(context).popUntil((route) => route.isFirst);
Both of these will ensure the route is disposed of and the user is disconnected as a result.
Option 2: A Nested Navigator
Another approach would be to introduce a new Navigator. This has the benefit that everything related to Stream chat is contained to a specific part of the widget tree.
Defining Routes and Nested Routes
In this example, our application has the following routes.
const routeHome = '/';
const routePrefixChat = '/chat/';
const routeChatHome = '$routePrefixChat$routeChatChannels';
const routeChatChannels = 'chat_channels';
const routeChatChannel = 'chat_channel';
For the /chat/
nested routes (routePrefixChat), this approach initializes Stream Chat in our application and introduces a nested navigator.
Let’s explore the code:
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({
Key? key,
}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return Provider.value(
value: navigatorKey,
child: MaterialApp(
navigatorKey: navigatorKey,
initialRoute: routeHome,
onGenerateRoute: (settings) {
late Widget page;
if (settings.name == routeHome) {
page = const HomeScreen();
} else if (settings.name!.startsWith(routePrefixChat)) {
final subRoute = settings.name!.substring(routePrefixChat.length);
page = ChatSetup(
setupChatRoute: subRoute,
);
} else {
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
},
),
);
}
}
In the above code you’re:
- Creating a navigator key, passing it to
MaterialApp
, and exposing it to the whole application using Provider (you can expose it however you want). - Creating
onGenerateRoute
that specifies what page to show depending on the route. Most importantly, if the route contains the routePrefixChat, it navigates to the ChatSetup page and passes in the remainder of the route.
For example, Navigator.pushNamed(context, routeChatHome)
will navigate to the ChatSetup page and pass in the nested route routeChatChannels.
Stream Chat Initialization, User Connection, and Nested Navigation
Within the ChatSetup widget, we’ll initialize Stream chat, connect a user, and create a new Navigator that handles the sub-navigation for the chat-specific pages.
class ChatSetup extends StatefulWidget {
const ChatSetup({Key? key, required this.setupChatRoute}) : super(key: key);
final String setupChatRoute;
@override
State<ChatSetup> createState() => _ChatSetupState();
}
class _ChatSetupState extends State<ChatSetup> {
late final Future<OwnUser> connectionFuture;
late final client = StreamChatClient(
'KEY',
logLevel: Level.OFF,
);
@override
void initState() {
super.initState();
connectionFuture = client.connectUser(
User(id: 'USER_ID'),
'TOKEN',
);
}
@override
void dispose() {
client.disconnectUser();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
child: StreamChat(
client: client,
child: FutureBuilder(
future: connectionFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Center(
child: CircularProgressIndicator(),
);
default:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return Navigator(
initialRoute: widget.setupChatRoute,
onGenerateRoute: _onGenerateRoute,
);
}
}
},
),
),
);
}
Route _onGenerateRoute(RouteSettings settings) {
late Widget page;
switch (settings.name) {
case routeChatChannels:
page = ChannelListPage(
client: client,
);
break;
case routeChatChannel:
final channel = settings.arguments as Channel;
page = StreamChannel(
channel: channel,
child: const ChannelPage(),
);
break;
default:
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
}
}
This ChatSetup widget is similar to what it was in the first option, the only difference is that we’re also introducing a nested navigator and handling those nested routes.
The steps for the above code are:
- Create a StreamChatClient instance
- Call
connectUser
withininitState
and await the result using a FutureBuilder - Introduce a StreamChat widget into the widget tree
- Introduce a new Navigator for the chat-specific routes
- Call
disconnectUser
withindispose
Displaying Stream Chat UI Widgets and Global Navigation
Then finally, the ChannelListPage could look something like the following:
class ChannelListPage extends StatefulWidget {
const ChannelListPage({
super.key,
required this.client,
});
final StreamChatClient client;
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final _controller = StreamChannelListController(
client: widget.client,
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
),
channelStateSort: const [SortOption('last_message_at')],
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _popChatPages() {
final nav = context.read<GlobalKey<NavigatorState>>();
nav.currentState!.pop();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
_popChatPages();
return true;
},
child: Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () {
_popChatPages();
},
),
),
body: RefreshIndicator(
onRefresh: _controller.refresh,
child: StreamChannelListView(
controller: _controller,
onChannelTap: (channel) {
Navigator.pushNamed(context, routeChatChannel,
arguments: channel);
},
),
),
),
);
}
}
There are two important things to note in the above code:
- We’re accessing the global NavigatorState using Provider and calling
pop()
on the back press. This will ensure that this entire route is disposed of and that the Stream connection is closed. - Within
onChannelTap
we’re callingpushNamed
and passing in the route to display a single channel page. This uses the navigator introduced in ChatSetup, which is the closest navigator within the widget tree.
A few things to take note of:
- You’ll always need to access the global navigator if you want to dispose of this route. you need to ensure that this route is popped or replaced, otherwise, the Stream connection will remain active for as long as the chat route is on the stack (or manually disconnected).
Option 3: Navigator 2.0 - Using GoRouter
This final example will be a combination of the first two options. The following code is an example of how to initialize Stream Chat in a part of the widget tree using the GoRouter package. This solution will change depending on how you do routing in your Flutter application and which package (if any) you use.
Application Routes and Conditional Stream Initialization
For this example we have the following routes and nested routes:
|_ '/' -> home page
|_ 'settings/' - settings page
|_ 'chat/' - chat home, shows the channels list page
|_ 'channel/' - specific channel page
In the MyApp widget, we’ll initialize GoRouter and the StreamChatClient.
However, we will only expose the StreamChat widget for certain routes.
Additionally, we’ll create a ChatSetup widget that connects and disconnects the user.
This widget will also only be injected for the /chat
routes.
class MyApp extends StatefulWidget {
const MyApp({
Key? key,
}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _client = StreamChatClient(
'q29npdvqjr99',
logLevel: Level.OFF,
);
bool wasPreviousRouteChat = false;
late final _router = GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) {
if (state.uri.host.startsWith('/chat')) {
wasPreviousRouteChat = true;
return StreamChat(
client: _client,
child: ChatSetup(client: _client, child: child),
);
} else {
if (wasPreviousRouteChat) {
wasPreviousRouteChat = false;
return StreamChat(
client: _client,
child: child,
);
} else {
return child;
}
}
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: 'chat',
builder: (context, state) => const ChannelListPage(),
routes: [
GoRoute(
path: 'channel',
builder: (context, state) {
final channel = state.extra as Channel;
return StreamChannel(
channel: channel,
child: const ChannelPage(),
);
},
),
],
),
],
),
],
),
],
);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: _router.routeInformationParser,
routeInformationProvider: _router.routeInformationProvider,
routerDelegate: _router.routerDelegate,
);
}
}
If you’re unfamiliar with GoRouter it will help to first read the documentation and then come back to this guide.
There are a few important things to note in the above code:
- Define our routes and nested routes
- Specify the initial route with
initialLocation
- Use the
navigatorBuilder
to wrap certain routes with StreamChat and ChatSetup.- The
wasPreviousRouteChat
**boolean is used to determine if the previous route was a chat route. This is important because when you press the back button from the/chat
route and navigate to the/
route, theStreamChatwidget still needs to be accessible while the navigation transition occurs. However, if you then navigate to the/setting
route, you no longer need theStreamChat** widget and can safely remove it.
- The
Navigating With GoRouter
Within the HomeScreen you can create a button that on press navigates to the /chat
route:
GoRouter.of(context).go('/chat');
Connecting and Disconnecting Users
Same as before, we’ll use the ChatSetup widget to connect and disconnect a user. This time the widget also takes in a child widget to display once the connection is finished. The child widget is dependent on the route we’re navigating to.
class ChatSetup extends StatefulWidget {
const ChatSetup({
Key? key,
required this.client,
required this.child,
}) : super(key: key);
final StreamChatClient client;
final Widget child;
@override
State<ChatSetup> createState() => _ChatSetupState();
}
class _ChatSetupState extends State<ChatSetup> {
late final Future<OwnUser> connectionFuture;
@override
void initState() {
super.initState();
connectionFuture = widget.client.connectUser(
User(id: 'USER_ID'),
'TOKEN',
);
}
@override
void dispose() {
widget.client.disconnectUser();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
child: FutureBuilder(
future: connectionFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Center(
child: CircularProgressIndicator(),
);
default:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return widget.child;
}
}
},
),
);
}
}
This will connect the Stream chat user within initState
and disconnect the user on dispose
. This is similar to the previous examples we explored.
Displaying the Stream UI Widgets
The ChannelListPage can look something like the following:
class ChannelListPage extends StatefulWidget {
const ChannelListPage({
super.key,
});
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final client = StreamChat.of(context).client;
late final _controller = StreamChannelListController(
client: client,
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
),
channelStateSort: const [SortOption('last_message_at')],
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: RefreshIndicator(
onRefresh: _controller.refresh,
child: StreamChannelListView(
controller: _controller,
onChannelTap: (channel) {
GoRouter.of(context).go('/chat/channel', extra: channel);
},
),
),
);
}
}
This is the same as before, the only difference is that the navigation is slightly different; now we’re navigating to the /chat/channel
route for individual channel pages.
Conclusion
This guide demonstrated three different ways to initialize Stream Chat in a part of the Flutter widget tree.
There are two key takeaways
- Accessing StreamChat depends on your location in the widget tree
- How you ultimately decide to expose StreamChat and connect users will be up to your application architecture and how you manage routing.
The above are only examples that can be refined and tweaked to suit your needs.