Support for SwiftUI with CarPlay – TripProgress UI not showing
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.
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?
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.
Any idea?
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.
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?
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:
- The
beginNavigationWithCarPlay(using:)method is called even if CarPlay is not connected.carPlayManagerin your code is lazily created, so this call will also create an instance ofcarPlayManageryou don't need. Please note,CarPlayManagerautomatically 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. - You don't cover the case when CarPlay was connected after the active guidance session started.
Possible fixes might look like this:
- 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)
}
/// ....
- 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)
}
}
- add handling for the navigation session stops in CarPlay
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 {
...
}