可拖拽 Bottom Sheet View Controller

Posted 北冥鱼_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了可拖拽 Bottom Sheet View Controller相关的知识,希望对你有一定的参考价值。

当我们想弹出一个预览视图,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()
        
    
    

参考:

  1. How To Present a Bottom Sheet View Controller in iOS

源码下载:CustomModalVC

以上是关于可拖拽 Bottom Sheet View Controller的主要内容,如果未能解决你的问题,请参考以下文章

可拖拽 Bottom Sheet View Controller

android 可拖拽View的简单实现

android 可拖拽View的简单实现

Android 自定义可拖拽View,界面渲染刷新后不会自动回到起始位置

Android自定义View之可拖拽悬浮控件 代码赏析

Android自定义View实现可拖拽的进度条