Core Location, MapKit and Push Notifications in iOS

Advanced iOS Development: Concurrency, Architecture, Security and Release · Data and Apple Frameworks · 8 min read · Updated

The path of a push

  1. After the person allows notifications, the app registers and the system gives it a device token.
  2. The app sends the token to your server, which stores it with the user.
  3. When something happens, your server asks APNs to deliver a message to that token. The key stays on the server.
  4. APNs delivers it, and the phone shows the notification.

Apps feel native when they use the phone: where you are, what is nearby, and a reminder at the right moment. These features share two things. They touch private data or attention, so Apple requires a permission and an honest reason. And they depend on the system, which decides when your code runs. This lesson covers location, maps and notifications, with the permission rules first.

Permissions: the rules for all private features

  • Every private feature needs a usage description in the app's Info settings, a short sentence that says *why*, such as *Used to show expenses near you*. Without it, the app is stopped at the moment you ask. Reviewers also read these sentences.
  • Ask at the moment the feature is needed, not at launch. A person who sees *Allow location?* right after opening the app, with no context, often says no.
  • Always handle no. A good app still works, with a clear way to change the setting later.
  • Ask for the least you need. *While Using the App* is enough for most apps.

Core Location

import CoreLocation
import Observation

@Observable
final class LocationModel: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var status: CLAuthorizationStatus
    var location: CLLocation?

    override init() {
        status = manager.authorizationStatus
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    }

    func requestAccess() {
        manager.requestWhenInUseAuthorization()
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        status = manager.authorizationStatus
        if status == .authorizedWhenInUse || status == .authorizedAlways {
            manager.requestLocation()      // one fix, uses less battery
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        location = locations.last
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location failed:", error)
    }
}

Add the key NSLocationWhenInUseUsageDescription to your target's Info settings. The delegate methods tell you when the person answers and when a position arrives. Choose the lowest accuracy that does the job, and ask for one position with requestLocation() when you only need one, because GPS drains the battery. Newer iOS versions also offer an async way to read location updates (CLLocationUpdate.liveUpdates()), which is worth learning once you know this delegate version.

MapKit in SwiftUI

From iOS 17, SwiftUI has a Map view you fill with *markers* and *annotations*, written like any other SwiftUI content.

import MapKit
import SwiftUI

struct PlacesMap: View {
    private let cafe = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278)

    var body: some View {
        Map(initialPosition: .region(
            MKCoordinateRegion(center: cafe,
                               span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)))
        ) {
            Marker("Coffee shop", systemImage: "cup.and.saucer.fill", coordinate: cafe)
            UserAnnotation()
        }
        .mapStyle(.standard)
        .mapControls { MapUserLocationButton(); MapCompass() }
    }
}

UserAnnotation shows the blue dot once you have permission. To find places, use MKLocalSearch, which searches Apple's map data.

func searchNearby(_ text: String, around center: CLLocationCoordinate2D) async throws -> [MKMapItem] {
    let request = MKLocalSearch.Request()
    request.naturalLanguageQuery = text
    request.region = MKCoordinateRegion(center: center,
                                        latitudinalMeters: 2000, longitudinalMeters: 2000)
    let response = try await MKLocalSearch(request: request).start()
    return response.mapItems
}

Local notifications

A local notification is scheduled by your app on the device, with no server. It suits reminders, such as *log today's expenses at 8 pm*. First ask for permission, then schedule a request with a trigger.

import UserNotifications

func scheduleDailyReminder() async throws {
    let center = UNUserNotificationCenter.current()
    let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
    guard granted else { return }

    let content = UNMutableNotificationContent()
    content.title = "Log your expenses"
    content.body = "Add today's spending while you remember it."
    content.sound = .default

    let trigger = UNCalendarNotificationTrigger(
        dateMatching: DateComponents(hour: 20, minute: 0), repeats: true)
    let request = UNNotificationRequest(identifier: "daily-reminder", content: content, trigger: trigger)
    try await center.add(request)
}
  • Using the same identifier again replaces the old request, so a person does not get two reminders.
  • By default, a notification does not show while your app is open. If you want it to, set a UNUserNotificationCenterDelegate and return the options you want from willPresent.
  • Give people a way to turn reminders off inside the app, in addition to the system settings.

Push notifications

A push notification is sent from your server, through Apple's service, APNs. The path is: your app asks the system to register, the system gives the app a device token, the app sends that token to your server, and the server asks APNs to deliver a message to that token. For the server to talk to APNs you create an authentication key (a .p8 file) in your Apple developer account. You add the Push Notifications capability to the app.

import UIKit
import SwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        print("Send this token to your server:", token)
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("Push registration failed:", error)
    }
}

@main
struct ExpenseTrackerApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    var body: some Scene { WindowGroup { ContentView() } }
}

// after the person has allowed notifications:
UIApplication.shared.registerForRemoteNotifications()

Background work

iOS stops an app soon after the person leaves it, to save battery. For work that must happen anyway, such as refreshing data, you do not run code yourself. You ask the system, and it decides when. BGTaskScheduler lets you register a refresh task, and the system runs it at a time it picks, which may be late, or not at all if the battery is low. A push with content-available can also wake the app briefly. Always design the app so that it works correctly when no background work ran, and treat background work as a bonus.

Common mistakes

  • Asking for permission on first launch with no context.
  • No handling for *denied*. The feature silently does nothing.
  • Keeping GPS on all the time.
  • Putting server secrets in the app, or expecting background tasks to run at an exact time.

Your task

  1. Add the location usage description, ask for permission from a button, and show the coordinates.
  2. Show a Map with one marker and the user's location, and search for *coffee* nearby.
  3. Schedule the daily reminder from a toggle in a settings screen, and cancel it when the toggle is off.
  4. Write down the steps of a push notification from your app to a phone, in order, in your own words.

How to check it worked

  • Denying permission shows a helpful message and the app keeps working.
  • The reminder arrives at the chosen time, and only one is scheduled.
  • You can explain why the APNs key must stay on the server.

Frequently asked questions

When should an app ask for location permission?

At the moment the feature is needed, with a clear reason in the usage description, and for the least access you need. Always handle the case where the person says no.

What is the difference between local and push notifications?

A local notification is scheduled by the app on the device with no server. A push notification is sent by your server through Apple's APNs service to the device token.

Can I rely on background tasks running at a set time?

No. You ask the system, and it decides when, or whether, to run them. Design the app to work correctly even if no background work happened.