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)
}
}
}
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.