DEV Community

Cover image for React Native Live Timer with iOS Live Activities
PEAKIQ
PEAKIQ

Posted on • Originally published at peakiq.in

React Native Live Timer with iOS Live Activities

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.

  1. Open your main target in Xcode
  2. Go to General > Deployment Info
  3. 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

  1. Right-click the YourApp group inside Xcode (not the blue project icon)
  2. Select New File > Swift File
  3. Name it TimeRecordWidget.swift
  4. 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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Register in the Objective-C Header

Add a new Objective-C file

  1. Right-click the same YourApp group
  2. Select New File > Objective-C File
  3. Name it TimeRecoredWidgetHeader.m
  4. 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
Enter fullscreen mode Exit fullscreen mode

Step 3 — Define Attributes and Widget UI

Create a new Widget Extension

  1. In Xcode, go to File > New > Target
  2. Search for Widget Extension and select it
  3. Name it TimeRecoredWidget
  4. Enable Include Live Activity when prompted
  5. 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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  )
}
Enter fullscreen mode Exit fullscreen mode

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 .timer text 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
Enter fullscreen mode Exit fullscreen mode

Then reinstall pods:

cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

Top comments (0)