iOS 10 has a feature I would like to replicate. When you 3D touch an album in the Apple Music app it opens the menu shown below. However unlike a normal peek and pop, it does not go away when you raise you finger. How do I replicate this?
-
Are you sure it's a 3D Touch and not just a long press? It also works on my iPhone 6+ which doesn't have 3D Touch. – Fogmeister Jul 17 '16 at 00:46
-
@Fogmeister Ok so I went and turned off 3D touch. If you notice it shows the same things but at the bottom with a cancel button added. I would actually like to do both. But the issue of how I would do it still stands. – Austin E Jul 17 '16 at 00:50
-
rather than using a peek and pop segue is it not possible to use some sort of force touch gesture to trigger it? I'm not on my computer at the moment but that's what I'd look for. – Fogmeister Jul 17 '16 at 00:52
-
@Fogmeister Ok, Ill look into it. When switching between a hold and a 3d touch it seems like they are done differently. When holding, it animates up from the bottom. When 3D touching it animates in from where the force was applied. – Austin E Jul 17 '16 at 00:54
-
The nice thing with 3D Touch is that you don't have to lift up your finger to select an action. It's really quick. Wish this was a default component. – Lapidus Jul 31 '17 at 15:09
3 Answers
The closest I got to replicating it is the following code.. It create a dummy-replica of the Music application.. Then I added the PeekPop-3D-Touch delegates.
However, in the delegate, I add an observer to the gesture recognizer and then cancel the gesture upon peeking but then re-enable it when the finger is lifted. To re-enable it, I did it async because the preview will disappear immediately without the async dispatch. I couldn't find a way around it..
Now if you tap outside the blue box, it will disappear like normal =]
https://i.stack.imgur.com/68Rwd.jpg https://i.stack.imgur.com/sOTnP.jpg
//
// ViewController.swift
// PeekPopExample
//
// Created by Brandon Anthony on 2016-07-16.
// Copyright © 2016 XIO. All rights reserved.
//
import UIKit
class MusicViewController: UITabBarController, UITabBarControllerDelegate {
var tableView: UITableView!
var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
self.initControllers()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
func initControllers() {
let libraryController = LibraryViewController()
let forYouController = UIViewController()
let browseController = UIViewController()
let radioController = UIViewController()
let searchController = UIViewController()
libraryController.title = "Library"
libraryController.tabBarItem.image = nil
forYouController.title = "For You"
forYouController.tabBarItem.image = nil
browseController.title = "Browse"
browseController.tabBarItem.image = nil
radioController.title = "Radio"
radioController.tabBarItem.image = nil
searchController.title = "Search"
searchController.tabBarItem.image = nil
self.viewControllers = [libraryController, forYouController, browseController, radioController, searchController];
}
}
And the implementation of ForceTouch pausing..
//
// LibraryViewController.swift
// PeekPopExample
//
// Created by Brandon Anthony on 2016-07-16.
// Copyright © 2016 XIO. All rights reserved.
//
import Foundation
import UIKit
//Views and Cells..
class AlbumView : UIView {
var albumCover: UIImageView!
var title: UILabel!
var artist: UILabel!
override init(frame: CGRect) {
super.init(frame: frame)
self.initControls()
self.setTheme()
self.doLayout()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func initControls() {
self.albumCover = UIImageView()
self.title = UILabel()
self.artist = UILabel()
}
func setTheme() {
self.albumCover.contentMode = .scaleAspectFit
self.albumCover.layer.cornerRadius = 5.0
self.albumCover.backgroundColor = UIColor.lightGray()
self.title.text = "Unknown"
self.title.font = UIFont.systemFont(ofSize: 12)
self.artist.text = "Unknown"
self.artist.textColor = UIColor.lightGray()
self.artist.font = UIFont.systemFont(ofSize: 12)
}
func doLayout() {
self.addSubview(self.albumCover)
self.addSubview(self.title)
self.addSubview(self.artist)
let views = ["albumCover": self.albumCover, "title": self.title, "artist": self.artist];
var constraints = Array<String>()
constraints.append("H:|-0-[albumCover]-0-|")
constraints.append("H:|-0-[title]-0-|")
constraints.append("H:|-0-[artist]-0-|")
constraints.append("V:|-0-[albumCover]-[title]-[artist]-0-|")
let aspectRatioConstraint = NSLayoutConstraint(item: self.albumCover, attribute: .width, relatedBy: .equal, toItem: self.albumCover, attribute: .height, multiplier: 1.0, constant: 0.0)
self.addConstraint(aspectRatioConstraint)
for constraint in constraints {
self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: constraint, options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: views))
}
for view in self.subviews {
view.translatesAutoresizingMaskIntoConstraints = false
}
}
}
class AlbumCell : UITableViewCell {
var firstAlbumView: AlbumView!
var secondAlbumView: AlbumView!
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.initControls()
self.setTheme()
self.doLayout()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func initControls() {
self.firstAlbumView = AlbumView(frame: CGRect.zero)
self.secondAlbumView = AlbumView(frame: CGRect.zero)
}
func setTheme() {
}
func doLayout() {
self.contentView.addSubview(self.firstAlbumView)
self.contentView.addSubview(self.secondAlbumView)
let views: [String: AnyObject] = ["firstAlbumView": self.firstAlbumView, "secondAlbumView": self.secondAlbumView];
var constraints = Array<String>()
constraints.append("H:|-15-[firstAlbumView(==secondAlbumView)]-15-[secondAlbumView(==firstAlbumView)]-15-|")
constraints.append("V:|-15-[firstAlbumView]-15-|")
constraints.append("V:|-15-[secondAlbumView]-15-|")
for constraint in constraints {
self.contentView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: constraint, options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: views))
}
for view in self.contentView.subviews {
view.translatesAutoresizingMaskIntoConstraints = false
}
}
}
//Details..
class DetailSongViewController : UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = UIColor.blue()
}
/*override func previewActionItems() -> [UIPreviewActionItem] {
let regularAction = UIPreviewAction(title: "Regular", style: .default) { (action: UIPreviewAction, vc: UIViewController) -> Void in
}
let destructiveAction = UIPreviewAction(title: "Destructive", style: .destructive) { (action: UIPreviewAction, vc: UIViewController) -> Void in
}
let actionGroup = UIPreviewActionGroup(title: "Group...", style: .default, actions: [regularAction, destructiveAction])
return [actionGroup]
}*/
}
//Implementation..
extension LibraryViewController : UIViewControllerPreviewingDelegate {
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
guard let indexPath = self.tableView.indexPathForRow(at: location) else {
return nil
}
guard let cell = self.tableView.cellForRow(at: indexPath) else {
return nil
}
previewingContext.previewingGestureRecognizerForFailureRelationship.addObserver(self, forKeyPath: "state", options: .new, context: nil)
let detailViewController = DetailSongViewController()
detailViewController.preferredContentSize = CGSize(width: 0.0, height: 300.0)
previewingContext.sourceRect = cell.frame
return detailViewController
}
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
//self.show(viewControllerToCommit, sender: self)
}
override func observeValue(forKeyPath keyPath: String?, of object: AnyObject?, change: [NSKeyValueChangeKey : AnyObject]?, context: UnsafeMutablePointer<Void>?) {
if let object = object {
if keyPath == "state" {
let newValue = change![NSKeyValueChangeKey.newKey]!.integerValue
let state = UIGestureRecognizerState(rawValue: newValue!)!
switch state {
case .began, .changed:
self.navigationItem.title = "Peeking"
(object as! UIGestureRecognizer).isEnabled = false
case .ended, .failed, .cancelled:
self.navigationItem.title = "Not committed"
object.removeObserver(self, forKeyPath: "state")
DispatchQueue.main.async(execute: {
(object as! UIGestureRecognizer).isEnabled = true
})
case .possible:
break
}
}
}
}
}
class LibraryViewController : UIViewController, UITableViewDelegate, UITableViewDataSource {
var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
self.initControls()
self.setTheme()
self.registerClasses()
self.registerPeekPopPreviews();
self.doLayout()
}
func initControls() {
self.tableView = UITableView(frame: CGRect.zero, style: .grouped)
}
func setTheme() {
self.edgesForExtendedLayout = UIRectEdge()
self.tableView.dataSource = self;
self.tableView.delegate = self;
}
func registerClasses() {
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Default")
self.tableView.register(AlbumCell.self, forCellReuseIdentifier: "AlbumCell")
}
func registerPeekPopPreviews() {
//if (self.traitCollection.forceTouchCapability == .available) {
self.registerForPreviewing(with: self, sourceView: self.tableView)
//}
}
func doLayout() {
self.view.addSubview(self.tableView)
let views: [String: AnyObject] = ["tableView": self.tableView];
var constraints = Array<String>()
constraints.append("H:|-0-[tableView]-0-|")
constraints.append("V:|-0-[tableView]-0-|")
for constraint in constraints {
self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: constraint, options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: views))
}
for view in self.view.subviews {
view.translatesAutoresizingMaskIntoConstraints = false
}
}
func numberOfSections(in tableView: UITableView) -> Int {
return 2
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return section == 0 ? 5 : 10
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return (indexPath as NSIndexPath).section == 0 ? 44.0 : 235.0
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return section == 0 ? 75.0 : 50.0
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return 0.0001
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return section == 0 ? "Library" : "Recently Added"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if (indexPath as NSIndexPath).section == 0 { //Library
let cell = tableView.dequeueReusableCell(withIdentifier: "Default", for: indexPath)
switch (indexPath as NSIndexPath).row {
case 0:
cell.accessoryType = .disclosureIndicator
cell.textLabel?.text = "Playlists"
case 1:
cell.accessoryType = .disclosureIndicator
cell.textLabel?.text = "Artists"
case 2:
cell.accessoryType = .disclosureIndicator
cell.textLabel?.text = "Albums"
case 3:
cell.accessoryType = .disclosureIndicator
cell.textLabel?.text = "Songs"
case 4:
cell.accessoryType = .disclosureIndicator
cell.textLabel?.text = "Downloads"
default:
break
}
}
if (indexPath as NSIndexPath).section == 1 { //Recently Added
let cell = tableView.dequeueReusableCell(withIdentifier: "AlbumCell", for: indexPath)
cell.selectionStyle = .none
return cell
}
return tableView.dequeueReusableCell(withIdentifier: "Default", for: indexPath)
}
}

- 22,723
- 11
- 93
- 186
-
I guess where I'm lost is that I don't have to swipe up. I know when I 3D touch an text thread and swipe up I get reply options. But when I 3D touch an album, it just animates the screen shown above. No swiping up or anything. – Austin E Jul 17 '16 at 00:56
This actually might be done using UIPreviewInteraction API.
https://developer.apple.com/documentation/uikit/uipreviewinteraction
It is almost similar to the Peek and Pop API.
Here we have 2 phases: Preview and Commit which are corresponding to the Peek and Pop in the later API. we have UIPreviewInteractionDelegate which gives us the access to the transition through these phases.
So what one should do is, to replicate the above Apple Music Popup,
Manually show a blur overlay during didUpdatePreviewTransition
Build an xib of the above menu and show it during didUpdateCommitTransition
- You can make the view stay there on commitTransition phase end.
Actually, apple has built a demo of this in the form of a Chat App.
Download the sample code from here and test it out.

- 870
- 2
- 10
- 27
I wrote some code to replicate like apple music style peek and pop.
Work like below
Explanation
- TopView.xib, TopView.swift (You can customize it)
- PeekAndPopActionView.swift (View for single action, such as download, share ..)
- PeekAndPopController.swift (Present, Dismiss the view)
- ForceTouchGestureRecognizer.swift (Detect Force Touch)
Usage
fileprivate let peekedViewController = PeekAndPopController()
@IBAction func presentAction(_ sender: Any) {
present(peekedViewController, animated: true)
}
let forceTouch = ForceTouchGestureRecognizer()
override func viewDidLoad() {
super.viewDidLoad()
forceTouch.addTarget(self, action: #selector(touchAction(_:)))
forceTouch.cancelsTouchesInView = false
view.addGestureRecognizer(forceTouch)
let download = PeekAndPopActionView(text: "Download", image: #imageLiteral(resourceName: "btnDownload"), handler: {
print("Download Action")
})
let playNext = PeekAndPopActionView(text: "Play Next", image: #imageLiteral(resourceName: "btnDownload"), handler: {
print("Play Next Action")
})
let playLast = PeekAndPopActionView(text: "Play Later", image: #imageLiteral(resourceName: "btnDownload"), handler: {
print("Play Last Action")
})
let share = PeekAndPopActionView(text: "Share", image: #imageLiteral(resourceName: "btnDownload"), handler: {
print("Share Action")
})
peekedViewController.addAction(download)
peekedViewController.addAction(playNext)
peekedViewController.addAction(playLast)
peekedViewController.addAction(share)
peekedViewController.topView = TopView().loadNib()
peekedViewController.topView?.handler = {
print("Play Play Play")
}
}
@objc func touchAction(_ gesture: ForceTouchGestureRecognizer) {
print(#function, gesture.touch?.location(in: view) ?? "")
present(peekedViewController, animated: true)
}

- 1,107
- 9
- 16