This tutorial demonstrates how to build an iOS app powered by a custom map style with embedded data (a tileset) and custom markers. This app relies heavily on the custom style already having your styles & data, so this tutorial focuses on the implementation of the app. Using a custom map style is ideal for cross platform applications as the style can be used in different platforms and reduces frontend code.
You will use the Mapbox Maps SDK for iOS to create a map view, display the custom map style and center the map over Boston, MA. Our map style includes geoJSON data for 10 Pet spas in Boston, and includes a custom marker SVG added as a symbol layer to our map style. You will use an onLayerTap
gesture manages taps on the map layer and sets state in your app with the tapped feature. This enables you to tap on each marker and learn more about the Point-of-Interest(POI). Once tapped, a DrawerView
slides up from the bottom of the screen and the feature properties from each POI are propagated to the DrawerView
, so you can view information, like the Pet Spa name and address. The app also includes a 'Reset Map' button that will reset the map view to its initial camera view after it has been interacted with.
The custom style for this tutorial can be previewed at this URL..
https://api.mapbox.com/styles/v1/examples/cm37hh1nx017n01qk2hngebzt.html?title=view&access_token=YOUR_MAPBOX_ACCESS_TOKEN&zoomwheel=true&fresh=true#11.41/42.3249/-71.0969
It contains our custom font, marker SVG and layers for the dog groomer locations. The tileset which is imported into the style and contains our geojson features can be previewed by itself at https://console.mapbox.com/studio/tilesets/examples.6bqg9nyi/#0.54/0/-71.1.
Let's get started creating our iOS Pet Spa Finder app!
Before you begin, you will need:
This tutorial assumes you have a basic understanding of Swift and iOS development and requires you to have setup the Mapbox Maps SDK for iOS in a SwiftUI project as covered in our Getting Started with Mapbox on iOS guide.
The Getting Started guide should leave you with a new SwiftUI project open in Xcode, having added the Mapbox Maps SDK for iOS dependency and having added your access token to your projects info.plist
file as seen below.
MapView
& import it into ContentView
In this step you will create your MapView
setting the styleURI
to the custom map style, and then import your MapView
it into your ContentView
.
MapView
Create a new Swift file in your project called MapboxMapView.swift
and add the following code:
import SwiftUI
import MapboxMaps
// MARK: MapboxMapView
struct MapboxMapView: UIViewRepresentable {
let center: CLLocationCoordinate2D
var onMapViewCreated: ((MapView) -> Void)? // Closure to pass back MapView
func makeUIView(context: Context) -> MapView {
let mapInitOptions = MapInitOptions(
cameraOptions: CameraOptions(
center: center,
zoom: 8.5),
// Custom style URI with fonts, markers & data
styleURI: StyleURI(rawValue: "mapbox://styles/examples/cm37hh1nx017n01qk2hngebzt") ?? .streets
)
let mapView = MapView(frame: .zero, mapInitOptions: mapInitOptions)
// Removes scale & compass on map
mapView.ornaments.options.compass.visibility = .hidden
mapView.ornaments.options.scaleBar.visibility = .hidden
// Use dispatch to make sure the closure is executed after the map view is initialized.
DispatchQueue.main.async {
if let onMapViewCreated = onMapViewCreated {
onMapViewCreated(mapView)
}
}
return mapView
}
func updateUIView(_ uiView: MapView, context: Context) {
// Any updates if needed
}
}
ContentView.swift
fileOpen your ContentView.swift
file, import MapboxMaps
and replace the ContentView
struct with the following:
import SwiftUI
import MapboxMaps
// MARK: ContentView
struct ContentView: View {
@State private var mapView: MapView? = nil
@State private var hasInteracted = false
var body: some View {
// Center coordinates for Boston, MA
let center = CLLocationCoordinate2D(
latitude: 42.34622,
longitude: -71.09290
)
ZStack(alignment: .bottom) {
MapboxMapView(
center: center,
onMapViewCreated: { mapView in
self.mapView = mapView
})
.ignoresSafeArea(.all)
}
}
}
Run the app in your emulator to load a full screen map centered over Boston, MA. Our custom style will be displaying a group of markers over the Boston area.
The next step is to listen for tap events on the markers in the custom style. When a marker is tapped, you want to extract the underlying properties which contain additional data about the groomer location. The properties are JSON based and can take any form, so a struct with a failable initializer is needed to safely introduce it into the app. You'll log the data from the marker to the console for now, and in the next step, you'll display the data in a DrawerView
.
DogGroomerLocation
structAdd this failable initializer to the top of your MapboxMapView.swift
file (above the MapboxMapView
struct). This safely unwraps the tapped feature and its feature properties to an Equatable
that we can use in our app.
// MARK: Struct Declaration
// with failable initializer
struct DogGroomerLocation: Equatable {
var storeName: String
var address: String
var city: String
var postalCode: String
var phoneFormatted: String? = nil
var rating: Double? = nil
init?(queriedFeature: QueriedFeature) {
guard let properties = queriedFeature.feature.properties,
case let .string(storeName) = properties["storeName"],
case let .string(address) = properties["address"],
case let .string(city) = properties["city"],
case let .string(postalCode) = properties["postalCode"] else { return nil }
self.storeName = storeName
self.address = address
self.city = city
self.postalCode = postalCode
if case let .string(phoneFormatted) = properties["phoneFormatted"] {
self.phoneFormatted = phoneFormatted
}
if case let .number(rating) = properties["rating"] {
self.rating = rating
}
}
}
...
onLayerTap
gesture to MapboxMapView
Next we'll need to add our setupLayerTapGesture
function and update our MapboxMapView
for the new functionality. In the code snippet below you'll see the changes highlighted. We've added a GestureTokens
class to manage the gesture tokens and a @Binding
for the selected feature name. We've added our private setupLayerTapGesture
function to handle the onLayerTap
gesture on the layer and we've added that function to the MapView creation.
Note: the first line of the update below, @ObservedObject var gestureTokens: GestureTokens
will cause a validation error until we define the class further down in the step. You can ignore this error for now.
...
// MARK: MapboxMapView
struct MapboxMapView: UIViewRepresentable {
let center: CLLocationCoordinate2D
@ObservedObject var gestureTokens: GestureTokens // Use the mutable tokens class
@Binding var selectedFeature: DogGroomerLocation? // Binding for selected feature name
var onMapViewCreated: ((MapView) -> Void)? // Closure to pass back MapView
func makeUIView(context: Context) -> MapView {
let mapInitOptions = MapInitOptions(
cameraOptions: CameraOptions(
center: center,
zoom: 8.5),
styleURI: StyleURI(rawValue: "mapbox://styles/examples/cm37hh1nx017n01qk2hngebzt") ?? .streets
)
let mapView = MapView(frame: .zero, mapInitOptions: mapInitOptions)
// Removes scale & compass on map
mapView.ornaments.options.compass.visibility = .hidden
mapView.ornaments.options.scaleBar.visibility = .hidden
// Use dispatch to make sure the closure is executed after the map view is initialized.
DispatchQueue.main.async {
if let onMapViewCreated = onMapViewCreated {
onMapViewCreated(mapView)
}
}
// Set up tap gesture handling on the layer
setupLayerTapGesture(for: mapView)
return mapView
}
func updateUIView(_ uiView: MapView, context: Context) {
// Any updates if needed
}
// MARK: Private Helper Function
private func setupLayerTapGesture(for mapView: MapView) {
mapView.gestures.onLayerTap("dog-groomers-boston-marker") { queriedFeature, _ in
let selectedGroomerLocation = DogGroomerLocation(queriedFeature: queriedFeature)
print("queriedFeature", queriedFeature.feature)
// Use self.selectedFeature since selectedFeature is a @Binding passed down from the parent view
self.selectedFeature = selectedGroomerLocation
return true
}.store(in: &gestureTokens.tokens)
}
}
Note the layer name we are providing the onLayerTap
function is dog-groomers-boston-marker
. This is the layer name in the custom style that we are tapping on to retrieve the feature properties. You can find this layer name in the custom style in Mapbox Studio.
ContentView
for new functionalityLastly we'll need to update our ContentView
to include the GestureTokens
class, the selectedFeature
binding and pass those into the MapboxMapView
. We'll also import the Combine
package as this contains the AnyCancellable
type we need to manage the gesture tokens.
import SwiftUI
import MapboxMaps
import Combine
// MARK: GestureTokens Class
class GestureTokens: ObservableObject {
var tokens = Set<AnyCancellable>()
}
// MARK: ContentView
struct ContentView: View {
@StateObject private var gestureTokens = GestureTokens()
@State private var selectedFeature: DogGroomerLocation? = nil
@State private var mapView: MapView? = nil
var body: some View {
let center = CLLocationCoordinate2D(
latitude: 42.34622,
longitude: -71.09290
)
ZStack(alignment: .bottom) {
MapboxMapView(
center: center,
gestureTokens: gestureTokens,
selectedFeature: $selectedFeature,
onMapViewCreated: { mapView in
self.mapView = mapView
})
.ignoresSafeArea(.all)
}
}
}
Now when you tap on a marker in the custom style, the feature properties will be printed to the console as seen in the video below.
With the app responding to taps and extracting the feature data from the tapped marker, the next step is to add UI to display the feature information to the user. Add a new view to display the properties in a drawer. Create a new file called DrawerView.swift
and add the following code:
import Foundation
import SwiftUI
import Turf
// MARK: DrawerView
struct DrawerView: View {
let feature: DogGroomerLocation
var onDismiss: () -> Void
@State private var animatedFeature: DogGroomerLocation?
var body: some View {
ZStack(alignment: .topTrailing) {
VStack {
VStack(spacing: 16) {
Text((feature.storeName))
.font(.title)
.padding(.vertical, 16)
let addressText = feature.address + ", " + feature.city + " " + feature.postalCode
InfoRow(imageName: "building", text: addressText)
PhoneRow(phone: feature.phoneFormatted ?? "N/A")
Rating(rating: feature.rating)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(EdgeInsets(top: 10, leading: 20, bottom: 40, trailing: 20))
.frame(maxWidth: .infinity)
.background(
Color.white
.ignoresSafeArea(edges: .bottom)
)
.ignoresSafeArea(edges: .bottom)
.cornerRadius(12)
.shadow(radius: 10)
.transition(.move(edge: .bottom))
.onAppear {
// Initialize animatedFeature onAppear
animatedFeature = feature
}
.onChange(of: feature) { oldFeature, newFeature in
// Trigger animation when feature changes
withAnimation(.easeInOut) {
animatedFeature = newFeature
}
}
.animation(.easeInOut, value: animatedFeature) // Animate based on changes to animatedFeature
}
closeButton
}
}
// Close button in top-right corner
var closeButton: some View {
Button(action: {
withAnimation {
onDismiss()
}
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 24))
.padding()
}
.padding(.top, 0) // Adds some space from the top of the screen
.padding(.trailing, 0) // Adds some space from the right of the screen
}
}
// MARK: Supporting Views
struct InfoRow: View {
let imageName: String
let text: String
var body: some View {
HStack {
Image(systemName: imageName)
.foregroundColor(.black)
Text(text)
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct PhoneRow: View {
let phone: String?
var body: some View {
HStack {
Image(systemName: "phone.fill")
.foregroundColor(.black)
.font(.system(size: 16))
// Make the phone number text tappable
Button(action: {
if let phoneNumber = phone, let url = URL(string: "tel://\(phoneNumber)") {
UIApplication.shared.open(url)
}
}) {
Text(phone ?? "N/A")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(PlainButtonStyle()) // Prevent default button styling (if you want to keep it consistent)
}
}
}
struct Rating: View {
let rating: Double?
var body: some View {
HStack {
// safely unwrap rating to a string representing the number
Text("Rating: \(rating.map { String(format: "%.1f", $0) } ?? "0")")
.font(.subheadline)
.bold()
// Loop to generate rating images
ForEach(0..<5, id: \.self) { index in
if let rating = rating {
// Round the rating to the nearest whole number
let roundedRating = Int(rating.rounded())
Image(systemName: "pawprint.fill")
.foregroundColor(index < roundedRating ? .blue : .gray) // Color based on the rating
.font(.system(size: 16))
} else {
// Handle case when rating is nil, if necessary
Image(systemName: "pawprint.fill")
.foregroundColor(.gray) // Default to gray if rating is nil
.font(.system(size: 16))
}
}
}
}
}
The DrawerView.swift
file has a lot happening so let's briefly cover what's happening in this file.
The main view is the DrawerView
which is a ZStack
and a nested VStack
containing a Text
view for the store name, an InfoRow
view for the address, a PhoneRow
view for the phone number, and a Rating
view for the rating. The views are defined in the Supporting Views section at the bottom.
The DrawerView
also contains a close button in the top right corner. The DrawerView
uses a @State
property to animate the view in and out of the screen.
ContentView
to include DrawerView
Now we'll add our DrawerView
to our ContentView
and pass the selected feature to it.
If the selectedFeature
exists we pass it into the DrawerView
and set the selectedFeature
to nil
when the close button is tapped. We also need to add the ignoresSafeArea
modifier to the ZStack
to make sure the DrawerView
is displayed over the map.
...
// MARK: ContentView
struct ContentView: View {
@StateObject private var gestureTokens = GestureTokens()
@State private var selectedFeature: DogGroomerLocation? = nil
@State private var mapView: MapView? = nil
var body: some View {
let center = CLLocationCoordinate2D(
latitude: 42.34622,
longitude: -71.09290
)
ZStack(alignment: .bottom) {
MapboxMapView(
center: center,
gestureTokens: gestureTokens,
selectedFeature: $selectedFeature,
onMapViewCreated: { mapView in
self.mapView = mapView
})
.ignoresSafeArea(.all)
if let feature = selectedFeature {
DrawerView(feature: feature) {
withAnimation {
selectedFeature = nil
}
}
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea(edges: [.bottom, .leading])
}
}
Now when you run the app in an emulator or in the preview, you should see the DrawerView
slide up from the bottom of the screen when you tap on a marker. The feature properties will be displayed in the DrawerView
and the close button will dismiss the view.
In this step you'll add a 'Reset Map' button to reset the map view to its initial camera view after it the map has been interacted with. You'll also add a GestureManagerDelegate
to listen for map interactions and set the hasInteracted
state to true
when the map has been interacted with.
Open the ContentView.swift
file and add the GestureManagerDelegateImplementation
class below the ContentView
struct, and update the ContentView
struct with the highlighted updates as seen below.
// MARK: ContentView
struct ContentView: View {
@StateObject private var gestureTokens = GestureTokens()
@State private var selectedFeature: DogGroomerLocation? = nil
@State private var mapView: MapView? = nil
@State private var hasInteracted = false
// Hold a strong reference to the gesture delegate
@State private var gestureDelegate: GestureManagerDelegateImplementation?
var body: some View {
let center = CLLocationCoordinate2D(
latitude: 42.34622,
longitude: -71.09290
)
ZStack(alignment: .bottom) {
MapboxMapView(
center: center,
gestureTokens: gestureTokens,
selectedFeature: $selectedFeature,
onMapViewCreated: { mapView in
self.mapView = mapView
// Create and retain the gesture delegate
let delegate = GestureManagerDelegateImplementation { gestureType in
// Asynchronously update the hasInteracted state
DispatchQueue.main.async {
self.hasInteracted = true
}
}
// Assign the gesture delegate
mapView.gestures.delegate = delegate
self.gestureDelegate = delegate // Retain the delegate strongly
})
.ignoresSafeArea(.all)
VStack(
alignment: .leading,
spacing: 10
) {
HStack(alignment: .top) {
Spacer()
if hasInteracted { // Only show button if interacted
Button( action: {
// Reset the map view to its original state
mapView?.camera.fly(to: CameraOptions(
center: center,
zoom: 8.5,
bearing: 0,
pitch: 0
), duration: 5.0)
// Reset the hasInteracted & selectedFeature vars
hasInteracted = false
selectedFeature = nil
}) {
Text("Reset Map")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.controlSize(.mini) // Reduce button size
.padding([.trailing], 8) // Add padding to the top
}
}
Spacer()
}
if let feature = selectedFeature {
DrawerView(feature: feature) {
withAnimation {
selectedFeature = nil
}
}
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea(edges: [.bottom, .leading])
}
}
// MARK: GestureManager
// GestureManagerDelegate Implementation
class GestureManagerDelegateImplementation: NSObject, GestureManagerDelegate {
var onGestureBegin: (GestureType) -> Void
init(onGestureBegin: @escaping (GestureType) -> Void) {
self.onGestureBegin = onGestureBegin
}
func gestureManager(_ gestureManager: GestureManager, didBegin gestureType: GestureType) {
// Notify when a gesture begins
onGestureBegin(gestureType)
//print("\(gestureType) didBegin")
}
func gestureManager(_ gestureManager: GestureManager, didEnd gestureType: GestureType, willAnimate: Bool) {
//print("\(gestureType) didEnd")
}
func gestureManager(_ gestureManager: GestureManager, didEndAnimatingFor gestureType: GestureType) {
//print("didEndAnimatingFor \(gestureType)")
}
}
Your app should now display a 'Reset Map' button when the map has been interacted with. The button will reset the map view to its original state and the hasInteracted
state will be reset to false
, hiding the reset button. The GestureManagerDelegateImplementation
class will listen for map interactions and set the hasInteracted
state to true
when the map has been interacted with.
As a last step, you'll add a small AppIntro
view to the ContentView
that will display over the map. This view will contain a welcome message and a call to action. We'll use a ZStack
to overlay the AppIntro
view over the map.
Add the AppIntro
view to the bottom of your ContentView.swift
and then call the AppIntro
view in the HStack
in the ContentView
struct.
Now add the AppIntro
view to the HStack
in the ContentView
struct:
...
VStack(
alignment: .leading,
spacing: 10
) {
HStack(alignment: .top) {
AppIntro()
Spacer()
if hasInteracted { // Only show button if interacted
Button( action: {
mapView?.camera.fly(to: CameraOptions(
center: center,
zoom: 8.5,
bearing: 0,
pitch: 0
), duration: 5.0)
// Reset the hasInteracted & selectedFeature vars
hasInteracted = false
selectedFeature = nil
}) {
Text("Reset Map")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
...
Congratulations! You've built an iOS app with the Mapbox Maps SDK for iOS. You're app should look like the video below.
MapView
to display a custom style centered over Boston, MA.onLayerTap
gesture to manage clicks on the map layer with and set state in your app with the clicked feature.DogGroomerLocation
struct and failable initializer to deconstruct feature properties.DrawerView
to display feature properties when a marker is tapped.AppIntro
view to display a welcome message and call to action.GestureManagerDelegate
to listen for map interactions.