Move the interactive map into a new MapLibreShim framework

Second step towards loading MapLibre on demand: everything that touches it now
lives in one framework, so that framework can later be left unlinked.

The interactive map view, its annotation view and the coordinate animator move
into MapLibreShim, which reaches the app through MapLibreShimProtocol. The app
side keeps its MapLibreMapView call site and becomes a thin wrapper that builds
a MapViewConfiguration, passing the app-only pieces (map style URLs, marker
views, tint colour) across as closures so the shim needs nothing from the app.

LocationAnnotation loses its MapLibre conformance and becomes a plain struct,
with the shim mapping it onto its own annotation type. MapLibre's log
forwarding moves into the shim as well, since TargetConfiguration can no longer
reach it.
This commit is contained in:
Stefan Ceriu
2026-08-12 11:22:55 +03:00
parent 5724e65d8e
commit 8753fe3980
14 changed files with 778 additions and 381 deletions
@@ -1,17 +1,41 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2026 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import MapLibre
import Compound
import CoreLocation
import MapLibreInterface
import MapLibreShim
import SwiftUI
struct MapLibreMapView: UIViewRepresentable {
/// The interactive map, rendered by the `MapLibreShim` framework.
struct MapLibreMapView: View {
/// Built once, so that MapLibre's logging is only configured once.
private static let shim: MapLibreShimProtocol = {
let shim = MapLibreShim()
shim.configureLogging { severity, message in
switch severity {
case .error:
MXLog.error(message)
case .warning:
MXLog.warning(message)
case .info:
MXLog.info(message)
case .debug:
MXLog.debug(message)
case .verbose:
MXLog.verbose(message)
}
}
return shim
}()
struct Options {
/// the final zoom level used when the first user location emit
let zoomLevel: Double
@@ -32,10 +56,6 @@ struct MapLibreMapView: UIViewRepresentable {
}
}
// MARK: - Properties
@Environment(\.colorScheme) private var colorScheme
let mapURLBuilder: MapTilerURLBuilderProtocol
let options: Options
@@ -56,295 +76,33 @@ struct MapLibreMapView: UIViewRepresentable {
/// Called when the user pan on the map
var userDidPan: (() -> Void)?
// MARK: - UIViewRepresentable
func makeUIView(context: Context) -> MLNMapView {
let mapView = makeMapView()
mapView.delegate = context.coordinator
setupMap(mapView: mapView, with: options)
return mapView
var body: some View {
Self.shim.makeMapView(configuration: configuration)
}
func updateUIView(_ mapView: MLNMapView, context: Context) {
// Don't set the same value twice. Otherwise, if there is an error loading the map, a loop
// is caused as the `error` binding being set, which triggers this update, which sets a
// new URL, which causes another error, and so it goes on round and round in a circle.
let dynamicMapURL = mapURLBuilder.interactiveMapURL(for: .init(colorScheme))
if mapView.styleURL != dynamicMapURL {
mapView.styleURL = dynamicMapURL
}
/// The configuration for the shim, with the app-only pieces (marker views, tint, styles)
/// resolved into the shared types it understands.
private var configuration: MapViewConfiguration {
let annotations = options.annotations
let mapURLBuilder = mapURLBuilder
let mediaProvider = mediaProvider
// If the center coordinate was updated externally (not by the map itself), move the map.
// Not applied while following a marker, where the camera is driven by the marker's position.
if showsUserLocationMode.followedMarkerID == nil,
let newCenter = mapCenterCoordinate,
newCenter != context.coordinator.lastReportedCenter {
context.coordinator.lastReportedCenter = newCenter
mapView.setCenter(newCenter, animated: true)
}
// Update existing annotation views with fresh SwiftUI content.
// This handles the case where the annotation's view data changes after
// the annotation was initially placed (e.g. user avatar loads asynchronously).
updateAnnotations(in: mapView)
showUserLocation(in: mapView)
context.coordinator.updateMarkerFollowing(in: mapView, markerID: showsUserLocationMode.followedMarkerID)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// MARK: - Private
private func setupMap(mapView: MLNMapView, with options: Options) {
mapView.addAnnotations(options.annotations)
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
mapView.centerCoordinate = options.mapCenter
}
private func updateAnnotations(in mapView: MLNMapView) {
let existingByID = Dictionary(uniqueKeysWithValues:
(mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) })
let updatedByID = Dictionary(uniqueKeysWithValues: options.annotations.map { ($0.id, $0) })
let existingIDs = Set(existingByID.keys)
let updatedIDs = Set(updatedByID.keys)
// Remove annotations that are no longer present
let removedIDs = existingIDs.subtracting(updatedIDs)
if !removedIDs.isEmpty {
let toRemove = removedIDs.compactMap { existingByID[$0] }
mapView.removeAnnotations(toRemove)
}
// Add new annotations
let addedIDs = updatedIDs.subtracting(existingIDs)
if !addedIDs.isEmpty {
let toAdd = addedIDs.compactMap { updatedByID[$0] }
mapView.addAnnotations(toAdd)
}
// Update existing annotations that are still present
let keptIDs = existingIDs.intersection(updatedIDs)
for id in keptIDs {
guard let existingAnnotation = existingByID[id],
let updatedAnnotation = updatedByID[id] else {
continue
}
CoordinateAnimator.animate(annotation: existingAnnotation,
to: updatedAnnotation.coordinate,
duration: 1.0)
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
annotationView.updateContent(with: updatedAnnotation.kind, mediaProvider: mediaProvider)
}
}
}
private func makeMapView() -> MLNMapView {
let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light))
mapView.logoViewPosition = .topLeft
mapView.attributionButtonPosition = .topLeft
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
mapView.tintColor = .compound.iconAccentPrimary
mapView.allowsRotating = false
mapView.allowsTilting = false
return mapView
}
private func showUserLocation(in mapView: MLNMapView) {
switch (showsUserLocationMode, options.annotations) {
case (.showAndFollow, _):
mapView.userTrackingMode = .follow
case (.show, let annotations) where !annotations.isEmpty:
// In the show mode, if there are annotations, we check the authorizationStatus,
// if it's not determined, we wont prompt the user with a request for permissions,
// because they should be able to see the annotations without sharing their location information.
guard mapView.locationManager.authorizationStatus != .notDetermined else { return }
fallthrough
case (.show, _):
mapView.showsUserLocation = true
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
case (.hide, _), (.hideAndFollowMarker, _):
// In hideAndFollowMarker mode the camera following is handled by the coordinator.
mapView.showsUserLocation = false
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
}
}
}
// MARK: - Coordinator
extension MapLibreMapView {
class Coordinator: NSObject, MLNMapViewDelegate {
// MARK: - Properties
var mapLibreView: MapLibreMapView
private var previousUserLocation: MLNUserLocation?
/// Tracks the last center coordinate reported by the map (or set programmatically),
/// so that `updateUIView` can tell apart external binding changes from internal ones.
var lastReportedCenter: CLLocationCoordinate2D?
/// The annotation the camera is currently locked on while in the `hideAndFollowMarker` mode.
private weak var followedAnnotation: LocationAnnotation?
/// Observes the followed annotation's coordinate, updated every frame by the `CoordinateAnimator`.
private var followedAnnotationObservation: NSKeyValueObservation?
// MARK: - Setup
init(_ mapLibreView: MapLibreMapView) {
self.mapLibreView = mapLibreView
}
// MARK: - Marker following
/// Keeps the camera locked on the marker with the given id, or stops following when nil.
func updateMarkerFollowing(in mapView: MLNMapView, markerID: String?) {
guard let markerID else {
stopFollowingMarker()
return
}
guard let annotation = (mapView.annotations ?? [])
.compactMap({ $0 as? LocationAnnotation })
.first(where: { $0.id == markerID }) else {
// The marker isn't on the map (yet), resolve it again on the next update.
stopFollowingMarker()
return
}
guard annotation !== followedAnnotation else { return }
startFollowingMarker(annotation, in: mapView)
}
private func startFollowingMarker(_ annotation: LocationAnnotation, in mapView: MLNMapView) {
followedAnnotation = annotation
followedAnnotationObservation = nil
// Animate to the marker first, attaching the per-frame tracking only on completion
// so that it doesn't cancel the transition.
mapView.setCenter(annotation.coordinate,
zoomLevel: mapView.zoomLevel,
direction: -1, // negative value keeps the current direction
animated: true) { [weak self, weak annotation, weak mapView] in
guard let self, let annotation, let mapView, annotation === followedAnnotation else { return }
// Mirror every animated coordinate update to keep the camera as smooth as the marker.
followedAnnotationObservation = annotation.observe(\.coordinate) { [weak mapView] annotation, _ in
// The coordinate is only ever updated by SwiftUI so KVO fires on the main actor.
MainActor.assumeIsolated {
mapView?.setCenter(annotation.coordinate, animated: false)
}
}
}
}
private func stopFollowingMarker() {
followedAnnotation = nil
followedAnnotationObservation = nil
}
// MARK: - MLNMapViewDelegate
func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
guard let annotation = annotation as? LocationAnnotation else {
return nil
}
return LocationAnnotationView(annotation: annotation, mediaProvider: mapLibreView.mediaProvider)
}
func mapViewDidFailLoadingMap(_ mapView: MLNMapView, withError error: Error) {
if mapLibreView.error != .failedLoadingMap {
mapLibreView.error = .failedLoadingMap
}
}
func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) {
guard let userLocation else { return }
mapLibreView.hasLoadedUserLocation = true
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
}
}
previousUserLocation = userLocation
updateGeolocationUncertainty(location: userLocation)
}
func mapView(_ mapView: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) {
switch manager.authorizationStatus {
case .denied, .restricted:
mapLibreView.isLocationAuthorized = false
case .authorizedAlways, .authorizedWhenInUse:
mapLibreView.isLocationAuthorized = true
case .notDetermined:
mapLibreView.isLocationAuthorized = nil
@unknown default:
break
}
}
func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
// While following a marker, don't flood the center binding with per-frame camera updates.
// Leaving `lastReportedCenter` untouched avoids an external re-centering when following stops.
guard mapLibreView.showsUserLocationMode.followedMarkerID == nil else { return }
// Avoid `Publishing changes from within view update` warnings
DispatchQueue.main.async { [mapLibreView, weak self] in
let center = mapView.centerCoordinate
self?.lastReportedCenter = center
mapLibreView.mapCenterCoordinate = center
}
}
func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool {
// Send userDidPan only for gestures that actually change the map center. The reason
// is an option set and gestures can come combined with other reasons, so check for
// containment instead of an exact match.
let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate]
if !reason.isDisjoint(with: centerChangingGestures) {
// Stop following immediately, the camera would fight the gesture otherwise.
stopFollowingMarker()
mapLibreView.userDidPan?()
}
return true
}
// MARK: Callout
func mapView(_ mapView: MLNMapView, annotationCanShowCallout annotation: MLNAnnotation) -> Bool {
false
}
// MARK: Private
private func updateGeolocationUncertainty(location: MLNUserLocation) {
guard let clLocation = location.location, clLocation.horizontalAccuracy >= 0 else {
mapLibreView.geolocationUncertainty = nil
return
}
mapLibreView.geolocationUncertainty = clLocation.horizontalAccuracy
}
}
}
// MARK: - MLNMapView convenient methods
private extension MapTilerStyle {
init(_ colorScheme: ColorScheme) {
switch colorScheme {
case .light:
self = .light
case .dark:
self = .dark
@unknown default:
fatalError()
}
return .init(zoomLevel: options.zoomLevel,
initialZoomLevel: options.initialZoomLevel,
mapCenter: options.mapCenter,
annotations: annotations.map { .init(id: $0.id, coordinate: $0.coordinate) },
styleURL: { mapURLBuilder.interactiveMapURL(for: $0 ? .dark : .light) },
tintColor: .compound.iconAccentPrimary,
markerView: { annotationID in
guard let kind = annotations.first(where: { $0.id == annotationID })?.kind else { return nil }
return AnyView(LocationMarkerView(kind: kind, mediaProvider: mediaProvider))
},
showsUserLocationMode: $showsUserLocationMode,
error: $error,
mapCenterCoordinate: $mapCenterCoordinate,
hasLoadedUserLocation: $hasLoadedUserLocation,
isLocationAuthorized: $isLocationAuthorized,
geolocationUncertainty: $geolocationUncertainty,
userDidPan: userDidPan)
}
}