How do I replicate iOS 10's Apple Music “Peek

2019-02-03 12:23发布

问题:

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?

回答1:

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 =]

http://i.imgur.com/073M2Ku.jpg http://i.imgur.com/XkwUBly.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)
    }
}


回答2:

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.



回答3:

I wrote some code to replicate like apple music style peek and pop.

  • Repository

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)
}