Create a custom VoiceOver Rotor to navigate MKAnnotationViews?

1.2k Views Asked by At

I plot several MKAnnotations on an MKMapView. I'd like VoiceOver users to be able to continue panning/zooming a map as they normally would, but I'd also like them to to be able to quickly and easily navigate through my MKAnnotations if they choose. I feel like a custom rotor is the perfect solution for this.

1

There are 1 best solutions below

0
On

Self answering here, because I spent an insane amount of time getting this right, and figured someone else might need this. At the time I needed to develop this, there were hardly any examples online that go into detail about creating custom rotors, and Apple's documentation is very sparse. I finally figured it out though, after watching and following (and pausing on screens of code) the WWDC Session 202 (begins at 24:17).

The trickiest thing I needed to figure out was how to reliably return a UIAccessibilityCustomRotorItemResult. For an MKMapView, you want to return MKAnnotationViews, but an annotation is not guaranteed to have an associated view (they're recycled, and if an annotation is offscreen, there's a good chance it's view has been reused), so my first attempts kept leaving out some, or most, of my annotations.

The magic is in setting the animated: property to false:

self.mapView.setCenter(requestedAnnotation.coordinate, animated: false)

You can't use view(for:MKAnnotation) because of reasons stated above, so what the line above does is moves the map so your pin is at the center. Because it's not animating, the annotation has it's view created immediately, and in the next line of code, in my testing, it's guaranteed to return an MKAnnotationView.

YVVM, but please feel free to add suggestions for improvement, as I feel navigating a map in this way is crucial for VoiceOver users.

func configureCustomRotors() {
  let favoritesRotor = UIAccessibilityCustomRotor(name: "Bridges") { predicate in
    let forward = (predicate.searchDirection == .next)

    // which element is currently highlighted
    let currentAnnotationView = predicate.currentItem.targetElement as? MKPinAnnotationView
    let currentAnnotation = (currentAnnotationView?.annotation as? BridgeAnnotation)

    // easy reference to all possible annotations
    let allAnnotations = self.mapView.annotations.filter { $0 is BridgeAnnotation }

    // we'll start our index either 1 less or 1 more, so we enter at either 0 or last element
    var currentIndex = forward ? -1 : allAnnotations.count

    // set our index to currentAnnotation's index if we can find it in allAnnotations
    if let currentAnnotation = currentAnnotation {
      if let index = allAnnotations.index(where: { (annotation) -> Bool in
        return (annotation.coordinate.latitude == currentAnnotation.coordinate.latitude) &&
    (annotation.coordinate.longitude == currentAnnotation.coordinate.longitude)
        }) {
          currentIndex = index
      }
    }

    // now that we have our currentIndex, here's a helper to give us the next element
    // the user is requesting
    let nextIndex = {(index:Int) -> Int in forward ? index + 1 : index - 1}

    currentIndex = nextIndex(currentIndex)

    while currentIndex >= 0 && currentIndex < allAnnotations.count {
      let requestedAnnotation = allAnnotations[currentIndex]

      // i can't stress how important it is to have animated set to false. save yourself the 10 hours i burnt, and just go with it. if you set it to true, the map starts moving to the annotation, but there's no guarantee the annotation has an associated view yet, because it could still be animating. in which case the line below this one will be nil, and you'll have a whole bunch of annotations that can't be navigated to
      self.mapView.setCenter(requestedAnnotation.coordinate, animated: false)
      if let annotationView = self.mapView.view(for: requestedAnnotation) {
        return UIAccessibilityCustomRotorItemResult(targetElement: annotationView, targetRange: nil)
      }

      currentIndex = nextIndex(currentIndex)
    }

    return nil
  }

  self.accessibilityCustomRotors = [favoritesRotor]
}