MapKit (MKMapView): zPosition does not work anymore on iOS11
Asked Answered
M

2

18

On iOS11 the zPosition stopped working for the annotationView.layer. Every time the map region changes.

enter image description here

  • No luck with original solution: layer.zPosition = X;
  • No luck with bringViewToFront/SendViewToBack methods

Xcode 8.3/9

UPDATE (SOLUTION thanks Elias Aalto):

When creating MKAnnotationView:

annotationView.layer.zPosition = 50;
if (IS_OS_11_OR_LATER) {
    annotationView.layer.name = @"50";
    [annotationView.layer addObserver:MeLikeSingleton forKeyPath:@"zPosition" options:0 context:NULL];

}

In MeLikeSingleton or whatever observer object you have there:

- (void)observeValueForKeyPath:(NSString *)keyPath
                         ofObject:(id)object
                           change:(NSDictionary *)change
                          context:(void *)context {

       if (IS_OS_11_OR_LATER) {

           if ([keyPath isEqualToString:@"zPosition"]) {
               CALayer *layer = object;
               int zPosition = FLT_MAX;
               if (layer.name) {
                   zPosition = layer.name.intValue;
               }
               layer.zPosition = zPosition;
               //DDLogInfo(@"Name:%@",layer.name);
           }

       } 
}
  • This solution uses the layer.name value to keep track of zOrder. In case you have many levels of zPosition (user location, cluster, pin, callout) ;)
  • No for loops, only KVO
  • I used a Singleton Object that observs the layer value changes. In case you have multiple MKMapViews used through out the app.

HOW IT WAS WORKING BEFORE IOS11

..is to use the

- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views

and set the zPosition here.

..but that (for some of us, still dunny why) does not work anymore in iOS11!

Mendie answered 2/10, 2017 at 2:20 Comment(4)
Did you find a solution to this? I'm dealing with the same exact thing.Pupa
yes, see original postMendie
To everyone in the same boat: zPosition is apparently broken in iOS 11. Please take your time to file a bug report: developer.apple.com/bug-reporting More of us will file, more attention this bug will get at Apple.Fruin
man you gotta love apple, just totally breaking things every major release like it ain't no thangInfundibuliform
D
11

The zPosition does work, it's just that MKMapView overwrites it internally based on the somewhat broken and useless MKFeatureDisplayPriority. If you just need a handful of annotations to persist on top of "everything else", you can do this semi cleanly by using KVO. Just add an observer to the annotation view's layer's zPosition and overwrite it as MKMapView tries to fiddle with it.

(Please excuse my ObjC)

Add the observer:

        [self.annotationView.layer addObserver:self forKeyPath:@"zPosition" options:0 context:nil];

Overrule MKMapView

 - (void)observeValueForKeyPath:(NSString *)keyPath
                  ofObject:(id)object
                    change:(NSDictionary *)change
                   context:(void *)context
{
    if(object == self.annotationView.layer)
    {
        self.annotationView.layer.zPosition = FLT_MAX;
    }
}

Profit

Deuteronomy answered 23/11, 2017 at 12:8 Comment(2)
This worked much better for me than using the rather strange [self.annotationView setDisplayPriority:MKFeatureDisplayPriorityDefaultLow/High] as that approach actually hides lower priority views that appear underneath a high priority view, rather than just reordering their z-indexes - fine if its a solid colour element, but not good if its semi transparent. Thanks!Deandreadeane
This solution started crashing apps for me. crashes.to/s/0f3a2ba5fefThumbnail
D
5

We can completely ignore MKMapView's attempts to modify MKAnnotationView layer's zPosition. Since MKAnnotationView uses standard CALayer as its layer and not some private class, we can subclass it and override its zPosition. To actually set zPosition we can provide our own accessor.

It will work much faster than KVO.

class ResistantLayer: CALayer {

    override var zPosition: CGFloat {
        get { return super.zPosition }
        set {}
    }
    var resistantZPosition: CGFloat {
        get { return super.zPosition }
        set { super.zPosition = newValue }
    }
}

class ResistantAnnotationView: MKAnnotationView {

    override class var layerClass: AnyClass {
        return ResistantLayer.self
    }
    var resistantLayer: ResistantLayer {
        return self.layer as! ResistantLayer
    }
}

UPDATE:

I've got one very inelegant method for selection of the topmost annotation view when tapping on overlapping annotations.

class MyMapView: MKMapView {

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

        // annotation views whose bounds contains touch point, sorted by visibility order
        let views =
            self.annotations(in: self.visibleMapRect)
                .flatMap { $0 as? MKAnnotation }
                .flatMap { self.view(for: $0) }
                .filter { $0.bounds.contains(self.convert(point, to: $0)) }
                .sorted(by: {
                    view0, view1 in

                    let layer0  = view0.layer
                    let layer1  = view1.layer
                    let z0      = layer0.zPosition
                    let z1      = layer1.zPosition

                    if z0 == z1 {
                        if  let subviews = view0.superview?.subviews,
                            let index0 = subviews.index(where: { $0 === view0 }),
                            let index1 = subviews.index(where: { $0 === view1 })
                        {
                            return index0 > index1
                        } else {
                            return false
                        }
                    } else {
                        return z0 > z1
                    }
                })

        // disable every annotation view except topmost one
        for item in views.enumerated() {
            if item.offset > 0 {
                item.element.isEnabled = false
            }
        }

        // re-enable annotation views after some time
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            for item in views.enumerated() {
                if item.offset > 0 {
                    item.element.isEnabled = true
                }
            }
        }

        // ok, let the map view handle tap
        return super.hitTest(point, with: event)
    }
}
Deadandalive answered 9/12, 2017 at 7:20 Comment(16)
Yes, it's on production. Are you positive that you returning subclass – not MKAnnotationView – from mapView(MKMapView, viewFor: MKAnnotation)?Deadandalive
Yeah, I returned the correct subclass. I put a print statement inside the empty set in ResistantLayer and it was calledResolve
And when later – somewhere in mapView(_:regionDidChangeAnimated:), for example – you checking zPositions of mapView.annotations?Deadandalive
This doesn't work for cluster annotations, you'll crash. (Works ok if you don't need cluster annotations though!)Newsmonger
@Newsmonger Why exactly? I am returning a subclass of MKMarkerAnnotationView for cluster annotations, got hits on empty zPosition.set and no crashes occurs.Deadandalive
@Deadandalive maybe I'm doing the subclass wrong, was getting Fatal error: Use of unimplemented initializer 'init(layer:)' for class MyLayer (I overrode one initializer but not the other). I'll test it out.Newsmonger
@Deadandalive yep this works great. Thanks for the poke, I probably would've given up otherwise!Newsmonger
Any idea how to use this approach for a pin annoation view? Seems it uses an internal "MKLayer" (CALayer subclass) and causes issues if we replace it with a custom CALayer?Nina
@Nina I have no sane ideas that will provide no significant performance decrease. It seems that Elias Aalto's answer is the way to go here.Deadandalive
Thanks, we had too many issues cleaning up after adding the observers using that other method but yours is brilliant! Thanks anyways,will just stick with custom markers...Nina
I used the above and found a problem. The display order is correct, but if you have markers overlaying each other, taps select annotations below the one you tapped. How would you make sure annotations respond to clicks/taps in the same order they are zPosition'ed?Fruin
@StackExchanger Yep, that's what UIView is responsible for, not CALayer. You need to manually maintain an order of your annotations within they superview's by something like annotationView.superview?.bringSubview(toFront: annotationView), or maybe you can subclass MKMapView and override it's hitTest(_:with:) to return annotation view with higher zPosition.Deadandalive
Thanks bteapot! I tried bringSubview(toFront: annotationView) just now - it does not help with overlaid marker tap order. I wonder what property is used to evaluate which annotation will be selected out of the vertical stack. I have not looked into hitTest yet.Fruin
@StackExchanger Interesting. I wasn't able to achieve any significant result at attempts to specify selection order. Maybe it's just designed like this, to allow selection of overlapping annotations by cycling through them? See https://mcmap.net/q/741748/-mkmapview-overlapping-mkannotations-only-allow-showing-two-of-the-callouts/826716Deadandalive
@StackExchanger I've got one method that I'm not very proud to, but it seems to work. See updated part of the answer.Deadandalive
@Deadandalive I see where you are going with this, thanks again! I like how cleanly you handled zIndex. Further patches in the name of a broken feature (zPosition) that is most likely to be sorted out at some point has a potential of messing something else up in the next iOS releases. How should DisplayPriority and zPosition interact, they probably shouldn't, right? I tried DisplayPriority and used numbers very close to required: 994-1000. It results in lower priority (<1000) annotations being conditionally hidden. DisplayPriority does not affect zPosition. Is there a ticket open with Apple?Fruin

© 2022 - 2024 — McMap. All rights reserved.