-
Notifications
You must be signed in to change notification settings - Fork 41
Open
Labels
triage meI really want to be triaged.I really want to be triaged.type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.‘Nice-to-have’ improvement, new feature or different behavior or design.
Description
Is there an existing issue for this?
- I have searched the existing issues
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:
- step-by-step instructions (maneuvers, descriptions, next step)
- navigation summary (estimated time, duration, distance)
- the map itinerary focused on the right, leaving the left side for the information overlay
- map clickable (pin, alternative itinerary, drag) ?
- ...
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:
- A template to inform users that CarPlay needs to be initialized in the app
- Hide the compass button
- A navigation bar with Start / Stop buttons (to start or stop guidance) and a Recenter button
- Left-side overlays (displayed only during guidance):
- Step-by-step instructions (including the next maneuver)
- Summary information
But I can’t do the following:
- Setting padding on the map only applies to the Google logo, not to the user position or the route polyline
- The map is not clickable (for example, to manage alternative routes)
Here is my code, do you think this is the right approach? (two files)
CarSceneDelegate
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()
}
}
}
CustomCarPlayLayout
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 = ""
}
}
}
Metadata
Metadata
Assignees
Labels
triage meI really want to be triaged.I really want to be triaged.type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.‘Nice-to-have’ improvement, new feature or different behavior or design.