Build an iOS marker app from a custom style & tileset

30 mins remaining
Build an iOS marker app from a custom style & tileset
Advanced
codeSwift

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.

Embedded data & tileset in the custom style

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!

Prerequisites

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.

Add your 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.

Create the MapView

Create a new Swift file in your project called MapboxMapView.swift and add the following code:

MapboxMapView.swift
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
}
}

Update the ContentView.swift file

Open your ContentView.swift file, import MapboxMaps and replace the ContentView struct with the following:

ContentView.swift
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.

Listen for and Respond to Tap Gestures on Markers

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.

Add failable initializer & DogGroomerLocation struct

Add 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.

MapboxMapView.swift
// 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
}
}
}
...

Add 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.

MapboxMapView.swift
...
// 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

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.

Update ContentView for new functionality

Lastly 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.

ContentView.swift

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.

Add a Drawer to display properties of the tapped marker

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:

DrawerView.swift
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.

Update 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.

ContentView.swift
...

// 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.

Add a 'Reset Map' button

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.

ContentView.swift

// 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.

Add a title and call to action

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.

ContentView.swift
 
AppIntro()
 
// MARK: Supporting View
 
// App Header - statically placed in top left of map.
 
struct AppIntro: View {
 

 
var body: some View {
 
VStack(alignment: .leading) {
 
HStack {
 
Image(systemName: "pawprint.fill")
 
.foregroundColor(.blue)
 
.padding([.leading, .top, .bottom], 6)
 
Text("Pet Spa Finder")
 
.font(.headline)
 
.foregroundColor(.black)
 
.cornerRadius(3)
 
.padding([.top, .trailing, .bottom], 6)
 
}
 
.padding(12)
 
.background(
 
RoundedRectangle(cornerRadius: 3)
 
.fill(Color.white)
 
)
 
//.border(Color.gray, width: 4)
 

 
Text("Click a marker for more information.")
 
.font(.caption)
 
.foregroundColor(.black)
 
.padding(8)
 
.frame(width: UIScreen.main.bounds.width * 0.45)
 
.background(
 
RoundedRectangle(cornerRadius: 3)
 
.fill(Color.white)
 
)
 
.cornerRadius(2)
 
}
 

 
}
 

Now add the AppIntro view to the HStack in the ContentView struct:

ContentView.swift
...
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)
}
...

Final Product

Congratulations! You've built an iOS app with the Mapbox Maps SDK for iOS. You're app should look like the video below.

Next Steps

What we covered

  • Adding a MapView to display a custom style centered over Boston, MA.
  • Adding a onLayerTap gesture to manage clicks on the map layer with and set state in your app with the clicked feature.
  • Creating a DogGroomerLocation struct and failable initializer to deconstruct feature properties.
  • Creating a DrawerView to display feature properties when a marker is tapped.
  • Creating a 'Reset Map' button to reset the map view to its original state.
  • Creating an AppIntro view to display a welcome message and call to action.
  • Adding a GestureManagerDelegate to listen for map interactions.

Learn More

このtutorialは役に立ちましたか?