A StreamController
in Dart lets you create and control a stream by hand. You decide what data goes into the stream, when it goes in, and when it ends.
This is useful when you don’t have data ready all at once. Instead, you want to send data step by step — like game updates, user actions, or timed messages.
In short, a StreamController
gives you full control of your stream: you’re the boss of the stream data.
Creating a Basic Stream Controller
To start using a stream controller, you create one with StreamController<Type>()
. You can then add data to it using .add()
. Anything you add will be sent to listeners.
In this example, we send simple greeting messages:
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen((msg) => print('Received: $msg'));
controller.add('Hello');
controller.add('Hi');
controller.add('Hey');
controller.close();
}
This code prints each message as it’s added. Once you’re done adding, call .close()
to finish the stream.
Adding Events Manually
With a StreamController
, you can manually send:
- Regular data using
.add()
- Errors using
.addError()
- A close signal using
.close()
Here’s a fun example of a joke teller that ends with an error:
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen(
(joke) => print('Joke: $joke'),
onError: (e) => print('Oops: $e'),
onDone: () => print('No more jokes!'),
);
controller.add('Why did the chicken cross the road?');
controller.add('To get to the other side!');
controller.addError('That joke was too old');
controller.close();
}
This shows how you can fully control what happens in your stream — good messages, errors, and when it ends.
Using sink
to Add Data
A StreamController
includes a .sink
property that provides a view limited to the StreamSink
interface. This means through .sink
, you can only add data, add errors, or close the stream—but you cannot listen or manage the stream itself. Using .sink
offers a cleaner and safer way to send events, keeping the rest of the controller’s features hidden.
This separation helps you write clearer code and better control what parts of your program can add data to the stream. It is especially useful when passing the controller around, so other parts only get access to .sink
and can’t accidentally listen or close the stream early.
In short, .sink
acts as a simple, one-way channel for sending events into the stream, making your code tidier and more secure.
Here’s an example that sends fruit names using the sink:
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen((fruit) => print('Fruit: $fruit'));
controller.sink.add('Apple');
controller.sink.add('Banana');
controller.sink.add('Cherry');
controller.sink.close();
}
Using .sink
provides a simpler, safer way to add data and close the stream. It hides the full controller features, making your code cleaner and easier to manage—especially when sharing the controller across different parts of your app.
Broadcast Controllers
Sometimes, you want more than one listener to receive events from the same stream. For this, you create a broadcast StreamController
using .broadcast()
.
A broadcast controller lets multiple listeners subscribe and get the same events.
Here’s a fun example where two players listen to game events:
import 'dart:async';
void main() {
var controller = StreamController<String>.broadcast();
controller.stream.listen((event) => print('Player 1: $event'));
controller.stream.listen((event) => print('Player 2: $event'));
controller.add('Game Started');
controller.add('Level Up!');
controller.close();
}
Both players receive every event because the controller is broadcast. This is perfect for things like game updates or chat messages where many parts of your app need the same data.
Controlling Streams with Timer
You can use a StreamController
to send data on a timer, adding events at set intervals.
Here’s a countdown example where the controller sends numbers every second until it reaches zero:
import 'dart:async';
void main() {
var controller = StreamController<int>();
int count = 3;
controller.stream.listen(
(num) => print('Countdown: $num'),
onDone: () => print('Go!'),
);
Timer.periodic(Duration(seconds: 1), (timer) {
if (count == 0) {
timer.cancel();
controller.close();
} else {
controller.add(count--);
}
});
}
This shows how a stream controller can work with timers to create timed events or animations in your app.
Passing Controller Between Functions
You can share a StreamController
across different parts of your code by passing it between functions. One function can add events, while another listens to them.
Here’s an example where one function sends game messages, and the main function listens:
import 'dart:async';
void startGame(StreamSink<String> sink) {
sink.add('Start!');
sink.add('Get ready!');
// Yo, no further streams will be added.
sink.close();
}
void main() {
var controller = StreamController<String>();
controller.stream.listen((msg) => print('Game: $msg'));
// Pass only the sink to startGame, so it can add events safely
startGame(controller.sink);
// Close the controller after sending events
controller.close();
}
By passing the .sink
instead of the full controller, you limit what startGame
can do—it can only add data or close the sink but cannot listen or close the controller itself. This helps keep your code safer and easier to manage.
Using Generic Types with Controllers
StreamController
works with any data type, including custom classes. This lets you stream rich data, not just simple strings or numbers.
Here’s an example streaming custom Animal
objects:
import 'dart:async';
class Animal {
final String name;
Animal(this.name);
}
void main() {
var controller = StreamController<Animal>();
controller.stream.listen((animal) => print('Animal: ${animal.name}'));
controller.add(Animal('Tiger'));
controller.add(Animal('Panda'));
controller.close();
}
This way, you can build streams that carry detailed information, keeping your data organized and meaningful.
StreamController in Widgets (Bonus)
In Flutter, StreamController
is very useful for updating UI in real-time.
You can connect the controller’s stream to a widget like StreamBuilder
to rebuild the UI when new data arrives.
Here’s a simple example line showing how to use it:
StreamBuilder<String>(
stream: controller.stream,
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Waiting...');
},
);
This lets your app react smoothly to data changes, making your UI lively and interactive.
Conclusion
A StreamController
lets you create your own streams by adding, listening to, and closing events whenever you want. It works with many data types—strings, numbers, custom objects—and fits well with timers or multiple listeners.
The simple rule: whenever you want full control over a stream’s data flow, use a StreamController
. It gives you flexibility and power to manage streams exactly how you need.