iOS 10 heading arrow for MKUserLocation dot
Asked Answered
F

4

22

The Maps app in iOS 10 now includes a heading direction arrow on top of the MKUserLocation MKAnnotationView. Is there some way I can add this to MKMapView in my own apps?

enter image description here

Edit: I'd be happy to do this manually, but I'm not sure if it's possible? Can I add an annotation to the map and have it follow the user's location, including animated moves?

Fidgety answered 29/9, 2016 at 6:16 Comment(3)
What about MKUserTrackingMode -> case followWithHeading? developer.apple.com/reference/mapkit/mkusertrackingmodeInitiative
Hi, thanks for the input but this does not enable the graphic as above.Fidgety
Actually more to the point, I would like to display a heading indicator, without the map following the user's location.Fidgety
F
3

I solved this by adding a subview to the MKUserLocation annotationView, like so

func mapView(mapView: MKMapView, didAddAnnotationViews views: [MKAnnotationView]) {
if annotationView.annotation is MKUserLocation {
    addHeadingViewToAnnotationView(annotationView)
    }
}

func addHeadingViewToAnnotationView(annotationView: MKAnnotationView) {
    if headingImageView == nil {
        if let image = UIImage(named: "icon-location-heading-arrow") {
            let headingImageView = UIImageView()
            headingImageView.image = image
            headingImageView.frame = CGRectMake((annotationView.frame.size.width - image.size.width)/2, (annotationView.frame.size.height - image.size.height)/2, image.size.width, image.size.height)
            self.headingImageView = headingImageView
        }
    }

    headingImageView?.removeFromSuperview()
    if let headingImageView = headingImageView {
        annotationView.insertSubview(headingImageView, atIndex: 0)
    }

    //use CoreLocation to monitor heading here, and rotate headingImageView as required
}
Fidgety answered 10/10, 2016 at 5:47 Comment(4)
can you please add more details code about CoreLocation to monitor heading?Dare
@AmritTiwari this answer should cover most of it https://mcmap.net/q/576093/-ios-10-heading-arrow-for-mkuserlocation-dotFidgety
can you please post it here?Dare
@AmritTiwari it's the top-rated answer on this same page. Just scroll up a bit...Fidgety
M
25

I also experienced this same issue (needing an orientation indicator without having the map spin around, similar to the Apple Maps app). Unfortunately Apple has not yet made the 'blue icon for heading' API available.

I created the following solution derived from @alku83's implementation.

  1. Ensure the class conforms to MKViewDelegate
  2. Add the delegate method to add a blue arrow icon to the maps location dot

    func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
        if views.last?.annotation is MKUserLocation {
            addHeadingView(toAnnotationView: views.last!)
        }
    }
    
  3. Add the method to create the 'blue arrow icon'.

    func addHeadingView(toAnnotationView annotationView: MKAnnotationView) {
        if headingImageView == nil {
            let image = #YOUR BLUE ARROW ICON#
            headingImageView = UIImageView(image: image)
            headingImageView!.frame = CGRect(x: (annotationView.frame.size.width - image.size.width)/2, y: (annotationView.frame.size.height - image.size.height)/2, width: image.size.width, height: image.size.height)
            annotationView.insertSubview(headingImageView!, at: 0)
            headingImageView!.isHidden = true
         }
    }
    
  4. Add var headingImageView: UIImageView? to your class. This is mainly needed to transform/rotate the blue arrow image.

  5. (In a different class/object depending on your architecture) Create a location manager instance, with the class conforming to CLLocationManagerDelegate protocol

    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        // Set up your manager properties here
        manager.delegate = self
        return manager
    }()
    
  6. Ensure your location manager is tracking user heading data locationManager.startUpdatingHeading() and that it stops tracking when appropriate locationManager.stopUpdatingHeading()

  7. Add var userHeading: CLLocationDirection? which will hold the orientation value

  8. Add the delegate method to be notified of when the heading values change, and change the userHeading value appropriately

    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
         if newHeading.headingAccuracy < 0 { return }
    
         let heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
         userHeading = heading
         NotificationCenter.default.post(name: Notification.Name(rawValue: #YOUR KEY#), object: self, userInfo: nil)
        }
    
  9. Now in your class conforming to MKMapViewDelegate, add the method to 'transform' the orientation of the heading image

       func updateHeadingRotation() {
            if let heading = # YOUR locationManager instance#,
                let headingImageView = headingImageView {
    
                headingImageView.isHidden = false
                let rotation = CGFloat(heading/180 * Double.pi)
                headingImageView.transform = CGAffineTransform(rotationAngle: rotation)
            }
        }
    
Moffett answered 25/11, 2016 at 15:55 Comment(5)
Excellent! Thank you so much!Kanal
Why do we need to post a Notification #YOUR KEY#? Is it not sufficient to call "updateHeadingRotation" when heading changes?Nudi
@Nudi yes, you can do it that way.Mukerji
Thanks! Instead of an imageview you can use this subview using an arcPath to mimic iOS 14 : gist.github.com/nmondollot/6cda942b9c4f130b91a27fac7ee7d9f9 Note: You need to get the heading accuracy using the didUpdateHeading delegate method.Burget
Ultra perfect solution @Burget 🙌🏼Philistine
L
5

Yes, you can do this manually.

The basic idea is to track user's location with CLLocationManager and use it's data for placing and rotating annotation view on the map.

Here is the code. I'm omitting certain things that are not directly related to the question (e.g. I'm assuming that user have already authorized your app for location access, etc.), so you'll probably want to modify this code a little bit

ViewController.swift

import UIKit
import MapKit

class ViewController: UIViewController, CLLocationManagerDelegate, MKMapViewDelegate {
    @IBOutlet var mapView: MKMapView!
    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.delegate = self
        return manager
    }()

    var userLocationAnnotation: UserLocationAnnotation!

    override func viewDidLoad() {
        super.viewDidLoad()

        locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation

        locationManager.startUpdatingHeading()
        locationManager.startUpdatingLocation()

        userLocationAnnotation = UserLocationAnnotation(withCoordinate: CLLocationCoordinate2D(), heading: 0.0)

        mapView.addAnnotation(userLocationAnnotation)
    }

    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        userLocationAnnotation.heading = newHeading.trueHeading
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        userLocationAnnotation.coordinate = locations.last!.coordinate
    }

    public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if let annotation = annotation as? UserLocationAnnotation {
            let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "UserLocationAnnotationView") ?? UserLocationAnnotationView(annotation: annotation, reuseIdentifier: "UserLocationAnnotationView")
            return annotationView
        } else {
            return MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil)
        }
    }

}

Here we are doing basic setup of the map view and starting to track user's location and heading with the CLLocationManager.

UserLocationAnnotation.swift

import UIKit
import MapKit

class UserLocationAnnotation: MKPointAnnotation {
    public init(withCoordinate coordinate: CLLocationCoordinate2D, heading: CLLocationDirection) {
        self.heading = heading

        super.init()
        self.coordinate = coordinate
    }

    dynamic public var heading: CLLocationDirection
}

Very simple MKPointAnnotation subclass that is capable of storing heading direction. dynamic keyword is the key thing here. It allows us to observe changes to the heading property with KVO.

UserLocationAnnotationView.swift

import UIKit
import MapKit

class UserLocationAnnotationView: MKAnnotationView {

    var arrowImageView: UIImageView!

    private var kvoContext: UInt8 = 13

    override public init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)

        arrowImageView = UIImageView(image: #imageLiteral(resourceName: "Black_Arrow_Up.svg"))
        addSubview(arrowImageView)
        setupObserver()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        arrowImageView = UIImageView(image: #imageLiteral(resourceName: "Black_Arrow_Up.svg"))
        addSubview(arrowImageView)
        setupObserver()
    }

    func setupObserver() {
        (annotation as? UserLocationAnnotation)?.addObserver(self, forKeyPath: "heading", options: [.initial, .new], context: &kvoContext)
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if context == &kvoContext {
            let userLocationAnnotation = annotation as! UserLocationAnnotation
            UIView.animate(withDuration: 0.2, animations: { [unowned self] in
                self.arrowImageView.transform = CGAffineTransform(rotationAngle: CGFloat(userLocationAnnotation.heading / 180 * M_PI))
            })
        }
    }

    deinit {
        (annotation as? UserLocationAnnotation)?.removeObserver(self, forKeyPath: "heading")
    }
}

MKAnnotationView subclass that does the observation of the heading property and then sets the appropriate rotation transform to it's subview (in my case it's just an image with the arrow. You can create more sophisticated annotation view and rotate only some part of it instead of the whole view.)

UIView.animate is optional. It is added to make rotation smoother. CLLocationManager is not capable of observing heading value 60 times per second, so when rotating fast, animation might be a little bit choppy. UIView.animate call solves this tiny issue.

Proper handling of coordinate value updates is already implemented in MKPointAnnotation, MKAnnotationView and MKMapView classes for us, so we don't have to do it ourselves.

Ledbetter answered 7/10, 2016 at 14:20 Comment(3)
Thanks for the input - it's a different approach than what I ended up using, but seems like this should also work. My only concern would be a disconnect between the additional annotation and the MKUserLocation annotation when certain events occur, eg. the location changes and the MKUserLocation annotation animates to a new position.Fidgety
You are not using the heading which you get from didUpdateHeading function and in observer you calculate the rotation using user location heading (i.e. blue dot). Right??Strongwilled
@AdeelUrRehman, no. I do use heading value from didUpdateHeading. I'm saving it to the heading property of the custom annotation and observing changes of that property in the annotation view. So it is exactly didUpdateHeading call triggers the rotation in UserLocationAnnotationView. And the value used for rotation angle is exactly the value received in didUpdateHeading.Ledbetter
F
3

I solved this by adding a subview to the MKUserLocation annotationView, like so

func mapView(mapView: MKMapView, didAddAnnotationViews views: [MKAnnotationView]) {
if annotationView.annotation is MKUserLocation {
    addHeadingViewToAnnotationView(annotationView)
    }
}

func addHeadingViewToAnnotationView(annotationView: MKAnnotationView) {
    if headingImageView == nil {
        if let image = UIImage(named: "icon-location-heading-arrow") {
            let headingImageView = UIImageView()
            headingImageView.image = image
            headingImageView.frame = CGRectMake((annotationView.frame.size.width - image.size.width)/2, (annotationView.frame.size.height - image.size.height)/2, image.size.width, image.size.height)
            self.headingImageView = headingImageView
        }
    }

    headingImageView?.removeFromSuperview()
    if let headingImageView = headingImageView {
        annotationView.insertSubview(headingImageView, atIndex: 0)
    }

    //use CoreLocation to monitor heading here, and rotate headingImageView as required
}
Fidgety answered 10/10, 2016 at 5:47 Comment(4)
can you please add more details code about CoreLocation to monitor heading?Dare
@AmritTiwari this answer should cover most of it https://mcmap.net/q/576093/-ios-10-heading-arrow-for-mkuserlocation-dotFidgety
can you please post it here?Dare
@AmritTiwari it's the top-rated answer on this same page. Just scroll up a bit...Fidgety
S
2

I wonder why no one offered a delegate solution. It does not rely on MKUserLocation but rather uses the approach proposed by @Dim_ov for the most part i.e. subclassing both MKPointAnnotation and MKAnnotationView (the cleanest and the most generic way IMHO). The only difference is that the observer is now replaced with a delegate method.

  1. Create the delegate protocol:

    protocol HeadingDelegate : AnyObject {
        func headingChanged(_ heading: CLLocationDirection)
    }
    
  2. Create MKPointAnnotation subclass that notifies the delegate. The headingDelegate property will be assigned externally from the view controller and triggered every time the heading property changes:

    class Annotation : MKPointAnnotation {
        weak var headingDelegate: HeadingDelegate?
        var heading: CLLocationDirection {
            didSet {
                headingDelegate?.headingChanged(heading)
            }
        }
    
        init(_ coordinate: CLLocationCoordinate2D, _ heading: CLLocationDirection) {
            self.heading = heading
            super.init()
            self.coordinate = coordinate
        }
    }
    
  3. Create MKAnnotationView subclass that implements the delegate:

    class AnnotationView : MKAnnotationView , HeadingDelegate {
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }
    
        override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
            super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
        }
    
        func headingChanged(_ heading: CLLocationDirection) {
            // For simplicity the affine transform is done on the view itself
            UIView.animate(withDuration: 0.1, animations: { [unowned self] in
                self.transform = CGAffineTransform(rotationAngle: CGFloat(heading / 180 * .pi))
            })
        }
    }
    
  4. Considering that your view controller implements both CLLocationManagerDelegate and MKMapViewDelegate there is very little left to do (not providing full view controller code here):

        // Delegate method of the CLLocationManager
        func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
            userAnnotation.heading = newHeading.trueHeading
        }
    
        // Delegate method of the MKMapView
        func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {        
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: NSStringFromClass(Annotation.self))
            if (annotationView == nil) {
                annotationView = AnnotationView(annotation: annotation, reuseIdentifier: NSStringFromClass(Annotation.self))
            } else {
                annotationView!.annotation = annotation
            }
    
            if let annotation = annotation as? Annotation {
                annotation.headingDelegate = annotationView as? HeadingDelegate
                annotationView!.image = /* arrow image */
            }
    
            return annotationView
        }
    

The most important part is where the delegate property of the annotation (headingDelegate) is assigned with the annotation view object. This binds the annotation with it's view such that every time the heading property is modified the view's headingChanged() method is called.

NOTE: didSet{} and willSet{} property observers used here were first introduced in Swift 4.

Sigfried answered 13/10, 2019 at 12:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.