iOS實作一個內容高度自適應的Bottom Sheet
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最重要的滑動以及動畫。首先先用code把ListViewController新增到SheetViewController的ContenterView中。這裡的containerHeight也很重要,藉由這個property讓sheet滑動時能動態的增減高度。
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
}
}
}
}
這裡在state為changed時只要最高沒有超過本身設定的full高度都可以自由的滑動,在滑動時去動態改變containerHeight來讓ListViewController的高度能及時的改變。changed還有一個值得注意的是setTranslation為zero,這是讓持續滑動的view能顯示正常的原因;讓每次拖動view都更新一次新的location。
最後再結束時去判斷該展開到全部或展開一半或是消失,使用velocity去判斷結束滑動時的最後一刻是往上還是往下。這裡如果最後滑動為往上而且高度超過了expanded則升到最高反之則回到expanded。往下的部分則是去判斷高度是否低於expanded的7成;如果是則消失否則回到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)
}