This content originally appeared on Level Up Coding - Medium and was authored by Tarik

Live Activities on iOS are a game changer, they let apps show real-time updates right on the Lock Screen or Dynamic Island. Naturally, I started imagining how cool it would be to have a workout timer running there, keeping users motivated without even unlocking their phones. But there was a catch: Live Activities are built with Apple’s native tools, like Swift and ActivityKit. That’s when I discovered a tool called expo-apple-targets instead of writing entire modules from scratch, which made the process much easier than I expected. In this article, I’ll walk you through how I built a timer-based Live Activity for iOS, the steps I took, and what I learned along the way.
Why Live Activities Got Me Obsessed
Live Activities aren’t just a cool trick, they’re a game-changer for user engagement. Apple’s Human Interface Guidelines describe them as a way to deliver “current, glanceable information” for time-sensitive tasks. Picture a fitness app showing your plank time mid-workout, a food delivery app tracking your pizza’s journey, or a ride-sharing app updating your driver’s ETA, all right on the Lock Screen or, on iPhone 14 Pro and later, the Dynamic Island. It’s like giving users a VIP pass to your app’s most important updates without them needing to unlock their phone.
I wanted users to see their workout timer ticking up during a set, keeping them focused without forcing them to open the app. It’s the kind of polish that makes an app feel pro, and I was all in. But you can not handle all this without touching any native layer, so I knew I’d be diving into Swift and wrestling with Xcode. Discovering expo-apple-targets, a package that bridges the gap between Expo’s JavaScript world and iOS’s native features. Huge props to Evan Bacon, this tool took a daunting task and made it feel like a challenging but doable puzzle.
The Big Picture: Live Activities in an Expo App
Live Activities are an iOS-only party. They’re powered by ActivityKit, a Swift-based framework, which means you’re writing native code whether you like it or not. For an Expo app, this is like trying to convince a fish to climb a tree—it’s possible, but it’s gonna take some effort. expo-apple-targets steps in like a seasoned guide, providing a framework to create native modules and widget extensions without starting from scratch. It’s built on Expo’s module system (Expo Modules API), which lets you write native code in Swift and expose it to JavaScript, making the whole process feel less like a root canal.
Setting Up the Project
Getting started wasn’t exactly a walk in the park, but expo-apple-targets made it manageable. Here’s how I kicked things off:
- Installing expo-apple-targets: A quick npm install expo-apple-targets got me rolling. The package’s README is a goldmine, guiding you through adding a widget extension in Xcode and updating app.json with the Live Activity entitlement (com.apple.developer.activitykit).
- Native Module Setup: I used Expo’s module system to create a Swift module that talks to ActivityKit. The Expo Modules docs emphasize defining clear function signatures and events, which helped me expose methods like startActivity to JavaScript without breaking a sweat.
- Widget Extension: This is where SwiftUI came in, letting me design the Live Activity’s look for the Lock Screen and Dynamic Island. expo-apple-targets provides a widget template, but I had to customize it to show a ticking timer and interactive buttons.
- Intents: These let users tap buttons on the widget to pause or resume the timer. expo-apple-targets simplifies intent setup, but I still had to debug a few misfires to get the notifications flowing right.
A timer that is updated every second?

When I first tackled the timer, my brain said, “Just update the Live Activity every second with the current time.” Sounds straightforward, right? Wrong. I stumbled across a Reddit thread where devs were discussing the ability to update the Live Activity timer. The verdict was unanimous: updating every second is a disaster. It’s like trying to power a spaceship with a hamster wheel; it’ll move, but it’ll burn out fast. Constant updates hammer the CPU, drain the battery, making your app sluggish.
That Reddit thread was a wake-up call. I found SwiftUI’s Text(timerInterval:) in Apple’s SwiftUI docs, and it was a revelation. This view handles timer updates natively, ticking away without needing to refresh the entire Live Activity. It’s like outsourcing the boring stuff to iOS so you can focus on the fun parts.
So, I ditched the every-second-update plan and went with a startedAt/pausedAt approach. Here’s why it’s a winner:
- Performance: Instead of firing off updates every second, I store the timer’s start time (startedAt) and pause time (pausedAt, if paused). The widget uses Text(timerInterval:) to show a live timer based on startedAt, and when paused, it displays a static time using pausedAt. This slashes API calls to near zero.
- Battery Efficiency: Frequent updates are a battery vampire. Letting SwiftUI handle the ticking keeps the app lean and users’ phones happy.
- API Compliance: Apple’s Live Activity APIs aren’t built for real-time spam. The startedAt/pausedAt setup plays nice with iOS’s expectations, reducing the chance of rate-limiting.
- Code Simplicity: Managing two timestamps is way easier than juggling a stream of updates. It keeps the code clean and my stress levels in check.
This approach felt like finding a secret shortcut in a video game. The timer runs smoothly, looks great, and doesn’t hog resources. The Reddit thread (here it is again) deserves a medal for pointing me in the right direction.
The Heart of the Live Activity
I’m not gonna bore you with every line of code, let’s focus on the key snippets that make this timer tick. I’ll be sharing the full source code on GitHub at the end of the article.
The LiveActivityAttributes Struct
This struct is the backbone of the Live Activity, defining its data and how it evolves.
struct LiveActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var startedAt: Date
var pausedAt: Date?
func getElapsedTimeInSeconds() -> TimeInterval {
if let pausedAt = pausedAt {
return pausedAt.timeIntervalSince(startedAt)
} else {
return Date().timeIntervalSince(startedAt)
}
}
func isRunning() -> Bool {
return pausedAt == nil
}
func getFormattedElapsedTime() -> String {
let elapsed = getElapsedTimeInSeconds()
let totalSeconds = Int(elapsed)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
func getFutureDate() -> Date {
return Date().addingTimeInterval(365 * 24 * 60 * 60)
}
}
var activityName: String
var activityIcon: String
}
This is like the DNA of the Live Activity. The outer LiveActivityAttributes holds static stuff, activityName (e.g., “Workout”) and activityIcon (e.g., “RUNNING”) set when the activity starts. The inner ContentState is where the action happens, tracking startedAt (when the timer began) and pausedAt (when it’s paused, if at all). The helper methods are the real MVPs:
- getElapsedTimeInSeconds() calculates how long the timer’s been running, stopping at pausedAt if it’s paused, or using the current time if it’s active. This keeps the logic tight and reusable.
- isRunning() is a simple check: if pausedAt is null, the timer’s live. It’s a clean way to avoid messy state checks elsewhere.
- getFormattedElapsedTime() turns seconds into a nice “MM:SS” or “HH:MM:SS” string for the widget. I added hours support after realizing some users might do ultra-long workouts like marathons.
- getFutureDate() sets a date a year out, which I feed to Text(timerInterval:) to let the timer run indefinitely without worrying about an end date.
I love how this struct keeps things organized. It’s like a well-packed backpack — everything you need, nothing you don’t. The Codable and Hashable conformance makes it easy to serialize for updates and use in SwiftUI, which was a lifesaver when syncing state between the module and widget.
Starting the Live Activity
The startActivity function is where the magic begins, kicking off a new Live Activity.
AsyncFunction("startActivity") { (activityName: String, activityIcon: String) -> String in
if #available(iOS 16.2, *) {
await self.endAllActivities()
self.startedAt = Date()
self.pausedAt = nil
if !ActivityAuthorizationInfo().areActivitiesEnabled {
return ""
}
let attributes = LiveActivityAttributes(activityName: activityName, activityIcon: activityIcon)
let contentState = LiveActivityAttributes.ContentState(startedAt: self.startedAt!, pausedAt: nil)
do {
let activity = try Activity.request(attributes: attributes, content: .init(state: contentState, staleDate: nil), pushType: nil)
self.currentActivityId = activity.id
self.sendTimerUpdateEvent()
return activity.id
} catch {
throw error
}
}
return ""
}
This function is the spark that lights the fire. It’s exposed to JavaScript via Expo’s module system, letting my React Native app call it with a name and icon. Here’s what it does:
- It checks for iOS 16.2 or later Live Activities don’t exist before that.
- It calls endAllActivities() to clear out any existing activities. iOS allows multiple Live Activities, but I wanted one at a time to avoid confusion.
- It sets startedAt to the current time and clears pausedAt, prepping the timer to run.
- It verifies that Live Activities are enabled (users can disable them in Settings, which threw me for a loop early on).
- It builds a LiveActivityAttributes with the provided name and icon, and a ContentState with the start time.
- It requests a new activity via Activity.request, which gives me an Activity object with a unique ID.
- It stores the ID, fires an update event to JavaScript, and returns the ID so the app can track it.
Pausing the Timer
Pausing the timer is handled by performPause, which updates the Live Activity’s state.
private func performPause(activityId: String?) async -> Bool {
guard #available(iOS 16.2, *), self.pausedAt == nil else {
return false
}
let targetId = activityId ?? self.currentActivityId
guard let targetId = targetId else {
return false
}
guard let activity = await self.findActivityById(targetId) else {
return false
}
guard let currentStartedAt = self.startedAt else {
return false
}
let now = Date()
self.pausedAt = now
let contentState = LiveActivityAttributes.ContentState(startedAt: currentStartedAt, pausedAt: self.pausedAt)
await activity.update(using: contentState)
self.sendTimerUpdateEvent()
return true
}
This is where the startedAt/pausedAt approach shines. When the user pauses the timer (either from the app or widget), this function:
- Checks that we’re on iOS 16.2 and the timer isn’t already paused (no double-pausing allowed).
- Grabs the activity ID, falling back to currentActivityId if none is provided.
- Finds the activity using findActivityById, which searches through active activities.
- Sets pausedAt to the current time, freezing the timer.
- Creates a new ContentState with the original startedAt and the new pausedAt.
- Updates the activity with activity.update, telling the widget to show the paused time.
- Sends an event to JavaScript to keep the app’s UI in sync.
The logic here is all about precision every check (like pausedAt == nil) is there to prevent weird edge cases, like pausing a non-existent activity. I leaned on the Expo Modules docs for event handling, using sendEvent to push updates to JavaScript. Early versions of this function didn’t validate startedAt, which led to some funky timer displays—lesson learned: always guard your state.
The Lock Screen View
The LockScreenLiveActivityView is what users see when their phone’s locked, and it’s gotta look sharp.

struct LockScreenLiveActivityView: View {
let context: ActivityViewContext<LiveActivityAttributes>
var body: some View {
ZStack {
Color.black.opacity(0.8)
.clipShape(RoundedRectangle(cornerRadius: 16))
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
ActivityIcon(activityIcon: mapJSIconToSFSymbol(context.attributes.activityIcon),
themeColor: .white,
size: 18)
Text(context.attributes.activityName)
.font(.headline)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
if !context.state.isRunning() {
Text(context.state.getFormattedElapsedTime())
.font(.system(size: 34, weight: .semibold, design: .rounded))
.foregroundColor(.white)
.fontWeight(.medium)
.monospacedDigit()
} else {
Text(timerInterval: context.state.startedAt...context.state.getFutureDate(),
pauseTime: nil,
countsDown: false,
showsHours: false)
.font(.system(size: 34, weight: .semibold, design: .rounded))
.foregroundColor(.white)
.fontWeight(.medium)
.monospacedDigit()
}
}
}
.frame(maxHeight: .infinity, alignment: .center)
Spacer()
VStack {
Spacer()
if context.state.isRunning() {
Button(intent: PauseIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#007bff"))
.frame(width: 44, height: 44)
Image(systemName: "pause.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
} else {
HStack(spacing: 8) {
Button(intent: CompleteIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#28a745"))
.frame(width: 44, height: 44)
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
Button(intent: ResumeIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#007bff"))
.frame(width: 44, height: 44)
Image(systemName: "play.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
}
}
Spacer()
}
}
.padding(16)
}
}
}
This is the visual heart of the Live Activity, and it’s where SwiftUI really flexes. The view is split into two sides:
- Left Side: Shows the activity name, icon, and timer. If the timer’s running, Text(timerInterval:) does the heavy lifting, counting up from startedAt to a year out (thanks to getFutureDate). If paused, it displays a static time from getFormattedElapsedTime. The .monospacedDigit() modifier keeps the numbers crisp, and I tweaked the font to make it bold and readable.
- Right Side: Handles buttons. When running, there’s a blue pause button. When paused, you get a green checkmark to open the app and a blue play button to resume. Each button fires an intent, which the native module catches and processes.
Diving into the Dynamic Island

This snippet defines how the Live Activity appears in the Dynamic Island’s three modes: expanded (full view), compact (squeezed), and minimal (bare-bones).
dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
ActivityIcon(activityIcon: mapJSIconToSFSymbol(context.attributes.activityIcon),
themeColor: .white,
size: 18)
Text(context.attributes.activityName)
.foregroundColor(.white)
.font(.system(size: 16, weight: .semibold))
}
if !context.state.isRunning() {
Text(context.state.getFormattedElapsedTime())
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.white)
.fontWeight(.medium)
.monospacedDigit()
} else {
Text(timerInterval: context.state.startedAt...context.state.getFutureDate(),
pauseTime: nil,
countsDown: false,
showsHours: false)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.white)
.fontWeight(.medium)
.monospacedDigit()
}
}
.padding(.leading, 8)
}
DynamicIslandExpandedRegion(.trailing) {
VStack {
Spacer()
if context.state.isRunning() {
Button(intent: PauseIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#007bff"))
.frame(width: 44, height: 44)
Image(systemName: "pause.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
.padding(.horizontal, 8)
} else {
HStack(spacing: 8) {
Button(intent: CompleteIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#28a745"))
.frame(width: 44, height: 44)
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
Button(intent: ResumeIntent()) {
ZStack {
Circle()
.fill(Color(hex: "#007bff"))
.frame(width: 44, height: 44)
Image(systemName: "play.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
}
}
}
.frame(maxHeight: .infinity)
}
} compactLeading: {
HStack(spacing: 4) {
ActivityIcon(activityIcon: mapJSIconToSFSymbol(context.attributes.activityIcon),
themeColor: .white,
size: 16)
Text(context.attributes.activityName)
.foregroundColor(.white)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.tail)
}
} compactTrailing: {
if !context.state.isRunning() {
Text(context.state.getFormattedElapsedTime())
.foregroundColor(.white)
.monospacedDigit()
.font(.system(size: 12, weight: .medium))
} else {
Text(timerInterval: context.state.startedAt...context.state.getFutureDate(),
pauseTime: nil,
countsDown: false,
showsHours: false)
.foregroundColor(.white)
.monospacedDigit()
.font(.system(size: 12, weight: .medium))
}
} minimal: {
ActivityIcon(activityIcon: mapJSIconToSFSymbol(context.attributes.activityIcon),
themeColor: .white,
size: 12)
}
}
- Expanded Mode: Shown on long-press, it’s split into .leading and .trailing. The leading side stacks the icon (e.g., a running figure) and name (“Workout”) with a big timer static for paused (getFormattedElapsedTime), ticking for active (Text(timerInterval:)). The trailing side has buttons: a blue pause button when running, or green checkmark (open app) and blue play (resume) when paused. I tweaked fonts and padding to fit the Island’s curves.
- Compact Mode: When space shrinks, compactLeading shows a tiny icon and truncated name, while compactTrailing displays the timer (static or ticking). The .lineLimit(1) keeps the name tidy, and a 12-point font ensures readability.
- Minimal Mode: Ultra-tight, it’s just a 12-point icon, like a dumbbell, to say “timer’s still here.” Simple but effective.
The _shared Folder: Why It’s a Big Deal

The _shared folder in expo-apple-targets is a top-level directory designed to link essential code to both your main app target and sub-targets, like the Live Activity widget.
Extensions, such as widgets, operate in separate processes and require specific files to be linked to their target as well as the main app’s. The _shared folder ensures these files are automatically linked to both, eliminating manual configuration. For my timer, I placed the LiveActivityAttributes struct here, guaranteeing the app and widget shared the same timer state (startedAt, pausedAt) without sync issues.
Launching the app from Live Activity Widget
The CompleteIntent lets users tap a checkmark button on the Live Activity widget to open the app, signaling they’re done with the timer. It’s a simple but crucial interaction for wrapping up a workout.
import AppIntents
import WidgetKit
@available(iOS 16.2, *)
struct CompleteIntent: AppIntent, LiveActivityIntent {
static var title: LocalizedStringResource = "Complete Exercise"
static var description: IntentDescription = "Opens the app to complete the exercise"
static var openAppWhenRun: Bool = true
init() {}
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: Notification.Name("completeActivityFromWidget"), object: nil)
return .result()
}
}
When the user taps the green checkmark, this intent fires, posting a completeActivityFromWidget notification. The native module catches it, triggers the onWidgetCompleteActivity event to JavaScript, and iOS opens the app, thanks to openAppWhenRun: true.
Handling User Taps with Intents
The PauseIntent is one of the intents that makes the widget interactive.

import AppIntents
import WidgetKit
@available(iOS 16.2, *)
struct PauseIntent: AppIntent, LiveActivityIntent {
static var title: LocalizedStringResource = "Pause Timer"
static var description: IntentDescription = "Pauses the current timer."
init() {}
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: Notification.Name("pauseTimerFromWidget"), object: nil)
return .result()
}
}
This intent is triggered when the user taps the pause button on the widget. It’s dead simple but powerful:
- It posts a notification (pauseTimerFromWidget) that the native module catches via setupNotificationObservers.
- The module then runs performPause, updating the Live Activity’s state and notifying JavaScript.
The beauty here is how intents create a seamless loop: user taps → intent fires → module updates → app stays in sync. expo-apple-targets made setting up intents a breeze by providing a template, but I still had to debug a case where taps didn’t register — turns out, I’d misspelled a notification name. Classic. The Expo Modules docs helped me understand how to handle events triggered by native actions, which was key to making this work.
Events talking to JavaScript

The events in the following section are how the native module keeps the React Native app in sync with the Live Activity’s state, using Expo’s event system.
onWidgetCompleteActivity
This event fires when the user taps the checkmark to complete the activity, triggered by CompleteIntent.
@objc func completeIntentHandler(_ notification: Notification) {
guard let currentId = self.currentActivityId, self.startedAt != nil else {
return
}
let elapsed = self.calculateElapsedTime()
sendEvent("onWidgetCompleteActivity", [
"activityId": currentId,
"elapsedTime": Int(elapsed)
])
}
The completeIntentHandler catches the completeActivityFromWidget notification, calculates the elapsed time, and sends the onWidgetCompleteActivity event to JavaScript with the activity ID and elapsed seconds. JavaScript can then update the app’s UI or log the workout’s completion
onLiveActivityUpdate
This event fires whenever the timer’s state changes (e.g., pause, resume).
private func sendTimerUpdateEvent() {
guard let currentId = self.currentActivityId else { return }
guard self.startedAt != nil else { return }
let state = self.pausedAt == nil ? "active" : "paused"
let elapsed = self.calculateElapsedTime()
sendEvent("onLiveActivityUpdate", [
"state": state,
"elapsedTime": Int(elapsed),
"activityId": currentId
])
}
Called after state changes (like in performPause or performResume), it sends the onLiveActivityUpdate event with the current state (“active” or “paused”), elapsed time, and activity ID. JavaScript listens for this to keep the app’s timer UI in sync with the widget.
Both events create a tight feedback loop between the native module and JavaScript.
Why This All Matters
This Live Activity isn’t just a timer, it’s a feature that makes my app feel like a native iOS citizen. Here’s why it’s a big deal:
- User Engagement: Showing a timer on the Lock Screen or Dynamic Island keeps users connected to the app without breaking their flow. It’s perfect for workouts, where every second counts.
- Native Polish: The widget’s clean design and interactive buttons make the app feel like it belongs on iOS, not like a cross-platform afterthought.
- Performance: The startedAt/pausedAt approach, powered by Text(timerInterval:), ensures the timer runs smoothly without draining the battery or hitting API limits.
expo-apple-targets and the Expo Modules API were the unsung heroes here, providing a robust framework for bridging JavaScript and Swift. The docs’ emphasis on clean function definitions and event handling kept my code organized and maintainable.
Final Thoughts
This Live Activity adds a smooth, native feel to the app, showing real-time updates right on the Lock Screen and Dynamic Island. Thanks to expo-apple-targets, the Expo Modules API, and insights from the community (yes, Reddit threads included), bringing it to life was quite straightforward. Live Activities are a powerful way to make apps feel more connected and dynamic.
Here is the GitHub link for the full source repo 👇🏻https://github.com/tarikfp/expo-live-activity-timer
Building a Live Activity Timer in Expo was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Tarik

Tarik | Sciencx (2025-05-02T00:44:25+00:00) Building a Live Activity Timer in Expo. Retrieved from https://www.scien.cx/2025/05/02/building-a-live-activity-timer-in-expo/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.