Hi, with Android Auto and NavigationTemplateBuilder, I can easily display everything I want, but with CarPlay, It’s not simple.
import CarPlay
import GoogleNavigation
import UIKit
import google_navigation_flutter
class CarSceneDelegate: BaseCarSceneDelegate, GMSNavigatorListener {
private var customLayout: CustomCarPlayLayout?
private var routeListenerAdded = false
private var navigatorStateCheckTimer: Timer?
private var lastGuidanceState: Bool = false
private weak var currentTemplate: CPMapTemplate?
private var waitingTemplate: CPInformationTemplate?
private var mapTemplateForRestore: CPMapTemplate?
private var cachedRemainingTime: TimeInterval?
private var cachedRemainingDistance: CLLocationDistance?
private var isNavigationReady: Bool = false
private weak var carPlayScene: CPTemplateApplicationScene?
private func getInterfaceController() -> CPInterfaceController? {
return carPlayScene?.interfaceController
}
override func getTemplate() -> CPMapTemplate {
let template = CPMapTemplate()
template.dismissPanningInterface(animated: false)
template.automaticallyHidesNavigationBar = true
currentTemplate = template
checkNavigationReady()
if isNavigationReady {
updateTemplateButtons()
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.showWaitingInformationTemplate()
}
}
return template
}
private func showWaitingInformationTemplate() {
guard let interfaceController = getInterfaceController() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.showWaitingInformationTemplate()
}
return
}
if waitingTemplate != nil || isNavigationReady {
return
}
let informationItem = CPInformationItem(
title: "En attente",
detail: "En attente de la session de navigation..."
)
waitingTemplate = CPInformationTemplate(
title: "Navigation",
layout: .leading,
items: [informationItem],
actions: []
)
if let waitingTemplate = waitingTemplate, let currentTemplate = currentTemplate {
mapTemplateForRestore = currentTemplate
interfaceController.setRootTemplate(waitingTemplate, animated: true) { [weak self] success, error in
if let error = error {
// Error handled silently
}
}
}
}
private func switchToMapTemplate() {
guard let interfaceController = getInterfaceController() else {
return
}
if waitingTemplate != nil {
let mapTemplate = mapTemplateForRestore ?? {
let template = CPMapTemplate()
template.dismissPanningInterface(animated: false)
template.automaticallyHidesNavigationBar = true
return template
}()
currentTemplate = mapTemplate
mapTemplateForRestore = nil
interfaceController.setRootTemplate(mapTemplate, animated: true) { [weak self] success, error in
if let error = error {
// Error handled silently
} else {
self?.waitingTemplate = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.updateTemplateButtons()
}
}
}
}
}
private func checkNavigationReady() {
guard let navView = getNavView(),
let mapView = navView.view() as? GMSMapView,
let navigator = mapView.navigator else {
isNavigationReady = false
return
}
isNavigationReady = navigator.currentRouteLeg != nil
}
private func updateTemplateButtons() {
guard let template = currentTemplate,
let navView = getNavView(),
let mapView = navView.view() as? GMSMapView,
let navigator = mapView.navigator,
navigator.currentRouteLeg != nil else {
showWaitingInformationTemplate()
return
}
switchToMapTemplate()
let isGuidanceActive = navigator.isGuidanceActive
if isGuidanceActive {
customLayout?.showOverlays()
} else {
customLayout?.hideOverlays()
}
let startOrQuitButton = CPBarButton(title: isGuidanceActive ? "Stop" : "Start") { [weak self] _ in
guard let self = self,
let mapView = self.getNavView()?.view() as? GMSMapView,
let navigator = mapView.navigator else {
return
}
let currentState = navigator.isGuidanceActive
navigator.isGuidanceActive = !currentState
self.sendCustomNavigationAutoEvent(
event: currentState ? "CarPlayEventStop" : "CarPlayEventStart",
data: [:]
)
}
let recenterButton = CPBarButton(title: "Re-center") { [weak self] _ in
let data = ["timestamp": String(Date().timeIntervalSince1970)]
self?.sendCustomNavigationAutoEvent(event: "recenter_button_pressed", data: data)
self?.getNavView()?.followMyLocation(
perspective: GMSNavigationCameraPerspective.tilted,
zoomLevel: nil
)
}
template.leadingNavigationBarButtons = [startOrQuitButton, recenterButton]
}
override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene)
if let carPlayScene = scene as? CPTemplateApplicationScene {
self.carPlayScene = carPlayScene
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.setupCustomOverlaysIfNeeded()
}
}
override func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnect interfaceController: CPInterfaceController,
from window: CPWindow
) {
navigatorStateCheckTimer?.invalidate()
navigatorStateCheckTimer = nil
customLayout?.removeFromSuperview()
customLayout = nil
routeListenerAdded = false
isNavigationReady = false
waitingTemplate = nil
mapTemplateForRestore = nil
carPlayScene = nil
super.templateApplicationScene(templateApplicationScene, didDisconnect: interfaceController, from: window)
}
// MARK: - Custom Overlay Setup
private func setupCustomOverlaysIfNeeded() {
if customLayout != nil {
return
}
guard let navView = getNavView(),
let mapView = navView.view() as? GMSMapView,
let parentView = mapView.superview else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.setupCustomOverlaysIfNeeded()
}
return
}
mapView.settings.compassButton = false
customLayout = CustomCarPlayLayout(navView: navView, frame: parentView.bounds)
customLayout?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
parentView.addSubview(customLayout!)
parentView.bringSubviewToFront(customLayout!)
customLayout?.hideOverlays()
attemptAttachListeners()
}
// MARK: - Listener Attachment
private func attemptAttachListeners() {
if routeListenerAdded {
return
}
guard let navView = getNavView() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.attemptAttachListeners()
}
return
}
guard let mapView = navView.view() as? GMSMapView else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.attemptAttachListeners()
}
return
}
guard let navigator = mapView.navigator else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.attemptAttachListeners()
}
return
}
navigator.remove(self)
navigator.add(self)
routeListenerAdded = true
lastGuidanceState = navigator.isGuidanceActive
checkNavigationReady()
if isNavigationReady {
updateTemplateButtons()
}
startNavigatorStateCheck()
}
private func startNavigatorStateCheck() {
navigatorStateCheckTimer?.invalidate()
navigatorStateCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
guard let mapView = self.getNavView()?.view() as? GMSMapView,
let navigator = mapView.navigator else {
return
}
let wasReady = self.isNavigationReady
self.checkNavigationReady()
if !wasReady && self.isNavigationReady {
self.switchToMapTemplate()
navigator.remove(self)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self,
let mapView = self.getNavView()?.view() as? GMSMapView,
let navigator = mapView.navigator else { return }
navigator.add(self)
}
self.updateTemplateButtons()
}
let currentState = navigator.isGuidanceActive
if self.lastGuidanceState != currentState {
self.lastGuidanceState = currentState
if currentState {
navigator.remove(self)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self,
let mapView = self.getNavView()?.view() as? GMSMapView,
let navigator = mapView.navigator else { return }
navigator.add(self)
}
}
self.updateTemplateButtons()
}
}
}
// MARK: - GMSNavigatorListener
func navigator(_ navigator: GMSNavigator, didUpdateRemainingTime remainingTime: TimeInterval) {
cachedRemainingTime = remainingTime
customLayout?.updateRemainingInfo(
remainingTime: cachedRemainingTime,
remainingDistance: cachedRemainingDistance
)
}
func navigator(_ navigator: GMSNavigator, didUpdateRemainingDistance remainingDistance: CLLocationDistance) {
cachedRemainingDistance = remainingDistance
customLayout?.updateRemainingInfo(
remainingTime: cachedRemainingTime,
remainingDistance: cachedRemainingDistance
)
}
func navigator(_ navigator: GMSNavigator, didUpdate navInfo: GMSNavigationNavInfo) {
if navigator.isGuidanceActive {
let nextManeuver = navInfo.remainingSteps.first?.maneuver
customLayout?.updateNavigationInfo(navInfo, nextManeuver: nextManeuver)
}
}
func navigatorDidChangeRoute(_ navigator: GMSNavigator) {
navigator.remove(self)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self,
let mapView = self.getNavView()?.view() as? GMSMapView,
let navigator = mapView.navigator else { return }
navigator.add(self)
}
checkNavigationReady()
if isNavigationReady {
switchToMapTemplate()
updateTemplateButtons()
} else {
showWaitingInformationTemplate()
}
}
}
import CarPlay
import GoogleNavigation
import UIKit
import google_navigation_flutter
/// Overlay view for CarPlay navigation with custom UI elements
class CustomCarPlayLayout: UIView {
// MARK: - Properties
private(set) weak var navView: GoogleMapsNavigationView?
private(set) var topOverlayView: UIView!
private(set) var bottomOverlayView: UIView!
private(set) var maneuverView: CustomManeuverView!
private(set) var nextStepPreviewView: UIView!
private(set) var nextStepLabel: UILabel!
private(set) var nextStepIconView: UIImageView!
private(set) var etaView: CustomETAView!
private(set) var waitingMessageView: UIView!
private(set) var waitingMessageLabel: UILabel!
// Size multiplier based on screen size (smaller screens = smaller UI)
private var sizeMultiplier: CGFloat {
let screenHeight = bounds.height
// CarPlay screens typically range from ~320 to ~800 points in height
// Base size for ~480pt height, scale up/down from there
return min(max(screenHeight / 480.0, 0.7), 1.3)
}
private var currentInstruction: String = ""
private var currentDistance: String = ""
private var currentManeuver: GMSNavigationManeuver = .unknown
// MARK: - Initialization
init(navView: GoogleMapsNavigationView, frame: CGRect) {
super.init(frame: frame)
self.navView = navView
setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Layout Setup
private func setupLayout() {
backgroundColor = .clear
isUserInteractionEnabled = false
setupTopOverlay()
setupBottomOverlay()
setupWaitingMessage()
}
private func setupTopOverlay() {
topOverlayView = UIView()
topOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
topOverlayView.translatesAutoresizingMaskIntoConstraints = false
topOverlayView.layer.cornerRadius = 12
topOverlayView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
topOverlayView.layer.borderWidth = 1
topOverlayView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
topOverlayView.clipsToBounds = true
addSubview(topOverlayView)
maneuverView = CustomManeuverView(sizeMultiplier: sizeMultiplier)
maneuverView.translatesAutoresizingMaskIntoConstraints = false
topOverlayView.addSubview(maneuverView)
// Next step preview view - separate from current step
nextStepPreviewView = UIView()
nextStepPreviewView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
nextStepPreviewView.translatesAutoresizingMaskIntoConstraints = false
nextStepPreviewView.layer.cornerRadius = 12
nextStepPreviewView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
nextStepPreviewView.layer.borderWidth = 1
nextStepPreviewView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
nextStepPreviewView.clipsToBounds = true
addSubview(nextStepPreviewView)
nextStepLabel = UILabel()
nextStepLabel.font = .systemFont(ofSize: 14 * sizeMultiplier, weight: .regular)
nextStepLabel.textColor = .white
nextStepLabel.numberOfLines = 1
nextStepLabel.lineBreakMode = .byTruncatingTail
nextStepLabel.translatesAutoresizingMaskIntoConstraints = false
nextStepPreviewView.addSubview(nextStepLabel)
nextStepIconView = UIImageView()
nextStepIconView.contentMode = .scaleAspectFit
nextStepIconView.tintColor = .white
nextStepIconView.translatesAutoresizingMaskIntoConstraints = false
nextStepPreviewView.addSubview(nextStepIconView)
let topHeight: CGFloat = 80 * sizeMultiplier
let padding: CGFloat = 8 * sizeMultiplier
let marginLeft: CGFloat = 10 * sizeMultiplier
let nextStepHeight: CGFloat = 32 * sizeMultiplier
let spacing: CGFloat = 4 * sizeMultiplier
NSLayoutConstraint.activate([
topOverlayView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
topOverlayView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
topOverlayView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
topOverlayView.heightAnchor.constraint(equalToConstant: topHeight),
maneuverView.topAnchor.constraint(equalTo: topOverlayView.topAnchor, constant: padding),
maneuverView.leadingAnchor.constraint(equalTo: topOverlayView.leadingAnchor, constant: padding),
maneuverView.trailingAnchor.constraint(equalTo: topOverlayView.trailingAnchor, constant: -padding),
maneuverView.bottomAnchor.constraint(equalTo: topOverlayView.bottomAnchor, constant: -padding),
nextStepPreviewView.topAnchor.constraint(equalTo: topOverlayView.bottomAnchor, constant: spacing),
nextStepPreviewView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
nextStepPreviewView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
nextStepPreviewView.heightAnchor.constraint(equalToConstant: nextStepHeight),
nextStepIconView.leadingAnchor.constraint(equalTo: nextStepPreviewView.leadingAnchor, constant: padding),
nextStepIconView.centerYAnchor.constraint(equalTo: nextStepPreviewView.centerYAnchor),
nextStepIconView.widthAnchor.constraint(equalToConstant: 24 * sizeMultiplier),
nextStepIconView.heightAnchor.constraint(equalToConstant: 24 * sizeMultiplier),
nextStepLabel.leadingAnchor.constraint(equalTo: nextStepIconView.trailingAnchor, constant: 8 * sizeMultiplier),
nextStepLabel.centerYAnchor.constraint(equalTo: nextStepPreviewView.centerYAnchor),
nextStepLabel.trailingAnchor.constraint(lessThanOrEqualTo: nextStepPreviewView.trailingAnchor, constant: -padding)
])
maneuverView.update(
roadName: "",
instruction: "En attente...",
maneuver: .unknown
)
topOverlayView.isHidden = true
nextStepPreviewView.isHidden = true
}
private func setupBottomOverlay() {
bottomOverlayView = UIView()
bottomOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
bottomOverlayView.translatesAutoresizingMaskIntoConstraints = false
bottomOverlayView.layer.cornerRadius = 8
bottomOverlayView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
bottomOverlayView.layer.borderWidth = 1
bottomOverlayView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
bottomOverlayView.clipsToBounds = true
addSubview(bottomOverlayView)
etaView = CustomETAView(sizeMultiplier: sizeMultiplier, isVertical: true)
etaView.translatesAutoresizingMaskIntoConstraints = false
bottomOverlayView.addSubview(etaView)
let bottomHeight: CGFloat = 40 * sizeMultiplier
let padding: CGFloat = 3 * sizeMultiplier
let horizontalPadding: CGFloat = 8 * sizeMultiplier
let marginLeft: CGFloat = 10 * sizeMultiplier
NSLayoutConstraint.activate([
bottomOverlayView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -8),
bottomOverlayView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
bottomOverlayView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
bottomOverlayView.heightAnchor.constraint(equalToConstant: bottomHeight),
etaView.topAnchor.constraint(equalTo: bottomOverlayView.topAnchor, constant: padding),
etaView.leadingAnchor.constraint(equalTo: bottomOverlayView.leadingAnchor, constant: horizontalPadding),
etaView.trailingAnchor.constraint(equalTo: bottomOverlayView.trailingAnchor, constant: -horizontalPadding),
etaView.bottomAnchor.constraint(equalTo: bottomOverlayView.bottomAnchor, constant: 0)
])
etaView.update(
remainingTime: nil,
remainingDistance: nil,
eta: "--:--"
)
bottomOverlayView.isHidden = true
}
private func setupWaitingMessage() {
waitingMessageView = UIView()
waitingMessageView.backgroundColor = UIColor.black.withAlphaComponent(0.9)
waitingMessageView.translatesAutoresizingMaskIntoConstraints = false
waitingMessageView.layer.cornerRadius = 12
waitingMessageView.layer.borderWidth = 2
waitingMessageView.layer.borderColor = UIColor.white.withAlphaComponent(0.4).cgColor
waitingMessageView.clipsToBounds = true
waitingMessageView.isUserInteractionEnabled = false
addSubview(waitingMessageView)
bringSubviewToFront(waitingMessageView)
waitingMessageLabel = UILabel()
waitingMessageLabel.text = "En attente de la session de navigation..."
waitingMessageLabel.font = .systemFont(ofSize: 18 * sizeMultiplier, weight: .medium)
waitingMessageLabel.textColor = .white
waitingMessageLabel.textAlignment = .center
waitingMessageLabel.numberOfLines = 0
waitingMessageLabel.translatesAutoresizingMaskIntoConstraints = false
waitingMessageView.addSubview(waitingMessageLabel)
let padding: CGFloat = 20 * sizeMultiplier
let maxWidth: CGFloat = 300 * sizeMultiplier
NSLayoutConstraint.activate([
waitingMessageView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor),
waitingMessageView.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor),
waitingMessageView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth),
waitingMessageView.leadingAnchor.constraint(greaterThanOrEqualTo: safeAreaLayoutGuide.leadingAnchor, constant: padding),
waitingMessageView.trailingAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.trailingAnchor, constant: -padding),
waitingMessageLabel.topAnchor.constraint(equalTo: waitingMessageView.topAnchor, constant: padding),
waitingMessageLabel.leadingAnchor.constraint(equalTo: waitingMessageView.leadingAnchor, constant: padding),
waitingMessageLabel.trailingAnchor.constraint(equalTo: waitingMessageView.trailingAnchor, constant: -padding),
waitingMessageLabel.bottomAnchor.constraint(equalTo: waitingMessageView.bottomAnchor, constant: -padding)
])
waitingMessageView.isHidden = true
}
// MARK: - Public Methods
func updateNavigationInfo(_ navInfo: GMSNavigationNavInfo, nextManeuver: GMSNavigationManeuver? = nil) {
guard let currentStep = navInfo.currentStep else {
hideOverlays()
return
}
showOverlays()
maneuverView.update(
roadName: currentStep.simpleRoadName,
instruction: currentStep.fullInstructionText,
maneuver: currentStep.maneuver
)
if let nextStep = navInfo.remainingSteps.first {
nextStepIconView.image = maneuverIcon(for: nextStep.maneuver)
let nextRoadName = nextStep.simpleRoadName
nextStepLabel.text = nextRoadName.isEmpty ? (nextStep.fullInstructionText) : nextRoadName
nextStepPreviewView.isHidden = false
} else {
nextStepPreviewView.isHidden = true
}
let remainingTime = TimeInterval(navInfo.timeToFinalDestinationSeconds)
let remainingDistance = CLLocationDistance(navInfo.distanceToFinalDestinationMeters)
updateRemainingInfo(remainingTime: remainingTime, remainingDistance: remainingDistance)
}
private func maneuverIcon(for maneuver: GMSNavigationManeuver) -> UIImage? {
let iconName: String
switch maneuver {
case .turnLeft, .turnSharpLeft, .turnSlightLeft:
iconName = "arrow.turn.up.left"
case .turnRight, .turnSharpRight, .turnSlightRight:
iconName = "arrow.turn.up.right"
case .turnUTurnCounterClockwise:
iconName = "arrow.uturn.left"
case .turnUTurnClockwise:
iconName = "arrow.uturn.right"
case .straight:
iconName = "arrow.up"
case .onRampLeft, .forkLeft:
iconName = "arrow.up.left"
case .onRampRight, .forkRight:
iconName = "arrow.up.right"
case .mergeLeft:
iconName = "arrow.merge"
case .mergeRight:
iconName = "arrow.merge"
case .destination, .destinationLeft, .destinationRight:
iconName = "mappin.circle.fill"
case .ferryBoat:
iconName = "ferry.fill"
case .ferryTrain:
iconName = "tram.fill"
default:
let maneuverString = String(describing: maneuver)
if maneuverString.lowercased().contains("roundabout") {
iconName = "arrow.triangle.turn.up.right.circle"
} else {
iconName = "arrow.up"
}
}
let iconPointSize: CGFloat = 20 * sizeMultiplier
return UIImage(systemName: iconName)?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: iconPointSize, weight: .medium)
)
}
func updateRemainingInfo(remainingTime: TimeInterval?, remainingDistance: CLLocationDistance?) {
etaView.update(
remainingTime: remainingTime,
remainingDistance: remainingDistance,
eta: remainingTime.map { calculateETA($0) } ?? "--:--"
)
}
func showOverlays() {
topOverlayView.isHidden = false
bottomOverlayView.isHidden = false
topOverlayView.setNeedsLayout()
bottomOverlayView.setNeedsLayout()
setNeedsLayout()
layoutIfNeeded()
}
func hideOverlays() {
topOverlayView.isHidden = true
bottomOverlayView.isHidden = true
nextStepPreviewView.isHidden = true
}
func showWaitingMessage(_ message: String? = nil) {
if let message = message {
waitingMessageLabel.text = message
}
hideOverlays()
waitingMessageView.isHidden = false
waitingMessageView.alpha = 1.0
bringSubviewToFront(waitingMessageView)
waitingMessageView.setNeedsLayout()
waitingMessageView.layoutIfNeeded()
setNeedsLayout()
layoutIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.waitingMessageView.setNeedsLayout()
self.waitingMessageView.layoutIfNeeded()
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
func hideWaitingMessage() {
waitingMessageView.isHidden = true
}
// MARK: - Overlay Width Calculation
/// Calcule la largeur totale occupée par les overlays (pour le padding de la map)
func getOverlayWidth() -> CGFloat {
let marginLeft: CGFloat = 10 * sizeMultiplier
let overlayWidth = bounds.width * 0.45
let totalWidth = overlayWidth + marginLeft
return totalWidth
}
// MARK: - Formatting Helpers
private func calculateETA(_ remainingSeconds: TimeInterval) -> String {
let eta = Date().addingTimeInterval(remainingSeconds)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: eta)
}
}
// MARK: - CustomManeuverView
class CustomManeuverView: UIView {
private let iconImageView = UIImageView()
private let instructionLabel = UILabel()
private var instructionLeadingConstraint: NSLayoutConstraint!
private var instructionLeadingFromIconConstraint: NSLayoutConstraint!
private let sizeMultiplier: CGFloat
init(sizeMultiplier: CGFloat = 1.0) {
self.sizeMultiplier = sizeMultiplier
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
self.sizeMultiplier = 1.0
super.init(coder: coder)
setupView()
}
private func setupView() {
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = .white
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.isHidden = true
addSubview(iconImageView)
let instructionFontSize: CGFloat = 18 * sizeMultiplier
instructionLabel.font = .systemFont(ofSize: instructionFontSize, weight: .regular)
instructionLabel.textColor = .white
instructionLabel.numberOfLines = 2
instructionLabel.lineBreakMode = .byTruncatingTail
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(instructionLabel)
let iconSize: CGFloat = 24 * sizeMultiplier
let spacing: CGFloat = 8 * sizeMultiplier
NSLayoutConstraint.activate([
iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: iconSize),
iconImageView.heightAnchor.constraint(equalToConstant: iconSize)
])
instructionLeadingConstraint = instructionLabel.leadingAnchor.constraint(equalTo: leadingAnchor)
instructionLeadingFromIconConstraint = instructionLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: spacing)
NSLayoutConstraint.activate([
instructionLabel.topAnchor.constraint(equalTo: topAnchor),
instructionLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
instructionLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
instructionLeadingConstraint
])
}
func update(roadName: String, instruction: String, maneuver: GMSNavigationManeuver) {
// Show icon only if there's a real maneuver (not straight or unknown)
let hasRealManeuver = maneuver != .straight && maneuver != .unknown
if hasRealManeuver {
iconImageView.image = maneuverIcon(for: maneuver)
iconImageView.isHidden = false
instructionLeadingConstraint.isActive = false
instructionLeadingFromIconConstraint.isActive = true
} else {
iconImageView.isHidden = true
instructionLeadingFromIconConstraint.isActive = false
instructionLeadingConstraint.isActive = true
}
if !roadName.isEmpty {
let attributedText = NSMutableAttributedString()
let versAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.white,
.font: UIFont.systemFont(ofSize: 16 * sizeMultiplier, weight: .regular)
]
attributedText.append(NSAttributedString(string: "vers ", attributes: versAttributes))
let roadNameAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.white,
.font: UIFont.systemFont(ofSize: 18 * sizeMultiplier, weight: .bold)
]
attributedText.append(NSAttributedString(string: roadName, attributes: roadNameAttributes))
instructionLabel.attributedText = attributedText
} else if !instruction.isEmpty {
instructionLabel.text = instruction
} else {
instructionLabel.text = ""
}
}
private func maneuverIcon(for maneuver: GMSNavigationManeuver) -> UIImage? {
let iconName: String
switch maneuver {
case .turnLeft, .turnSharpLeft, .turnSlightLeft:
iconName = "arrow.turn.up.left"
case .turnRight, .turnSharpRight, .turnSlightRight:
iconName = "arrow.turn.up.right"
case .turnUTurnCounterClockwise:
iconName = "arrow.uturn.left"
case .turnUTurnClockwise:
iconName = "arrow.uturn.right"
case .straight:
iconName = "arrow.up"
case .onRampLeft, .forkLeft:
iconName = "arrow.up.left"
case .onRampRight, .forkRight:
iconName = "arrow.up.right"
case .mergeLeft:
iconName = "arrow.merge"
case .mergeRight:
iconName = "arrow.merge"
case .destination, .destinationLeft, .destinationRight:
iconName = "mappin.circle.fill"
case .ferryBoat:
iconName = "ferry.fill"
case .ferryTrain:
iconName = "tram.fill"
default:
let maneuverString = String(describing: maneuver)
if maneuverString.lowercased().contains("roundabout") {
iconName = "arrow.triangle.turn.up.right.circle"
} else {
iconName = "arrow.up"
}
}
let iconPointSize: CGFloat = 24 * sizeMultiplier
return UIImage(systemName: iconName)?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: iconPointSize, weight: .medium)
)
}
}
// MARK: - CustomETAView
class CustomETAView: UIView {
private let etaValueLabel = UILabel()
private let etaTitleLabel = UILabel()
private let remainingTimeValueLabel = UILabel()
private let remainingTimeUnitLabel = UILabel()
private let remainingDistanceValueLabel = UILabel()
private let remainingDistanceUnitLabel = UILabel()
private let sizeMultiplier: CGFloat
init(sizeMultiplier: CGFloat = 1.0, isVertical: Bool = false) {
self.sizeMultiplier = sizeMultiplier
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
self.sizeMultiplier = 1.0
super.init(coder: coder)
setupView()
}
private func setupView() {
let etaValueFontSize: CGFloat = 20 * sizeMultiplier
let etaValueFont = UIFont.systemFont(ofSize: etaValueFontSize, weight: .bold)
etaValueLabel.font = etaValueFont
etaValueLabel.textColor = .white
etaValueLabel.textAlignment = .left
etaValueLabel.adjustsFontSizeToFitWidth = false
etaValueLabel.numberOfLines = 1
etaValueLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(etaValueLabel)
let etaTitleFontSize: CGFloat = 11 * sizeMultiplier
let etaTitleFont = UIFont.systemFont(ofSize: etaTitleFontSize, weight: .regular)
etaTitleLabel.text = "arrivée"
etaTitleLabel.font = etaTitleFont
etaTitleLabel.textColor = .white
etaTitleLabel.textAlignment = .left
etaTitleLabel.adjustsFontSizeToFitWidth = false
etaTitleLabel.numberOfLines = 1
etaTitleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(etaTitleLabel)
let timeValueFontSize: CGFloat = 20 * sizeMultiplier
let timeValueFont = UIFont.systemFont(ofSize: timeValueFontSize, weight: .bold)
remainingTimeValueLabel.font = timeValueFont
remainingTimeValueLabel.textColor = UIColor.systemGreen // Green color like in image
remainingTimeValueLabel.textAlignment = .center
remainingTimeValueLabel.adjustsFontSizeToFitWidth = false
remainingTimeValueLabel.numberOfLines = 1
remainingTimeValueLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(remainingTimeValueLabel)
let timeUnitFontSize: CGFloat = 11 * sizeMultiplier
let timeUnitFont = UIFont.systemFont(ofSize: timeUnitFontSize, weight: .regular)
remainingTimeUnitLabel.font = timeUnitFont
remainingTimeUnitLabel.textColor = UIColor.systemGreen
remainingTimeUnitLabel.textAlignment = .center
remainingTimeUnitLabel.adjustsFontSizeToFitWidth = false
remainingTimeUnitLabel.numberOfLines = 1
remainingTimeUnitLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(remainingTimeUnitLabel)
let distanceValueFontSize: CGFloat = 20 * sizeMultiplier
let distanceValueFont = UIFont.systemFont(ofSize: distanceValueFontSize, weight: .bold)
remainingDistanceValueLabel.font = distanceValueFont
remainingDistanceValueLabel.textColor = .white
remainingDistanceValueLabel.textAlignment = .center
remainingDistanceValueLabel.adjustsFontSizeToFitWidth = false
remainingDistanceValueLabel.numberOfLines = 1
remainingDistanceValueLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(remainingDistanceValueLabel)
let distanceUnitFontSize: CGFloat = 11 * sizeMultiplier
let distanceUnitFont = UIFont.systemFont(ofSize: distanceUnitFontSize, weight: .regular)
remainingDistanceUnitLabel.font = distanceUnitFont
remainingDistanceUnitLabel.textColor = .white
remainingDistanceUnitLabel.textAlignment = .center
remainingDistanceUnitLabel.adjustsFontSizeToFitWidth = false
remainingDistanceUnitLabel.numberOfLines = 1
remainingDistanceUnitLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(remainingDistanceUnitLabel)
NSLayoutConstraint.activate([
etaValueLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
etaValueLabel.topAnchor.constraint(equalTo: topAnchor),
etaTitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
etaTitleLabel.topAnchor.constraint(equalTo: etaValueLabel.bottomAnchor, constant: -2 * sizeMultiplier),
remainingTimeValueLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
remainingTimeValueLabel.firstBaselineAnchor.constraint(equalTo: etaValueLabel.firstBaselineAnchor),
remainingTimeUnitLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
remainingTimeUnitLabel.firstBaselineAnchor.constraint(equalTo: etaTitleLabel.firstBaselineAnchor),
remainingDistanceValueLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
remainingDistanceValueLabel.firstBaselineAnchor.constraint(equalTo: etaValueLabel.firstBaselineAnchor),
remainingDistanceUnitLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
remainingDistanceUnitLabel.firstBaselineAnchor.constraint(equalTo: etaTitleLabel.firstBaselineAnchor),
remainingDistanceUnitLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0)
])
}
func update(remainingTime: TimeInterval?, remainingDistance: CLLocationDistance?, eta: String) {
// ETA
etaValueLabel.text = eta
// Remaining time - format as "X" and "min"
if let time = remainingTime {
let minutes = Int(time) / 60
remainingTimeValueLabel.text = "\(minutes)"
remainingTimeUnitLabel.text = "min"
} else {
remainingTimeValueLabel.text = "--"
remainingTimeUnitLabel.text = ""
}
if let distance = remainingDistance {
if distance >= 1000 {
let km = distance / 1000.0
remainingDistanceValueLabel.text = String(format: "%.1f", km)
remainingDistanceUnitLabel.text = "km"
} else {
remainingDistanceValueLabel.text = String(format: "%.0f", distance)
remainingDistanceUnitLabel.text = "m"
}
} else {
remainingDistanceValueLabel.text = "--"
remainingDistanceUnitLabel.text = ""
}
}
}
Is there an existing issue for this?
Use case
Hi, with Android Auto and NavigationTemplateBuilder, I can easily display everything I want, but with CarPlay, It’s not simple.
I’d like to provide a good CarPlay experience with:
I’m trying to do these things, but I’m not sure if I’m taking the right approach
Proposal
For now, I do the following:
But I can’t do the following:
Here is my code, do you think this is the right approach? (two files)
CarSceneDelegate
CustomCarPlayLayout