mapbox / mapbox-maps-ios

Interactive, thoroughly customizable maps for iOS powered by vector tiles and Metal
https://www.mapbox.com/mapbox-mobile-sdk
Other
462 stars 149 forks source link

Can't animate moving `MapViewAnnotation` #2213

Open feelingsonice opened 1 month ago

feelingsonice commented 1 month ago

Observed behavior and steps to reproduce

Changing the coordinate of a MapViewAnnotation via withAnimation causes the view to re-appear instead of moving to the new location.

import SwiftUI
@_spi(Experimental) import MapboxMaps

struct MapboxMapView: View {
    private let position = Viewport.camera(
        center: .londonA,
        zoom: 16
    )
    @State var curLocation: CLLocationCoordinate2D = .londonA

    var body: some View {
        Map(initialViewport: position) {
            MapViewAnnotation(coordinate: curLocation) {
                Circle()
            }
        }
        .ignoresSafeArea()
        .overlay(alignment: . bottomTrailing) {
            Button("Change location") {
                withAnimation {
                    curLocation = .londonB // SHOULD CAUSE THE ANNOTATION TO MOVE, NOT RE-APPEAR
                }
            }
        }
    }
}

extension CLLocationCoordinate2D {
    static let londonA = CLLocationCoordinate2D(
        latitude: 51.507222,
        longitude: -0.1275
    )

    static let londonB = CLLocationCoordinate2D(
        latitude: 51.508222,
        longitude: -0.1276
    )
}

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.

persidskiy commented 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)
        }
    }
}
feelingsonice commented 1 month ago

@persidskiy thanks for the work around. This is helpful.

feelingsonice commented 1 month ago

@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?

feelingsonice commented 3 weeks ago

Hey @persidskiy, any updates here?