{"id":60,"date":"2022-04-11T19:26:19","date_gmt":"2022-04-11T19:26:19","guid":{"rendered":"https:\/\/icab.de\/blog\/?p=60"},"modified":"2022-04-11T19:26:21","modified_gmt":"2022-04-11T19:26:21","slug":"classic-mapkit-on-watchos","status":"publish","type":"post","link":"https:\/\/icab.de\/blog\/2022\/04\/11\/classic-mapkit-on-watchos\/","title":{"rendered":"\u201eClassic\u201c MapKit on watchOS"},"content":{"rendered":"\n<p>If you want to show a native map view within your watchOS App you have currently two options: using either the \u201eclassic\u201c watchOS Interface elements or SwiftUI. <\/p>\n\n\n\n<p>SwiftUI is relatively new and was introduced with watchOS 7 on the Apple Watch (and with iOS 14 on the iPhone\/iPad). SwiftUI is still a work in progress, so there are limitations which will be resolved in the future. But when it comes to the MapKit it is already much more powerful than the \u201eclassic\u201c watchOS interface elements. So in case you don\u2019t care about compatibility with older watchOS releases and don\u2019t mind in creating your user interface in code, you should use SwiftUI. <\/p>\n\n\n\n<p>But if your App should still work under watchOS 6 and older or you want to create your user interface graphically via Storyboard, you may still need to use the classic watchOS API. Unfortunytely the classic  MapKit API is extremly limited.  While the map view of SwiftUI lets you scroll and zoom the map out of the box, the classic watchOS API won\u2019t support this at all. Even worse, tapping the Map will immediatelly quit your App and will launch Apple\u2019s Maps App. Also while SwiftUI lets you place any number of annotations (for example pins, markers) on the map, the classic watchOS only supports up to 5 annotations.<\/p>\n\n\n\n<p>This blog post will explain how you can implement scrolling and zooming capabilities with the classic watchOS APIs, so you can get a similar experience as with the new SwiftUI. <\/p>\n\n\n\n<p>The topic of this blog post is based on the experiences of my iOS\/watchOS Apps <a href=\"http:\/\/www.pado-app.de\/\">Pado<\/a> (all about geo tracking for sports, vacation etc), and <a href=\"https:\/\/mobile.clauss-net.de\/Wigemo\/\">Wigemo<\/a> <a href=\"http:\/\/youtu.be\/JHXMjHQuKPI\"><img loading=\"lazy\" decoding=\"async\" width=\"768\" height=\"432\" align=\"right\" class=\"wp-image-64\" style=\"width: 300px;padding:4px 10px;\" src=\"https:\/\/icab.de\/blog\/wp-content\/uploads\/2022\/04\/WigemoWatch-0001-preview.jpg\" alt=\"\u201e\u201c\" srcset=\"https:\/\/icab.de\/blog\/wp-content\/uploads\/2022\/04\/WigemoWatch-0001-preview.jpg 768w, https:\/\/icab.de\/blog\/wp-content\/uploads\/2022\/04\/WigemoWatch-0001-preview-300x169.jpg 300w\" sizes=\"auto, (max-width: 768px) 100vw, 768px\" \/><\/a> (a general Maps App where you can save and manage your favorite places, plan vacations etc, with a direct link to Wikipedia). In both Apps you have a MapView which can be scrolled and zoomed without restriction. In Pado the map will also show tracks (overlays) on the Map and in Wigemo the map can have any number of annotations (pins) and is even able to cluster these pins (combine multiple pins into one with a number if they are getting too close). Check out the video that is linked here, how this looks in Wigemo. Especially Pado was developeed many years before Apple introduced SwiftUI, therefore SwiftUI was never an option here.<\/p>\n\n\n\n<p><strong>Step One: Creating your Userinterface in a Storyboard<\/strong><\/p>\n\n\n\n<p>Since we create an App using the classic watchOS API, the user interface is build via Storyboard. In the Storyboard, just drag a Map (WKInterfaceMap) from the Library window into an Interface controller. Because we use the digital crown for zooming, also add a WKInterfacePicker into the Interface Controller in the Storyboard. Then connect both with the IBOutlet varaibales in the code file of the InterfaceController.<\/p>\n\n\n\n<p class=\"has-background has-small-font-size\" style=\"background-color:#f9f2f4\"><code>class InterfaceController: WKInterfaceController {<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;@IBOutlet weak var mapView: WKInterfaceMap!<br>\n&nbsp;&nbsp;&nbsp;&nbsp;@IBOutlet weak var pickerView: WKInterfacePicker!<br>\n<\/code><\/p>\n\n\n\n<p><strong>Issue One: Taps are leaving the App<\/strong><\/p>\n\n\n\n<p>The first issue to solve would be that tapping the map would immediatelly quit the App and switch to Apple\u2019s Map App. This task is an easy one, all that needs to be done is to remove the checkmark of the \u201eenabled\u201c checkbox in the \u201eMaps\u201c section of the \u201eAttributes Inspector\u201c panel in the Storyboard in XCode. Then the map view will ignore taps and so the user won\u2019t be kicked out of the App anymore.<\/p>\n\n\n\n<p><strong>Issue Two: Zooming<\/strong><\/p>\n\n\n\n<p>For the zoom feature we follow the convention which almost all popular map providers (OpenStreetMap, Google, Apple etc) are using: A zoom level of 0 covers the whole earth, adding 1 to the zoom level will divide the visible area by 2 (horizontally and vertically). With the length of the equator and the current zoom level it is then possible to find out how large the visible area is. Together with the center coordinate of this area we have all information to configure the mapView to show exactly this region. <\/p>\n\n\n\n<p>Therefore we first define a few constants for the min and max zoom level and the equator length (in meters). The zoom level is initialized with a useful default value and will later change whenever the user uses the digital crown to zoom in or out. The visible distance of the Map can be directly calculated by the equator length and the zoom level. The location is the current center coordinate of the map, initialized with any coordinate you like (in this example this is simply a fixed one, but you can also use the Location Manager to ask for the user location) and it is also later changed by the user when scrolling the map. <\/p>\n\n\n\n<p class=\"has-background has-small-font-size\" style=\"background-color:#f9f2f4\"><code><br>\n&nbsp;&nbsp;&nbsp;&nbsp;let minZoomLevel = 4.0<br>\n&nbsp;&nbsp;&nbsp;&nbsp;let maxZoomLevel = 18.0<br>\n&nbsp;&nbsp;&nbsp;&nbsp;let equatorLength = 40075000.0<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;var zoomLevel = 10.0<br>\n&nbsp;&nbsp;&nbsp;&nbsp;var visibleDistance: Double { return equatorLength \/ pow(2, zoomLevel) }<br>\n&nbsp;&nbsp;&nbsp;&nbsp;var location = CLLocationCoordinate2D(latitude: 49, longitude: 9)<br>\n<\/code><\/p>\n\n\n\n<p>The next step will be to setup the Picker control which is used to zoom in and out. The first step is to connect the IBAction \u201ezoomAction\u201c in the code with the picker control in the storyboard. \u201ezoomAction&#8221; will be called whenever the digital crown is rotated. <\/p>\n\n\n\n<p>In the \u201eawake\u201c method we initialize the Picker control. In order to get the zooming more smooth, each step of the digital crown should increase or decrease the zoom level by 0.5, therefore the picker control needs twice as many picker items as zoom levels are available (you can add more items per zoom level to get an even more smooth experience, but this also has the disadvantage that zooming fully in or out requires more steps and needs more time which can be less comfortable to use &#8211; using steps of 0.5 is a good compromise). These PickerItems don\u2019t need a title because they are invisble anyways, so we simply reuse a single PickerItem and add it multiple times into the Picker control. Then we set the currently selected Picker item to the one which represents the current (in this case the default) zoom level.  And last, because the Picker Control should be invisible, its height is set to 0. The \u201ezoomAction\u201c method is called whenever the crown is rotated which will then calulate the new zoom level and then update the map. As you can see, zooming can be implemented in just a few lines of code and is not very compicated.<\/p>\n\n\n\n<p class=\"has-background has-small-font-size\" style=\"background-color:#f9f2f4\"><code><br>\n&nbsp;&nbsp;&nbsp;&nbsp;override func awake(withContext context: Any?) {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let count = Int(maxZoomLevel-minZoomLevel)*2<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let pickerItem = WKPickerItem()<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let pickerItems = [WKPickerItem](repeatElement(pickerItem, count:count))<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;pickerView.setItems(pickerItems)<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let idx = Int((zoomLevel - minZoomLevel) * 2)<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;pickerView.setSelectedItemIndex(idx)<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;pickerView.setHeight(0)<br>\n&nbsp;&nbsp;&nbsp;&nbsp;}<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;@IBAction func zoomAction(_ value: Int) {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;zoomLevel = Double(value) \/ 2 + minZoomLevel<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;setupMap()<br>\n&nbsp;&nbsp;&nbsp;&nbsp;}<br>\n<\/code><\/p>\n\n\n\n<p><strong>Issue Three: Scrolling<\/strong><\/p>\n\n\n\n<p>In order to scroll the visible map region we can use a standard PanGestureRecognizer. Therefore simply add one into the maps view in the Storyboard and link it to the IBAction \u201escrollAction\u201c in the InterfaceController code file. Whenever the user moves his finger on the map, the \u201escrollAction&#8221; method will be called where we can then scroll the map accordingly.<\/p>\n\n\n\n<p>The pan gesture recognizer will tell us the position of the finger on the screen, so to scroll the map we need the horizontal and vertical distance in pixels the finger has moved between calls of this method and transform these values into changes of the geographical coordinate. This calculation can be a little bit complicated because the earth is not flat, but projected on a flat screen. So moving the finger a certain distance on the screen does not translate in the same changes for latitude and longitude when beeing near the poles or near the equator. But fortunytely we do not need a perfect solution, an approximation is fine. More information about the details of the approximation which I\u2019ve used can be found on <a href=\"https:\/\/gis.stackexchange.com\/questions\/2951\/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters\/2964#2964\">this web page<\/a>. Based on the movement of the finger in pixels this method calculates the changes for the latitude (dY) and longitude (dX), normalizes or \u201eclips&#8221; these if they are getting to large or small and then use these to update the new center coordinate and finally update the map. We only accept longitude values between -180 and 180 and latitude values between -75 and 75. Though valid latitude values lie between -90 and 90, the limit to -75 to 75 makes life a lot of easier for this sample project. These values are used for the center of the visible map view, therefore the total visible range can be much larger. And because the map view can not handle a visible area which goes beyond the -90\/90 boundary this limit will prevent any issues. If you need to inspect the poles as well, you need to add some more checks, which is omitted here to keep the example code simple.<\/p>\n\n\n\n<p class=\"has-background has-small-font-size\" style=\"background-color:#f9f2f4\"><code><br>\n&nbsp;&nbsp;&nbsp;&nbsp;var touchPoint = CGPoint()<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;@IBAction func scrollAction(_ sender: WKPanGestureRecognizer) {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let pt = sender.locationInObject()<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if sender.state == .began {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;touchPoint = pt<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;} else {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let screenSize = WKInterfaceDevice.current().screenBounds.size<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let distance = visibleDistance<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let x = (touchPoint.x - pt.x) \/ screenSize.width * distance <br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let y = (touchPoint.y - pt.y) \/ screenSize.width * distance <br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;let R:CGFloat = 111111<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var dY = y\/R<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var dX = x\/(R*cos(location.latitude\/180 * Double.pi))<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;while location.longitude + dX &lt; -180 { dX += 360 }<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;while location.longitude + dX &gt; 180  { dX -= 360 }<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if location.latitude - dY &gt; 75  { dY = location.latitude - 75 }<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if location.latitude - dY &lt; -75 { dY = location.latitude + 75 }<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;touchPoint = pt<br><br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;location.longitude += dX<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;location.latitude -= dY<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;setupMap()<br>\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}<br>\n&nbsp;&nbsp;&nbsp;&nbsp;}<\/code><\/p>\n\n\n\n<p>&nbsp; <\/p>\n\n\n\n<p><strong>Last Step<\/strong><\/p>\n\n\n\n<p>The very last step would be to update the map each time the zoom level has changed or the map is scrolled. This method is very simple again, based on the current center location and the visible distance (calculated from the zoom level) the region can be calculated using a MapKit method and then it will be passed to the map view which will then display the new region on the screen.<\/p>\n\n\n\n<p class=\"has-background has-small-font-size\" style=\"background-color:#f9f2f4\"><code><br>\nfunc setupMap() {<br>\n&nbsp;&nbsp;&nbsp;&nbsp;let distance = visibleDistance<br>\n&nbsp;&nbsp;&nbsp;&nbsp;let region = MKCoordinateRegion(center: location, latitudinalMeters: distance, longitudinalMeters: distance)<br>\n&nbsp;&nbsp;&nbsp;&nbsp;mapView.setRegion(region)<br>\n}<br> <\/code><\/p>\n\n\n\n<p>Overall there&#8217;s not that much code necessary to implement  scrolling and zooming for the classic watchOS API. <\/p>\n\n\n\n<p>The complete source code of this App  (XCode project) is also <a href=\"https:\/\/www.icab.de\/dev\/MapViewTest.zip\">available for download<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>If you want to show a native map view within your watchOS App you have currently two options: using either the \u201eclassic\u201c watchOS Interface elements or SwiftUI. SwiftUI is relatively new and was introduced with watchOS 7 on the Apple Watch (and with iOS 14 on the iPhone\/iPad). SwiftUI is still a work in progress, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[7,6,5],"class_list":["post-60","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-mapkit","tag-swift","tag-watchos"],"_links":{"self":[{"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/posts\/60","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/comments?post=60"}],"version-history":[{"count":19,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/posts\/60\/revisions"}],"predecessor-version":[{"id":81,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/posts\/60\/revisions\/81"}],"wp:attachment":[{"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/media?parent=60"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/categories?post=60"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/icab.de\/blog\/wp-json\/wp\/v2\/tags?post=60"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}