Skip to main content
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. Live Activity example lock screen

Source code

GitHub Repo

Languages used

  • Swift
  • SwiftUI

Features used

Steps

1

Add Widget Extension

In Xcode go to File -> New -> Target and search for widget:Live Activity Step 1 setupName your Extension TripActivityExtension and press Finish:Live Activity Step 1 finishThis will create and add a new folder and a few files to your project. We will be working with the TripActivityExtensionLiveActivity file.Live Activity Step 1 file structure
2

Add app entitlements

Add Supports Live Activities and Supports Live Activities Frequent Updates to the info.plist of your main app:Live Activity Step 1 finish
3

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
    }
}
4

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)")
    }
}
5

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)
        }
    }
}
6

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])
    }
}
7

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
    }
}
8

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)
        }
    }
}
9

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
Live Activity Step 9 finish
10

Create LockScreenView

Add Views/LockScreenView.swift
import 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()
        }
    }
}
11

Create ModeImageView

Add Views/ModeImageView.swift
import 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"
        }
    }
}
12

Create ProgressBar

Add Views/ProgressBar.swift
import 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)
        }
    }
}
13

Create RadarArrowImage

Add Views/RadarArrowImage.swift
import 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)
    }
}
14

Create TripColors

Add Helpers/TripColors.swift
import SwiftUI

enum TripColors {
    static let twilight = Color(UIColor(hexString: "#2A1688"))
    static let grayAccent = Color(UIColor(hexString: "#ACBDC8"))
    static let background = Color.white
}
15

Create TripFormatters

Add Helpers/TripFormatters.swift
import 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"
        }
    }
}
16

Add UIColor extension

Add Extensions/UIColor+Hex.swift
import 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
        )
    }
}
17

Download Assets

The assets used in this example Live Activity can be downloaded here. Add them to TripActivityExtension/Assets.
18

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!
19

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:Live Activity Step 19 geofence
20

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 Live Activity example lock screen Dynamic Island Live Activity example dynamic island Expanded Dynamic Island Live Activity example dynamic island expanded

Support

Have questions? Contact us at radar.com/support.