Documentation Index
Fetch the complete documentation index at: https://docs.radar.com/llms.txt
Use this file to discover all available pages before exploring further.
In this tutorial, we will leverage Trips to build a Live Activity for iOS applications to monitor and display the progress of a trip from start to finish, the trip destination, and the arrival ETA on a user’s lock screen and Dynamic Island. If you want to see the full project, you can clone the source code in the section below. This tutorial assumes you have a Radar account and an existing iOS app with the Radar SDK implemented. A detailed implementation overview can be found here.
Source code
GitHub Repo
Languages used
Features used
Steps
Add app entitlements
Add Supports Live Activities and Supports Live Activities Frequent Updates to the info.plist of your main app:
Create trip activity manager
In your app target, create a TripLiveActivityManager.swift and define a TripLiveActivityManager class. This is where all of the logic for updating our live activity will live so create a shared instance and add the variables defined below:import Foundation
import ActivityKit
import RadarSDK
@available(iOS 16.2, *)
final class TripLiveActivityManager {
static let shared = TripLiveActivityManager()
private init() {}
private var currentActivity: Activity<TripActivityExtensionAttributes>?
/// Duration (in seconds) to keep the activity visible after ending
private let dismissalDelay: TimeInterval = 5
var hasActiveActivity: Bool {
currentActivity != nil
}
}
Add activity handlers
There are three public methods we will use from the TripLiveActivityManager to handle starting the live activity, updating it when a user’s location is updated, and ending it when the trip is completed. Let’s add those.func startActivity(trip: RadarTrip) {
guard checkActivitiesEnabled() else { return }
// End any existing activity first
endActivity()
Task {
let contentState = await buildContentState(from: trip)
createActivity(contentState: contentState)
}
}
func updateActivity(trip: RadarTrip, statusOverride: String? = nil) {
guard let activity = currentActivity else {
print("No active Live Activity to update")
return
}
Task {
let contentState = await buildContentState(from: trip, statusOverride: statusOverride)
await activity.update(.init(state: contentState, staleDate: nil))
}
}
func endActivity(status: String = "completed") {
guard let activity = currentActivity else {
print("No active Live Activity to end")
return
}
let finalState = TripActivityExtensionAttributes.ContentState(
name: "Trip Ended",
tripId: "",
status: status,
etaDuration: nil,
mode: nil,
destinationAddress: nil
)
Task {
await activity.end(
.init(state: finalState, staleDate: nil),
dismissalPolicy: .after(.now + dismissalDelay)
)
currentActivity = nil
print("Live Activity ended: \(status)")
}
}
Create private methods for TripActivityManager
Add the private methods to TripActivityManager for creating the activity, building the content state (the data to be displayed in our live activity), and fetching the destination address with the help of Radar.reverseGeocode().private func checkActivitiesEnabled() -> Bool {
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
print("Live Activities are not enabled - check Settings")
return false
}
return true
}
private func createActivity(contentState: TripActivityExtensionAttributes.ContentState) {
do {
let activity = try Activity.request(
attributes: TripActivityExtensionAttributes(),
content: .init(state: contentState, staleDate: nil),
pushType: nil
)
currentActivity = activity
} catch {
print("Error starting Live Activity: \(error.localizedDescription)")
}
}
private func buildContentState(from trip: RadarTrip, statusOverride: String? = nil) async -> TripActivityExtensionAttributes.ContentState {
let destinationAddress = await fetchDestinationAddress(from: trip)
return TripActivityExtensionAttributes.ContentState(
name: trip.externalId ?? trip._id,
tripId: trip._id,
status: statusOverride ?? trip.status.stringValue,
etaDuration: Double(trip.etaDuration),
mode: trip.mode.stringValue,
destinationAddress: destinationAddress
)
}
private func fetchDestinationAddress(from trip: RadarTrip) async -> String? {
guard let destinationLocation = trip.destinationLocation else {
return nil
}
let location = CLLocation(
latitude: destinationLocation.coordinate.latitude,
longitude: destinationLocation.coordinate.longitude
)
return await withCheckedContinuation { continuation in
Radar.reverseGeocode(location: location) { status, addresses in
let address = addresses?.first?.formattedAddress?.truncatedAtFirstComma
continuation.resume(returning: address)
}
}
}
Add String Extension
Finally, add a String extension to truncate the destination address for display purposes.private extension String {
var truncatedAtFirstComma: String {
guard let commaIndex = firstIndex(of: ",") else { return self }
return String(self[..<commaIndex])
}
}
Handle Live Activity
Now that we have our activity manager, we can create the logic for handling our Live Activity. in your app’s AppDelegate, add the following function:@available(iOS 16.2, *)
private func handleTripLiveActivity(user: RadarUser?) {
guard let trip = user?.trip else {
TripLiveActivityManager.shared.endActivity(status: "completed")
return
}
let hasActivity = TripLiveActivityManager.shared.hasActiveActivity
switch trip.status {
case .started, .approaching, .arrived:
if !hasActivity {
TripLiveActivityManager.shared.startActivity(trip: trip)
} else {
// If trip is "started" but we already have an activity, show as "in_progress"
let statusOverride = (trip.status == .started) ? "in_progress" : nil
TripLiveActivityManager.shared.updateActivity(trip: trip, statusOverride: statusOverride)
}
case .completed:
TripLiveActivityManager.shared.endActivity(status: "completed")
case .canceled:
TripLiveActivityManager.shared.endActivity(status: "canceled")
case .expired:
TripLiveActivityManager.shared.endActivity(status: "expired")
default:
break
}
}
Add RadarDelegate functions
All that is left for us to do in the app is to hook into the RadarDelegate to listen for location updates and Radar events. First, in applicationDidFinishLaunchingWithOptions set the delegate after Radar.initialize():func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//....existing code
Radar.initialize(publishableKey: "prj_test_pk_...", options: radarInitializeOptions )
Radar.setDelegate(self)
//....existing code
return true
}
and add the delegate methods below in AppDelegate:func didReceiveEvents(_ events: [RadarEvent], user: RadarUser?) {
if #available(iOS 16.2, *) {
for event in events {
if event.type == .userStoppedTrip {
TripLiveActivityManager.shared.endActivity(status: "completed")
}
}
}
}
func didUpdateLocation(_ location: CLLocation, user: RadarUser) {
if #available(iOS 16.2, *) {
if user.trip != nil {
handleTripLiveActivity(user: user)
}
}
}
Build Live Activity views
We now have all of the Live Activity and Radar SDK handlers we need to create and update a Live Activity based on Radar Trip data. All we need to do from here is create the SwiftUI views and helper functions in our TripActivityExtension to display active trip data. This example includes various assets and design choices you may want to change but for the sake of this tutorial the TripActivityExtension file structure is as follows:TripActivityExtension/
├── Assets
├── TripActivityExtensionLiveActivity.swift
├── TripActivityExtensionBundle.swift
├── Views/
│ ├── LockScreenView.swift
│ ├── DynamicIslandViews.swift
│ ├── ProgressBar.swift
│ └── RadarArrowImage.swift
├── Helpers/
│ ├── TripColors.swift
│ └── TripFormatters.swift
└── Extensions/
└── UIColor+Hex.swift
Make sure the Target Membership for these files includes your app and the extension

Create LockScreenView
Add Views/LockScreenView.swiftimport ActivityKit
import WidgetKit
import SwiftUI
@available(iOS 16.2, *)
struct LockScreenView: View {
let context: ActivityViewContext<TripActivityExtensionAttributes>
var body: some View {
ZStack {
content
gradientOverlay
}
.activityBackgroundTint(TripColors.background)
.activitySystemActionForegroundColor(.white)
}
// MARK: - Content
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
headerRow
Spacer()
statusText
Spacer()
progressSection
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.top, 10)
}
// MARK: - Header
private var headerRow: some View {
HStack(alignment: .center, spacing: 8) {
RadarArrowImage(variant: .twilight)
.frame(width: 20, height: 20)
Spacer()
Text("\(context.state.destinationAddress ?? "undefined") | \(TripFormatters.formatDuration(context.state.etaDuration))")
.font(.system(size: 16, weight: .regular))
.foregroundColor(TripColors.twilight)
.lineLimit(1)
.truncationMode(.head)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.padding(.trailing, 10)
}
// MARK: - Status
private var statusText: some View {
let step = TripFormatters.mapStatusToStep(context.state.status)
return Text(TripFormatters.statusMessage(for: step))
.font(.system(size: 16, weight: .bold))
.foregroundColor(TripColors.twilight)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.bottom, 12)
.padding(.top, 6)
.padding(.horizontal, 8)
}
// MARK: - Progress
private var progressSection: some View {
HStack(alignment: .center, spacing: 4) {
ProgressBar(currentStep: TripFormatters.mapStatusToStep(context.state.status))
}
.padding(.horizontal, 8)
.padding(.bottom, 10)
}
// MARK: - Gradient Overlay
private var gradientOverlay: some View {
VStack {
LinearGradient(
gradient: Gradient(stops: [
.init(color: TripColors.twilight.opacity(0.2), location: 0.0),
.init(color: TripColors.twilight.opacity(0.1), location: 0.5),
.init(color: Color.clear, location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 60)
Spacer()
}
}
}
Create ModeImageView
Add Views/ModeImageView.swiftimport WidgetKit
import SwiftUI
struct ModeImageView: View {
let mode: String?
let width: CGFloat
let height: CGFloat
let topPadding: CGFloat
init(mode: String?, width: CGFloat = 30, height: CGFloat = 30, topPadding: CGFloat = 4) {
self.mode = mode
self.width = width
self.height = height
self.topPadding = topPadding
}
var body: some View {
Image(imageName(for: mode))
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: width, height: height)
.padding(.top, topPadding)
.foregroundColor(.white)
}
private func imageName(for mode: String?) -> String {
switch mode {
case "car":
return "radarCarWhite"
case "bike":
return "radarBikeWhite"
default:
return "radarWalkingWhite"
}
}
}
Create ProgressBar
Add Views/ProgressBar.swiftimport ActivityKit
import WidgetKit
import SwiftUI
struct ProgressBar: View {
let currentStep: Int
let totalSteps: Int = 4
var isDynamicIsland: Bool = false
var progressColor: Color {
isDynamicIsland ? .white : TripColors.twilight
}
var body: some View {
HStack(spacing: 0) {
ForEach(1...totalSteps, id: \.self) { step in
Circle()
.fill(step <= currentStep ? progressColor : Color(UIColor(hexString: "#ACBDC8")).opacity(0.5))
.frame(width: step == currentStep ? 30 : 12, height: step == currentStep ? 30 : 12)
.overlay(
Group {
if step == currentStep {
Image(iconForStep(step))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
}
}
)
if step < totalSteps {
if #available(iOS 15.0, *) {
Rectangle()
.fill(rectangleFill(for: step))
.frame(height: 2)
.frame(maxWidth: .infinity)
} else {
Rectangle()
.fill(Color(UIColor(hexString: "#ACBDC8")).opacity(0.5))
.frame(height: 2)
.frame(maxWidth: .infinity)
}
}
}
}
}
func iconForStep(_ step: Int) -> String {
let baseIcon: String
switch step {
case 1:
baseIcon = "radarFlagStart"
case 2:
baseIcon = "radarWalking"
case 3:
baseIcon = "radarWalking"
case 4:
baseIcon = "radarFlagEnd"
default:
baseIcon = "radarFlagStar"
}
if isDynamicIsland == true {
return baseIcon + "Blue"
} else {
return baseIcon + "White"
}
}
@available(iOS 15.0, *)
func rectangleFill(for step: Int) -> AnyShapeStyle {
let incompleteStepColor = Color(UIColor(hexString: "#ACBDC8")).opacity(0.5)
if step > currentStep - 1 {
return AnyShapeStyle(incompleteStepColor)
} else {
return AnyShapeStyle(progressColor)
}
}
}
Create RadarArrowImage
Add Views/RadarArrowImage.swiftimport SwiftUI
@available(iOS 16.2, *)
struct RadarArrowImage: View {
enum Variant {
case twilight, white
var imageName: String {
switch self {
case .twilight: return "RadarArrowTwilight"
case .white: return "RadarArrowWhite"
}
}
}
let variant: Variant
var size: CGFloat = 20
var body: some View {
Image(variant.imageName)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
}
}
Create TripColors
Add Helpers/TripColors.swiftimport SwiftUI
enum TripColors {
static let twilight = Color(UIColor(hexString: "#2A1688"))
static let grayAccent = Color(UIColor(hexString: "#ACBDC8"))
static let background = Color.white
}
Create TripFormatters
Add Helpers/TripFormatters.swiftimport Foundation
enum TripFormatters {
static func formatDuration(_ duration: Double?, compact: Bool = false) -> String {
guard let duration = duration else { return "Unknown" }
let minutes = Int(duration.rounded())
if minutes < 1 {
return "Arriving"
} else if minutes == 1 {
return compact ? "1 min" : "1 minute"
} else {
return compact ? "\(minutes) min" : "\(minutes) minutes"
}
}
static func formatDistance(_ meters: Float) -> String {
let miles = meters * 0.000621371
if miles < 0.1 {
return String(format: "%.0f ft", meters * 3.28084)
} else {
return String(format: "%.1f mi", miles)
}
}
static func mapStatusToStep(_ status: String) -> Int {
switch status {
case "started": return 1
case "in_progress": return 2
case "approaching": return 3
case "arrived", "completed", "expired", "canceled": return 4
default: return 0
}
}
static func statusMessage(for step: Int) -> String {
switch step {
case 0: return "Your trip status is unknown"
case 1: return "Your order is confirmed"
case 2: return "Your trip is in progress"
case 3: return "Approaching your destination"
case 4: return "You have arrived at your destination"
default: return "Your trip status is unknown"
}
}
}
Add UIColor extension
Add Extensions/UIColor+Hex.swiftimport UIKit
extension UIColor {
convenience init(red: Int, green: Int, blue: Int) {
assert(red >= 0 && red <= 255, "Invalid red component")
assert(green >= 0 && green <= 255, "Invalid green component")
assert(blue >= 0 && blue <= 255, "Invalid blue component")
self.init(
red: CGFloat(red) / 255.0,
green: CGFloat(green) / 255.0,
blue: CGFloat(blue) / 255.0,
alpha: 1.0
)
}
convenience init(rgb: Int) {
self.init(
red: (rgb >> 16) & 0xFF,
green: (rgb >> 8) & 0xFF,
blue: rgb & 0xFF
)
}
convenience init(hexString: String) {
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 1, 52)
}
self.init(
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
alpha: Double(a) / 255
)
}
}
Download Assets
The assets used in this example Live Activity can be downloaded here. Add them to TripActivityExtension/Assets. Update Main View
Update TripActivityExtensionLiveActivity.swift to use the new views.import ActivityKit
import WidgetKit
import SwiftUI
// MARK: - Activity Attributes
struct TripActivityExtensionAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var name: String
var tripId: String
var status: String
var etaDuration: Double?
var mode: String?
var destinationAddress: String?
}
}
@available(iOS 16.2, *)
struct TripActivityExtensionLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripActivityExtensionAttributes.self) { context in
LockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
RadarArrowImage(variant: .white, size: 30)
.frame(maxHeight: .infinity, alignment: .center)
.padding(.leading, 16)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Text(TripFormatters.formatDuration(context.state.etaDuration, compact: true))
.font(.system(size: 18, weight: .regular))
.foregroundColor(.white)
}
.padding(.trailing, 16)
}
DynamicIslandExpandedRegion(.bottom) {
HStack(alignment: .center, spacing: 4) {
ProgressBar(
currentStep: TripFormatters.mapStatusToStep(context.state.status),
isDynamicIsland: true
)
}
.padding(.horizontal)
.padding(.top, 8)
}
} compactLeading: {
ModeImageView(mode: context.state.mode, width: 18, height: 18, topPadding: 0)
} compactTrailing: {
Text(TripFormatters.formatDuration(context.state.etaDuration, compact: true))
.foregroundColor(.white)
} minimal: {
ModeImageView(mode: context.state.mode, width: 18, height: 18, topPadding: 0)
}
}
}
}
// MARK: - Preview Data
@available(iOS 16.2, *)
extension TripActivityExtensionAttributes {
fileprivate static var preview: TripActivityExtensionAttributes {
TripActivityExtensionAttributes()
}
}
@available(iOS 16.2, *)
extension TripActivityExtensionAttributes.ContentState {
fileprivate static var started: TripActivityExtensionAttributes.ContentState {
TripActivityExtensionAttributes.ContentState(
name: "My Trip",
tripId: "trip_123",
status: "started",
etaDuration: 15.5,
mode: "car",
destinationAddress: "123 Main St, San Francisco, CA"
)
}
fileprivate static var approaching: TripActivityExtensionAttributes.ContentState {
TripActivityExtensionAttributes.ContentState(
name: "My Trip",
tripId: "trip_123",
status: "approaching",
etaDuration: 2.0,
mode: "car",
destinationAddress: "123 Main St, San Francisco, CA"
)
}
}
@available(iOS 18.0, *)
#Preview("Notification", as: .content, using: TripActivityExtensionAttributes.preview) {
TripActivityExtensionLiveActivity()
} contentStates: {
TripActivityExtensionAttributes.ContentState.started
TripActivityExtensionAttributes.ContentState.approaching
}
We now have a fully functional Live Activity that updates based on Radar Trips data on a user’s lock screen and Dynamic Island! Create a Geofence
With our Live Activity set up, the last thing we need to do is start a trip. If you don’t have any geofences in your Radar project, you will need to add one. For this example I have created the geofence with the identifier trip12345, shown below:
Start a trip
In order to test your new Live Activity, call Radar.startTrip with a destinationGeofenceTag and destinationGeofenceExternalId that match the Geofence in your Radar project. You can also include any additional RadarTripOptions you need. See the example below for reference:let uniqueTripId = "trip_\(Int(Date().timeIntervalSince1970))"
let tripOptions = RadarTripOptions(externalId: uniqueTripId, destinationGeofenceTag: "trip_activity", destinationGeofenceExternalId: "trip12345", scheduledArrivalAt: nil, startTracking: false)
tripOptions.mode = .car
tripOptions.approachingThreshold = 5
let trackingOptions = RadarTrackingOptions.presetContinuous
Radar.startTrip(options: tripOptions, trackingOptions: trackingOptions)
Conclusion
Congratulations on finishing the tutorial! Once you have started a trip, you should see your Live Activity on your lock screen and in your dynamic island.
Lock Screen
Dynamic Island
Expanded Dynamic Island
Support
Have questions? Contact us at radar.com/support.