#BuiltWithHERE: How MyRoute-app B.V. Integrated Android Auto & Apple CarPlay into their HERE Flutter SDK-based app - Part 2
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:
- Implementing a routing screen
a. Flutter (Dart)
b. Android Auto - Android (Kotlin)
c. Apple CarPlay - iOS (Swift) - Conclusion
- Notes
- 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:
...
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:
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:
...
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
:
...
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
:
//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();
...
}
}
..
// 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
:
// 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) {
...
}
}
// 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
.
...
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:
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
:
...
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
:
...
// 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:
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
:
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:
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
:
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:
Apple CarPlay Simulator:
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
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
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