iosipadpopoversize-classesadaptive-layout

How can I create a segue that will use a popover on iPad, and push onto the navigation stack on the iPhone?


In my app, there are certain view controllers where on iPad (or to be more specific, a regular horizontal size class) it makes sense to present them as popovers, but on iPhone (or a compact horizontal size class) it makes sense to push them onto the navigation stack. Is there an elegant way to support this? By default, if I use a "Present as Popover" segue, it will display modally on iPhone, which isn't what I want.

I've found a way to get the behavior I want, but it's ugly and seems error-prone. I choose between two different segues based on what size class I'm currently in. In order to support iOS 9 multitasking, I implement [UIViewController willTransitionToTraitCollection:withTransitionCoordinator] and manually move the view controller between a popover and the navigation controller (this part seems particularly error-prone).

It seems like there should be some simple way to implement either a custom segue to handle this, or some sort of custom adaptive presentation controller, but I haven't been able to wrap my head around it. Has anyone had success doing this?


Solution

  • Here's what I ended up building. I'm not super happy with it, which is why I haven't posted it until now. It won't support two segues going to view controllers with the same class, and it requires you to keep track of the source rect and source view for the popover yourself. But maybe it will be a good starting point for someone else.

    PushPopoverSegue.swift

    import UIKit
    
    class PushPopoverSegue: UIStoryboardSegue {
    
        var sourceBarButtonItem: UIBarButtonItem!
        var permittedArrowDirections: UIPopoverArrowDirection = .Any
    
        override func perform() {
            assert( self.sourceViewController.navigationController != nil )
            assert( self.sourceBarButtonItem != nil )
    
            if self.sourceViewController.traitCollection.horizontalSizeClass == .Compact {
                self.sourceViewController.navigationController!.pushViewController(self.destinationViewController, animated: true)
            }
            else {
                let navigationController = UINavigationController(rootViewController: self.destinationViewController)
                let popover = UIPopoverController(contentViewController: navigationController)
                popover.presentPopoverFromBarButtonItem(self.sourceBarButtonItem, permittedArrowDirections: self.permittedArrowDirections, animated: true)
            }
        }
    
    }
    

    UIViewController+PushPopoverTransition.h

    #import <UIKit/UIKit.h>
    
    @interface UIViewController (PushPopoverTransition)
    
    - (void) transitionPushPopoversToHorizontalSizeClass: (UIUserInterfaceSizeClass) sizeClass withMapping: (NSDictionary*) mapping;
    
    @end
    

    UIViewController+PushPopoverTransition.m

    #import "UIViewController+PushPopoverTransition.h"
    
    @implementation UIViewController (PushPopoverTransition)
    
    - (void) transitionPushPopoversToHorizontalSizeClass: (UIUserInterfaceSizeClass) sizeClass withMapping: (NSDictionary*) mapping
    {
        if ( sizeClass == UIUserInterfaceSizeClassCompact )
        {
            if ( self.presentedViewController == nil )
                return;
    
            NSParameterAssert( [self.presentedViewController isKindOfClass:[UINavigationController class]] );
            UINavigationController* navigationController = (UINavigationController*) self.presentedViewController;
            NSArray* viewControllers = navigationController.viewControllers;
            UIViewController* topOfStack = viewControllers[0];
    
            if ( [mapping.allKeys containsObject:NSStringFromClass( [topOfStack class] ) ] )
            {
                [self.presentedViewController dismissViewControllerAnimated:NO completion:^{
                    for ( UIViewController* viewController in viewControllers )
                        [self.navigationController pushViewController:viewController animated:NO];
                }];
            }
        }
        else if ( sizeClass == UIUserInterfaceSizeClassRegular )
        {
            NSUInteger indexOfSelf = [self.navigationController.viewControllers indexOfObject:self];
    
            if ( indexOfSelf < self.navigationController.viewControllers.count  - 1 )
            {
                UIViewController* topOfStack = self.navigationController.viewControllers[indexOfSelf + 1];
                if ( [mapping.allKeys containsObject:NSStringFromClass( [topOfStack class] )] )
                {
                    NSArray* poppedControllers = [self.navigationController popToViewController:self animated:NO];
                    UINavigationController* navigationController = [[UINavigationController alloc] init];
                    navigationController.modalPresentationStyle = UIModalPresentationPopover;
                    navigationController.viewControllers = poppedControllers;
    
                    id popoverSource = mapping[NSStringFromClass( [topOfStack class] )];
                    if ( [popoverSource isKindOfClass:[UIBarButtonItem class]] )
                    {
                        navigationController.popoverPresentationController.barButtonItem = popoverSource;
                    }
                    else if ( [popoverSource isKindOfClass:[NSArray class]] )
                    {
                        NSArray* popoverSourceArray = (NSArray*) popoverSource;
                        NSParameterAssert(popoverSourceArray.count == 2);
                        UIView* sourceView = popoverSourceArray[0];
                        CGRect sourceRect = [(NSValue*) popoverSourceArray[1] CGRectValue];
                        navigationController.popoverPresentationController.sourceView = sourceView;
                        navigationController.popoverPresentationController.sourceRect = sourceRect;
                    }
    
                    [self presentViewController:navigationController animated:NO completion:nil];
                }
            }
        }
    }
    
    @end
    

    Example Usage

    Create a segue in interface builder, and set its "Kind" to Custom, and its "Class" to PushPopoverSegue.

    ViewController.m

    - (void) prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
    {
        ((PushPopoverSegue*) segue).sourceView = /* source view */;
        ((PushPopoverSegue*) segue).sourceRect = /* source rect */;
    }
    
    -(void) willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
    {
        if ( newCollection.horizontalSizeClass == UIUserInterfaceSizeClassUnspecified )
            return;
    
        [self transitionPushPopoversToHorizontalSizeClass:newCollection.horizontalSizeClass withMapping:@{
            @"MyDestinationViewController": @[ /* source view */,
                                           [NSValue valueWithCGRect:/* source rect*/] ]
        }];
    }