mapbox-navigation-ios icon indicating copy to clipboard operation
mapbox-navigation-ios copied to clipboard

Support for SwiftUI with CarPlay – TripProgress UI not showing

Open DrikABrak opened this issue 7 months ago • 7 comments

Hi,

Has anyone successfully implemented CarPlay support using SwiftUI?

I'm currently integrating Mapbox Navigation in a SwiftUI-based app. Everything works fine on iPhone, including the TripProgress UI (ETA, distance remaining, etc.), but on CarPlay, the same UI does not appear, even though navigation is active and routing works.

I'm using the CarPlayManager as documented, and CarPlay connects correctly (the delegate is set and invoked), but the trip summary elements are missing.

If anyone has experience or an example of integrating CarPlay with SwiftUI (even partially), I’d greatly appreciate your insight!

Even a minimal working SwiftUI example would be extremely helpful.

Thanks in advance

See screenshots.

Image

Image

DrikABrak avatar Jun 12 '25 06:06 DrikABrak

Hi @DrikABrak, thanks for reaching out! To better understand the context and reproduce the issue, would you be able to share a minimal example project that demonstrates your current setup?

volkdmitri avatar Jun 16 '25 12:06 volkdmitri

Hi @volkdmitri, thanks for your reply. It's a complicated task to extract minimal code to test the CarPlay functionality of my project, but here are the main codes to launch the navigation that works on the iPhone but not on CarPlay.

MyApp

import SwiftUI
import SwiftData
import Firebase
import CarPlay
import MapboxNavigationCore
import MapboxNavigationUIKit

@main
struct MyApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    init() {
        FirebaseApp.configure()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
var navigationProvider: MapboxNavigationProvider = {
    return MapboxNavigationProvider(coreConfig: .init())
}()

class AppDelegate: NSObject, UIApplicationDelegate {
    
    var _carPlayManager: Any? = nil
    var carPlayManager: CarPlayManager {
        if _carPlayManager == nil {
            _carPlayManager = CarPlayManager(navigationProvider: navigationProvider)
        }

        return _carPlayManager as! CarPlayManager
    }
    
    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {

        if connectingSceneSession.role == .carTemplateApplication {
            let config = UISceneConfiguration(name: "CarPlayConfiguration", sessionRole: connectingSceneSession.role)
            config.delegateClass = CarPlaySceneDelegate.self
            config.sceneClass = CPTemplateApplicationScene.self
            return config
        }

        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

My CarPlaySceneDelegate

import UIKit
import CarPlay
import MapboxNavigationUIKit
import MapboxNavigationCore
import SwiftUI

class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didConnect interfaceController: CPInterfaceController,
        to window: CPWindow
    ) {
        print("[CarPlay] Connecté")

        appDelegate.carPlayManager.delegate = appDelegate
        appDelegate.carPlayManager.templateApplicationScene(
            templateApplicationScene,
            didConnectCarInterfaceController: interfaceController,
            to: window
        )
    }

    func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didDisconnect interfaceController: CPInterfaceController,
        from window: CPWindow
    ) {
        print("[CarPlay] Déconnecté")

        appDelegate.carPlayManager.delegate = nil

        appDelegate.carPlayManager.templateApplicationScene(
            templateApplicationScene,
            didDisconnectCarInterfaceController: interfaceController,
            from: window
        )
    }
}

Extension (maybe something missed here)

extension AppDelegate: CarPlayManagerDelegate {
    func carPlayManager(
        _ carPlayManager: MapboxNavigationUIKit.CarPlayManager,
        leadingNavigationBarButtonsCompatibleWith traitCollection: UITraitCollection,
        in carPlayTemplate: CPMapTemplate,
        for activity: MapboxNavigationUIKit.CarPlayActivity,
        cameraState: MapboxNavigationCore.NavigationCameraState
    ) -> [CPBarButton]? {
        return nil
    }

...
}

My NavigationViewController

struct MBNavigationViewController: UIViewControllerRepresentable {
    @ObservedObject var mapManager: MapManager
    @Binding var isPresented: Bool
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    private var currentLocation: CLLocation? {
        navigationProvider.navigation().currentLocationMatching?.location
    }
    
    func makeUIViewController(context: Context) -> NavigationViewController {
        
        navigationProvider.apply(coreConfig: .init(
            locationSource: .simulation()
        ))
        
        let mapboxNavigation = navigationProvider.mapboxNavigation
        
        let navigationOptions = NavigationOptions(
            mapboxNavigation: mapboxNavigation,
            voiceController: navigationProvider.routeVoiceController,
            eventsManager: navigationProvider.eventsManager()
        )
        
        let navigationViewController = NavigationViewController(
            navigationRoutes: mapManager.currentNavigationRoutes!,
            navigationOptions: navigationOptions
        )
        
        navigationViewController.delegate = context.coordinator
        navigationViewController.routeLineTracksTraversal = true
        navigationViewController.floatingButtonsPosition = .topTrailing
        
        if let currentLocation, appDelegate.carPlayManager.currentActivity != .navigating {
            appDelegate.carPlayManager.beginNavigationWithCarPlay(using: currentLocation.coordinate)
        }
        
        return navigationViewController
    }

    func updateUIViewController(_ uiViewController: NavigationViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(mapManager: mapManager, isPresented: $isPresented)
    }

    class Coordinator: NSObject, NavigationViewControllerDelegate {
        var mapManager: MapManager
        @Binding var isPresented: Bool

        init(mapManager: MapManager, isPresented: Binding<Bool>) {
            self.mapManager = mapManager
            self._isPresented = isPresented
        }

        func navigationViewControllerDidDismiss(_ navigationViewController: NavigationViewController, byCanceling canceled: Bool) {
            isPresented = false
        }
    }
}

I don't know if these snippets of code are enough to find the source of the problem, but if necessary, I can provide other codes. Thanks.

DrikABrak avatar Jun 18 '25 06:06 DrikABrak

Any idea?

DrikABrak avatar Jun 26 '25 06:06 DrikABrak

Hello @DrikABrak Try to use CarPlayNavigationViewController/CarPlayMapViewController to work with CarPlay. These are specific objects which responsible to handle CPMapTemplate overlay and add corresponding items on the screen.

chizhavko avatar Aug 18 '25 07:08 chizhavko

Hello @chizhavko,

First of all, thank you for your reply. Before testing CarPlayNavigationController/CarPlayMapViewController, I wanted to know if this works on the iOS part as well? Actually, I already have navigation working on iOS and I just want to adapt this navigation feature for CarPlay, but only if CarPlay is detected. So if I use a CarPlayNavigationController, will it also work on iOS, or do I need to add a condition like if CarPlay?

DrikABrak avatar Aug 18 '25 13:08 DrikABrak

Hi @DrikABrak

CarPlayManager, CarPlayNavigationController, and CarPlayMapViewController are classes designed to handle the navigation and show the navigation UI only on CarPlay. They use Apple's CarPlay library, which is only available in CarPlay. You need to keep using NavigationViewController on iOS. You will need to use the same MapboxRoutingProvider instance to handle the navigation logic, but use NavigationViewController to handle iOS UI and CarPlayManager to handle CarPlay UI.

Please check this CarPlay-related docs and this CarPlayExample app.

In your code snippets, I see CarPlayManager.beginNavigationWithCarPlay(using:). call, but you need to make a couple of adjustments to make it work. Most likely, your app creates 2 separate delegates, for CarPlay and for the SwiftUI app, so you should store carPlayManager in some static variable. Without it, you will partly configure 2 independent instancies of CarPlayManager.

So it might looks like this:

    static var _carPlayManager: CarPlayManager? = nil
    var carPlayManager: CarPlayManager {
        if Self._carPlayManager == nil {
            Self._carPlayManager = CarPlayManager(navigationProvider: navigationProvider)
        }

        return Self._carPlayManager!
    }

You might want to add some additional improvements to your code. Currently, your code snippet:

  1. The beginNavigationWithCarPlay(using:) method is called even if CarPlay is not connected. carPlayManager in your code is lazily created, so this call will also create an instance of carPlayManager you don't need. Please note, CarPlayManager automatically starts a free drive session by default. This will start an unnecessary billing session. This method should be called when the CarPlay is connected to the app.
  2. You don't cover the case when CarPlay was connected after the active guidance session started.

Possible fixes might look like this:

  1. the fix for the case when CarPlay was connected before the active guidance session starts
    func makeUIViewController(context: Context) -> NavigationViewController { 
/// ....
// Check if CarPlay is already connected before calling `beginNavigationWithCarPlay(using:)`.
// Do not create CarPlayManager if CarPlay is not connected.
        if let carPlayManager = AppDelegate._carPlayManager,
// Use the coordinate of the start waypoint.
           let coordinate = navigationRoutes.waypoints.first?.coordinate {
            carPlayManager.beginNavigationWithCarPlay(using: coordinate)
        }
/// ....
  1. add fix for the case when CarPlay was connected after the active guidance session started
func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didConnect interfaceController: CPInterfaceController,
        to window: CPWindow
    ) {
/// ....
// Add an additional beginNavigationWithCarPlay call
        if let navigationRoutes = navigationProvider.tripSession().currentNavigationRoutes,
           let coordinate = navigationRoutes.waypoints.first?.coordinate {
            appDelegate.carPlayManager.beginNavigationWithCarPlay(using: coordinate)
        }
}
 
  1. add handling for the navigation session stops in CarPlay

kried avatar Sep 08 '25 18:09 kried

Hi @kried

Thanks for your complete feedback. Indeed, the issue was caused by having two instances of the CarPlayManager. I’ve created a dedicated CarPlayCoordinator class to handle this. It’s now working — I just need to make sure that interactions on the iPhone are blocked when CarPlay is running. Here is my code:

class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
    
    func sceneDidActivate(_ scene: UIScene) {
        CarPlayCoordinator.shared.carPlayActivated = true
    }
    
    func sceneWillResignActive(_ scene: UIScene) {
        CarPlayCoordinator.shared.carPlayActivated = false
    }
    
    func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didConnect interfaceController: CPInterfaceController,
        to window: CPWindow
    ) {
        CarPlayCoordinator.shared.carPlayActivated = true

        CarPlayCoordinator.shared.carPlaySceneDidConnect(
            templateApplicationScene: templateApplicationScene,
            interfaceController: interfaceController,
            window: window
        )
        
        guard let carPlayManager = CarPlayCoordinator.shared.carPlayManager else { return }
        
        if let navigationRoutes = navigationProvider.tripSession().currentNavigationRoutes,
           let coordinate = navigationRoutes.waypoints.first?.coordinate {
            carPlayManager.beginNavigationWithCarPlay(using: coordinate)
        }
    }

    func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didDisconnect interfaceController: CPInterfaceController,
        from window: CPWindow
    ) {
        CarPlayCoordinator.shared.carPlayActivated = false
        
        CarPlayCoordinator.shared.carPlaySceneDidDisconnect(
            templateApplicationScene: templateApplicationScene,
            interfaceController: interfaceController,
            window: window
        )
    }
}


final class CarPlayCoordinator {
    static let shared = CarPlayCoordinator()
    
    private init() {}
    
    var carPlayManager: CarPlayManager?
    var carPlayInterfaceController: CPInterfaceController?
    var carPlayActivated: Bool = false
    
    func carPlaySceneDidConnect(templateApplicationScene: CPTemplateApplicationScene,
                                interfaceController: CPInterfaceController,
                                window: CPWindow) {
        Task { @MainActor in
            if self.carPlayManager == nil {
                self.carPlayManager = CarPlayManager(navigationProvider: navigationProvider)
            }
            self.carPlayManager?.delegate = self

            self.carPlayManager?.templateApplicationScene(
                templateApplicationScene,
                didConnectCarInterfaceController: interfaceController,
                to: window
            )
            self.carPlayInterfaceController = interfaceController
            
            self.displayAlertTemplate()
        }
    }
    
    func carPlaySceneDidDisconnect(templateApplicationScene: CPTemplateApplicationScene,
                                   interfaceController: CPInterfaceController,
                                   window: CPWindow) {
        carPlayManager?.templateApplicationScene(
            templateApplicationScene,
            didDisconnectCarInterfaceController: interfaceController,
            from: window
        )
        carPlayInterfaceController = interfaceController
    }
    
    func noRouteAlertTemplate() -> CPInformationTemplate {
        let item = CPInformationItem(title: "", detail: NSLocalizedString("carplay_info", comment: ""))
        
        return CPInformationTemplate(
            title: "Info",
            layout: .leading,
            items: [item],
            actions: []
        )
    }
    
    func displayAlertTemplate() {
        let alertTemplate = noRouteAlertTemplate()
        self.carPlayInterfaceController?.setRootTemplate(alertTemplate, animated: false) { arg, error  in
            if let error {
                print("Error Show CarPlay : \(error.localizedDescription)")
            }
        }
    }
    
    func dismissAlertTemplate() {
        self.carPlayInterfaceController?.dismissTemplate(animated: true) { value, error in
            if let error {
                print("Error Dismiss CarPlay : \(error.localizedDescription)")
            }
        }
    }
}

extension CarPlayCoordinator: CarPlayManagerDelegate {
...
}

DrikABrak avatar Sep 09 '25 06:09 DrikABrak