3D Touch Peek and Pop

In my last post I covered adding 3D Touch Quick Actions. This time I will look at adding peek and pop previewing of a view controller to my WorldFacts sample project. It is an example of a master-detail split view controller. The master view controller is table view controller of type UYLCountryTableViewController and the detail view controller is of type UYLCountryViewController.

Last updated: Jun 12, 2020: Peek and Pop are deprecated in iOS 13 in favour of contextual menus. See Adding Context Menus in iOS 13.

Peeking and Popping

Touching a country cell in the master table view first blurs the surrounding content and then when the user presses harder shows a preview of the country view controller. If the user continues to press the preview pops to the country view:

Peeking a view controller

Interface Builder Support for Peek and Pop

The release notes for Xcode 7.1 include the news that you will be able to add peek and pop to storyboard segues directly in Interface Builder:

Interface Builder supports enabling Peek & Pop for segues. Peek & Pop segues will be omitted when running on OS versions prior to iOS 9.1.

That makes it almost too easy to add peek and pop to a storyboard segue:

Peek and Pop Segue

I could stop here but note the caveat in the release notes. This will only work for iOS 9.1 so until you want to drop support for iOS 9.0.x you will need to add code. That should be no big deal as the code to add support is trivial.

Steps to Support Peek & Pop

  • Register a view controller and one or more of its views for previewing.
  • Use the previewingContext:viewControllerForLocation: delegate method to create the preview (peek).
  • Use the previewingContext:commitViewController: delegate method to transition (pop) to the previewed view controller.

Note: These methods are deprecated in iOS 13, you should migrate to context menus. See Adding Context Menus in iOS 13.

Registering

Let’s get started by checking if 3D Touch is available and then registering our view controller as the 3D Touch preview delegate. Add the following to the viewDidLoad method of our table view controller:

if ([self.traitCollection
  respondsToSelector:@selector(forceTouchCapability)] &&
  (self.traitCollection.forceTouchCapability == 
     UIForceTouchCapabilityAvailable)) {
  [self registerForPreviewingWithDelegate:self sourceView:self.view];
}

We also need to declare that our view controller conforms to the previewing delegate protocol:

@interface UYLCountryTableViewController ()
           <UIViewControllerPreviewingDelegate, ...>

Notes:

  • The UITraitCollection class gains an extra property in iOS 9 to show if 3D Touch is available. To avoid crashing on pre-iOS 9 devices we do the respondsToSelector dance first before checking the property. (Too bad we cannot use Swift API availability checking).

  • Specify the view the user will touch to trigger the preview as the sourceView argument. In this case we use the table view. You can register more than one view for the same view controller.

  • The registerForPreviewingWithDelegate: method returns a context object for managing the preview. The systems manages the life cycle of this object so you do not need to unregister when the view controller is deallocated.

  • If you want to disable previewing for a view you can call unregisterForPreviewingWithContext: with the context returned when you registered.

Peeking

When a user presses the source view we registered our view controller receives a call to the previewingContext:viewControllerForLocation: delegate method:

- (UIViewController *)previewingContext:
  (id<UIViewControllerPreviewing>)previewingContext
  viewControllerForLocation:(CGPoint)location {

The delegate method contains the location of the touch which we can use to get the indexPath of the table view cell that the user touched:

  NSIndexPath *indexPath = [self.tableView 
    indexPathForRowAtPoint:location];

Then using the indexPath we can retrieve the country object and table view cell:

  Country *country = [self countryForIndexPath:indexPath];
  if (country) {
    CountryCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];

Once we have the table view cell we use the cell frame as the sourceRect for the previewing context. As we will see shortly the system uses this sourceRect to create the visual effect that tells the user they can peek:

    if (cell) {
      previewingContext.sourceRect = cell.frame;

We then create, configure and return our detail view controller for the preview. Note that there is a subtle problem here. Our detail view controller, a UYLCountryViewController, is usually embedded in a navigation controller and uses the title in the navigation bar to show the name of the country. So instead of creating a UYLCountryViewController for our preview we need to create the navigation controller that embeds the view controller. This is easy enough to do straight from our storyboard:

      UINavigationController *navController = [self.storyboard 
      instantiateViewControllerWithIdentifier:@"UYLCountryNavController"];
      [self configureNavigationController:navController
        withCountry:country];
      return navController;
    }
  }

If any of the above steps failed we return nil to disable the preview:

  return nil;
}

When a user first presses on our registered view the content surrounding the sourceRect blurs to show that peeking is possible.

Blur

If the user presses harder they see the preview of our view controller:

Peek

If you want to adjust the size of the preview set the preferredContentSize property of the view controller.

Popping

When the user presses deeper on the peek view we transition with a pop to the view. We already have an instance of the view controller so in the previewingContext:commitViewController delegate method we present it as normal - in this case a show detail presentation:

- (void)previewingContext:
    (id<UIViewControllerPreviewing>)previewingContext
    commitViewController:(UIViewController *)viewControllerToCommit {
  [self showDetailViewController:viewControllerToCommit sender:self];
}

Preview Actions

When the user swipes upwards on a preview you can, if it makes sense, display quick actions to the user. You create the actions in the view controller that you are previewing by returning an array of preview actions (or groups of actions) from the method previewActionItems. For example, if we lazily instantiate the array of actions, it might look something like this:

- (NSArray<id<UIPreviewActionItem>> *)previewActionItems {
  return self.previewActions;
}

Then to create a UIPreviewAction, we specify the action title, style and a block to handle the action:

- (NSArray<id<UIPreviewActionItem>> *)previewActions {    
  if (_previewActions == nil) {
    UIPreviewAction *printAction = [UIPreviewAction
     actionWithTitle:@"Print"
               style:UIPreviewActionStyleDefault
             handler:^(UIPreviewAction * _Nonnull action,
                    UIViewController * _Nonnull previewViewController) {
      // ... code to handle action here
      }];
    _previewActions = @[printAction];
  }
  return _previewActions;
}

Note though that we cannot simply add the above code to our UYLCountryViewController class. As we saw when setting up the preview we are previewing a navigation controller that embeds our detail view controller. So if we want to add actions we would need to subclass the navigation controller. I will leave that as an exercise for the reader.

Sample Code

The updated sample code is in my Code Examples GitHub repository.

Further Reading