A comprehensive step-by-step guide to building real-time Lock Screen & Dynamic Island experiences in React Native
*Originally published on GeekyAnts Blog by Yuvraj Kumar, Software Engineer III
Understanding Live Activities & Dynamic Island
Live Activities change how users engage with time-sensitive information. They bring real-time updates straight to the iPhone's Lock Screen and Dynamic Island. For flight-tracking apps, this means you do not have to keep opening them to check departure gates, delays, or boarding status.
Dynamic Island Integration (iPhone 14 Pro and later) provides three distinct presentation modes:
- Minimal: Single icon or short text (gate number)
- Compact: Leading and trailing elements (flight status + countdown)
- Expanded: Full, detailed view with interactive elements
In flight-tracking scenarios, users can monitor boarding countdowns, receive gate-change notifications, and access quick actions like viewing boarding passes — all without unlocking their device.
Key Benefits
- 40% higher engagement than traditional push notifications
- Persistent visibility during critical travel moments
- Seamless integration with iOS design language
- Real-time updates even when the app is terminated
Project Configuration Requirements
Push Notification Setup
Live Activities depend on Apple Push Notification Service (APNs) for real-time updates. Configure push notifications first:
- In Xcode: Select your app target → Signing & Capabilities
- Add Capability: Click "+" and select "Push Notifications"
-
Verify Entitlements: Ensure
aps-environmentis properly configured
Info.plist Configuration
Enable Live Activities in your main app's Info.plist:
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
The NSSupportsLiveActivitiesFrequentUpdates key is essential for flight tracking as it allows updates more frequently than the standard limit — critical for gate changes and boarding updates.
Widget Extension Target
Create a separate Widget Extension for Live Activities:
- Add Target: File → New → Target → Widget Extension
-
Configuration: Name it
FlightTrackerWidget, include Live Activity intent - Build Settings: Set deployment target to iOS 16.1+, configure App Groups for data sharing
Build Phases — Critical Step
For React Native compatibility, verify the Build Phases configuration:
- Navigate to Build Phases → Embed App Extensions
- Ensure "Copy only when installing" is unchecked
This step is crucial for Live Activities to function properly in React Native environments.
Live Activity Widget Structure
Core SwiftUI Implementation
The Widget Extension contains several key files, but focus on these essential components:
FlightActivityAttributes.swift (Data Structure)
import ActivityKit
import Foundation
struct FlightActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var flightStatus: String
var gateNumber: String
var boardingTime: Date
var delayMinutes: Int
var isBoarding: Bool
}
// Static data (doesn't change during the activity)
var flightNumber: String
var origin: String
var destination: String
var scheduledDeparture: Date
var airline: String
}
FlightActivityWidget.swift (UI Implementation)
import ActivityKit
import SwiftUI
import WidgetKit
struct FlightActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FlightActivityAttributes.self) { context in
// Lock Screen / Banner UI
FlightLockScreenView(context: context)
.activityBackgroundTint(Color.black.opacity(0.8))
.activitySystemActionForegroundColor(Color.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded view
DynamicIslandExpandedRegion(.leading) {
FlightStatusLeadingView(context: context)
}
DynamicIslandExpandedRegion(.trailing) {
GateInfoTrailingView(context: context)
}
DynamicIslandExpandedRegion(.bottom) {
BoardingProgressView(context: context)
}
} compactLeading: {
Image(systemName: "airplane")
.foregroundColor(.blue)
} compactTrailing: {
Text(context.state.gateNumber)
.font(.caption2)
.bold()
} minimal: {
Image(systemName: context.state.isBoarding ? "airplane.departure" : "airplane")
.foregroundColor(context.state.isBoarding ? .green : .blue)
}
}
}
}
struct FlightLockScreenView: View {
let context: ActivityViewContext<FlightActivityAttributes>
var body: some View {
VStack(spacing: 8) {
HStack {
VStack(alignment: .leading) {
Text(context.attributes.flightNumber)
.font(.headline)
.bold()
Text("\(context.attributes.origin) → \(context.attributes.destination)")
.font(.subheadline)
}
Spacer()
StatusBadgeView(status: context.state.flightStatus)
}
Divider().background(Color.white.opacity(0.3))
HStack {
InfoItemView(
icon: "door.right.hand.open",
label: "Gate",
value: context.state.gateNumber
)
Spacer()
InfoItemView(
icon: "clock",
label: "Boarding",
value: context.state.boardingTime.formatted(date: .omitted, time: .shortened)
)
Spacer()
if context.state.delayMinutes > 0 {
InfoItemView(
icon: "exclamationmark.triangle",
label: "Delay",
value: "+\(context.state.delayMinutes)m",
valueColor: .orange
)
}
}
if context.state.isBoarding {
BoardingNowBannerView()
}
}
.padding()
.foregroundColor(.white)
}
}
Understanding the Architecture
- ActivityAttributes: Defines both static data (flight details) and dynamic state (status, gate)
- Lock Screen Layout: Primary real estate for detailed information
- Dynamic Island Regions: Different areas serve specific purposes (leading for status, trailing for gate info)
Native Module Bridge Implementation
To control Live Activities from React Native, create a bridge using three essential files:
1. LiveActivityModule.swift (Core Logic)
import ActivityKit
import Foundation
import React
@objc(LiveActivityModule)
class LiveActivityModule: NSObject {
private var activeActivities: [String: Activity<FlightActivityAttributes>] = [:]
@objc func startFlightActivity(
_ flightData: NSDictionary,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
reject("ACTIVITIES_DISABLED", "Live Activities are not enabled", nil)
return
}
do {
let attributes = FlightActivityAttributes(
flightNumber: flightData["flightNumber"] as? String ?? "",
origin: flightData["origin"] as? String ?? "",
destination: flightData["destination"] as? String ?? "",
scheduledDeparture: Date(timeIntervalSince1970: flightData["scheduledDeparture"] as? Double ?? 0),
airline: flightData["airline"] as? String ?? ""
)
let initialState = FlightActivityAttributes.ContentState(
flightStatus: flightData["status"] as? String ?? "On Time",
gateNumber: flightData["gate"] as? String ?? "TBD",
boardingTime: Date(timeIntervalSince1970: flightData["boardingTime"] as? Double ?? 0),
delayMinutes: flightData["delayMinutes"] as? Int ?? 0,
isBoarding: false
)
let activity = try Activity<FlightActivityAttributes>.request(
attributes: attributes,
contentState: initialState,
pushType: .token
)
activeActivities[activity.id] = activity
// Listen for push token updates
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
// Send token to React Native
self.sendEvent("onPushTokenUpdate", body: [
"activityId": activity.id,
"pushToken": tokenString
])
}
}
resolve([
"activityId": activity.id,
"success": true
])
} catch {
reject("START_FAILED", "Failed to start Live Activity: \(error.localizedDescription)", error)
}
}
@objc func updateFlightActivity(
_ activityId: String,
updateData: NSDictionary,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
guard let activity = activeActivities[activityId] else {
reject("NOT_FOUND", "Activity not found: \(activityId)", nil)
return
}
let updatedState = FlightActivityAttributes.ContentState(
flightStatus: updateData["status"] as? String ?? activity.contentState.flightStatus,
gateNumber: updateData["gate"] as? String ?? activity.contentState.gateNumber,
boardingTime: Date(timeIntervalSince1970: updateData["boardingTime"] as? Double ?? activity.contentState.boardingTime.timeIntervalSince1970),
delayMinutes: updateData["delayMinutes"] as? Int ?? activity.contentState.delayMinutes,
isBoarding: updateData["isBoarding"] as? Bool ?? activity.contentState.isBoarding
)
Task {
await activity.update(using: updatedState)
resolve(["success": true])
}
}
@objc func endFlightActivity(
_ activityId: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
guard let activity = activeActivities[activityId] else {
reject("NOT_FOUND", "Activity not found", nil)
return
}
Task {
await activity.end(dismissalPolicy: .immediate)
self.activeActivities.removeValue(forKey: activityId)
resolve(["success": true])
}
}
@objc static func requiresMainQueueSetup() -> Bool {
return false
}
}
2. RCTFlightActivityModule.m (Objective-C Bridge)
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(LiveActivityModule, NSObject)
RCT_EXTERN_METHOD(
startFlightActivity:(NSDictionary *)flightData
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
RCT_EXTERN_METHOD(
updateFlightActivity:(NSString *)activityId
updateData:(NSDictionary *)updateData
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
RCT_EXTERN_METHOD(
endFlightActivity:(NSString *)activityId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
@end
3. FlightActivity-Bridging-Header.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
Critical Setup: Add the bridging header file path in Build Settings → Swift Compiler - General → Objective-C Bridging Header.
React Native Service Integration
FlightActivityService.ts
Creating a service to manage Live Activities from JavaScript:
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
const { LiveActivityModule } = NativeModules;
interface FlightData {
flightNumber: string;
origin: string;
destination: string;
airline: string;
scheduledDeparture: number; // Unix timestamp
status: string;
gate: string;
boardingTime: number; // Unix timestamp
delayMinutes: number;
}
interface UpdateData {
status?: string;
gate?: string;
boardingTime?: number;
delayMinutes?: number;
isBoarding?: boolean;
}
interface ActivityResult {
activityId: string;
success: boolean;
}
class FlightActivityService {
private eventEmitter: NativeEventEmitter | null = null;
private pushTokenCallback: ((token: string, activityId: string) => void) | null = null;
constructor() {
if (Platform.OS === 'ios' && LiveActivityModule) {
this.eventEmitter = new NativeEventEmitter(LiveActivityModule);
this.setupEventListeners();
}
}
private setupEventListeners() {
this.eventEmitter?.addListener('onPushTokenUpdate', (data) => {
if (this.pushTokenCallback) {
this.pushTokenCallback(data.pushToken, data.activityId);
}
});
}
async startActivity(flightData: FlightData): Promise<ActivityResult> {
if (Platform.OS !== 'ios') {
throw new Error('Live Activities are only available on iOS');
}
if (!LiveActivityModule) {
throw new Error('LiveActivityModule is not available');
}
return LiveActivityModule.startFlightActivity(flightData);
}
async updateActivity(activityId: string, updateData: UpdateData): Promise<{ success: boolean }> {
if (Platform.OS !== 'ios' || !LiveActivityModule) return { success: false };
return LiveActivityModule.updateFlightActivity(activityId, updateData);
}
async endActivity(activityId: string): Promise<{ success: boolean }> {
if (Platform.OS !== 'ios' || !LiveActivityModule) return { success: false };
return LiveActivityModule.endFlightActivity(activityId);
}
onPushTokenUpdate(callback: (token: string, activityId: string) => void) {
this.pushTokenCallback = callback;
}
}
export const flightActivityService = new FlightActivityService();
export type { FlightData, UpdateData, ActivityResult };
App Integration (React Example)
import React, { useState, useCallback } from 'react';
import { View, Button, Text, Alert } from 'react-native';
import { flightActivityService, FlightData } from './FlightActivityService';
const FlightTrackerApp = () => {
const [activityId, setActivityId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const flightData: FlightData = {
flightNumber: 'GA 2025',
origin: 'BLR',
destination: 'BOM',
airline: 'GeekyAir',
scheduledDeparture: Date.now() / 1000 + 3600,
status: 'On Time',
gate: 'A12',
boardingTime: Date.now() / 1000 + 2700,
delayMinutes: 0,
};
const startTracking = useCallback(async () => {
setIsLoading(true);
try {
const result = await flightActivityService.startActivity(flightData);
setActivityId(result.activityId);
// Register for push token updates
flightActivityService.onPushTokenUpdate(async (token, id) => {
// Send push token to your backend
await fetch('https://your-api.com/register-push-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activityId: id, pushToken: token }),
});
});
Alert.alert('Success', 'Flight tracking started!');
} catch (error) {
Alert.alert('Error', `Failed to start tracking: ${error}`);
} finally {
setIsLoading(false);
}
}, []);
const simulateGateChange = useCallback(async () => {
if (!activityId) return;
await flightActivityService.updateActivity(activityId, {
gate: 'B7',
status: 'Gate Changed',
delayMinutes: 15,
});
}, [activityId]);
const simulateBoarding = useCallback(async () => {
if (!activityId) return;
await flightActivityService.updateActivity(activityId, {
isBoarding: true,
status: 'Boarding Now',
});
}, [activityId]);
const endTracking = useCallback(async () => {
if (!activityId) return;
await flightActivityService.endActivity(activityId);
setActivityId(null);
}, [activityId]);
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 20 }}>
Flight Tracker
</Text>
{!activityId ? (
<Button title="Start Tracking" onPress={startTracking} disabled={isLoading} />
) : (
<>
<Text style={{ marginBottom: 10 }}>Activity ID: {activityId.slice(0, 8)}...</Text>
<Button title="Simulate Gate Change" onPress={simulateGateChange} />
<Button title="Start Boarding" onPress={simulateBoarding} />
<Button title="End Tracking" onPress={endTracking} color="red" />
</>
)}
</View>
);
};
export default FlightTrackerApp;
Push Notifications Architecture
Backend Implementation (Node.js)
Live Activities can receive updates even when your app is completely terminated through APNs:
const http2 = require('http2');
const fs = require('fs');
const jwt = require('jsonwebtoken');
class APNsLiveActivityService {
constructor(config) {
this.teamId = config.teamId;
this.keyId = config.keyId;
this.privateKey = fs.readFileSync(config.privateKeyPath);
this.bundleId = config.bundleId;
this.isProduction = config.isProduction || false;
this.client = null;
this.tokenCache = null;
this.tokenExpiry = 0;
}
getAPNsHost() {
return this.isProduction
? 'api.push.apple.com'
: 'api.sandbox.push.apple.com';
}
generateJWT() {
const now = Math.floor(Date.now() / 1000);
if (this.tokenCache && now < this.tokenExpiry) {
return this.tokenCache;
}
const token = jwt.sign(
{ iss: this.teamId, iat: now },
this.privateKey,
{ algorithm: 'ES256', keyid: this.keyId }
);
this.tokenCache = token;
this.tokenExpiry = now + 3000; // Refresh before 1-hour expiry
return token;
}
async getConnection() {
if (!this.client || this.client.destroyed) {
this.client = http2.connect(`https://${this.getAPNsHost()}`);
}
return this.client;
}
async sendLiveActivityUpdate(pushToken, flightUpdate) {
const client = await this.getConnection();
const token = this.generateJWT();
const payload = {
aps: {
timestamp: Math.floor(Date.now() / 1000),
event: 'update',
'content-state': {
flightStatus: flightUpdate.status,
gateNumber: flightUpdate.gate,
boardingTime: flightUpdate.boardingTime,
delayMinutes: flightUpdate.delayMinutes || 0,
isBoarding: flightUpdate.isBoarding || false,
},
alert: {
title: flightUpdate.alertTitle || 'Flight Update',
body: flightUpdate.alertBody || `Gate: ${flightUpdate.gate} | ${flightUpdate.status}`,
},
},
};
return new Promise((resolve, reject) => {
const path = `/3/device/${pushToken}`;
const payloadBuffer = Buffer.from(JSON.stringify(payload));
const req = client.request({
':method': 'POST',
':path': path,
'authorization': `bearer ${token}`,
'apns-topic': `${this.bundleId}.push-type.liveactivity`,
'apns-push-type': 'liveactivity',
'apns-priority': '10',
'content-type': 'application/json',
'content-length': payloadBuffer.length,
});
req.write(payloadBuffer);
req.end();
let statusCode;
req.on('response', (headers) => {
statusCode = headers[':status'];
});
req.on('end', () => {
if (statusCode === 200) {
resolve({ success: true });
} else {
reject(new Error(`APNs returned status ${statusCode}`));
}
});
req.on('error', reject);
});
}
}
// Usage example
const apnsService = new APNsLiveActivityService({
teamId: process.env.APPLE_TEAM_ID,
keyId: process.env.APPLE_KEY_ID,
privateKeyPath: './AuthKey.p8',
bundleId: 'com.geekyants.flighttracker',
isProduction: process.env.NODE_ENV === 'production',
});
// Express endpoint to handle flight status updates
app.post('/flight-update', async (req, res) => {
const { activityId, pushToken, flightUpdate } = req.body;
try {
await apnsService.sendLiveActivityUpdate(pushToken, flightUpdate);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Push Notification Flow:
- App starts Live Activity and receives a unique push token
- Token sent to the backend and stored with the activity ID
- Backend monitors flight data changes
- Real-time updates are sent via APNs to a specific activity
- iOS automatically updates Live Activity UI
Key Flow Benefits
- Updates work even when the app is completely closed
- Each Live Activity has a unique push token
- No polling required — real push notifications
- Automatic UI refresh by the iOS system
- Battery efficient — no background processing
- Multiple activities can run simultaneously
Example APNs Payload Structure
{
"aps": {
"timestamp": 1700000000,
"event": "update",
"content-state": {
"flightStatus": "Boarding Now",
"gateNumber": "B7",
"boardingTime": 1700003600,
"delayMinutes": 15,
"isBoarding": true
},
"alert": {
"title": "Boarding Started",
"body": "Gate B7 is now boarding — hurry!"
}
}
}
Interactive Elements & Deep Links
AppIntents Implementation
Adding interactive buttons to your Live Activity:
import AppIntents
struct ViewBoardingPassIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "View Boarding Pass"
static var isDiscoverable: Bool = false
@Parameter(title: "Activity ID")
var activityId: String
init() {}
init(activityId: String) {
self.activityId = activityId
}
func perform() async throws -> some IntentResult {
// This triggers deep link handling in the main app
return .result()
}
}
struct CheckInIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Check In"
static var isDiscoverable: Bool = false
@Parameter(title: "Flight Number")
var flightNumber: String
init() {}
init(flightNumber: String) {
self.flightNumber = flightNumber
}
func perform() async throws -> some IntentResult {
return .result()
}
}
Integration in Live Activity UI
struct InteractiveLiveActivityView: View {
let context: ActivityViewContext<FlightActivityAttributes>
var body: some View {
HStack(spacing: 12) {
Button(intent: ViewBoardingPassIntent(activityId: context.attributes.flightNumber)) {
Label("Boarding Pass", systemImage: "qrcode")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
Button(intent: CheckInIntent(flightNumber: context.attributes.flightNumber)) {
Label("Check In", systemImage: "checkmark.circle")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
}
}
React Native Deep Link Handling
import { Linking } from 'react-native';
import { useEffect } from 'react';
const useDeepLinkHandler = () => {
useEffect(() => {
const handleDeepLink = (event: { url: string }) => {
const url = new URL(event.url);
switch (url.pathname) {
case '/boarding-pass':
const flightNumber = url.searchParams.get('flight');
navigateToBoardingPass(flightNumber);
break;
case '/check-in':
const flight = url.searchParams.get('flight');
navigateToCheckIn(flight);
break;
}
};
Linking.addEventListener('url', handleDeepLink);
return () => Linking.removeAllListeners('url');
}, []);
};
Push-to-Start Implementation (iOS 17.2+)
Push-to-Start allows your backend to initiate Live Activities without user interaction:
Backend Implementation
async function sendPushToStartActivity(pushToStartToken, flightData) {
const payload = {
aps: {
timestamp: Math.floor(Date.now() / 1000),
event: 'start',
'content-state': {
flightStatus: 'Scheduled',
gateNumber: flightData.gate || 'TBD',
boardingTime: flightData.boardingTime,
delayMinutes: 0,
isBoarding: false,
},
'attributes-type': 'FlightActivityAttributes',
attributes: {
flightNumber: flightData.flightNumber,
origin: flightData.origin,
destination: flightData.destination,
airline: flightData.airline,
scheduledDeparture: flightData.scheduledDeparture,
},
alert: {
title: `${flightData.flightNumber} Tracking Started`,
body: `${flightData.origin} → ${flightData.destination} — Live tracking is now active`,
},
},
};
return apnsService.sendToDevice(pushToStartToken, payload, 'liveactivity');
}
Strategic Use Cases
- Auto-activate tracking 24 hours before departure
- Begin monitoring immediately after successful booking
- Start the activity when the gate is assigned
- Initiate during the mobile check-in process
Business Impact & Implementation Strategy
Engagement Metrics
Live Activities create measurable business value through enhanced user engagement and new revenue streams:
User Engagement
| Metric | Impact |
|---|---|
| Interaction rate vs. push notifications | +40% |
| Reduction in app abandonment during delays | -60% |
| Increase in session duration when active | +50% |
Conversion Impact
| Metric | Impact |
|---|---|
| Ancillary service bookings (upgrades, meals) | +30% |
| Click-through rate on cross-sell offers | +45% |
| Reduction in customer support queries | -20% |
Conclusion
iOS Live Activities represent a significant evolution in mobile user experience, particularly for time-sensitive applications like flight tracking. The technical implementation requires coordination between native iOS development and React Native, but the user engagement and business benefits justify the complexity.
Key Success Factors
- User-Centric Design: Focus on displaying only essential information that users need at critical moments
- Technical Reliability: Robust error handling and graceful degradation for network issues
- Performance Optimization: Minimize battery impact through intelligent update strategies
- Business Integration: Leverage Live Activities for revenue generation, not just user experience
The investment in Live Activities technology positions your application at the forefront of mobile user experience while creating new opportunities for user engagement and revenue generation.
Note: This guide reflects best practices as of September 2025 for iOS 17.5+ and React Native 0.74+. Always refer to Apple's latest documentation for current APIs and requirements.
Written by **Yuvraj Kumar, Software Engineer III at GeekyAnts — a global software development and technology consulting firm specializing in cross-platform mobile and web engineering.
GeekyAnts is a React Native development company with deep expertise in iOS and Android native integrations.






Top comments (0)