Open feelingsonice opened 1 month ago
Hi, thank you for the report. Can you please share your use-case - do you animate just from A to B linearly, or do animate along the path?
The support of withAnimation
potentially can solve the former case, but not the latter. So currently such animations are not supported out of the box.
I've created an internal ticket for further investigation https://mapbox.atlassian.net/browse/MAPSIOS-1510
As a workaround, you can implement them similarly to the UIKit example:
import SwiftUI
import MapboxMaps
@Observable
class AnimatedCoordinate {
private(set) var value: CLLocationCoordinate2D
private var animator: Animator?
init(initial value: CLLocationCoordinate2D) {
self.value = value
}
func animate(to coordinate: CLLocationCoordinate2D, duration: TimeInterval) {
animator = Animator(duration: duration, start: value, end: coordinate, handler: { [weak self] in
self?.value = $0
}, onEnd: { [weak self] in
self?.animator = nil
})
}
}
private class Animator {
private let startTime = CACurrentMediaTime()
private let link = Link()
private let duration: TimeInterval
private let handler: (CLLocationCoordinate2D) -> Void
private let endHandler: () -> Void
private let start: CLLocationCoordinate2D
private let end: CLLocationCoordinate2D
private let distance: LocationDistance
private class Link: NSObject {
private var impl: CADisplayLink?
var callback: (() -> Void)?
override init() {
super.init()
impl = CADisplayLink(target: self, selector: #selector(animateNextStep))
impl!.add(to: .main, forMode: .default)
}
@objc func animateNextStep() {
callback?()
}
func invalidate() {
impl?.invalidate()
impl = nil
}
deinit {
invalidate()
}
}
init(duration: TimeInterval, start: CLLocationCoordinate2D, end: CLLocationCoordinate2D, handler: @escaping (CLLocationCoordinate2D) -> Void, onEnd: @escaping () -> Void) {
self.start = start
self.end = end
self.distance = start.distance(to: end)
self.handler = handler
self.endHandler = onEnd
self.duration = duration
link.callback = { [weak self] in
self?.animateNextStep()
}
}
private func animateNextStep() {
let progress = (CACurrentMediaTime() - startTime) / duration
let currentDistanceOffset = distance * min(progress, 1)
if let coord = LineString([start, end]).coordinateFromStart(distance: currentDistanceOffset) {
handler(coord)
}
if progress >= 1 {
link.invalidate()
endHandler()
}
}
}
Usage:
struct MyMap: View {
@State private var coordinate = AnimatedCoordinate(initial: .helsinki)
var body: some View {
Map {
MapViewAnnotation(coordinate: coordinate.value) {
Text("🚀")
}
}
.onMapTapGesture { ctx in
coordinate.animate(to: ctx.coordinate, duration: 2)
}
}
}
@persidskiy thanks for the work around. This is helpful.
@persidskiy coming back to this after some testing. I'm seeing the MapViewAnnotation
stutter when animated this way. The stuttering happens mostly when the viewport has to transition to another location over a large distance, or when anything has to travel over a large distance in general.
For more context, I'm want to animate moving MapViewAnnotation
because Puck2D
don't support any custom views and I want an annotation that can follow a changing location.
Here's my implementation, it's not the same as what you have but mechanistically the same:
@_spi(Experimental) import MapboxMaps
import SwiftUI
import UIKit
import Combine
@MainActor
extension CADisplayLink {
static func durations() -> AsyncStream<CFTimeInterval> {
AsyncStream { continuation in
let displayLink = DisplayLink { displayLink in
continuation.yield(displayLink.targetTimestamp - displayLink.timestamp)
}
continuation.onTermination = { _ in
Task { await displayLink.stop() }
}
}
}
@MainActor
private class DisplayLink: NSObject {
private var displayLink: CADisplayLink!
private let handler: (CADisplayLink) -> Void
init(mode: RunLoop.Mode = .default, handler: @escaping (CADisplayLink) -> Void) {
self.handler = handler
super.init()
displayLink = CADisplayLink(target: self, selector: #selector(handle(displayLink:)))
displayLink.add(to: .main, forMode: mode)
}
func stop() {
displayLink.invalidate()
}
@objc func handle(displayLink: CADisplayLink) {
handler(displayLink)
}
}
}
struct MapView: View {
@State private var viewport = Viewport.followPuck(zoom: 18, bearing: .heading).padding(.top, 400)
@State private var userLocationViewModel = UserLocationViewModel()
var body: some View {
MapReader { proxy in
Map(viewport: $viewport) {
if let userLocation = userLocationViewModel.curAnimatedLoc {
MapViewAnnotation(coordinate: userLocation) {
// Custom view
}
.allowOverlap(true)
.allowOverlapWithPuck(true)
.ignoreCameraPadding(true)
}
}
.presentsWithTransaction(true)
.onStyleLoaded { [unowned userLocationViewModel] _ in
guard let locationManager = proxy.location else { fatalError("locationManager not found") }
locationManager.onLocationChange.observe { [unowned userLocationViewModel] locations in
userLocationViewModel.setTargetLocation(locations.last!.coordinate)
}.store(in: &userLocationViewModel.cancellables)
}
.ignoresSafeArea()
.task {
for await duration in CADisplayLink.durations() {
userLocationViewModel.animateOneFrame(duration: duration)
}
}
}
}
@Observable
class UserLocationViewModel {
var curAnimatedLoc: CLLocationCoordinate2D?
@ObservationIgnored private var latitudeRateOfChange: Double = 0.0
@ObservationIgnored private var longitudeRateOfChange: Double = 0.0
@ObservationIgnored private var progress: CFTimeInterval = 0.0
@ObservationIgnored var cancellables = Set<AnyCancellable>()
func setTargetLocation(_ loc: CLLocationCoordinate2D) {
guard let cur = curAnimatedLoc else {
curAnimatedLoc = loc
return
}
// Assuming that the average location update frequency is once per sec
latitudeRateOfChange = loc.latitude - cur.latitude
longitudeRateOfChange = loc.longitude - cur.longitude
progress = 0
}
func animateOneFrame(duration: CFTimeInterval) {
guard progress < 1, curAnimatedLoc != nil else {
return
}
curAnimatedLoc?.latitude += latitudeRateOfChange * duration
curAnimatedLoc?.longitude += longitudeRateOfChange * duration
progress += duration
}
}
}
Do you foresee any fix to it?
Hey @persidskiy, any updates here?
Observed behavior and steps to reproduce
Changing the
coordinate
of aMapViewAnnotation
viawithAnimation
causes the view to re-appear instead of moving to the new location.Expected behavior
The annotation should move to the new location.
Notes / preliminary analysis
Seems to have been possible in UIKit examples but the behavior seems different in SwiftUI.