Originally published on PEAKIQ
Source: https://www.peakiq.in/blog/building-a-live-timer-with-ios-live-activities-in-react-native
Integrate Lock Screen, Dynamic Island, and real-time updates with a native Swift bridge and React Native — no third-party Live Activity library needed.
By the end of this guide you will have:
- A timer that updates live on the Lock Screen and Dynamic Island
- Start, update, and stop controls called from JavaScript
- A confirmation dialog and flash message on stop
Final Output
- Start a timer from React Native
- Live Activity shows the elapsed time since start, the start date, and a dynamic record ID
- User can stop the timer and see a success flash message
Prerequisites
Before adding Live Activities, update your deployment target to iOS 16.1.
- Open your main target in Xcode
- Go to General > Deployment Info
- Set Minimum Deployment Target to 16.1
Enable Live Activities in Info.plist
Open Info.plist, hover over Information Property List until the + button appears, then add the key Supports Live Activities and set it to YES.
Step 1 — Create the Native Module
Open Xcode
Navigate to ios/YourApp.xcworkspace and open the workspace in Xcode, not VS Code.
Add a new Swift file
- Right-click the
YourAppgroup inside Xcode (not the blue project icon) - Select New File > Swift File
- Name it
TimeRecordWidget.swift - When prompted, choose Create Bridging Header
The bridging header allows Swift to work alongside Objective-C in the same project.
Paste the native module code
// TimeRecordWidget.swift
import Foundation
import ActivityKit
import React
@objc(TimeRecordWidget)
class TimeRecordWidget: NSObject {
var pulseTimer: Timer?
var pulseStep = 0
@objc(startActivity:)
func startActivity(data: NSDictionary) {
guard let recordId = data["recordId"] as? String,
let username = data["username"] as? String,
let name = data["name"] as? String,
let startDateString = data["startDate"] as? String else {
print("Live Activity: Missing required fields")
return
}
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let startDate = formatter.date(from: startDateString) else {
print("Live Activity: Invalid date format: \(startDateString)")
return
}
Task {
if #available(iOS 16.1, *) {
let attributes = TimeRecoredWidgetAttributes(
name: name,
recordId: recordId,
username: username
)
let contentState = TimeRecoredWidgetAttributes.ContentState(
startDate: startDate,
pulseScale: 1.0,
pulseOpacity: 0.7
)
let content = ActivityContent(state: contentState, staleDate: nil)
do {
let activity = try Activity<TimeRecoredWidgetAttributes>.request(
attributes: attributes,
content: content,
pushType: nil
)
startPulseTimer(activity: activity)
} catch {
print("Failed to start Live Activity: \(error)")
}
}
}
}
func updatePulseState(activity: Activity<TimeRecoredWidgetAttributes>, step: Int) {
let pulseStates: [(CGFloat, Double)] = [(1.0, 0.7), (1.2, 1.0)]
let pulseScale = pulseStates[step % pulseStates.count].0
let pulseOpacity = pulseStates[step % pulseStates.count].1
let newState = TimeRecoredWidgetAttributes.ContentState(
startDate: activity.content.state.startDate,
pulseScale: pulseScale,
pulseOpacity: pulseOpacity
)
Task {
let content = ActivityContent(state: newState, staleDate: nil)
await activity.update(content, alertConfiguration: nil)
}
}
func startPulseTimer(activity: Activity<TimeRecoredWidgetAttributes>) {
pulseTimer?.invalidate()
pulseTimer = nil
pulseStep = 0
pulseTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
self.pulseStep += 1
self.updatePulseState(activity: activity, step: self.pulseStep)
if activity.activityState != .active {
timer.invalidate()
self.pulseTimer = nil
}
}
if let pulseTimer = pulseTimer {
RunLoop.main.add(pulseTimer, forMode: .common)
}
}
@objc(endActivity)
func endActivity() {
Task {
if #available(iOS 16.1, *) {
for activity in Activity<TimeRecoredWidgetAttributes>.activities {
let currentState = activity.content.state
let finalState = TimeRecoredWidgetAttributes.ContentState(
startDate: currentState.startDate,
pulseScale: currentState.pulseScale,
pulseOpacity: currentState.pulseOpacity
)
let finalContent = ActivityContent(state: finalState, staleDate: nil)
await activity.end(finalContent, dismissalPolicy: .immediate)
}
}
}
}
@objc(checkPermission:rejecter:)
func checkPermission(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
if #available(iOS 16.2, *) {
resolve(ActivityAuthorizationInfo().areActivitiesEnabled)
} else {
resolve(true)
}
}
}
Step 2 — Register in the Objective-C Header
Add a new Objective-C file
- Right-click the same
YourAppgroup - Select New File > Objective-C File
- Name it
TimeRecoredWidgetHeader.m - Skip creating a bridging header if one already exists
Paste the bridge registration
// TimeRecoredWidgetHeader.m
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(TimeRecordWidget, NSObject)
RCT_EXTERN_METHOD(startActivity:(NSDictionary *)data)
RCT_EXTERN_METHOD(endActivity)
RCT_EXTERN_METHOD(checkPermission:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end
Step 3 — Define Attributes and Widget UI
Create a new Widget Extension
- In Xcode, go to File > New > Target
- Search for Widget Extension and select it
- Name it
TimeRecoredWidget - Enable Include Live Activity when prompted
- Click Activate when asked to activate the scheme
This creates three files: TimeRecoredWidget.swift, TimeRecoredWidgetLiveActivity.swift, and TimeRecoredWidgetBundle.swift.
Important: Click
TimeRecoredWidgetLiveActivity.swift, open the File Inspector on the right, and confirm your main app target is checked under Target Membership — not just the widget extension. This is the most common build failure cause.
Paste the Live Activity UI
Replace the contents of TimeRecoredWidgetLiveActivity.swift:
// TimeRecoredWidgetLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI
struct TimeRecoredWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimeRecoredWidgetAttributes.self) { context in
lockScreenView(context: context)
} dynamicIsland: { context in
dynamicIslandView(context: context)
}
}
}
// Lock Screen View
@ViewBuilder
func lockScreenView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center) {
Image("action_icon")
.resizable()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(context.attributes.name)
.font(.headline)
.foregroundColor(.white)
Text("ID: \(context.attributes.recordId)")
.font(.footnote)
.foregroundColor(.white)
}
Spacer()
HStack(spacing: 8) {
Image(systemName: "clock")
.font(.footnote)
.foregroundColor(.red)
Text(context.state.startDate, style: .relative)
.font(.headline)
.monospacedDigit()
.foregroundColor(.red)
}
}
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Started at \(formattedDate(context.state.startDate))")
.font(.footnote)
.foregroundColor(.white)
Text("User: \(context.attributes.username)")
.font(.footnote)
.foregroundColor(.white)
}
Spacer()
Link(destination: URL(string: "yourapp://stop-timer/\(context.attributes.recordId)")!) {
ZStack {
Circle()
.fill(Color.black)
.frame(width: 36, height: 36)
.overlay(Circle().stroke(Color.red, lineWidth: 2))
Image(systemName: "stop.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.red)
}
}
}
}
.padding()
}
// Dynamic Island View
func dynamicIslandView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> DynamicIsland {
DynamicIsland {
DynamicIslandExpandedRegion(.center) {
HStack {
Image("action_icon")
.resizable()
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(context.attributes.name)
.font(.headline)
.foregroundColor(.white)
.lineLimit(1)
Text("ID: \(context.attributes.recordId)")
.font(.footnote)
.foregroundColor(.white)
}
Spacer()
HStack(spacing: 8) {
Image(systemName: "clock")
.font(.footnote)
.foregroundColor(.red)
Text(context.state.startDate, style: .relative)
.font(.headline)
.monospacedDigit()
.foregroundColor(.red)
}
}
.padding(.horizontal, 12)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
VStack(alignment: .leading) {
Text("Started at \(formattedDate(context.state.startDate))")
.font(.footnote)
.foregroundColor(.white)
Text("User: \(context.attributes.username)")
.font(.footnote)
.foregroundColor(.white)
}
Spacer()
Link(destination: URL(string: "yourapp://stop-timer/\(context.attributes.recordId)")!) {
ZStack {
Circle()
.fill(Color.black)
.frame(width: 36, height: 36)
.overlay(Circle().stroke(Color.red, lineWidth: 2))
Image(systemName: "stop.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.red)
}
}
}
.padding(.horizontal, 12)
}
} compactLeading: {
Image("action_icon")
.resizable()
.frame(width: 28, height: 28)
} compactTrailing: {
HStack(spacing: 4) {
Text(context.state.startDate, style: .timer)
.font(.system(size: 16, weight: .heavy))
.foregroundColor(.red)
Circle()
.fill(Color.red)
.frame(width: 20, height: 20)
.opacity(context.state.pulseOpacity)
.scaleEffect(context.state.pulseScale)
.animation(.easeInOut(duration: 0.5), value: context.state.pulseOpacity)
}
} minimal: {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
}
.widgetURL(URL(string: "yourapp://action-timer/\(context.attributes.recordId)"))
.keylineTint(.red)
}
func formattedDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "dd MMM, h:mm a"
return formatter.string(from: date)
}
Define the Attributes model
Create a new Swift file TimeRecoredWidgetAttributes.swift in your main app target:
// TimeRecoredWidgetAttributes.swift
import Foundation
import ActivityKit
import SwiftUI
public struct TimeRecoredWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var startDate: Date
var pulseScale: CGFloat
var pulseOpacity: Double
}
var name: String
var recordId: String
var username: String
}
React Native Integration
Triggering start from JavaScript
import { Alert, NativeModules, Platform } from 'react-native'
import { showMessage } from 'react-native-flash-message'
import { checkLiveActivityPermission, showLiveActivityPermissionAlert } from '@utils/checkLiveActivityPermission'
const TimeRecordWidget = NativeModules.TimeRecordWidget
const payload = {
recordId: "1823",
username: "manoj@example.com",
name: "Time Record",
startDate: new Date().toISOString()
}
const onStartActivity = async () => {
if (Platform.OS !== 'ios') return
try {
const permitted = await checkLiveActivityPermission()
if (!permitted) {
showLiveActivityPermissionAlert()
return
}
TimeRecordWidget?.startActivity(payload)
} catch (error) {
console.error(error)
}
}
Stopping with confirmation and flash message
const onEndActivity = () => {
Alert.alert(
'Stop Timer',
'Are you sure you want to stop the timer?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Stop',
style: 'destructive',
onPress: () => {
TimeRecordWidget?.endActivity()
showMessage({
message: 'Timer stopped successfully',
type: 'success',
duration: 2000,
autoHide: false,
hideOnPress: true,
icon: 'auto',
floating: true,
})
},
},
],
{ cancelable: true }
)
}
Summary
| Step | What you do |
|---|---|
| Create Swift file |
TimeRecordWidget.swift with @objc methods |
Create .m file |
TimeRecoredWidgetHeader.m for RCT bridging |
| Add Widget target | Widget Extension with Live Activity enabled |
| Define attributes |
TimeRecoredWidgetAttributes for timer state |
| Connect React Native | Call methods from JS via NativeModules
|
Result
- A timer starts and displays live on the Lock Screen and Dynamic Island
- Shows the started date and a record ID in the UI
- Stoppable from React Native with a confirmation dialog and success feedback
Tips
- Use
.timertext style for live-updating elapsed time without any manual refresh logic - Avoid updating the activity state more than once per second to preserve battery life
- This same pattern extends to
pause,resume, and progress percentage with minimal changes
Troubleshooting — Build Issues
If you get build errors related to Swift support, add this inside target 'YourApp' do in your Podfile:
use_frameworks! :linkage => :static
Then reinstall pods:
cd ios && pod install
Top comments (0)