swiftanimationswiftuiannotationsmapkit

Animate Map Annotation Swift UI


I am trying to animate map annotations when they appear with an interactive spring. But currently no animation takes place. The actual annotation label does not accept any view modifiers like .animation or .transition so I have them positioned on my custom label instead. How can I animate the label with a scale up combined with a spring like motion?

struct LocationsView: View {
    @EnvironmentObject private var vm: LocationsViewModel
    @State var showStories: Bool = false
    
    var body: some View {
        ZStack {
            mapLayer.ignoresSafeArea()
        }
    }
    
    private var mapLayer: some View {
        MapReader { mapProxy in
            Map(position: $vm.mapCameraPosition) {
              if showStories {
                ForEach(vm.locations) { location in
                    Annotation(
                        location.shouldShowName ? location.name : "",
                        coordinate: location.coordinates) {
                            Circle().foregroundStyle(.red).frame(width: 50, height: 50)
                                 .transition(.scale.combined(with: .identity))
                                 .animation(.interpolatingSpring(stiffness: 0.5, damping: 0.5), value: showStories)
                        }
                }
              }
            }
            .onTapGesture {
                showStories.toggle()
            }
            .onMapCameraChange(frequency: .continuous) { context in
                guard let center = mapProxy.convert(context.region.center, to: .local) else { return }
                for i in vm.locations.indices {
                    if let point = mapProxy.convert(vm.locations[i].coordinates, to: .local) {
                        vm.locations[i].shouldShowName = abs(point.x - center.x) < 250 && abs(point.y - center.y) < 250
                    } else {
                        vm.locations[i].shouldShowName = false
                    }
                }
            }
        }
    }
}

class LocationsViewModel: ObservableObject {
    @Published var locations: [LocationMap]    // All loaded locations
    @Published var mapLocation: LocationMap {    // Current location on map
        didSet {
            updateMapRegion(location: mapLocation)
        }
    }
    @Published var mapCameraPosition = MapCameraPosition.automatic
    let mapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
    
    init() {
        let locations = LocationsDataService.locations
        self.locations = locations
        self.mapLocation = locations.first!
        
        self.updateMapRegion(location: locations.first!)
    }
    private func updateMapRegion(location: LocationMap) {
        withAnimation(.easeInOut) {
            mapCameraPosition = .region(MKCoordinateRegion(
                center: location.coordinates,
                span: mapSpan))
        }
    }
    func showNextLocation(location: LocationMap) {
        withAnimation(.easeInOut) {
            mapLocation = location
        }
    }
}

struct LocationMap: Identifiable {
    let id: String = UUID().uuidString
    let name: String
    let cityName: String
    let coordinates: CLLocationCoordinate2D
    var shouldShowName = false
}

class LocationsDataService {
    static let locations: [LocationMap] = [
        LocationMap(name: "Colosseum", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8902, longitude: 12.4922)),
        LocationMap(name: "Pantheon", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8986, longitude: 12.4769)),
        LocationMap(name: "Trevi Fountain", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.9009, longitude: 12.4833))
    ]
}

struct SwiftfulMapAppApp: View {
    @StateObject private var vm = LocationsViewModel()
    var body: some View {
        VStack {
            LocationsView().environmentObject(vm)
        }
    }
}

Solution

  • The appearance and disappearance of an Annotation cannot be animated. You should animate the view instead.

    Put the if around the Circle, not the Annotation:

    Map(position: $vm.mapCameraPosition) {
        ForEach(vm.locations) { location in
            Annotation(
                location.shouldShowName && showStories ? location.name : "",
                coordinate: location.coordinates
            ) {
                // this ZStack here so that the we have somewhere to put the .animation modifier
                ZStack {
                    if showStories {
                        Circle().foregroundStyle(.red)
                            .transition(.scale)
                    }
                }
                .frame(width: 50, height: 50)
                 // .animation can't go on the Circle because it will disappear
                .animation(.bouncy, value: showStories)
            }
    
        }
    }
    

    If you only want to animate the appearance of annotations but not the disappearance, you can keep the if around the annotations, and write a custom view that animates itself onAppear.

    Map(position: $vm.mapCameraPosition) {
        if showStories {
            ForEach(vm.locations) { location in
                Annotation(
                    location.shouldShowName ? location.name : "",
                    coordinate: location.coordinates) {
                        AnnotationCircle()
                    }
            }
        }
    }
    
    struct AnnotationCircle: View {
        @State var scale = 0.0
        var body: some View {
            Circle()
                .foregroundStyle(.red)
                .transition(.scale)
                .frame(width: 50, height: 50)
                .scaleEffect(scale)
                .animation(.bouncy, value: scale)
                .onAppear {
                    scale = 1.0
                }
                .onDisappear {
                    scale = 0.0
                }
        }
    }
    

    The disappearance won't be animated, because the annotation is removed immediately, before the view can animate anything.