Bottom sheet modal view controller with swift
当我们想弹出一个预览视图,bottom sheet modal view controller 非常实用。在 iOS 中,长按拖拽手势可以让 controller 上滑或者向下消失。
实现原理是,通过监听拖拽事件,动态改变 view 之间的 auto layout 约束,并加上少许动画。
下面看源码:
第一个页面 ViewController.swift:
import UIKit
class ViewController: UIViewController {
// Defined UI views
lazy var titleLabel: UILabel = {
let label = UILabel()
label.text = "百字令·偶忆"
label.font = .boldSystemFont(ofSize: 32)
return label
}()
lazy var textView: UITextView = {
let textView = UITextView(frame: .zero)
textView.font = UIFont.systemFont(ofSize: 22)
textView.isEditable = false
textView.text = "横街南巷,记钿车小小,翠帘徐揭。绿酒分曹人散后,心事低徊潜说。莲子湖头,枇杷花下,绾就同心结。明珠未斛,朔风千里催别。\n同是沦落天涯,青青柳色,争忍先攀折。红浪香温围夜玉,堕我怀中明月。暮雨空归,秋河不动,虬箭丁丁咽。十年一梦,鬓丝今已如雪。 "
return textView
}()
lazy var registerButton: UIButton = {
let button = UIButton()
button.setTitle("Get Started", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = view.tintColor
button.layer.cornerRadius = 8
button.clipsToBounds = true
return button
}()
lazy var containerStackView: UIStackView = {
let spacer = UIView()
let stackView = UIStackView(arrangedSubviews: [titleLabel, textView, spacer, registerButton])
stackView.axis = .vertical
stackView.spacing = 16.0
return stackView
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
setupConstraints()
// 3. Add action
registerButton.addTarget(self, action: #selector(presentModalController), for: .touchUpInside)
}
func setupView() {
// cosmetics
view.backgroundColor = .systemBackground
}
// Add subviews and set constraints
func setupConstraints() {
view.addSubview(containerStackView)
containerStackView.translatesAutoresizingMaskIntoConstraints = false
let safeArea = view.safeAreaLayoutGuide
// Call .activate method to enable the defined constraints
NSLayoutConstraint.activate([
// Set containerStackView edges to superview with 24 spacing
containerStackView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 24),
containerStackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -24),
containerStackView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24),
containerStackView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24),
// Set button height
registerButton.heightAnchor.constraint(equalToConstant: 50)
])
}
// To be updated
@objc func presentModalController() {
let vc = CustomModalViewController()
vc.modalPresentationStyle = .overCurrentContext
// Keep animated value as false
// Custom Modal presentation animation will be handled in VC itself
self.present(vc, animated: false)
}
}
第二个页面 CustomModalViewController.swift:
import UIKit
class CustomModalViewController: UIViewController {
// define lazy views
lazy var titleLabel: UILabel = {
let label = UILabel()
label.text = "Get Started"
label.font = .boldSystemFont(ofSize: 20)
return label
}()
lazy var notesLabel: UILabel = {
let label = UILabel()
label.text = "横街南巷,记钿车小小,翠帘徐揭。绿酒分曹人散后,心事低徊潜说。莲子湖头,枇杷花下,绾就同心结。明珠未斛,朔风千里催别。\n同是沦落天涯,青青柳色,争忍先攀折。红浪香温围夜玉,堕我怀中明月。暮雨空归,秋河不动,虬箭丁丁咽。十年一梦,鬓丝今已如雪。 "
label.font = .systemFont(ofSize: 16)
label.textColor = .darkGray
label.numberOfLines = 0
return label
}()
lazy var contentStackView: UIStackView = {
let spacer = UIView()
let stackView = UIStackView(arrangedSubviews: [titleLabel, notesLabel, spacer])
stackView.axis = .vertical
stackView.spacing = 12.0
return stackView
}()
lazy var containerView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 16
view.clipsToBounds = true
return view
}()
let maxDimmedAlpha: CGFloat = 0.6
lazy var dimmedView: UIView = {
let view = UIView()
view.backgroundColor = .black
view.alpha = maxDimmedAlpha
return view
}()
let defaultHeight: CGFloat = 300
let dismissibleHeight: CGFloat = 200
let maximumContainerHeight: CGFloat = UIScreen.main.bounds.height - 64
// keep updated with new height
var currentContainerHeight: CGFloat = 300
// Dynamic container constraint
var containerViewHeightConstraint: NSLayoutConstraint?
var containerViewBottomConstraint: NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
setupView()
setupConstraints()
// tap gesture on dimmed view to dismiss
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.handleCloseAction))
dimmedView.addGestureRecognizer(tapGesture)
setupPanGesture()
}
@objc func handleCloseAction() {
animateDismissView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animateShowDimmedView()
animatePresentContainer()
}
func setupView() {
view.backgroundColor = .clear
}
func setupConstraints() {
// Add subviews
view.addSubview(dimmedView)
view.addSubview(containerView)
dimmedView.translatesAutoresizingMaskIntoConstraints = false
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(contentStackView)
contentStackView.translatesAutoresizingMaskIntoConstraints = false
// Set static constraints
NSLayoutConstraint.activate([
// set dimmedView edges to superview
dimmedView.topAnchor.constraint(equalTo: view.topAnchor),
dimmedView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
dimmedView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// set container static constraint (trailing & leading)
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// content stackView
contentStackView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 32),
contentStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -20),
contentStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20),
contentStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20),
])
// Set dynamic constraints
// First, set container to default height
// after panning, the height can expand
containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: defaultHeight)
// By setting the height to default height, the container will be hide below the bottom anchor view
// Later, will bring it up by set it to 0
// set the constant to default height to bring it down again
containerViewBottomConstraint = containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: defaultHeight)
// Activate constraints
containerViewHeightConstraint?.isActive = true
containerViewBottomConstraint?.isActive = true
}
@objc func handlePanGesture(gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
// drag to top will be minus value and vice versa
print("Pan gesture y offset: \(translation.y)")
// get drag direction
let isDraggingDown = translation.y > 0
print("Dragging direction: \(isDraggingDown ? "going down" : "going up")")
// New height is based on value of dragging plus current container height
let newHeight = currentContainerHeight - translation.y
// Handle based on gesture state
switch gesture.state {
case .changed:
// This state will occur when user is dragging
if newHeight < maximumContainerHeight {
// Keep updating the height constraint
containerViewHeightConstraint?.constant = newHeight
// refresh layout
view.layoutIfNeeded()
}
case .ended:
// This happens when user stop drag,
// so we will get the last height of container
// Condition 1: If new height is below min, dismiss controller
if newHeight < dismissibleHeight {
self.animateDismissView()
}
else if newHeight < defaultHeight {
// Condition 2: If new height is below default, animate back to default
animateContainerHeight(defaultHeight)
}
else if newHeight < maximumContainerHeight && isDraggingDown {
// Condition 3: If new height is below max and going down, set to default height
animateContainerHeight(defaultHeight)
}
else if newHeight > defaultHeight && !isDraggingDown {
// Condition 4: If new height is below max and going up, set to max height at top
animateContainerHeight(maximumContainerHeight)
}
default:
break
}
}
func setupPanGesture() {
// add pan gesture recognizer to the view controller's view (the whole screen)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handlePanGesture(gesture:)))
// change to false to immediately listen on gesture movement
panGesture.delaysTouchesBegan = false
panGesture.delaysTouchesEnded = false
view.addGestureRecognizer(panGesture)
}
func animateContainerHeight(_ height: CGFloat) {
UIView.animate(withDuration: 0.4) {
// Update container height
self.containerViewHeightConstraint?.constant = height
// Call this to trigger refresh constraint
self.view.layoutIfNeeded()
}
// Save current height
currentContainerHeight = height
}
func animatePresentContainer() {
// Update bottom constraint in animation block
UIView.animate(withDuration: 0.3) {
self.containerViewBottomConstraint?.constant = 0
// Call this to trigger refresh constraint
self.view.layoutIfNeeded()
}
}
func animateShowDimmedView() {
dimmedView.alpha = 0
UIView.animate(withDuration: 0.4) {
self.dimmedView.alpha = self.maxDimmedAlpha
}
}
func animateDismissView() {
// hide blur view
dimmedView.alpha = maxDimmedAlpha
UIView.animate(withDuration: 0.4) {
self.dimmedView.alpha = 0
} completion: { _ in
// once done, dismiss without animation
self.dismiss(animated: false)
}
// hide main view by updating bottom constraint in animation block
UIView.animate(withDuration: 0.3) {
self.containerViewBottomConstraint?.constant = self.defaultHeight
// call this to trigger refresh constraint
self.view.layoutIfNeeded()
}
}
}
参考:
源码下载:CustomModalVC