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

3

8

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?

Cary answered 28/8, 2015 at 12:41 Comment(2)
did you find a better solution for what you were trying to achieve?Gunnar
I did, but it's not perfect. Added it as an answer, would love to hear if you figure out something better.Cary
C
1

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*/] ]
    }];
}
Cary answered 7/2, 2016 at 4:10 Comment(4)
Have you found anything better or are you still using this solution?Kneehigh
Still using it. It's still working for me, so I haven't had much reason to go back to this.Cary
Thanks. I will give it a try. Did you know that UIPopoverController was deprecated in iOS 9 though?Kneehigh
Also, it doesn't look like the segue includes sourceView and sourceRect. I don't know Swift, but hopefully I can figure it out : )Kneehigh
B
2

According to me this is simplest way,

Step 1: Create two segues from your one controller to another.
Step 2: Set one segue's segue property to push and popover of another
Step 3: Now call perform segue according to your requirements, i.e.iPad or iPhone

Here is a sample code

Sample code Note : Change bool condition to false to check another condition in didSelectRowAtIndexPath.

Blindly answered 28/8, 2015 at 13:1 Comment(2)
Yeah, that's what I'm doing now. But it gets particularly messy when trying to implement iOS 9 multitasking. I'm hoping to find a more elegant approach.Cary
Ok, this is approach that I thought.Blindly
C
1

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*/] ]
    }];
}
Cary answered 7/2, 2016 at 4:10 Comment(4)
Have you found anything better or are you still using this solution?Kneehigh
Still using it. It's still working for me, so I haven't had much reason to go back to this.Cary
Thanks. I will give it a try. Did you know that UIPopoverController was deprecated in iOS 9 though?Kneehigh
Also, it doesn't look like the segue includes sourceView and sourceRect. I don't know Swift, but hopefully I can figure it out : )Kneehigh
S
0

For iOS 17, I just configured a Popover segue in the storyboard and then did this in the presenting view controller:

- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)animated completion:(void (^)(void))completion {
    if ( UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad )
        [self.navigationController pushViewController:viewControllerToPresent animated:animated];
    else
        [super presentViewController:viewControllerToPresent animated:animated completion:completion];
}

Seems to work; not sure if this was possible in earlier SDK versions.

Selfconscious answered 22/12, 2023 at 4:31 Comment(2)
That method of UIViewController has existed since iOS 5 so this would have been possible since then.Highchair
The real tricky part is handling transitions between compact and regular size classes, and switching between the different presentation styles properly at this time. Unfortunately I don’t think they’ve done anything to make this easier over the many years since I initially asked this question.Cary

© 2022 - 2025 — McMap. All rights reserved.