mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
implement annotation animations in MapLibreMapView
This commit is contained in:
@@ -679,6 +679,7 @@
|
|||||||
733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; };
|
733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; };
|
||||||
7366E5783D1871D42CF99D34 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; };
|
7366E5783D1871D42CF99D34 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; };
|
||||||
738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.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 */; };
|
73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; };
|
||||||
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; };
|
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; };
|
||||||
7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.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 = "<group>"; };
|
FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = "<group>"; };
|
||||||
FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = "<group>"; };
|
FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = "<group>"; };
|
||||||
FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreviewsDictionary.swift; sourceTree = "<group>"; };
|
FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreviewsDictionary.swift; sourceTree = "<group>"; };
|
||||||
|
FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateAnimator.swift; sourceTree = "<group>"; };
|
||||||
FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||||
FABAC5C4373B0EC24D399663 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/SAS.strings"; sourceTree = "<group>"; };
|
FABAC5C4373B0EC24D399663 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/SAS.strings"; sourceTree = "<group>"; };
|
||||||
FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = "<group>"; };
|
FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -6263,6 +6265,7 @@
|
|||||||
C17C3586C93F3A314C1CC318 /* MapLibre */ = {
|
C17C3586C93F3A314C1CC318 /* MapLibre */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */,
|
||||||
AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */,
|
AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */,
|
||||||
622D09D4ECE759189009AEAF /* MapLibreMapView.swift */,
|
622D09D4ECE759189009AEAF /* MapLibreMapView.swift */,
|
||||||
B81B6170DB690013CEB646F4 /* MapLibreModels.swift */,
|
B81B6170DB690013CEB646F4 /* MapLibreModels.swift */,
|
||||||
@@ -8227,6 +8230,7 @@
|
|||||||
A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */,
|
A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */,
|
||||||
EA6613B29BA671F39CE1B1D2 /* ConfirmationDialog.swift in Sources */,
|
EA6613B29BA671F39CE1B1D2 /* ConfirmationDialog.swift in Sources */,
|
||||||
AC7AA215D60FBC307F984028 /* Consumable.swift in Sources */,
|
AC7AA215D60FBC307F984028 /* Consumable.swift in Sources */,
|
||||||
|
73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */,
|
||||||
C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */,
|
C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */,
|
||||||
633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */,
|
633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */,
|
||||||
CE8296D4AD30DDC6D0C67A74 /* CreateRoomScreen.swift in Sources */,
|
CE8296D4AD30DDC6D0C67A74 /* CreateRoomScreen.swift in Sources */,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import SwiftUI
|
|||||||
|
|
||||||
final class LocationAnnotation: NSObject, MLNAnnotation, Identifiable {
|
final class LocationAnnotation: NSObject, MLNAnnotation, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
var coordinate: CLLocationCoordinate2D
|
// @objc dynamic is required to make animations work
|
||||||
|
@objc dynamic var coordinate: CLLocationCoordinate2D
|
||||||
var kind: LocationMarkerKind
|
var kind: LocationMarkerKind
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ struct MapLibreMapView: UIViewRepresentable {
|
|||||||
let updatedAnnotation = updatedByID[id] else {
|
let updatedAnnotation = updatedByID[id] else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
existingAnnotation.coordinate = updatedAnnotation.coordinate
|
CoordinateAnimator.animate(annotation: existingAnnotation,
|
||||||
|
to: updatedAnnotation.coordinate,
|
||||||
|
duration: 1.0)
|
||||||
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
|
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
|
||||||
annotationView.updateContent(with: updatedAnnotation.kind, mediaProvider: mediaProvider)
|
annotationView.updateContent(with: updatedAnnotation.kind, mediaProvider: mediaProvider)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user