iOS 9: How to Peek & Pop A Specific View Inside a UITableViewCell
Over the past few days, I’ve been hectically working on the try! Swift Conference app (now only 2.5 more weeks to show time 😱)! One of my stretch goal MVP features was implementing 3D Touch to Peek & Pop speaker and presentation information in the app. Luckily, @allonsykraken made it so easy with this Peek & Pop Spirit Guide, that it took only minutes to implement on the main table views in my app.
However, I got slightly stuck on the Q&A Session screen. I wanted specific views in the cell – the speaker images – to peek & pop, not the entire cell as in the other table views!
Since this took me slightly longer to figure out, I wanted to add a note on how I did it (make sure to read Peek & Pop Spirit Guide first!).
The Problem
To get the Peak & Pop to work, we need to conform to the UIViewControllerPreviewingDelegate protocol – this is what tells us the location of the view the user 3D Touched, and this is where we return the ViewController that should be Peeked & Popped.
Since my images are in the cell and I want to differentiate between which speaker image was 3D Touched, I configured my cell conform to the UIViewControllerPreviewingDelegate:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
// QASessionTableViewCell // full code for QASessionTableViewCell here: https://github.com/NatashaTheRobot/trySwiftApp/blob/master/trySwift/QASessionTableViewCell.swift protocol QASessionSpeakerPopDelegate: class { // this will be implemented by the view controller // to configure proper navigation func onCommitViewController(viewController: UIViewController) } extension QASessionTableViewCell: UIViewControllerPreviewingDelegate { // UIViewControllerPreviewingDelegate conformance func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { let viewsTo3DTouch = [speaker1ImageView, speaker2ImageView, speaker3ImageView] for (index, view) in viewsTo3DTouch.enumerate() where touchedView(view, location: location) { if let speaker = qaSession?.speakers[index] { // returns the correct view controller // for displaying the speakers info to peek & pop! return viewControllerForSpeaker(speaker) } } return nil } func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) { // if the user decides to pop the view, // we need the table view to navigate to the correct view controller // the delegate will be assigned when configuring the cell delegate?.onCommitViewController(viewControllerToCommit) } // helper methods private func touchedView(view: UIView, location: CGPoint) -> Bool { let locationInView = view.convertPoint(location, fromView: contentView) return CGRectContainsPoint(view.bounds, locationInView) } private func viewControllerForSpeaker(speaker: Speaker) -> UIViewController { let speakerDetailVC = SpeakerDetailViewController() speakerDetailVC.speaker = speaker return speakerDetailVC } } // QASessionsTableViewController // full code here: https://github.com/NatashaTheRobot/trySwiftApp/blob/master/trySwift/QASessionsTableViewController.swift extension QASessionsTableViewController: QASessionSpeakerPopDelegate { // the view controller is delegated to handle proper navigation // when the view is popped func onCommitViewController(viewController: UIViewController) { navigationController?.pushViewController(viewController, animated: true) } } |
The problem is that in order for the Peek & Pop functionality to work, we need to register the view and delegate by calling registerForPreviewingWithDelegate, but registerForPreviewingWithDelegate is a method in the UIViewController, so we cannot register the views within the cell!
The Solution
The key here is that we now have to register every single cell in the view controller (vs the entire table view like on the Schedule or Speaker tab in the demo above!).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// QASessionsTableViewController class QASessionsTableViewController: UITableViewController { // truncated // full code here: https://github.com/NatashaTheRobot/trySwiftApp/blob/master/trySwift/QASessionsTableViewController.swift override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier(String(QASessionTableViewCell), forIndexPath: indexPath) as! QASessionTableViewCell let qaSession = dataSource.qaSessions[indexPath.section] cell.configure(withQASession: qaSession, delegate: self) // THIS IS WHERE THE MAGIC HAPPENS // We need to register every cell as the force touch delegate! if traitCollection.forceTouchCapability == .Available { registerForPreviewingWithDelegate(cell, sourceView: cell.contentView) } return cell } } |
UPDATE
@davedelong pointed out that having a cell produce a view controller seems wrong. Which I totally agree with! But at the time of writing the code with a tight deadline, I couldn’t figure out a better solution. Luckily, @davedelong also pointed me to the better solution, one that allows me to keep my view controller code in the view controller!
This code could use some refactoring, but hopefully you get the general idea!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
// QASessionsTableViewController class QASessionsTableViewController: UITableViewController { var dataSource: QASessionDataSourceProtocol! override func viewDidLoad() { super.viewDidLoad() // other setup here // Just register the whole table view for force touch // no need to register the individual cells! if traitCollection.forceTouchCapability == .Available { registerForPreviewingWithDelegate(self, sourceView: tableView) } } // truncated } // MARK: Force Touch on Speaker Images extension QASessionsTableViewController: UIViewControllerPreviewingDelegate { func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { if let indexPath = tableView.indexPathForRowAtPoint(location) { let cell = tableView.cellForRowAtIndexPath(indexPath) as! QASessionTableViewCell let viewsTo3DTouch = [cell.speaker1ImageView, cell.speaker2ImageView, cell.speaker3ImageView] for (index, view) in viewsTo3DTouch.enumerate() where touchedView(view, location: location) { //This will show the image clearly and blur the rest of the screen for our peek. // have to convert the image view coordinates to table view coordinate space // (let me know if there's a better way to do the coordinate space conversion) let viewRectInTableView = tableView.convertRect(view.frame, fromCoordinateSpace: view.superview!) previewingContext.sourceRect = viewRectInTableView // configuring the view controller to show let qaSession = dataSource.qaSessions[indexPath.section] let speaker = qaSession.speakers[index] return viewControllerForSpeaker(speaker) } } return nil } func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) { navigationController?.pushViewController(viewControllerToCommit, animated: true) } private func touchedView(view: UIView, location: CGPoint) -> Bool { let locationInView = view.convertPoint(location, fromView: tableView) return CGRectContainsPoint(view.bounds, locationInView) } private func viewControllerForSpeaker(speaker: Speaker) -> UIViewController { let speakerDetailVC = SpeakerDetailViewController() speakerDetailVC.speaker = speaker return speakerDetailVC } } |
Here is the demo of what this looks like – notice that the image selected is no longer blurry while everything around it is!
Conclusion
So it’s really not that hard to get a super specific view to work with 3D Touch! And in general, 3D touch is super simple and fun to implement. I highly recommend adding this super simple, but fun and powerful interaction to your app!