How to define the order of overlapping MKAnnotationViews? - iphone

I have several MKAnnotations (and their corresponding views) in my map, and it sometimes gets really crowded. Now, the annotations in my app come in two flavors: some are bound to stay where they are, while others will move as time goes on. I'd prefer to have the more stable ones visually in the background and the moving ones to always pass in front of them.
One would think, perhaps, that the annotations most recently added to the map would end up to the front (or alternatively at the very back, at least) but this just doesn't seem to be the rule. As far as I can tell, I create and add ALL the non-moving annotations first, and then add some newly-instantiated moving annotations, but many of them (although not all!) end up drawn under the perpetually stock-still ones.
Interestingly, when time goes by, and yet new moving annotations are created, they tend to gravitate more to the top than the first ones - even if all moving annotation objects were created only after the nonmoving parts were already added to the map.
Does anyone know a trick to alter this strange natural order of the annotation views on the map? I tried to search the Map Kit API, but it doesn't seem to speak of such a thing.

Ok, so for solution use method from MKMapViewDelegate
- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views
In this method you should rearrange AnnotationView after it was added to mapKit View.
So, code may looks like this:
- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views {
for (MKAnnotationView * annView in views) {
TopBottomAnnotation * ann = (TopBottomAnnotation *) [annView annotation];
if ([ann top]) {
[[annView superview] bringSubviewToFront:annView];
} else {
[[annView superview] sendSubviewToBack:annView];
}
}
}
This works for me.

Under iOS 11 the implementation of displayPriority broke all the solutions which use bringSubviewToFront or zPosition.
If you override the annotation view's CALayer, you can wrestle control of zPosition back from the OS.
class AnnotationView: MKAnnotationView {
/// Override the layer factory for this class to return a custom CALayer class
override class var layerClass: AnyClass {
return ZPositionableLayer.self
}
/// convenience accessor for setting zPosition
var stickyZPosition: CGFloat {
get {
return (self.layer as! ZPositionableLayer).stickyZPosition
}
set {
(self.layer as! ZPositionableLayer).stickyZPosition = newValue
}
}
/// force the pin to the front of the z-ordering in the map view
func bringViewToFront() {
superview?.bringSubviewToFront(toFront: self)
stickyZPosition = CGFloat(1)
}
/// force the pin to the back of the z-ordering in the map view
func setViewToDefaultZOrder() {
stickyZPosition = CGFloat(0)
}
}
/// iOS 11 automagically manages the CALayer zPosition, which breaks manual z-ordering.
/// This subclass just throws away any values which the OS sets for zPosition, and provides
/// a specialized accessor for setting the zPosition
private class ZPositionableLayer: CALayer {
/// no-op accessor for setting the zPosition
override var zPosition: CGFloat {
get {
return super.zPosition
}
set {
// do nothing
}
}
/// specialized accessor for setting the zPosition
var stickyZPosition: CGFloat {
get {
return super.zPosition
}
set {
super.zPosition = newValue
}
}
}

Try to setup annotation view layer's zPosition (annotationView.layer.zPosition) in:
- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views;

Swift 3:
I get pin locations from API and I was having similar issues, the pins that had to be on top weren't. I was able to solve it like this.
var count = 0 // just so we don't get the same index in bottom pins
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
for view in views {
view.layer.zPosition = CGFloat(count)
}
count += 1
if count > 500 {
count = 250 // just so we don't end up with 999999999999+ as a value for count, plus I have at least 30 pins that show at the same time and need to have lower Z-Index values than the top pins.
}
}
Hope this helps

In the delegate function, you can select the pin to force it on top:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?` {
...
if my annotation is the special one {
annotationView.isSelected = true
}
...
}

I'm finding that this reordering the annotation views causes the callout that pops up when one is clicked to no longer be on top of all the annotations. I've even tried refining it so that instead of bringSubviewToFront and sendSubviewToBack, I use insertSubview:aboveSubview and insertSubview:belowSubview: where the second argument is the first annotationView in the list. This would seem to cause much less front to back scattering, but the call outs still pop up under some annotations.

I really needed to do this, and none of the (current) answers seemed to provide a reliable implementation. They sort of worked, but panning the map, selecting annotations, or zooming in could mess up the order again.
The final, well behaved solution wasn't so trivial, so I'll just outline the steps I took here. The annotation ordering that MKMapView uses doesn't respect the added order, or even the order of an overriden annotations property. So...
Steps
• Create a CADisplayLink
• Every frame, reorder annotations using both the layer zPosition, and the view's ordering in the superview's subviews array.
• If the view is selected, promote it to the front in your ordering scheme
• Tapping on annotations still respects internal MKMapView ordering, despite the already made changes. To counter this, add an MKMapViewDelegate
• In the delegate object's mapView:didSelect: method, check if the selected annotation is what you'd like it to be
• You can figure out the correct/prioritised annotation by running hit tests on the annotations yourself, with your own ordering taken into account
• If the selected annotation is correct, great. If not, manually select the correct annotation using selectAnnotation:animated:
And there you have it. The above method seems to work well, and the performance hit from running this each frame isn't too bad. You could also look at switching to MapBox, which I believe supports annotation ordering, but this isn't always an option for various reasons.

Related

Why is the subviews property of UISegmentedControl returning more values than the numberOfSegments property?

I've got a bit of a bug that's causing a crash. I need to apply accessibility features to the segments in a UISegmentedControl.
As far as I know, the only way to access segments from a UISegmentedControl is through its subviews property, which should return an array of type UISegment.
But with two segments on the segmented control, subviews is returning two UISegments and two UIImageViews for a total of four values.
extension UISegmentedControl {
func applyAccessibiltyFeatures() {
// subviews should return an array of type UISegment
for (index, view) in self.subviews.enumerated() {
// This line crashes
if let title = self.titleForSegment(at: index) {
// Do something with accessibility
view.doSomething(title: title)
}
}
}
}
The crash is caused by trying to access the title for a UISegment that doesn't exist. It's out of bounds because the subviews property returns four values even though there are only two segments in the segmented control. (Two UISegments and two UIImageViews.)
I've got a band-aid fix that stops the crash, but I can't guarantee that the accessibility features are being activated.
func applyAccessibiltyFeatures() {
// This guarantees that we don't go out of bounds, but
//if the first two subviews are the UIImageViews, then it doesn't matter
for index in 0..<self.numberOfSegments {
let view = self.subviews[index]
if let title = self.titleForSegment(at: index) {
// Do something with accessibility
view.doSomething(title: title)
}
}
}
I've tried using contactMap to filter the subviews down to just UISegments, but UISegment is private and unaccessible.
Why could the subviews property be returning an extra two values?

When I call setLeftBarButtonItem,something like "Screen Edge Pan Gesutre Recognizer" just can not work

I know it is a feature of iOS7 added to UINavigationController to pop current ViewController by panning from the screen's left edge. And I found there is a "Screen Edge Pan Gesture Recognizer" in Object Library. But when I implement it by code, its behavior is slightly different from the previous one.
I want to know why this behavior just gone when I call setLeftBarButtonItem method. Hopes someone could help me.
Finally I found the solution to my problem.
class MyViewController : UIGestureRecognizerDelegate {
override func viewDidLoad() {
self.navigationItem.setLeftBarButtonItem(backButtonItem, animated: true) // disable the gesture recognizer
// the magic code
self.navigationController?.interactivePopGestureRecognizer.delegate = self
}
}

UICollectionView interactive layout transition using iOS 7 APIs

I'm trying to get a handle on new iOS 7 APIs that allow for interactive, animated view controller transitions, including transitions between UICollectionViewLayouts.
I've taken and modified sample code from WWDC 2013, "iOS-CollectionViewTransition", which can be found here: https://github.com/timarnold/UICollectionView-Transition-Demo
The original demo, which was not in a working state when I found it, can be accessed with an Apple Developer account, here: https://developer.apple.com/downloads/index.action?name=WWDC%202013
My version of the app presents a collection view with two layouts, both UICollectionViewFlowLayout layouts with different properties.
Tapping on a cell in the first layout properly animates to the second, including, crucially, the tapped-on-item being scrolled to in the new layout. At first I was confused about how the new collection view knows to set its content offset so that the appropriate cell is visible, but I learned it does this based on the selected property of the presenting collection view.
Pinching on an item in the first layout should animate, using UICollectionViewTransitionLayout, UIViewControllerAnimatedTransitioning, and UIViewControllerInteractiveTransitioning, to the new layout as well. This works, but the pinched-at cell is not scrolled to in the new layout or the transition layout.
I've tried setting the selected property on the pinched-on cell at various locations (to try to mimic the behavior described when tapping on an item to push the new view controller), to no avail.
Any ideas about how to solve this problem?
You can manipulate the contentOffset yourself during the transition, which actually gives you finer-grained control than UICollectionView's built-in animation.
For example, you can define your transition layout like this to interpolate between the "to" and "from" offsets. You just need to calculate the "to" offset yourself based on how you want things to end up:
#interface MyTransitionLayout : UICollectionViewTransitionLayout
#property (nonatomic) CGPoint fromContentOffset;
#property (nonatomic) CGPoint toContentOffset;
#end
#import "MyTransitionLayout.h"
#implementation MyTransitionLayout
- (void) setTransitionProgress:(CGFloat)transitionProgress
{
super.transitionProgress = transitionProgress;
CGFloat f = 1 - transitionProgress;
CGFloat t = transitionProgress;
CGPoint offset = CGPointMake(f * self.fromContentOffset.x + t * self.toContentOffset.x, f * self.fromContentOffset.y + t * self.toContentOffset.y);
self.collectionView.contentOffset = offset;
}
#end
One thing to note is that the contentOffset will be reset to the "from" value when the transition completes, but you can negate that by setting it back to the "to" offset in the completion block of startInteractiveTransitionToCollectionViewLayout
CGPoint toContentOffset = ...;
[self.collectionViewController.collectionView startInteractiveTransitionToCollectionViewLayout:layout completion:^(BOOL completed, BOOL finish) {
if (finish) {
self.collectionView.contentOffset = toContentOffset;
}
}];
UPDATE
I posted an implementation of this and a working example in a new GitHub library TLLayoutTransitioning. The example is non-interactive, intended to demonstrate improved animation over setCollectionViewLayout:animated:completion, but it utilizes the interactive transitioning APIs combined with the technique described above. Take a look at the TLTransitionLayout class and try running the "Resize" example in the Examples workspace.
Perhaps TLTransitionLayout can accomplish what you need.
UPDATE 2
I added an interactive example to the TLLayoutTransitioning library. Try running the "Pinch" example in the Examples workspace. This one pinches the visible cells as a group. I'm working on another example that pinches an individual cell such that the cell follows your fingers during the transition while the other cells follow the default linear path.
UPDATE 3
I've recently added more content offset placement options: Minimal, Center, Top, Left, Bottom and Right. And transitionToCollectionViewLayout: now supports 30+ easing functions courtesy of Warren Moore's AHEasing library.
Thank you Timothy Moose. It works for iOS14 too. I didn't try interactions via finger but for a simple animation of changing a grid layout on list layout it works fine. You can replace
self.collectionView?.contentOffset = ...
with
setContentOffset(_ contentOffset: yourOffset, animated: false)
If you don't do this, content will bounce a bit during the animation.
Here's my example in Swift:
final class SFDocumentsManagerTransitionLayout: UICollectionViewTransitionLayout {
var fromContentOffset: CGFloat = 0
var toContentOffset: CGFloat = 0
override var transitionProgress: CGFloat {
didSet {
let f = 1 - self.transitionProgress
let t = self.transitionProgress
self.collectionView?.setContentOffset(CGPoint(x: 0,
y: f * self.fromContentOffset + t * self.toContentOffset),
animated: false)
}
}
}

MKMapView: Zoom to default "country level"

when putting a MKMapView into a UIViewController and setting it to Satellite view, the map is zoomed and adjusted to show the whole country the user is current located in: For example the United States as shown below.
When the user is now zooming the map or I'm zooming the map programmatically, it doesn't seem to be able to restore this default zoom level.
Of course I could find out the coordinates of this default zoom in order to zoom back to that setting, but practically I would need to do that for every single country which isn't really worth it.
Does someone know how to solve this problem?
To do that, it needs two types of information. The center coordinates for the selected country and the correct value to set for region.span on the map. It likely has access to that data from the Apple Maps database. I am not aware of that being made public to us. But there are other geocoding databases out there on the internet if you search around that might give you those two values.
If you find one that has what you want, then you set it on the map using:
myMKMapView.region.center = // Center coordinates of country
myMKMapView.region.span = // A value large enough to enclose the country.
You could just simply save default area on viewDidAppear and then use it when needed :}
class MyController: UIViewController {
#IBOutlet weak var mapView: MKMapView!
var defaultRegion: MKCoordinateRegion?
override func viewDidAppear(animated: Bool) {
defaultRegion = mapView.region
}
func showDefaultMapArea() {
if let region = defaultRegion {
mapView.setRegion(region, animated: true)
}
}

Programmatically determine order in which UIView items should be read by VoiceOver

I am making my iPhone app accessible. VoiceOver is pretty impressive. When a user uses VoiceOver, it automatically reads off the items on the screen and allows the user to double-tap anywhere on the screen to select that item. However, I want VoiceOver to read the items in a specific order, but it always begins with the UINavigationBar items including the back button. I don't want these items not to be read at all, I just want to start with a specific item. Is there a VoiceOver-equivalent of "firstResponder"?
Yes
There is a protocol called UIAccessibilityContainer that is implemented by NSObject. It enables you to customize the container behaviour using these three methods:
accessibilityElementCount
accessibilityElementAtIndex:
indexOfAccessibilityElement:
If you have a main view where you want to control the order of the accessibility elements you would just implement these three methods and return the suitable view/index. One more thing is that the container view cannot be an accessibility element itself so you should override isAccessibilityElement: and return NO;
- (BOOL)isAccessibilityElement {
return NO;
}
Example implementations
I suggest that you either have an array of all the views in the order you want them to appear or use the tag property if you don't use it for anything else. The implementation of the protocol becomes super simple.
Array of elements
I'm assuming that you have an array called accessibleElements that store the elements in the correct order.
- (NSInteger)accessibilityElementCount {
return self.accessibleElements.count;
}
- (id)accessibilityElementAtIndex:(NSInteger)index {
return self.accessibleElements[index];
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
return [self.accessibleElements indexOfObject:element];
}
Tagged elements
I'm assuming that your subviews are tagged continuously from 0 up to the number of subviews.
- (NSInteger)accessibilityElementCount {
return self.subviews.count;
}
- (id)accessibilityElementAtIndex:(NSInteger)index {
// Not that self should have a tag<0 or tag>count otherwise it will
// return itself for that tag instead of the element you want it to.
return [self viewWithTag:index];
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
return ((UIView *)element).tag;
}
In some cases, setting UIAccessibilityTraitSummaryElement on one item can do this. (My game seems too dynamic for this to help much.)
Swift 5 You can specify the order of element in accessibilityElements property
self.accessibilityElements = [lable1, label2, label2].compactMap {$0}
For specifying the order of CollectionView cells.
// MARK: collection view accessibility order
override func accessibilityElementCount() -> Int {
return numberOfItemInSection()// specify your items count
}
override func accessibilityElement(at index: Int) -> Any? {
return collectionView.cellForItem(at: IndexPath(item: index, section: 0))
}