Live Activities in Flutter

Hey there! I'm Pranav Masekar, a dedicated Flutter developer who's all about crafting captivating mobile apps that not only look stunning but also deliver unforgettable user experiences. But it doesn't stop there – I'm also a passionate blogger, sharing insights and best practices within the Flutter community. By contributing to the growth and knowledge-sharing of fellow developers, I'm committed to fostering a dynamic and collaborative environment. And let's not forget my DevOps journey – as a seasoned engineer, I've got the CI/CD pipelines, infrastructure-as-code, and cloud platforms down to an art.
Introduction
Live Activities are a slick way to surface real‑time updates on iOS, right on the Lock Screen and inside the Dynamic Island. In this post, we'll wire up a minimal timer Live Activity in a Flutter app using Pigeon (for type-safe platform channels) and SwiftUI/ActivityKit on iOS.
You'll see exactly how to start and stop a Live Activity from Flutter, while SwiftUI handles presentation. We'll skim the SwiftUI views and focus on the start/stop flow. If you hit any code questions, the repo link at the end has the full source.
Prerequisites
Flutter app targeting iOS 16.2+
Xcode with ActivityKit capability enabled for the iOS target and widget extension
A brief understanding of Swift/SwiftUI
Demo
https://www.loom.com/share/ef753646eb804f13a1fa507cd223868e?sid=b93ca866-7c38-48f1-9796-4d87fd41a85a
What exactly is going to happen
Here’s the step‑by‑step plan you’ll implement before we dive into code:
Define a typed bridge with Pigeon
Create a tiny host API with
startLiveActivity()andstopLiveActivity().Generate a Dart client and a Swift protocol, so calls are type‑safe on both sides.
Register the iOS handler at app launch
In
AppDelegate, instantiateLiveActivityImplementation.Wire it to Flutter’s binary messenger (only on iOS 16.2+).
Implement the Live Activity lifecycle in Swift
startLiveActivity(): request anActivity<TimerAttributes>with anendTime20 minutes ahead, then start a 1‑second update timer.updateLiveActivity(): if time is up, end the activity; otherwise, push the latest content (keeping state minimal with justendTime).stopLiveActivity(): cancel the update timer and end the activity immediately.
Build the Lock Screen + Dynamic Island UI in SwiftUI
Use
ActivityConfigurationforTimerAttributesto render on the Lock Screen and across Island states.Implement expanded regions (leading/trailing/center/bottom) and compact/minimal variants.
Compute remaining time, colors, and progress in helper functions based on
endTime.
Hook up Flutter UI controls
Call
startLiveActivity()when the user taps Start; run a local_tickTimerfor the on‑screen countdown.Call
stopLiveActivity()when the user taps Stop or when the countdown finishes.Keep UI resilient: ignore platform errors and track
_isLiveActivityActiveto avoid duplicate calls.
The bridge: Pigeon host API
Define a tiny host API with two methods: startLiveActivity() and stopLiveActivity(). Then let Pigeon generate the Dart client and Swift protocol for you.
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/live_activity_api.dart',
dartOptions: DartOptions(),
kotlinOut:
'android/app/src/main/kotlin/com/example/ios_playground/LiveActivityApi.kt',
kotlinOptions: KotlinOptions(),
swiftOut: 'ios/Runner/LiveActivityApi.swift',
swiftOptions: SwiftOptions(),
dartPackageName: 'com.pranav.iosPlayground',
),
)
@HostApi()
abstract class LiveActivityApi {
void startLiveActivity();
void stopLiveActivity();
}
After running Pigeon, you'll get a Dart client and Swift protocol for the same API.
iOS: SwiftUI + ActivityKit implementation
Implement the protocol in Swift, manage an Activity<TimerAttributes>, and expose start/stop.
class LiveActivityImplementation: LiveActivityApi {
private var activity: Activity<TimerAttributes>? = nil
private var updateTimer: Timer? = nil
private var originalEndTime: Date? = nil
func startLiveActivity() throws {
print("Starting Live Activity")
let attributes = TimerAttributes(timerName: "Timer One")
originalEndTime = Date().addingTimeInterval(60 * 20)
let state = TimerAttributes.ContentState(
endTime: originalEndTime!
)
let content = ActivityContent(state: state, staleDate: nil)
do {
activity = try Activity<TimerAttributes>.request(
attributes: attributes,
content: content,
pushType: nil
)
print("Live Activity created successfully: \(activity?.id ?? "No ID")")
startUpdatingLiveActivity()
} catch {
print("Failed to create Live Activity: \(error.localizedDescription)")
print("Error details: \(error)")
}
}
Stop is just as small:
func stopLiveActivity() throws {
updateTimer?.invalidate()
updateTimer = nil
let state = TimerAttributes.ContentState(endTime: .now)
let content = ActivityContent(state: state, staleDate: nil)
Task {
await activity?.end(content, dismissalPolicy: .immediate)
}
}
Live updates: refreshing the Live Activity
Once started, we keep the Live Activity fresh by scheduling a timer that updates the content state every second. When time is up, we end the Live Activity immediately.
private func startUpdatingLiveActivity() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateLiveActivity()
}
}
private func updateLiveActivity() {
guard let activity = activity, let originalEndTime = originalEndTime else { return }
let currentTime = Date()
// Check if timer has expired
if currentTime >= originalEndTime {
try? self.stopLiveActivity()
return
}
// Update with the original end time (the TimerView will calculate the remaining time)
let state = TimerAttributes.ContentState(endTime: originalEndTime)
let content = ActivityContent(state: state, staleDate: nil)
Task {
await activity.update(content)
}
}
Scheduler: a 1s
TimertriggersupdateLiveActivity().Expire check: if now ≥
originalEndTime, we callstopLiveActivity().State update: we keep sending the same
endTime; the SwiftUI view computes remaining time fromDate().Async update:
await activity.update(content)pushes changes to the Live Activity safely.
The Live Activity’s attributes and state:
struct TimerAttributes: ActivityAttributes {
var timerName: String
public struct ContentState : Codable, Hashable {
var endTime: Date
}
}
Minimal SwiftUI for the widget/Dynamic Island lives in the extension. We’ll skip details here; the repo has the full view. For completeness, here’s the top of the widget:
struct TimerLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerAttributes.self) { context in
TimerView(context: context)
} dynamicIsland: { context in
DynamicIsland {
Dynamic Island UI in SwiftUI (what shows up where)
The Dynamic Island has multiple regions and sizes. Here’s how each part is composed, and how it reacts to the timer’s remaining time.
- Expanded regions: leading, trailing, center, bottom.
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "timer")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Text(context.attributes.timerName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
}
Text(expandedTimerText(context: context))
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundColor(expandedTimerColor(context: context))
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 4) {
// Progress ring
ZStack {
Circle()
.stroke(Color(.systemGray5), lineWidth: 3)
.frame(width: 32, height: 32)
Circle()
.trim(from: 0, to: progressValue(context: context))
.stroke(expandedTimerColor(context: context), style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 32, height: 32)
.rotationEffect(.degrees(-90))
}
Text(expandedStatusText(context: context))
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
}
}
DynamicIslandExpandedRegion(.center) {
VStack(spacing: 8) {
Text("Timer Active")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Text(expandedStatusText(context: context))
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Spacer()
Text("Tap to view details")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Spacer()
}
.padding(.top, 8)
}
- Compact variants: appear when the Island is in its small state.
} compactLeading: {
HStack(spacing: 4) {
Image(systemName: "timer")
.font(.system(size: 10, weight: .medium))
.foregroundColor(compactTimerColor(context: context))
Text(compactTimerText(context: context))
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(compactTimerColor(context: context))
}
}
} compactTrailing: {
HStack(spacing: 2) {
// Progress dots
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(index < compactProgressDots(context: context) ? compactTimerColor(context: context) : Color(.systemGray4))
.frame(width: 4, height: 4)
}
}
}
} minimal: {
HStack(spacing: 3) {
Image(systemName: "timer")
.font(.system(size: 8, weight: .medium))
.foregroundColor(minimalTimerColor(context: context))
Text(minimalTimerText(context: context))
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(minimalTimerColor(context: context))
}
}
Wire Pigeon in AppDelegate
Register your implementation so Flutter calls land in Swift:
if #available(iOS 16.2, *) {
let liveActivityImplementation = LiveActivityImplementation()
let controller = window?.rootViewController as! FlutterViewController
LiveActivityApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: liveActivityImplementation)
}
Flutter: start and stop from UI
From Flutter, create the LiveActivityApi and call start/stop based on user actions. Here’s the relevant part of a simple timer screen:
Future<void> _start() async {
if (_isRunning) return;
setState(() { _isRunning = true; });
if (!_isLiveActivityActive) {
try {
await _liveActivityApi.startLiveActivity();
setState(() { _isLiveActivityActive = true; });
} catch (_) {
// Best-effort: keep UI responsive if platform call fails
}
}
_tickTimer?.cancel();
_tickTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_remainingSeconds <= 0) {
timer.cancel();
setState(() {
_remainingSeconds = 0;
_isRunning = false;
});
_stopLiveActivityIfActive();
} else {
setState(() { _remainingSeconds -= 1; });
}
});
}
And the stop flow:
Future<void> _stop() async {
if (!_isRunning) return;
_tickTimer?.cancel();
_tickTimer = null;
_remainingSeconds = _maxSeconds;
setState(() { _isRunning = false; });
await _stopLiveActivityIfActive();
}
Future<void> _stopLiveActivityIfActive() async {
if (!_isLiveActivityActive) return;
try {
await _liveActivityApi.stopLiveActivity();
} catch (_) {
// Ignore platform errors for UI flow
}
setState(() { _isLiveActivityActive = false; });
}
That’s it. The Flutter side is just two method calls. Pigeon handles the message plumbing, and Swift/ActivityKit takes care of the Live Activity lifecycle.
Common fixes
Ensure iOS target supports iOS 16.2+ and the widget extension is added to the workspace/scheme.
Turn on the Live Activities capability for the app and extension.
When testing on Simulator, use an iPhone with Dynamic Island or view the Lock Screen.
Full source and Dynamic Island UI
- Repo: Link
Keep Fluttering 💙💙💙



