mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
d595e4fd83
Continue grouping the standalone modules under Components. The MapLibre frameworks were nested inside the app folder even though they are separate targets, so move them alongside the other components and update the XcodeGen includes.
127 lines
5.1 KiB
Swift
127 lines
5.1 KiB
Swift
//
|
|
// 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 `MapLibreAnnotation`'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: MapLibreAnnotation?
|
|
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: MapLibreAnnotation,
|
|
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: MapLibreAnnotation,
|
|
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
|
|
}
|
|
}
|
|
}
|