iOS實作一個內容高度自適應的Bottom Sheet

吳展文David Wu 2020/12/09 16:41:07
1965

ios實作一個內容高度自適應的Bottom Sheet

 

最近在專案中遇上需要做一個像是Android Bottom Sheet一樣的View,由於iOS並沒有類似的元件想當然只能自己手刻。一開始使用Google大法去參考網路大神們的文章以及套件的實作方法,再自己吸收後整理出自己需要的部分進行實作。

 

這次要實作的是一個有兩段滑動的Bottom Sheet,接下來就來解析實作概念及重點。

 

1.建立Sheet外層

我這裡實作的概念是把Sheet想成有外層Sheet的殼以及內容頁,只要替換內容頁Sheet就可以重複使用。全部的手勢控制以及動畫都在Sheet外殼實作。

 

這裡我們先建立一個VC取名為SheetViewController,使用xib以利未來重複使用。這頁xib只有兩個view,一個是最上方的滑動樣式的View以及下方裝載內容的ContenterView

 

 

 

2.建立內容

這邊為了示範便利使用TableView取名為ListViewController。這頁很簡單就是把要顯示的假資料設定好。這頁只有一個重點要注意;那就是要記得拉TableView底下約束的Outlet,這條約束很重要是用來決定畫面能否完整呈現的因素之一。

 

 

 

code的部分就很簡單把資料裝填而已

 

class ListViewController: UIViewController {
    
    // MARK: - IBOutlets
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var bottomConstraint: NSLayoutConstraint!
    
    var data = [Int]()
    
    // MARK: - View lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupData()
        setupUI()
    }
    
    private func setupData() {
        for i in 0...30 {
            data.append(i)
        }
        tableView.reloadData()
    }
    
    private func setupUI() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
}

extension ListViewController:UITableViewDelegate,UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = String(data[indexPath.row])
        return cell
    }
}

 

3.實作Sheet行為與動畫

回到SheetViewController,我們在前兩步時已經建立好所有的layout接著來實作Sheet最重要的滑動以及動畫。首先先用codeListViewController新增到SheetViewControllerContenterView中。這裡的containerHeight也很重要,藉由這個propertysheet滑動時能動態的增減高度。

 

private func setupSheetContentView() {
        listViewController = ListViewController(nibName: "ListViewController", bundle: nil)
        addChild(listViewController)
        listViewController.view.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(listViewController.view)
        
        let top = NSLayoutConstraint(item: listViewController.view!, attribute: .top, relatedBy: .equal, toItem: containerView, attribute: .top, multiplier: 1, constant: 0)
        let leading = NSLayoutConstraint(item: listViewController.view!, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .leading, multiplier: 1, constant: 0)
        let trailing = NSLayoutConstraint(item: listViewController.view!, attribute: .trailing, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: 1, constant: 0)
        containerHeight = NSLayoutConstraint(item: listViewController.view!, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .height, multiplier: 1, constant: 0)

        containerView.addConstraint(top)
        containerView.addConstraint(leading)
        containerView.addConstraint(trailing)
        containerView.addConstraint(containerHeight)

        listViewController.didMove(toParent: self)
    }

 

 

接著在SheetViewController viewDidAppear時會呼叫setupFirstPop來呈現按下Show Sheet 按鈕時的第一次彈出動畫

 

private func setupFirstPop() {
        containerHeight.constant = sheetExpandedHeight
        UIView.animate(withDuration: 0.6, animations: {
            self.view.frame = CGRect(x: 0, y: SheetYAnchor.expanded, width: self.view.frame.width, height: self.view.superview!.frame.height)
        })
        listViewController.bottomConstraint.constant =  self.containerHeight.constant + containerView.frame.minY + SheetYAnchor.expanded
    }

 

 

這裡containerHeight設定成sheetExpandedHeight,這裡的sheetExpandedHeight的設定的高度是self.view.frame.height - SheetYAnchor.expanded - containerView.frame.minY 詳細資訊都在最後的code中。然後listViewController.bottomConstraint.constant則是去計算第一次彈出高度sheetExpandedHeight的畫面偏移量來讓TableView可以完整顯示。

 

 

再來就是SheetViewController最重要的部分,手勢控制。這裡在SheetViewController本身加上一個PanGesture來控制滑動行為。

 

@objc private func panGesture(_ recognizer: UIPanGestureRecognizer) {
        if recognizer.state == .changed {
            let translation = recognizer.translation(in: view)
            let minY = view.frame.minY
            if (minY + translation.y >= SheetYAnchor.full) {
                containerHeight.constant += -translation.y
                view.frame = CGRect(x: 0, y: minY + translation.y, width: view.frame.width, height: view.frame.height)
                recognizer.setTranslation(CGPoint.zero, in: view)
            }
        }
        
        if recognizer.state == .ended {
            let director: Direction = recognizer.velocity(in: self.view).y >= 0 ? .down : .up
            
            if director == .up {
                if self.view.frame.minY < SheetYAnchor.expanded {
                    slideToFull()
                    return
                } else {
                    slideToExtanded()
                }
            }
            
            if (director == .down) {
                if (self.view.frame.minY > SheetYAnchor.expanded * 1.3) {
                    let velocity = (0.2 * recognizer.velocity(in: self.view).y)
                    let animationDuration = TimeInterval(abs(velocity*0.001) + 1.2)
                    slideDownAndDismiss(duration: animationDuration)
                    return
                } else {
                    slideToExtanded()
                    return
                }
            }
        }
    }

 

這裡在statechanged時只要最高沒有超過本身設定的full高度都可以自由的滑動,在滑動時去動態改變containerHeight來讓ListViewController的高度能及時的改變。changed還有一個值得注意的是setTranslationzero,這是讓持續滑動的view能顯示正常的原因;讓每次拖動view都更新一次新的location

 

最後再結束時去判斷該展開到全部或展開一半或是消失,使用velocity去判斷結束滑動時的最後一刻是往上還是往下。這裡如果最後滑動為往上而且高度超過了expanded則升到最高反之則回到expanded。往下的部分則是去判斷高度是否低於expanded7成;如果是則消失否則回到expanded

以上就是大致的實踐邏輯最後一點要注意的是當關掉Sheet時完成一定要把Sheet給銷毀不然View會一直存在。

 

private func slideDownAndDismiss(duration:TimeInterval) {
        UIView.animate(
            withDuration: duration,
            delay: 0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.8,
            options: [.curveEaseOut],
            animations: {
                self.view.frame = CGRect(x: 0, y: (self.view.superview?.frame.maxY)!, width: self.view.frame.width, height: self.view.frame.height)
        }, completion: { complete in
            self.removeFromParent()
            self.view.removeFromSuperview()
        })
    }

 

附上成果GIF

 

 

SheetViewController完整code

class SheetViewController: UIViewController {
    
    // MARK: - IBOutlets
    @IBOutlet weak var containerView: UIView!
        
    // MARK: - Params
    var listViewController:ListViewController!
    
    var sheetExpandedHeight:CGFloat {
        get {
            return self.view.frame.height - SheetYAnchor.expanded - containerView.frame.minY
        }
    }
    
    var sheetFullHeight:CGFloat {
        get {
            return self.view.frame.height - SheetYAnchor.full - containerView.frame.minY
        }
    }
    var containerHeight:NSLayoutConstraint!
    
    // MARK: - View lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupGesture()
        setupSheetContentView()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setupFirstPop()
    }
    
    private func setupUI() {
        view.layer.cornerRadius = 20
        view.clipsToBounds = true
    }
    
    private func setupGesture() {
        let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(panGesture))
        view.addGestureRecognizer(gesture)
    }
    
    private func setupSheetContentView() {
        listViewController = ListViewController(nibName: "ListViewController", bundle: nil)
        addChild(listViewController)
        listViewController.view.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(listViewController.view)
        
        let top = NSLayoutConstraint(item: listViewController.view!, attribute: .top, relatedBy: .equal, toItem: containerView, attribute: .top, multiplier: 1, constant: 0)
        let leading = NSLayoutConstraint(item: listViewController.view!, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .leading, multiplier: 1, constant: 0)
        let trailing = NSLayoutConstraint(item: listViewController.view!, attribute: .trailing, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: 1, constant: 0)
        containerHeight = NSLayoutConstraint(item: listViewController.view!, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .height, multiplier: 1, constant: 0)

        containerView.addConstraint(top)
        containerView.addConstraint(leading)
        containerView.addConstraint(trailing)
        containerView.addConstraint(containerHeight)

        listViewController.didMove(toParent: self)
    }

    private func setupFirstPop() {
        containerHeight.constant = sheetExpandedHeight
        UIView.animate(withDuration: 0.6, animations: {
            self.view.frame = CGRect(x: 0, y: SheetYAnchor.expanded, width: self.view.frame.width, height: self.view.superview!.frame.height)
        })
        listViewController.bottomConstraint.constant =  self.containerHeight.constant + containerView.frame.minY + SheetYAnchor.expanded
    }

    @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) {
        if recognizer.state == .changed {
            let translation = recognizer.translation(in: view)
            let minY = view.frame.minY
            if (minY + translation.y >= SheetYAnchor.full) {
                containerHeight.constant += -translation.y
                view.frame = CGRect(x: 0, y: minY + translation.y, width: view.frame.width, height: view.frame.height)
                recognizer.setTranslation(CGPoint.zero, in: view)
            }
        }
        
        if recognizer.state == .ended {
            let director: Direction = recognizer.velocity(in: self.view).y >= 0 ? .down : .up
            
            if director == .up {
                if self.view.frame.minY < SheetYAnchor.expanded {
                    slideToFull()
                    return
                } else {
                    slideToExtanded()
                    return
                }
            }
            
            if (director == .down) {
                if (self.view.frame.minY > SheetYAnchor.expanded * 1.3) {
                    let velocity = (0.2 * recognizer.velocity(in: self.view).y)
                    let animationDuration = TimeInterval(abs(velocity*0.001) + 1.2)
                    slideDownAndDismiss(duration: animationDuration)
                    return
                } else {
                    slideToExtanded()
                    return
                }
            }
        }
    }
    
    private func slideToFull() {
        containerHeight.constant = sheetFullHeight
        UIView.animate(withDuration: 0.3, animations: {
            self.view.frame = CGRect(x: 0, y: SheetYAnchor.full, width: self.view.frame.width, height: self.view.superview!.frame.height)
            self.view.layoutIfNeeded()
        })
    }
    
    private func slideToExtanded() {
        containerHeight.constant = sheetExpandedHeight
        UIView.animate(withDuration: 0.3, animations: {
            self.view.frame = CGRect(x: 0, y: SheetYAnchor.expanded, width: self.view.frame.width, height: self.view.superview!.frame.height)
            self.view.layoutIfNeeded()
        })
    }
    
    private func slideDownAndDismiss(duration:TimeInterval) {
        UIView.animate(
            withDuration: duration,
            delay: 0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.8,
            options: [.curveEaseOut],
            animations: {
                self.view.frame = CGRect(x: 0, y: (self.view.superview?.frame.maxY)!, width: self.view.frame.width, height: self.view.frame.height)
        }, completion: { complete in
            self.removeFromParent()
            self.view.removeFromSuperview()
        })
    }
}

extension SheetViewController {
    private enum Direction {
        case up
        case down
    }
    
    private enum SheetYAnchor {
        static let full: CGFloat = 130
        static var expanded: CGFloat = 400
    }
}

 

在要顯示Sheet的ViewController加上以下即可顯示

@IBAction func showTapped(_ sender: UIButton) {
        let bottomSheetVC = SheetViewController()

        self.addChild(bottomSheetVC)
        self.view.addSubview(bottomSheetVC.view)
        bottomSheetVC.didMove(toParent: self)

        let height = view.frame.height
        let width  = view.frame.width
        bottomSheetVC.view.frame = CGRect(x: 0, y: view.frame.maxY , width: width, height: height)
    }
吳展文David Wu