How to optimize SwiftUI Map drag performance when many annotaitons showing under macOS? - macos

This is really a curious problem for SwiftUI Map under macOS.
The code is definitely the same both on macOS & iOS/iPadOS, if we add tens of annotations on the map, then drag around, it's very smooth on the iOS/iPadOS, but feeling stuck, stuck and stuck on the macOS.
Is there any special coding thing we forget to setting on macOS for SwiftUI map? It's really confusion for the same code but different performance consequences...
Here is the full project files you may download from OneDrive:
https://1drv.ms/u/s!Ank9gPMc1GKYk_UlZ2V3BC_BArMofQ?e=Nsco9k
Below is the code you can check to:
import SwiftUI
import MapKit
struct ContentView: View {
#State var annotationTrackList = [TyphoonMKAnnotation]()
#State var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 31.7, longitude: 118.4),
latitudinalMeters: 100_000,
longitudinalMeters: 100_000)
var body: some View {
ZStack {
Map(coordinateRegion: $region, annotationItems: annotationTrackList) { item in
MapAnnotation(coordinate: item.coordinate) {
ZStack(alignment: .center) {
//color
Image(systemName: "bubble.middle.bottom.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(item.color)
.aspectRatio(contentMode: .fit)
//icon
Image(systemName: "tornado")
.resizable()
.frame(width: 16, height: 16)
.aspectRatio(contentMode: .fit)
.foregroundColor(.white)
}
//location title
Text(item.title ?? "")
.font(Font.system(size: 11, weight: .semibold))
.foregroundColor(.white)
}
}
}
.ignoresSafeArea()
.onAppear {
initialValues()
}
}
private func initialValues() {
var annotationList = [TyphoonMKAnnotation]()
let jsonData = TyphoonJsonData()
//forecast
let forecastJson = jsonData.forecast()
guard
let forecastList = forecastJson["forecast"].array
else { return }
for item in forecastList {
let type = item["type"].stringValue
let latitude = item["lat"].stringValue
let longitude = item["lon"].stringValue
let coordinate = CLLocationCoordinate2D(latitude: Double(latitude)!, longitude: Double(longitude)!)
let annotation = TyphoonMKAnnotation(coordinate: coordinate)
annotation.title = type
annotation.color = .pink
annotationList.append(annotation)
}
//history
let trackJson = jsonData.track()
guard
let trackList = trackJson["track"].array
else { return }
for item in trackList {
let type = item["type"].stringValue
let latitude = item["lat"].stringValue
let longitude = item["lon"].stringValue
let coordinate = CLLocationCoordinate2D(latitude: Double(latitude)!, longitude: Double(longitude)!)
let annotation = TyphoonMKAnnotation(coordinate: coordinate)
annotation.title = type
annotation.color = .green
annotationList.append(annotation)
}
self.annotationTrackList = annotationList
}
}
class TyphoonMKAnnotation: Identifiable {
let id = UUID()
var coordinate: CLLocationCoordinate2D
var title: String?
var subtitle: String?
var index: Int?
var color: Color?
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
}

Related

Tracking scroll position in a List SwiftUI

So in the past I have been tracking the scroll position using a scroll view but I've fallen into a situation where I need to track the position using a List. I am using a List because I want some of the built in real estate to create my views such as the default List styles.
I can get the value using PreferenceKeys, but the issue is when I scroll to far upwards, the PreferenceKey value will default back to its position 0, breaking my show shy header view logic.
This is the TrackableListView code
struct TrackableListView<Content: View>: View {
let offsetChanged: (CGPoint) -> Void
let content: Content
init(offsetChanged: #escaping (CGPoint) -> Void = { _ in }, #ViewBuilder content: () -> Content) {
self.offsetChanged = offsetChanged
self.content = content()
}
var body: some View {
List {
GeometryReader { geometry in
Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("ListView")).origin)
}
.frame(width: 0, height: 0)
content
.offset(y: -10)
}
.coordinateSpace(name: "ListView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
And this is my ContentView:
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
#State private var offsetPositionValue: CGFloat = 0
#State private var isShyHeaderVisible = false
var body: some View {
NavigationView {
ZStack {
TrackableListView { offset in
withAnimation {
contentOffset = offset.y
}
} content: {
Text("\(contentOffset)")
}
.overlay(
ZStack {
HStack {
Text("Total points")
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("20,000 pts")
.foregroundColor(.white)
.padding(.leading, 50)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width)
.background(Color.green)
.offset(y: contentOffset < 50 ? 0 : -5)
.opacity(contentOffset < 50 ? 1 : 0)
.transition(.move(edge: .top))
}
.frame(maxHeight: .infinity, alignment: .top)
)
}
.navigationTitle("Hello")
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarTitleDisplayMode(.inline)
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
}
}
}
The issue was that Other Views in my hierarchy (probably introduced by SwiftUI) could be sending the default value, which is why you start getting zero sometimes.
What I needed was a way to determine when to use or forward the new value, versus when to ignore it.
To do this I had to make my value optional GCPoint, with a nil default value, then in your reduce method when using PreferenceKeys you have to do:
if let nextValue = nextValue() {
value = nextValue
}
Then make sure your CGPoint is an optional value.

swiftui freeze when scroll too fast in scrollview with lazyvgrid

I have a widget list in scrollview, each row have 3 elements. Some elements will show a stopwatch by SwiftUI Text timer. The Text timer will increase by 1 every second. Every thing work well at first, but If I scroll the list too fast, the Text timer will be freeze, won't update anymore, unless scroll the list again.
Bellow recording is my screen recording at iPhone 12 Pro Max. For the first time, slowly scroll from the "Category 2" position to the top, and the stopwatch refreshes normally. For the second and third times, quickly scroll to the top, and the stopwatch stops and never refreshes.
Here is my code:
import SwiftUI
extension Date {
func formatDate(_ format: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = format
return formatter.string(from: self)
}
func getZeroData() -> Date {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
var startDateStr = self.formatDate("yyyy-MM-dd")
startDateStr = "\(startDateStr) 00:00:00"
return dateFormatter.date(from: startDateStr) ?? Date()
}
}
struct SectionHeaderView: View {
let title: String
var body: some View {
HStack() {
Text(title)
.font(.system(size: 19.0))
.fontWeight(.bold)
Spacer()
}
.padding(.leading, 10.0)
.frame(height: 44.0)
}
}
struct CellView: View {
let date: Date
let categoryIndex: Int
let rowIndex: Int
init(date: Date, categoryIndex: Int, rowIndex: Int) {
self.date = date
self.categoryIndex = categoryIndex
self.rowIndex = rowIndex
//print("CellView init is \(self.categoryIndex)->\(self.rowIndex)")
}
var body: some View {
print("CellView show is \(self.categoryIndex)->\(self.rowIndex)")
// 1. cellview has a very complex layout, so I need to use GeometryReader to get size for inner complex component.
// 2. In order to show the problem conveniently, only the test code is shown here, but the problem in the video above can also be reproduced
return GeometryReader { geo in
VStack() {
Text("\(categoryIndex) -> \(rowIndex)")
if((self.categoryIndex==0 || self.categoryIndex==2) && self.rowIndex<=4) {
Text(date.getZeroData(), style: .timer)
Text(date.formatDate("HH:mm:ss"))
}
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.gray.opacity(0.2))
}
}
}
struct ContentView: View {
let date: Date = Date()
let widgetGap: CGFloat = 10.0
let widgetWidth: CGFloat
let widgetHeight: CGFloat
let columns: [GridItem]
//let columns: [GridItem] = Array(repeating: .init(.fixed(UIScreen.main.bounds.width/3.0), spacing: 0), count: 3)
init() {
let col: Int = 3
widgetWidth = (UIScreen.main.bounds.width - widgetGap * CGFloat(col + 1))/CGFloat(col)
//let widgetSize = CGSize(width: 160, height: 160)
widgetHeight = widgetWidth
columns = Array(repeating: .init(.fixed(widgetWidth), spacing: widgetGap), count: col)
}
var body: some View {
print("ContentView show")
return ScrollView() {
ForEach(0 ..< 6, id: \.self) { categoryIndex in
Section(header: SectionHeaderView(title: "Category \(categoryIndex)") ){
LazyVGrid(columns: columns, spacing: widgetGap + 5.0) {
ForEach(0 ..< 13, id: \.self) { rowIndex in
// 1. cellview has a very complex layout, so I need to use compositingGroup before cornerRadius to get a high accuracy radius
CellView(date: date, categoryIndex: categoryIndex, rowIndex: rowIndex)
.frame(width: widgetWidth, height: widgetHeight, alignment: .center)
.compositingGroup()
.cornerRadius(10)
.shadow(color: .black.opacity(0.05), radius: 6)
}
}
}
}
}
}
}
I also try to use List to replace Scrollview + Lazyvgrid. This situation has improved. But List is hard to custom style, such as remove seperateline , remove padding.
Is there any way to solve this problem?
SOS!!

How do I set a pin on Current Location, use Pin as Geofence, alert user onEnter, then reset all / start over again?

I'm a product designer trying to spin up a location-based reminder concept to validate a pretty early stage idea. I could prototype all of these screens in Figma, but real-time Location and a contextual alert are foundational to my hypothesis, so here I am pulling my hair out trying to accomplish this in SwiftUI 😅
https://www.figma.com/proto/jxYwPbqe4oqsXBbf6CZDDi/Location-Reminder-Spec?page-id=0%3A1&node-id=1%3A189&viewport=161%2C384%2C0.12701110541820526&scaling=scale-down
I've been piecing this together from a variety of tutorials (Ray Wenderlich, Hacking Swift, Apple's Swift UI) and snippets about Location etc. but most of the tutorials have more objects and views and other stuff that I don't need which is complicating my scrappy MVP collage approach.
So far, I've got an MKMapView centered on my the coordinates of the User's Location and I've got my button displayed.
I'm wondering
How to create a CLCircularRegion using the coordinates from user location?
How to place a MKMapAnnotation on the Map using the coordinates from the user location and how to remove the annotation after pressing a button inside the notification?
With the above, I think I'll be able to sort out how to fire the notification and show the notification onEnter.
I've got 4 files in the project so far...
LocationManager.swift
import Foundation
import MapKit
import CoreLocation
class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject {
#Published var region = MKCoordinateRegion()
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locations.last.map{
region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
}
}
extension CLLocation {
var latitude: Double {
return self.coordinate.latitude
}
var longitude: Double {
return self.coordinate.longitude
}
}
ContentView.swift
import CoreLocation
import MapKit
import SwiftUI
struct ContentView: View {
#StateObject var manager = LocationManager()
#State private var locations = [MKPointAnnotation] ()
var body: some View {
VStack {
HStack {
Text("Location Reminder")
.font(.largeTitle)
.bold()
.foregroundColor(.black)
Spacer()
}
.padding()
ZStack (alignment: .bottomTrailing) {
Map(coordinateRegion: $manager.region,
showsUserLocation: true)
.ignoresSafeArea()
ReminderButton()
.padding()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ReminderButton.swift
import Foundation
import SwiftUI
struct ReminderButton: View {
var body: some View {
VStack {
Spacer()
Button(action: {
}) {
HStack {
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: 40, height: 40)
Text("Add a Reminder")
}
.padding()
.background(Color.white)
.cornerRadius(40)
.shadow(color: .gray, radius: 0.2, x: 1, y: 1)
}
}
}
}
I have an empty file that I've named Geofence.swift where I plan on creating a CLCircularRegion and setting up the notifications / alerts onEnter etc.
Appreciate any and all help / advice / suggestions. Also please LMK if there are ways to improve my post and/or if it belongs elsewhere.
import SwiftUI
import MapKit
import CoreLocation
struct SingleLocationView: View {
#StateObject var manager = LocationManager()
#State private var locations = [PinnedLocation] ()
#State var userTrackingMode: MapUserTrackingMode = .follow
let radius: CGFloat = 160 //meters approx 0.1 miles
var body: some View {
VStack {
HStack {
VStack{
Text("Location Reminder")
.font(.largeTitle)
.bold()
.foregroundColor(.black)
}
Spacer()
}
.padding()
ZStack (alignment: .bottomTrailing) {
GeometryReader{ geo in
Map(coordinateRegion: $manager.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $userTrackingMode, annotationItems: locations, annotationContent: { annotation in
MapAnnotation(coordinate: annotation.coordinate, anchorPoint: CGPoint(x: 0.5, y: 0.5), content: {
//Use this vs Circle because your map is a rectangle circumference is an oval
//Maybe a stretched Circle could be a little more accurate
RoundedRectangle(cornerRadius: 25)
.foregroundColor(.white)
.overlay(
Image(systemName: "mappin").foregroundColor(.red))
// The screen vertical and horizontal multiplied by your radius divided Map vertial/horizontal span in meters
.frame(width: (geo.size.width/manager.regionSpanInLongitudeDeltaMeters.value) * radius, height: (geo.size.height/manager.regionSpanInLatitudeDeltaMeters.value) * radius, alignment: .center)
})
})
.ignoresSafeArea()
}
ReminderButton(locations: $locations).environmentObject(manager)
.padding()
}
}
}
}
struct SingleLocationView_Previews: PreviewProvider {
static var previews: some View {
SingleLocationView()
}
}
//You MKAnnotaion is a Protocol you need to create your own class
//MKPointAnnotation is a MKShape for use with MKMapView
class PinnedLocation: NSObject, MKAnnotation, Identifiable{
var title: String?
var subtitle: String?
var coordinate: CLLocationCoordinate2D
init(title: String? = nil, subtitle: String? = nil, coordinate: CLLocationCoordinate2D) {
self.title = title
self.subtitle = subtitle
self.coordinate = coordinate
}
///Get the CLCircularRegion with the given radius
func getCLCircularRegion(radius: CGFloat) -> CLCircularRegion{
CLCircularRegion(center: self.coordinate, radius: radius, identifier: "\(self.id)")
}
}
extension UnitLength{
//This is approx do research on degrees to meters
static let degree = UnitLength(symbol: "degree", converter: UnitConverterLinear(coefficient: 111111))
}
class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject {
#Published var region = MKCoordinateRegion()
//If you do it the way you were doing it get the most current location from here to append to your array
#Published var lastLocation: CLLocation = CLLocation()
///Region gives span in degrees. This is a rough conversion
var regionSpanInLatitudeDeltaMeters: Measurement<UnitLength>{
Measurement(value: region.span.latitudeDelta, unit: UnitLength.degree).converted(to: .meters)
}
///Region gives span in degrees. This is a rough conversion
var regionSpanInLongitudeDeltaMeters: Measurement<UnitLength>{
Measurement(value: region.span.longitudeDelta, unit: UnitLength.degree).converted(to: .meters)
}
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
//Don't need it SwiftUI does this for you
//manager.requestWhenInUseAuthorization()
//manager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locations.last.map{
region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
lastLocation = locations.last!
}
}
extension CLLocationCoordinate2D{
var annotation: PinnedLocation{
PinnedLocation(coordinate: CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude))
}
}
struct ReminderButton: View {
//The button joins your location manager with the locations displayed. It needs access to both
#EnvironmentObject var manager: LocationManager
#Binding var locations: [PinnedLocation]
var body: some View {
VStack {
Spacer()
Button(action: {
//Append the current center/location to your list of annotions
locations.append(manager.region.center.annotation)
}) {
HStack {
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: 40, height: 40)
Text("Add a Reminder")
}
.padding()
.background(Color.white)
.cornerRadius(40)
.shadow(color: .gray, radius: 0.2, x: 1, y: 1)
}
}
}
}

How to change UI state when user touch GoogleMaps' Marker in SwiftUI?

This question is about SwiftUI.
I'm trying to show a map and allow the user to touch any marker available. When it happens, I wish to change a text on my view, reflecting that user's action.
After a lot of search, I think the solution can be somewhere near Observable protocol, but I just can't figure out the right way for doing that. Here's my code:
struct Home: View {
// Here's the attribute I want to be changed when user touches the marker
var selectedMarker: GMSMarker?
var body: some View {
VStack(spacing: 0) {
// Condition to be applied when user touches the marker
if (selectedMarker == nil){
Text("No marker selected").padding()
}else{
Text("Now, there's a marker selected").padding()
}
GoogleMapsHome()
}
.navigationBarBackButtonHidden(true)
.navigationBarTitle(Text("Marker question"), displayMode: .inline)
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
Home()
}
}
Here's the GoogleMaps definition:
struct GoogleMapsHome: UIViewRepresentable {
private let zoom: Float = 18
// Just for didactic purposes. Later, I'm going to use LocationManager
let lat: Double = -15.6692660716233
let lng: Double = -47.83980712156295
func makeUIView(context: Self.Context) -> GMSMapView {
let camera = GMSCameraPosition.camera(
withLatitude: lat,
longitude: lng,
zoom: zoom)
let mapView = GMSMapView.map(withFrame: CGRect.zero, camera: camera)
mapView.mapType = .hybrid
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
mapView.animate(toLocation: CLLocationCoordinate2D(latitude: lat, longitude: lng))
let position = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let marker = GMSMarker(position: position)
marker.title = "You"
marker.map = mapView
}
func makeCoordinator() -> Coordinator {
Coordinator(owner: self)
}
class Coordinator: NSObject, GMSMapViewDelegate, ObservableObject {
let owner: GoogleMapsHome // access to owner view members,
init(owner: GoogleMapsHome) {
self.owner = owner
}
#Published var selectedMarker: GMSMarker? {
willSet { objectWillChange.send() }
}
func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
print("A marker has been touched by the user")
self.selectedMarker = marker
return true
}
}
}
I hope someone can help me and, later, this question become useful for anyone with the same need.
Best regards!
After a while, I found a way to solve it.
The keywords for that are "Coordinator" and "Binding".
Of course, I'm not sure if this is the right way, or the best, but it worked, at least.
import Foundation
import SwiftUI
import GoogleMaps
struct Home: View {
#State var selectedMarker: GMSMarker?
var body: some View {
VStack(spacing: 0) {
if (selectedMarker == nil){
Text("No marker selected").padding()
}else{
Text("There's a marker selected").padding()
}
GoogleMapsHome(selectedMarker: self.$selectedMarker)
}
.navigationBarBackButtonHidden(true)
.navigationBarTitle(Text("Map Test"), displayMode: .inline)
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
Home()
}
}
struct GoogleMapsHome: UIViewRepresentable {
private let zoom: Float = 18
let lat: Double = -15.6692660716233
let lng: Double = -47.83980712156295
#Binding var selectedMarker: GMSMarker?
func makeCoordinator() -> Coordinator {
return Coordinator(
owner: self,
selectedMarker: $selectedMarker)
}
func makeUIView(context: Self.Context) -> GMSMapView {
let camera = GMSCameraPosition.camera(
withLatitude: lat,
longitude: lng,
zoom: zoom)
let mapView = GMSMapView.map(withFrame: CGRect.zero, camera: camera)
mapView.mapType = .hybrid
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
mapView.animate(toLocation: CLLocationCoordinate2D(latitude: lat, longitude: lng))
let position = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let marker = GMSMarker(position: position)
marker.title = "You"
marker.map = mapView
}
class Coordinator: NSObject, GMSMapViewDelegate, ObservableObject {
let owner: GoogleMapsHome // access to owner view members,
#Binding var selectedMarker: GMSMarker?
init(
owner: GoogleMapsHome,
selectedMarker: Binding<GMSMarker?>
) {
self.owner = owner
_selectedMarker = selectedMarker
}
func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
print("A marker has been touched")
self.selectedMarker = marker
return true
}
}
}

Animation not working with combination of GeometryReader and NavigationView

I've got an animated Image which is sliding up and down, using the offset and a timer. This works totally fine until you combine a GeometryReader and a NavigationView. For both, NavView and GeoReader on their own, the animation is working as well. Any solutions? (I know, in this example the GeometryReader is not needed)
struct TestView: View{
#State var offsetSwipeUp: CGFloat = 0
var body: some View{
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
return NavigationView {
GeometryReader { geometry in
Image(systemName: "chevron.up")
.animation(.easeInOut(duration: 1))
.onReceive(timer){ _ in
if self.offsetSwipeUp == .zero{
self.offsetSwipeUp = -10
} else {
self.offsetSwipeUp = .zero
}
}
.offset(y: CGFloat(self.offsetSwipeUp))
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
}
}
In this case order of modifiers looks important.
The below variant works. Tested with Xcode 11.4 / iOS 13.4
struct TestView: View {
#State var offsetSwipeUp: CGFloat = 0
var body: some View{
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
return NavigationView {
GeometryReader { geometry in
Image(systemName: "chevron.up")
.animation(.easeInOut(duration: 1))
.offset(y: CGFloat(self.offsetSwipeUp))
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
.onReceive(timer){ _ in
if self.offsetSwipeUp == .zero{
self.offsetSwipeUp = -10
} else {
self.offsetSwipeUp = .zero
}
}
}
}

Resources