UI customization with SwiftUI

The iOS tutorial gets you set up with a “batteries included” UI and sane defaults, but this doesn’t work for every use case. This page walks you through the ways to customize the SwiftUI frontend to your liking.

Customizing the map

Ferrostar includes a map view built with the MapLibre SwiftUI DSL. This is designed to be fairly configurable, so if the existing customizations don’t work for you, we’d love to hear why via an issue on GitHub!

In the case that you want complete control though, the provided wrappers around map view are not that complex.

TODO: Docs on how to build your own navigation views + describe the current overlay layers.

The demo app is designed to be instructive in showing many available options, so be sure to look at that to build intuition.

Style

You can pass a style URL to any of the navigation map view constructors. You can vary this dynamically as your app theme changes (ex: in dark mode).

Camera

The camera supports two-way manipulation via SwiftUI bindings. TODO: more documentation

Adding map layers

You can add your own overlays too! The makeMapContent closure argument of the various map and navigation views enables you to add more layers. See the demo app for an example, where we add a little dot showing the raw location in addition to the puck, which snaps to the route line.

Customizing the instruction banners

Ferrostar includes a number of views related to instruction banners. These are composed together to provide sensible defaults, but you can customize a number of things.

Distance formatting

By default, banners and other UI elements involving distance will be formatted using an MKDistanceFormatter.

This should “just work” for most cases as it is aware of the device’s locale. However, you can customize the formatting by passing in any arbitrary Formatter. This can be your own specially configured MKDistanceFormatter or a custom subclass which formats things to your liking.

The InstructionsView is shipped with Ferrostar is the default banner view. It uses the public domain directions iconography from Mapbox in a standard layout.

This view is an excellent example of composability, and is comprised of several subviews. The units of the InstructionsView are controlled using the formatter settings you passed to the NavigationMapView (if you’re using it). If you’re not using the NavigationMapView, you can pass a formatter directly.

You can also build your own custom banners using the provided components, or start from scratch.

TODO: Expose a view builder argument so that users can easily swap for their own view.

Customizing the navigation view grid

The batteries included UI has a lot of widgets, like zoom buttons and a status bar showing your ETA.

This grid is completely customizable! You can add, move, or replace widgets from the defaults.

Refer to the CustomizableNavigatingInnerGridView public protocol and extension. The PortraitNavigationOverlayView and LandscapeNavigationOverlayView are complete overlay configuration examples. Specifically, they are the ones used by default (in the DynamicallyOrientingNavigationView). With this context, you should be able to see how the default views are composed from others, and design your own custom overlay configuration, mixing the views provided in FerrostarMapLibre with your own!

Customizing the high-level navigation views

The DynamicallyOrientingNavigationView on both platforms also has a high level of flexibility via both constructor arguments and (for SwiftUI) view modifiers. We won't make an exhaustive list here, but have a look at the documentation / code, or explore in your IDE.

A few non-obvious notes for SwiftUI users are in order. First, if you want to display speed limits, make sure you use the navigationSpeedLimit view modifier on your navigation view. We may add a default in the future, but which signage to use is somewhat tricky and perhaps app-specific.

Second, road names are included from your routes by default. You can change the styling with view modifiers, or replace it with your own view like so:

// DynamicallyOrientingNavigationView(...)
    .navigationCurrentRoadView(currentRoadNameViewBuilder: {
	    // TODO: Your view here, based on state...
		EmptyView()
	})

If you want to disable road names completely, you can return EmptyView() as shown above.