Skip to main content

Command Palette

Search for a command to run...

Live Activities in Flutter

Published
7 min read
Live Activities in Flutter
P

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:

  1. Define a typed bridge with Pigeon

    • Create a tiny host API with startLiveActivity() and stopLiveActivity().

    • Generate a Dart client and a Swift protocol, so calls are type‑safe on both sides.

  2. Register the iOS handler at app launch

    • In AppDelegate, instantiate LiveActivityImplementation.

    • Wire it to Flutter’s binary messenger (only on iOS 16.2+).

  3. Implement the Live Activity lifecycle in Swift

    • startLiveActivity(): request an Activity<TimerAttributes> with an endTime 20 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 just endTime).

    • stopLiveActivity(): cancel the update timer and end the activity immediately.

  4. Build the Lock Screen + Dynamic Island UI in SwiftUI

    • Use ActivityConfiguration for TimerAttributes to 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.

  5. Hook up Flutter UI controls

    • Call startLiveActivity() when the user taps Start; run a local _tickTimer for the on‑screen countdown.

    • Call stopLiveActivity() when the user taps Stop or when the countdown finishes.

    • Keep UI resilient: ignore platform errors and track _isLiveActivityActive to 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 Timer triggers updateLiveActivity().

  • Expire check: if now ≥ originalEndTime, we call stopLiveActivity().

  • State update: we keep sending the same endTime; the SwiftUI view computes remaining time from Date().

  • 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

Keep Fluttering 💙💙💙