From 6a77a04ed724aa0a3fe66755d47287307890887b Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Fri, 17 Apr 2026 17:27:52 +0200 Subject: [PATCH] implement annotation animations in MapLibreMapView --- ElementX.xcodeproj/project.pbxproj | 4 + .../Other/MapLibre/CoordinateAnimator.swift | 126 ++++++++++++++++++ .../Other/MapLibre/LocationAnnotation.swift | 3 +- .../Other/MapLibre/MapLibreMapView.swift | 4 +- 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index be410a996..4cf84fb8c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -679,6 +679,7 @@ 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; 7366E5783D1871D42CF99D34 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; }; 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; }; + 73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -3013,6 +3014,7 @@ FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = ""; }; FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = ""; }; FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreviewsDictionary.swift; sourceTree = ""; }; + FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateAnimator.swift; sourceTree = ""; }; FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = ""; }; FABAC5C4373B0EC24D399663 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/SAS.strings"; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; @@ -6263,6 +6265,7 @@ C17C3586C93F3A314C1CC318 /* MapLibre */ = { isa = PBXGroup; children = ( + FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */, AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */, 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */, B81B6170DB690013CEB646F4 /* MapLibreModels.swift */, @@ -8227,6 +8230,7 @@ A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */, EA6613B29BA671F39CE1B1D2 /* ConfirmationDialog.swift in Sources */, AC7AA215D60FBC307F984028 /* Consumable.swift in Sources */, + 73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */, C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */, 633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */, CE8296D4AD30DDC6D0C67A74 /* CreateRoomScreen.swift in Sources */, diff --git a/ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift b/ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift new file mode 100644 index 000000000..06e27b335 --- /dev/null +++ b/ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift @@ -0,0 +1,126 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import CoreLocation +import QuartzCore + +/// Smoothly animates a `LocationAnnotation`'s coordinate from its current +/// position to a new one using a `CADisplayLink` for frame-accurate updates. +/// +/// ## How it works +/// +/// MapLibre repositions annotation views whenever it observes a KVO change on +/// the annotation's `coordinate` property (which must be `@objc dynamic`). +/// Rather than setting the destination coordinate in one step— which would +/// cause the pin to jump — we interpolate between the start and end coordinates +/// over a configurable duration, updating the coordinate on every display frame. +/// Because MapLibre handles the actual view positioning through KVO, the +/// annotation moves correctly even while the user pans or zooms the map, +/// and even if the annotation is currently off-screen. +/// +/// Only one animation runs per annotation at a time; starting a new animation +/// cancels the previous one and begins from the annotation's current position. +final class CoordinateAnimator { + private var displayLink: CADisplayLink? + private weak var annotation: LocationAnnotation? + private let startCoordinate: CLLocationCoordinate2D + private let endCoordinate: CLLocationCoordinate2D + private let duration: CFTimeInterval + private var startTime: CFTimeInterval? + + /// Keeps a strong reference to each active animator so it isn't deallocated + /// while the display link is running. Keyed by annotation ID. + private static var activeAnimators: [String: CoordinateAnimator] = [:] + + private init(annotation: LocationAnnotation, + to end: CLLocationCoordinate2D, + duration: CFTimeInterval) { + self.annotation = annotation + startCoordinate = annotation.coordinate + endCoordinate = end + self.duration = duration + } + + /// Starts animating the annotation's coordinate to `end` over `duration` seconds. + /// If the annotation is already being animated, the in-flight animation is + /// cancelled and a new one starts from the current position. + static func animate(annotation: LocationAnnotation, + to end: CLLocationCoordinate2D, + duration: CFTimeInterval) { + guard annotation.coordinate.latitude != end.latitude + || annotation.coordinate.longitude != end.longitude else { + return + } + + // Cancel any in-flight animation for this annotation + activeAnimators[annotation.id]?.stop() + + let animator = CoordinateAnimator(annotation: annotation, to: end, duration: duration) + activeAnimators[annotation.id] = animator + animator.start() + } + + // MARK: - Private + + private func start() { + let displayLink = CADisplayLink(target: self, selector: #selector(tick)) + // Using .common so the animation keeps running during scroll tracking + displayLink.add(to: .main, forMode: .common) + self.displayLink = displayLink + } + + private func stop() { + displayLink?.invalidate() + displayLink = nil + if let annotation { + Self.activeAnimators.removeValue(forKey: annotation.id) + } + } + + /// Called on every display frame. Linearly interpolates between the start + /// and end coordinates using an ease-in-out curve, then updates the + /// annotation's coordinate to trigger MapLibre's KVO repositioning. + @objc private func tick(_ displayLink: CADisplayLink) { + guard let annotation else { + stop() + return + } + + if startTime == nil { + startTime = displayLink.timestamp + } + + let elapsed = displayLink.timestamp - (startTime ?? displayLink.timestamp) + let linearProgress = min(elapsed / duration, 1.0) + let easedProgress = quadraticEaseInOut(linearProgress) + + let latitude = startCoordinate.latitude + (endCoordinate.latitude - startCoordinate.latitude) * easedProgress + let longitude = startCoordinate.longitude + (endCoordinate.longitude - startCoordinate.longitude) * easedProgress + annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + + if linearProgress >= 1.0 { + stop() + } + } + + /// Quadratic ease-in-out: accelerates during the first half, decelerates + /// during the second half. + /// + /// - Parameter t: Linear progress in the range `0...1`. + /// - Returns: Eased progress in the range `0...1`. + private func quadraticEaseInOut(_ t: Double) -> Double { + if t < 0.5 { + // Ease-in: parabolic acceleration from 0 to the midpoint + return 2 * t * t + } else { + // Ease-out: parabolic deceleration from the midpoint to 1 + // Equivalent to: 1 - 2 * (1 - t)^2 + let shifted = t - 1 + return 1 - 2 * shifted * shifted + } + } +} diff --git a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift index c66e62d82..72245d2a6 100644 --- a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift +++ b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift @@ -13,7 +13,8 @@ import SwiftUI final class LocationAnnotation: NSObject, MLNAnnotation, Identifiable { let id: String - var coordinate: CLLocationCoordinate2D + // @objc dynamic is required to make animations work + @objc dynamic var coordinate: CLLocationCoordinate2D var kind: LocationMarkerKind // MARK: - Setup diff --git a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift index 980c41dc2..1431635c4 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift @@ -129,7 +129,9 @@ struct MapLibreMapView: UIViewRepresentable { let updatedAnnotation = updatedByID[id] else { continue } - existingAnnotation.coordinate = updatedAnnotation.coordinate + 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) }