Skip to main content

#BuiltWithHERE: How MyRoute-app B.V. Integrated Android Auto & Apple CarPlay into their HERE Flutter SDK-based app - Part 2

MRA_BWH

Introduction

#BuiltWithHERE is a series showcasing how developers are using the HERE platform to solve location-related business problems. Each blog post highlights a company who has developed a solution that is timely, innovative, and uses HERE products and/or data. This is part two of the technical tutorial on integrating Android Auto and Apple CarPlay into a HERE SDK for Flutter app. If you missed part 1, go back and read it here. For more info on MyRoute-app, checkout the first blog feature from May 2024.

With that, let's get into part 2 of the technical details, with the MyRoute-app team!

Note: The final codebase can be found here if you're short on time.

Tutorial Outline for Part 2:

  1. Implementing a routing screen
    a. Flutter (Dart)
    b. Android Auto - Android (Kotlin)
    c. Apple CarPlay - iOS (Swift)
  2. Conclusion
  3. Notes
  4. Useful links

Implementing a routing screen

If you've followed the article so far you should be greeted with a view of a map when running your application in Android Auto / Apple CarPlay. We're going to expand on this in the form of a routing screen, lucky for us this already exists in the reference app.
Implementing such a feature should give you an understanding of how you could implement interactions with Android Auto / Apple CarPlay from Flutter code and the other way around.

To keep the scope of this article reasonable we're implementing the following features. Updating the list of available routes shown to the users, update the selected route and a way to stop routing. The interactions performed on the phone will be reflected on the Android Auto / Apple CarPlay screen and vice versa. Let's get started!

Flutter (Dart)

First off we're going to define all the necessary methods and classes in the pigeon definition file (pigeons/car.dart) we created earlier:

Copied
        ...

class PgRouteOption {
    final int hashCode;

    final int lengthInMeters;

    final int durationInSeconds;

    final String distanceString;

    final String durationString;

    const PgRouteOption({
        required this.hashCode,
        required this.lengthInMeters,
        required this.durationInSeconds,
        required this.distanceString,
        required this.durationString,
    });
}

class PgLatLng {
    final double latitude;
    final double longitude;

    const PgLatLng(this.latitude, this.longitude);
}

class PgRouteOptionsUpdatedMessage {
    final PgLatLng origin;
    final PgLatLng destination;
    final List<PgRouteOption?> routeOptions;

    const PgRouteOptionsUpdatedMessage({
        required this.origin,
        required this.destination,
        required this.routeOptions,
    });
}

@FlutterApi()
abstract class CarToFlutterApi {
    void setupMapView();

    void updateSelectedRouteOption(int routeOptionIndex);

    void stopRouting();

    void onDisconnect();
}

@HostApi()
abstract class FlutterToCarApi {
    void onStartRouting();

    void onRouteOptionsUpdated(PgRouteOptionsUpdatedMessage message);

    void onRouteOptionSelected(int routeOptionIndex);

    void onStopRouting();
}
  

As you can see we've defined some new methods in the CarToFlutterApi class we created earlier. These methods will be used to interact with the routing state from within the platform code. We also defined a new class FlutterToCarApi, as the name suggests this will be used to interact with our platform code from the Flutter part of our application. Besides the two "API" classes we've also added a few other classes. These simply serve as a way for us to pass structured data between our platform code and Flutter.

With the changes to our pigeon definitions we need to regenerate the resulting code. As we've done before run the following command:

Copied
        dart run pigeon --input pigeons/car.dart

  

Now that the pigeon code has been generated you will see some errors within your project. Let's resolve that!

Calls to a class defined as either @HostApi() or @FlutterApi() are always expected to be handled, when not - an exception is thrown. This is why we make sure these kinds of classes are always active.
We want to handle calls to the methods of these classes in different part of our codebase. To make this easier we're going to be implementing an observer pattern for both our platform code and Flutter code.

In our lib/car/car_to_flutter.dart add the following code:

Copied
        ...

abstract interface class CarRoutingListener {
    void stopRouting();
    void updateSelectedRouteOption(int routeOptionIndex);
}

class CarToFlutter extends CarToFlutterApi {

    ...

    final Set<CarRoutingListener> _carRoutingListeners = {};

    void addRoutingListener(CarRoutingListener listener) {
        _carRoutingListeners.add(listener);
    }

    void removeRoutingListener(CarRoutingListener listener) {
        _carRoutingListeners.remove(listener);
    }

    @override
    void stopRouting() {
        for (final listener in _carRoutingListeners) {
            listener.stopRouting();
        }
    }

    @override
    void updateSelectedRouteOption(int routeOptionIndex) {
        for (final listener in _carRoutingListeners) {
            listener.updateSelectedRouteOption(routeOptionIndex);
        }
    }

    @override
    void onDisconnect() {
        _locationUpdatesSubscription?.cancel();
    }
}
  

The implementations of the stopRouting and updateSelectedRouteOption method are propagating to any registered listener. Thus the CarRoutingListener class is what we need to implement wherever we'd be interested in handling these given methods. In our case that would be the RoutingScreen over at lib/routing/routing_screen.dart:

Copied
        ...

class _RoutingScreenState extends State<RoutingScreen>
        with TickerProviderStateMixin, Positioning
        // add this line
        implements CarRoutingListener {
    
    ...

    late CarToFlutter _carToFlutterApi;

    @override
    void initState() {

        ...

        WidgetsBinding.instance.addPostFrameCallback((_) {
            if (!context.mounted) return;

            _carToFlutterApi =
                Provider.of<CarToFlutter>(context, listen: false);
            _carToFlutterApi.addRoutingListener(this);
        });
    }

    @override
    void dispose() {
        _carToFlutterApi.removeRoutingListener(this);

        ...

    }

    ...

    @override
    void stopRouting() {
        Navigator.of(context).pop();
    }

    @override
    void updateSelectedRouteOption(int routeOptionIndex) {
        _routesTabController.animateTo(routeOptionIndex);
    }
}
  

As you can see in the RoutingScreen we're acting upon calls to stopRouting and updateSelectedRouteOption and are updating the UI on the phone accordingly.

However this only works in one direction. Any interactions on the phone aren't reflected in our Android Auto / Apple CarPlay screen. For that to happen we need to make changes to the LandingScreen as well as the RoutingScreen:

Copied
        //lib/landing_screen.dart
...

class _LandingScreenState extends State<LandingScreen>
        with Positioning, WidgetsBindingObserver {
    ...

    void _showRoutingScreen(WayPointInfo destination) async {
        final GeoCoordinates currentPosition = lastKnownLocation != null
            ? lastKnownLocation!.coordinates
            : Positioning.initPosition;

        FlutterToCarApi().onStartRouting();

        ...
    }
}

..
  
Copied
        // lib/routing/routing_screen.dart
...

class _RoutingScreenState extends State<RoutingScreen>
        with TickerProviderStateMixin, Positioning
        implements CarRoutingListener {
    ...

    _updateSelectedRoute() {
        ...

        FlutterToCarApi().onRouteOptionSelected(_selectedRouteIndex);
    }

    ...
    
    Widget _buildBottomNavigationBar(context) {
        ...

            IconButton(
                icon: Icon(Icons.close),
                color: colorScheme.primary,
                onPressed: () {
                    // add this line
                    FlutterToCarApi().onStopRouting();
                    Navigator.of(context).pop();
                },
            ),

        ...
    }
    
    ...

    _onRoutingEnd(Routing.RoutingError? error, List<Routing.Route>? routes) {
        // add the 2 lines below
        final origin = _wayPointsController.first;
        final destination = _wayPointsController.last;

        if (routes == null || routes.isEmpty) {
            if (error != null) {
                ...
            }

            // add the following line
            FlutterToCarApi().onRouteOptionsUpdated(PgRouteOptionsUpdatedMessage(
                origin: origin.coordinates.toPgLatLng(),
                destination: destination.coordinates.toPgLatLng(),
                routeOptions: [],
            ));

            return;
        }

        ...

        _routesTabController.addListener(() => _updateSelectedRoute());

        // add the following line
        FlutterToCarApi().onRouteOptionsUpdated(PgRouteOptionsUpdatedMessage(
            origin: origin.coordinates.toPgLatLng(),
            destination: destination.coordinates.toPgLatLng(),
            routeOptions: routes.map((e) => e.toPgRouteOption(context)).toList(),
        ));

        _addRoutesToMap();

        ...
    }
}
  

Where we needed a reference to the binaryMessenger in our platform code we don't need such a thing within our Flutter code. There the ServicesBinding.defaultBinaryMessenger will be used.

In the code written above we make use of some extensions and helper functions which don't exists yet. Let us resolve this to finalize the Flutter part of the implementation!

Create a new file called pigeon_extensions.dart in lib/common/extensions:

import 'package:flutter/material.dart' show BuildContext;
import 'package:here_sdk/core.dart';
import 'package:here_sdk/routing.dart';

import '../../pigeons/car.pg.dart';
import '../util.dart' as Util;

extension RoutePigeonUtil on Route {
    PgRouteOption toPgRouteOption(BuildContext context) => PgRouteOption(
                hashCode: hashCode,
                lengthInMeters: lengthInMeters,
                durationInSeconds: duration.inSeconds,
                distanceString: Util.makeDistanceString(context, lengthInMeters),
                durationString: Util.makeDurationString(context, duration.inSeconds),
            );
}

extension GeoCoordinatesPigeonUtil on GeoCoordinates {
    PgLatLng toPgLatLng() => PgLatLng(latitude: latitude, longitude: longitude);
}

Remove the _buildDurationString from lib/routing/route_info_widget.dart and move it over to lib/common/util.dart:

Copied
        // lib/routing/route_info_widget.dart

...

class RouteInfo extends StatelessWidget {

    ...

    @override
    Widget build(BuildContext context) {
        ...

            TextSpan(
                // replace `_buildDurationString(...)` with `Util.makeDurationString(...)`
                text: Util.makeDurationString(
                    context, route.duration.inSeconds) + " ",
                ...
                children: [
                    if (route.trafficDelay.inSeconds > Duration.secondsPerMinute)
                        TextSpan(
                          text: Util.formatString(
                                AppLocalizations.of(context)!.trafficDelayText, [
                                    // replace `_buildDurationString(...)` with `Util.makeDurationString(...)`
                                    Util.makeDurationString(context, route.trafficDelay.inSeconds)
                                ]),

                            ...
                        ),
                    ...
                ],
            ),

        ...
    }

    ...

    // remove this whole method
    String _buildDurationString(BuildContext context, int durationInSeconds) {
        ...
    }
}

  
Copied
        // lib/common/util.dart

...

String makeDurationString(BuildContext context, int durationInSeconds) {
    int minutes = (durationInSeconds / 60).truncate();
    int hours = (minutes / 60).truncate();
    minutes = minutes % 60;

    if (hours == 0) {
        return "$minutes ${AppLocalizations.of(context)!.minuteAbbreviationText}";
    } else {
        String result =
            "$hours ${AppLocalizations.of(context)!.hourAbbreviationText}";
        if (minutes != 0) {
            result +=
                " $minutes ${AppLocalizations.of(context)!.minuteAbbreviationText}";
        }
        return result;
    }
}

...
  

Add a first and last getter to the WayPointsController in lib/routing/waypoints_controller.dart.

Copied
        ...

class WayPointsController extends ValueNotifier<List<WayPointInfo>> {
    ...

    /// Return the first waypoint in the waypoints list.
    WayPointInfo get first => super.value.first;

    /// Return the last waypoint in the waypoints list.
    WayPointInfo get last => super.value.last;

    ...
}
  

And with that the Flutter side of our implementation is completed! Let's handle Android Auto next.

Android Auto - Android (Kotlin)

Just as we implemented CarToFlutterApi in our Flutter code we need to implement the FlutterToCarApi in our platform code. We're also using the same observer pattern again. Create a new file called FlutterToCar.kt in the same directory as your MainActivity.kt with the following content:

Copied
        package com.example.RefApp

class FlutterToCar private constructor() : FlutterToCarApi {
    companion object {
        val instance = FlutterToCar()
    }

    // generic observer

    private var genericObservers: Set<GenericObserver> = setOf()

    fun addObserver(observer: GenericObserver) {
        genericObservers = genericObservers.plus(observer)
    }

    fun removeObserver(observer: GenericObserver) {
        genericObservers = genericObservers.minus(observer)
    }

    // routing observer

    private var routingObservers: Set<RoutingObserver> =
        setOf()

    fun addObserver(observer: RoutingObserver) {
        routingObservers = routingObservers.plus(observer)
    }

    fun removeObserver(observer: RoutingObserver) {
        routingObservers = routingObservers.minus(observer)
    }

    interface GenericObserver {
        fun onStartRouting()
    }

    override fun onStartRouting() {
        for (observer in genericObservers) {
            observer.onStartRouting()
        }
    }

    interface RoutingObserver {
        fun onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage) {}

        fun onRouteOptionSelected(routeOptionId: Long) {}

        fun onStopRouting() {}
    }

    override fun onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage) {
        for (observer in routingObservers) {
            observer.onRouteOptionsUpdated(message)
        }
    }

    override fun onRouteOptionSelected(routeOptionIndex: Long) {
        for (observer in routingObservers) {
            observer.onRouteOptionSelected(routeOptionIndex)
        }
    }

    override fun onStopRouting() {
        for (observer in routingObservers) {
            observer.onStopRouting()
        }
    }
}
  

This class will serve as an entrypoint for all calls made to FlutterToCarApi from our Flutter code.
And just as for our Flutter code we need to ensure this new class initializes whenever our application launches. We do this in the MainApplication:

Copied
        ...

class MainApplication : Application() {
    override fun onCreate() {
        ...

        FlutterToCarApi.setUp(flutterEngine.dartExecutor.binaryMessenger, FlutterToCar.instance)
    }

    override fun onTerminate() {
        FlutterEngineCache.getInstance()
            .get(MainActivity.FLUTTER_ENGINE_ID)
            ?.dartExecutor
            ?.binaryMessenger
            ?.let { binaryMessenger ->
                FlutterToCarApi.setUp(binaryMessenger, null)
            }

        FlutterEngineCache.getInstance().remove(MainActivity.FLUTTER_ENGINE_ID)

        super.onTerminate()
    }
}
  

The only thing that remains is implementing the observers we created earlier and adding calls to CarToFlutterApi methods in the right places. For our HelloMapScreen this is pretty straight forward. We need to handle calls from FlutterToCarApi.onStartRouting and call into CarToFlutterApi.onDisconnect:

Copied
        ...
// add these lines
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
...

class HelloMapScreen(carContext: CarContext) : Screen(carContext), SurfaceCallback,
    // add this line
    DefaultLifecycleObserver, FlutterToCar.GenericObserver {
    ...

    init {
        ...

        lifecycle.addObserver(this)
        FlutterToCar.instance.addObserver(this)
    }

    override fun onDestroy(owner: LifecycleOwner) {
        FlutterToCar.instance.removeObserver(this)

        carToFlutterApi?.onDisconnect { }
    }

    ...

    /**
     * [FlutterToCar.GenericObserver] methods
     */

    /** */

    override fun onStartRouting() {
        screenManager.push(RoutingScreen(carContext))
    }
}
  

For everything related to the routing we're going to implement a RoutingScreen, we already mentioned it in our HelloMapScreen.onStartRouting implementation. Create a new file RoutingScreen.kt in the same directory as the HelloMapScreen.kt. Add to it the following code:

Copied
        package com.example.RefApp

import android.text.SpannableString
import android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Distance
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.DurationSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.flutter.embedding.engine.FlutterEngineCache

class RoutingScreen(carContext: CarContext) : Screen(carContext), DefaultLifecycleObserver,
    FlutterToCar.RoutingObserver {

    private var routeOptions: List<PgRouteOption>? = null
    private var selectedRouteOptionIndex: Int = 0

    private val carToFlutterApi: CarToFlutterApi?

    init {
        lifecycle.addObserver(this)
        FlutterToCar.instance.addObserver(this)

        carToFlutterApi = FlutterEngineCache.getInstance()
            .get(MainActivity.FLUTTER_ENGINE_ID)
            ?.dartExecutor
            ?.binaryMessenger
            ?.let { binaryMessenger ->
                CarToFlutterApi(binaryMessenger)
            }
    }

    override fun onDestroy(owner: LifecycleOwner) {
        FlutterToCar.instance.removeObserver(this)

        carToFlutterApi?.stopRouting { }
    }

    override fun onGetTemplate(): Template {
        val builder = RoutePreviewNavigationTemplate.Builder().setHeaderAction(Action.BACK)

        if (routeOptions == null) {
            builder.setLoading(true)
        } else {
            val itemListBuilder = ItemList.Builder()

            if (routeOptions!!.isEmpty()) {
                itemListBuilder.setNoItemsMessage("No routes found.")
            } else {
                itemListBuilder.setOnSelectedListener(this::onRouteSelected)

                for (routeOption in routeOptions!!) {
                    val rowBuilder =
                        Row.Builder().setTitle(createTitleSpan(routeOption))

                    itemListBuilder.addItem(rowBuilder.build())
                }

                itemListBuilder.setSelectedIndex(selectedRouteOptionIndex)
            }
            builder.setItemList(itemListBuilder.build())
        }

        val navigateAction = Action.Builder()
            .setTitle("Unimplemented")
            .build()
        builder.setNavigateAction(navigateAction)

        return builder.build()
    }

    private fun onRouteSelected(selectedIndex: Int) {
        carToFlutterApi?.updateSelectedRouteOption(selectedIndex.toLong()) {}
    }

    private fun createDistance(lengthInMeters: Long): Distance {
        return if (lengthInMeters < 1000) {
            Distance.create(lengthInMeters.toDouble(), Distance.UNIT_METERS)
        } else if (lengthInMeters < 10_000) {
            Distance.create(
                lengthInMeters.toDouble() / 1000.0,
                Distance.UNIT_KILOMETERS_P1
            )
        } else {
            Distance.create(
                lengthInMeters.toDouble() / 1000.0,
                Distance.UNIT_KILOMETERS
            )
        }
    }

    private fun createTitleSpan(routeOption: PgRouteOption): SpannableString {
        val distanceSpan =
            DistanceSpan.create(createDistance(routeOption.lengthInMeters))
        val durationSpan = DurationSpan.create(routeOption.durationInSeconds)

        val titleSpan = SpannableString("  \u00b7  ")
        titleSpan.setSpan(
            durationSpan,
            0,
            1,
            SPAN_INCLUSIVE_EXCLUSIVE
        )
        titleSpan.setSpan(
            distanceSpan,
            4,
            5,
            SPAN_INCLUSIVE_EXCLUSIVE
        )

        return titleSpan
    }

    /**
     * [FlutterToCar.RoutingObserver] methods
     */

    /** */
    override fun onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage) {
        this.routeOptions = message.routeOptions.filterNotNull()
        selectedRouteOptionIndex = 0

        invalidate()
    }

    override fun onRouteOptionSelected(routeOptionId: Long) {
        selectedRouteOptionIndex = routeOptionId.toInt()

        invalidate()
    }

    override fun onStopRouting() {
        finish()
    }
}
  

The onGetTemplate-method is where we construct the UI of the routing interface shown on the Android Auto screen. The rest of the methods either help with constructing this UI or update is accordingly.
With the RoutingScreen completed we are done with the Android part of our implementation.

Try routing to a destination on your phone while Android Auto is connected. You should see the Android Auto UI update to reflect the new state on the phone.
Selecting a different route or exiting the routing screen should also be reflected from whatever device you did your interaction.

Let's move on to the final implementation for iOS.


Apple CarPlay - iOS (Swift)

As you'll see the implementation for iOS is comparable to the ones we made for Flutter and Android.

We'll start by, once again, implementing the FlutterToCarApi. Create a new file FlutterToCar.swift under ios/Runner:

Copied
        protocol GenericDelegate: AnyObject {
    func onStartRouting()
}

protocol RoutingDelegate: AnyObject {
    func onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage)
    func onRouteOptionSelected(routeOptionIndex: Int64)
    func onStopRouting()
}

private struct WeakGenericDelegate {
    weak var value: GenericDelegate?
    
    init(_ value: GenericDelegate) {
        self.value = value
    }
}

private struct WeakRoutingDelegate {
    weak var value: RoutingDelegate?
    
    init(_ value: RoutingDelegate) {
        self.value = value
    }
}

class FlutterToCar: FlutterToCarApi {
    private init() {}
    
    static let shared = FlutterToCar()
    
    // MARK: delegate methods
    
    private var genericDelegates: Array<WeakGenericDelegate> = Array()
    
    func addDelegate(delegate: GenericDelegate) {
        genericDelegates.append(WeakGenericDelegate(delegate))
        
        // reap disposed delegates
        genericDelegates.removeAll { $0.value == nil }
    }
    
    func removeDelegate(delegate: GenericDelegate) {
        if let index = genericDelegates.firstIndex(where: { $0.value === delegate}) {
            genericDelegates.remove(at: index)
        }
    }
    
    private var routingDelegates: Array<WeakRoutingDelegate> = Array()
    
    func addDelegate(delegate: RoutingDelegate) {
        routingDelegates.append(WeakRoutingDelegate(delegate))
        
        // reap disposed delegates
        routingDelegates.removeAll { $0.value == nil }
    }
    
    func removeDelegate(delegate: RoutingDelegate) {
        if let index = routingDelegates.firstIndex(where: { $0.value === delegate}) {
            routingDelegates.remove(at: index)
        }
    }
    
    // MARK: FlutterToCarApi methods
    
    func onStartRouting() throws {
        for delegate in genericDelegates {
            delegate.value?.onStartRouting()
        }
    }
    
    func onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage) throws {
        for delegate in routingDelegates {
            delegate.value?.onRouteOptionsUpdated(message: message)
        }
    }
    
    func onRouteOptionSelected(routeOptionIndex: Int64) throws {
        for delegate in routingDelegates {
            delegate.value?.onRouteOptionSelected(routeOptionIndex: routeOptionIndex)
        }
    }
    
    func onStopRouting() throws {
        for delegate in routingDelegates {
            delegate.value?.onStopRouting()
        }
    }
}
  

Once again we're implementing the observer pattern, though it's called "Delegate" this time around. We use the Weak...Delegate classes to prevent memory leaks.
To ensure calls to FlutterToCarApi are caught we initialize it on app boot. Update the iPhoneSceneDelegate as follows:

Copied
        class iPhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
    ...

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        ...

        FlutterToCarApiSetup.setUp(
            binaryMessenger: flutterEngine.binaryMessenger,
            api: FlutterToCar.shared
        )
    }
    
    func sceneDidDisconnect(_ scene: UIScene) {
        if let flutterEngine = iPhoneSceneDelegate.flutterEngine {
            FlutterToCarApiSetup.setUp(
                binaryMessenger: flutterEngine.binaryMessenger,
                api: nil
            )
            
            flutterEngine.destroyContext()
            iPhoneSceneDelegate.flutterEngine = nil
        }
    }
}
  

As our final action we need to implement the delegates/protocols we defined before. Make the following changes to the CarPlaySceneDelegate:

Copied
        class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate,
        // add this line
        CPMapTemplateDelegate, GenericDelegate, RoutingDelegate {
    ...

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                  didConnect interfaceController: CPInterfaceController,
                                  to window: CPWindow) {
        self.interfaceController = interfaceController
        self.carPlayWindow = window

        // add this line
        carPlayMapTemplate.mapDelegate = self

        ...

        // add these 2 lines
        FlutterToCar.shared.addDelegate(delegate: self as GenericDelegate)
        FlutterToCar.shared.addDelegate(delegate: self as RoutingDelegate)
    }

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                  didDisconnect interfaceController: CPInterfaceController,
                                  from window: CPWindow) {
        ...
        
        // add the code below
        carPlayMapTemplate.mapDelegate = nil
        
        FlutterToCar.shared.removeDelegate(delegate: self as GenericDelegate)
        FlutterToCar.shared.removeDelegate(delegate: self as RoutingDelegate)

        if let flutterEngine = iPhoneSceneDelegate.flutterEngine {
            CarToFlutterApi(binaryMessenger: flutterEngine.binaryMessenger).onDisconnect { _ in }
        }
    }

    // add all the methods shown below

    // MARK: Helpers
    
    private func tripPreviewTextConfiguration() -> CPTripPreviewTextConfiguration {
        return CPTripPreviewTextConfiguration(
            startButtonTitle: "Unimplemented",
            additionalRoutesButtonTitle: nil,
            overviewButtonTitle: nil
        )
    }
    
    // MARK: CPMapTemplateDelegate
    
    func mapTemplate(
        _ mapTemplate: CPMapTemplate,
        selectedPreviewFor trip: CPTrip,
        using routeChoice: CPRouteChoice
    ) {
        let index = trip.routeChoices.firstIndex(of: routeChoice)
        
        if let flutterEngine = iPhoneSceneDelegate.flutterEngine {
            CarToFlutterApi(binaryMessenger: flutterEngine.binaryMessenger)
                .updateSelectedRouteOption(routeOptionIndex: Int64(index!)) { _ in }
        }
    }
    
    // MARK: GenericDelegate
    
    func onStartRouting() {
        if #available(iOS 14.0, *) {
            carPlayMapTemplate.backButton = CPBarButton(title: "Back") { [weak self] _ in
                if let flutterEngine = iPhoneSceneDelegate.flutterEngine {
                    CarToFlutterApi(binaryMessenger: flutterEngine.binaryMessenger).stopRouting { _ in }
                }
                
                self?.carPlayMapTemplate.hideTripPreviews()
                self?.carPlayMapTemplate.backButton = nil
            }
        } else {
            let button = CPBarButton(type: .text) { [weak self] _ in
                if let flutterEngine = iPhoneSceneDelegate.flutterEngine {
                    CarToFlutterApi(binaryMessenger: flutterEngine.binaryMessenger).stopRouting { _ in }
                }
                
                self?.carPlayMapTemplate.hideTripPreviews()
                self?.carPlayMapTemplate.backButton = nil
            }
            button.title = "Back"
            carPlayMapTemplate.backButton = button
        }
        
        carPlayMapTemplate.showTripPreviews([], textConfiguration: tripPreviewTextConfiguration())
    }
    
    // MARK: RoutingDelegate
    
    func onRouteOptionsUpdated(message: PgRouteOptionsUpdatedMessage) {
        let trip = CPTrip(
            origin: MKMapItem(from: message.origin),
            destination: MKMapItem(from: message.destination),
            routeChoices: message.routeOptions
                .filter { $0 != nil }
                .map({ routeOption in
                    let routeChoice = CPRouteChoice(
                        summaryVariants: [routeOption!.durationString],
                        additionalInformationVariants: [routeOption!.distanceString],
                        selectionSummaryVariants: []
                    )
                    
                    return routeChoice
                })
        )
        carPlayMapTemplate.showRouteChoicesPreview(
            for: trip,
            textConfiguration: tripPreviewTextConfiguration()
        )
    }
    
    func onRouteOptionSelected(routeOptionIndex: Int64) {
        // No API is provided to programmatically change the selected routeOption.
        // Implementing an alternative solution is out of the scope of this article.
    }
    
    func onStopRouting() {
        carPlayMapTemplate.hideTripPreviews()
        carPlayMapTemplate.backButton = nil
    }
}

// add this extension
extension MKMapItem {
    convenience init(from pgLatLng: PgLatLng) {
        self.init(
            placemark: MKPlacemark(
                coordinate: CLLocationCoordinate2D(
                    latitude: pgLatLng.latitude,
                    longitude: pgLatLng.longitude
                )
            )
        )
    }
}
  

A lot is happening in the code above so let's break it down. The first two methods take care of connecting to, and disconnecting, from Apple CarPlay. The method func mapTemplate(_ mapTemplate: CPMapTemplate, selectedPreviewFor trip: CPTrip, using routeChoice: CPRouteChoice) is part of the CPMapTemplateDelegate and is called when a different route option is selected on the Apple CarPlay screen. In this method we call to our Flutter code with the selected option.

The finals methods are part of the GenericDelegate and RoutingDelegate. Any calls we receive from our Flutter code are applied to the UI of our Apple CarPlay screen. Finally we end with an extension of MKMapItem which is merely added as a convenience to clean up our code.

And that's it! You should have a rudimentary implementation of Android Auto and Apple CarPlay within a Flutter app. Be sure to try it out. The same as with Android, try routing to a destination on your phone while Apple CarPlay is connected. 

Android Auto DHU:

AA

Apple CarPlay Simulator:

CP_Sim

Conclusion

Hopefully this technical blog post has inspired you with regards to what's possible when using the HERE SDK for Flutter. Building a full integration with both Android Auto and Apple CarPlay can be a lot of work, but using the HERE SDK and the pigeon package will make it a lot easier!

There is still a lot we haven't covered, and to give you some ideas we'll briefly talk about some potential improvements.

For all turn-by-turn related logic instead of using a VisualNavigator look into using a Navigator. This class has all the same capabilities with regards to listeners such as RouteProgressListener but doesn't do any rendering. Now that you might be dealing with multiple VisualNavigators, due to Android Auto / Apple CarPlay, the Navigator can serve as a nice in between layer.

We also haven't delved deep into using the HereMapController which updates the map on a Android Auto / Apple CarPlay screen. Since it functions the same as any other HereMapController there isn't much more to say about it. It can draw markers, manipulate the camera and other standard functions. It will be highly dependent on your project's structure, its wishes, and how you want to go about using it. Perhaps look into using a proxy class which extends HereMapController in order to apply updates to multiple maps at the same time. Again, there are many ways to go about this so be sure to experiment with what best works for you.

Additional Notes

Besides the missing features in our example project there might be some less obvious ones. Keep these in mind when working on your own projects.
The state of Android Auto / Apple CarPlay doesn't "catch up" to the app state. I.e., when the phone is on the routing screen and Android Auto / Apple CarPlay connects it will not automatically be on the equivalent routing screen. This example projects assumes that the app is actively running on the phone, in the foreground, when a connection to Android Auto or Apple CarPlay is established. It is certainly possible to implement ways around this requirement, however this is far out-of-scope of this article.

During development and since releasing the Android Auto and Apple CarPlay integration for our own navigation app we've noticed some quirks. These might be insightful for developing your own integrations so we listed some below.

Some Android Auto units provide their own location data instead of using the phone's GPS. Most of the time this results in even better location data. However in certain cases each location event only contains a latitude and longitude, no heading, speed, etc. This can cause some issues with regards to the location indicator.

It is not possible to use a debug build of your Android app with any Android Auto unit. This is only possible with the DHU.
Only builds downloaded from the Play Store work with Android Auto units. These can however be internal builds and don't have to be released to the public.

Sadly gestures don't work when combining Android Auto / Apple CarPlay and the HERE Flutter SDK. Even if the display supports touch it won't respond. (This applies to the display with Android Auto / Apple CarPlay, not the phone itself.)

-Joost

Useful links

Apple CarPlay Programming Guide

Apple CarPlay Documentation

Android Auto Guide

Android Auto API documentation

HERE SDK reference application Flutter

HERE iOS SDK - Integration with CarPlay

HERE Android SDK - Integration with Android Auto

HERE Flutter SDK - Integration with CarPlay and Android Auto

HERE SDK Examples

Aaron Falk

Aaron Falk

Principal Developer Evangelist

Have your say

Sign up for our newsletter

Why sign up:

  • Latest offers and discounts
  • Tailored content delivered weekly
  • Exclusive events
  • One click to unsubscribe