mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 09:42:39 +00:00
a520d94760
* Enable Swift 6.2 with default main actor isolation for the app target Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default). # Conflicts: # ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved * Update the code generation templates for Swift 6 Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings. # Conflicts: # ElementX/Sources/Mocks/Generated/GeneratedMocks.swift * Make the data layer and services nonisolated and Sendable Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation. * Adopt the new isolation across call sites, mocks and signatures Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms. ^ Conflicts: ^ ElementX/Sources/Services/SecureBackup/SecureBackupController.swift * Remove redundant @MainActor annotations With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required. * Fix concurrency hotspots surfaced by the migration Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs. * Fix the unit tests for Swift 6 concurrency Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures. * offload diffs on concurrent threads instead of main actor * make room info protocols sendable * use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview. * use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor. * small pr comments issues being addressed * update enterprise * Update StaticCode to 1.1.0 and specify the required Swift isolation. * fix ElementCallServiceTests * update enterprise * re-generate mocks * update enterprise * enterprise new code conformed to swift 6 --------- Co-authored-by: Doug <douglase@element.io>
350 lines
15 KiB
Swift
350 lines
15 KiB
Swift
//
|
|
// Copyright 2025 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 SwiftUI
|
|
|
|
struct MapLibreMapView: UIViewRepresentable {
|
|
struct Options {
|
|
/// the final zoom level used when the first user location emit
|
|
let zoomLevel: Double
|
|
/// The initial zoom level used when the map it firstly loaded and the user location is not yet available, in case of annotations this property is not being used
|
|
let initialZoomLevel: Double
|
|
|
|
/// The initial map center
|
|
let mapCenter: CLLocationCoordinate2D
|
|
|
|
/// Map annotations
|
|
let annotations: [LocationAnnotation]
|
|
|
|
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
|
|
self.zoomLevel = zoomLevel
|
|
self.initialZoomLevel = initialZoomLevel
|
|
self.mapCenter = mapCenter
|
|
self.annotations = annotations
|
|
}
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let mapURLBuilder: MapTilerURLBuilderProtocol
|
|
|
|
let options: Options
|
|
|
|
let mediaProvider: MediaProviderProtocol?
|
|
|
|
/// Behavior mode of the current user's location, can be hidden, only shown and shown following the user
|
|
@Binding var showsUserLocationMode: ShowUserLocationMode
|
|
/// Bind view errors if any
|
|
@Binding var error: MapLibreError?
|
|
/// Coordinate of the center of the map
|
|
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
|
|
@Binding var hasLoadedUserLocation: Bool
|
|
@Binding var isLocationAuthorized: Bool?
|
|
/// The radius of uncertainty for the location, measured in meters.
|
|
@Binding var geolocationUncertainty: CLLocationAccuracy?
|
|
|
|
/// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|