iOS/Swift: UIPanGestureReconizer

by DaoNM2
745 views

Lời mở đầu

Xin chào mọi người, hôm nay mình sẽ giới thiệu với các bạn về UIPanGestureReconizer. Và mình sẽ hướng dẫn mọi người cách sử dụng và ứng dụng nó vào thực tế. Mình hi vọng sau khi xem hết bài viết này, mọi người có thể áp dụng nó vào các ứng dụng sau này nếu cần đến 😀

Ý tưởng

Chắc hẳn ai sử dụng iOS đều sử dụng qua tính năng “Control Center” của iOS. Để mở Control center lên ta phải vuốt từ dưới màn hình lên và ẩn đi thì ngược lại, nó còn cho phép người dùng kéo thả một cách mượt mà.

UIGestureRecognizer là gì?

UIGestureRecognizer là class định nghĩa một tập hợp các hành vi phổ biến có thể được cấu hình cho tất cả các cử chỉ cụ thể. Dạng như chạm, kéo thả …

UIGestureRecognizer được sử dụng để nhận dạng các loại hành vi của người dùng khi tương tác lên màn hình và thực hiện hành động được cấu hình.

Dưới đây là một số subclasses của nó:

UIPanGestureRecognizer là gì?

UIPangestureRecognizer là subclass của UIGestureRecognizer dùng để nhận biết hành vi kéo thả.

Ví dụ về UIPanGestureRecognizer

Để bắt đầu chúng ta cần mở XCode và tạo mới một Single View App. Sau khi tạo project xong chúng ta mở file Main.storyboard và kéo vào một UIView -> đổi màu nền(Background color) của view sang một màu khác cho dễ nhìn.

Khi này chúng ta cần gán constraint cho view đó theo Hình 1
Cần chú ý ở đây chúng ta sẽ constraint bottom của view vào bottom của superview thay vì Safe Area để khi build trên các dòng điện thoại tai thỏ (iphone x, xs ….) sẽ không bị trắng ở dưới của màn hình.

Hình 1

Tiếp đến chúng ta sẽ add 1 UIView nhỏ vào trong UIView vừa mới tạo để thể hiện view này có thể kéo thả được.
Chúng ta cũng constraint UIView nhỏ để nó nằm trên top và giữa của superview kết quả chúng ta thu được như Hình 2

Hình 2

Vậy là chúng ta đã xong phần UI, tiếp đến chúng ta cần kéo IBOutlet top constraint cho cái view to và đặt tên nó là topViewContainer kết quả sẽ được như hình 3

Chúng ta kéo IBOutlet cho top constraint của view để làm gì? Mục đích ở đây là để sau này chúng ta sẽ thay đổi giá trị của topConstraint.constant -> thay đổi vị trí và để tạo hiệu ứng chuyển động.

Tiếp đến chúng ta kéo outlet cho thằng View to đặt tên là viewContainer

OK, giờ chúng ta sẽ đi vào code chi tiết.
Giờ chúng ta mở file ViewController.swift ra và tạo enum cho các trạng thái mở rộng

// 1: Tạo 3 trạng thái mở rộng cho viewContainer
enum ExpansionState {
    case compressed
    case haft
    case expanded
}

Tạo pangesture và gán nó cho viewContainer, trong code mình đã comment rõ từng dòng, từng hàm để các bạn có thể hiểu được nó làm gì.

   // thêm UIGestureRecognizerDelegate để sử dụng được PanGesture
class ViewController: UIViewController, UIGestureRecognizerDelegate {

    @IBOutlet weak var viewContainer: UIView!
    @IBOutlet weak var topViewContainer: NSLayoutConstraint!
    // Khởi tạo trạng thái
    var expansionState: ExpansionState = .haft
    // Khởi tạo giá trị cũ của topViewContainer
    var oldTopViewContainer: CGFloat = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupGestureRecognizers()
        // Khởi tạo vị trí bắt đầu cho việc animte viewContainer.
        // Ở đây chúng ta đang để nó ở trạng thái compressed có nghĩa là ở trạng thái expansion nhỏ nhất.
        topViewContainer.constant = topViewContainer(forState: .compressed)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // Do mình muốn viewContainer sẽ chuyển động từ dưới lên trên và dừng lại ở trạng thái expansion 1 nửa màn hình
        // Nên mình gọi hàm phía dưới để nó thực hiện chuyển động vì ở viewDidLoad nó đang ở trạng thái compressed
        animateTopConstraint(constant: topViewContainer(forState: .haft), velocity: CGPoint(x: 0, y: 50))
    }

    // Cài đặt Pangesture,
    func setupGestureRecognizers() {
        let panGestureRecognizer = UIPanGestureRecognizer(target: self,
                                                          action: #selector(panGestureDidMove(sender:)))
        panGestureRecognizer.delegate = self
        viewContainer.isUserInteractionEnabled = true

        viewContainer.addGestureRecognizer(panGestureRecognizer)
    }

    @objc func panGestureDidMove(sender: UIPanGestureRecognizer) {
        let translationPoint = sender.translation(in: view.superview)
        // velocity là vận tốc chuyển động của hành động, dạng như bạn vuốt vào màn hình nhanh hay chậm
        let velocity = sender.velocity(in: view.superview)

        switch sender.state {
        case .changed:
            panGesture(didChangeTranslationPoint: translationPoint, withVelocity: velocity)
        case .ended:
            panGesture(didEndTranslationPoint: translationPoint, withVelocity: velocity)
        default:
            return
        }
    }

    // Phương thức này để tính toán giá trị viewContainer.top theo các trạng thái thái mà nó cần tính
    func topViewContainer(forState state: ExpansionState) -> CGFloat {
        let heightView = self.view.bounds.height
        switch state {
        case .compressed:
            return heightView - 100
        case .haft:
            return heightView / 2
        case .expanded:
            return 100
        }
    }
}

Tiếp đến chúng ta cần tạo Extenstion cho phần animation để phục vụ cho việc chuyển động view container

/ MARK: Animation
extension ViewController {
    /// Animates the top constraint of the drawerViewController by a given constant
    /// using velocity to calculate a spring and damping animation effect.
    func animateTopConstraint(constant: CGFloat, velocity: CGPoint) {
        let previousConstraint = topViewContainer.constant
        let distance = previousConstraint - constant
        let springVelocity = velocity.y != 0 ? max(1 / (abs(velocity.y / distance)), 0.08) : 0.08
        let springDampening = CGFloat(0.6)

        UIView.animate(withDuration: 0.5,
                       delay: 0.0,
                       usingSpringWithDamping: springDampening,
                       initialSpringVelocity: springVelocity,
                       options: [.curveLinear],
                       animations: {
                        self.topViewContainer.constant = constant
                        self.oldTopViewContainer = constant
                        self.view.layoutIfNeeded()
        }, completion: nil)
    }
}

Tiếp đến chúng ta cần các hàm để xử lí khi mà người dùng kéo viewContainer
Ở trong code mình đã comment các hàm, các dòng để mọi người dễ hiểu, hãy đọc trong code nhé.

//MARK: PanGesture handle
extension ViewController {

    // Hàm này được gọi khi PanGestureRecognizer nhận ra được sự thay đổi của view,
    // trong hàm này chúng ta có thể thực hiện một số điều kiện để giới hạn việc kéo của người dùng
    func panGesture(didChangeTranslationPoint translationPoint: CGPoint,
                              withVelocity velocity: CGPoint) {
        let newConstraintConstant = oldTopViewContainer + translationPoint.y
        // Giới hạn việc người dùng kéo quá xa so với phía trên
        if newConstraintConstant >= 0 {
            topViewContainer.constant = newConstraintConstant
        }
    }

    // Phương thức này được gọi khi kết thúc pan
    func panGesture(didEndTranslationPoint translationPoint: CGPoint,
                              withVelocity velocity: CGPoint) {
        // Velocity là kiểu CGPoint vì vậy nó có 2 giá trị x và y
        // x: đại diện cho vận tốc theo chiều ngang
        // y: đại điện cho vận tốc theo chiều dọc
        // Do mình đang muốn thực hiện kéo thả theo chiều dọc nên ở ví dụ này mình sẽ dùng thuộc tính vận tốc y
        if abs(velocity.y) <= 50.0 {
            // 50: ở đây là vận tốc mà mình coi nó là người dùng đang kéo từ từ. Con số này các bạn có thể tùy chỉnh
            // Giá trị của y < 0 có nghĩa là người dùng đang kéo lên trên và y > 0 là kéo xuống dưới
            // dấu của y sẽ thể hiện chiều di chuyển của nó.
            // Vì vậy mình cần sử dụng hàm abs() trước khi thực hiện phép so sánh
            drag(lowVelocity: velocity)
        } else if velocity.y > 0 {
            dragDown(highVelocity: velocity)
        } else {
            dragUp(highVelocity: velocity)
        }
    }

    // Hàm này thực hiện việc xác định xem view đang gần vị trí của trạng thái nào hơn sẽ chuyển sang trạng thái đó.
    func drag(lowVelocity velocity: CGPoint) {
        let compressedTopConstraint = topViewContainer(forState: .compressed)
        let haftTopConstraint = topViewContainer(forState: .haft)
        let expandedTopConstraint = topViewContainer(forState: .expanded)

        let expandedDifference = abs(topViewContainer.constant - expandedTopConstraint)
        let haftDifference = abs(topViewContainer.constant - haftTopConstraint)
        let compressedDifference = abs(topViewContainer.constant - compressedTopConstraint)

        let heightArray = [expandedDifference, haftDifference, compressedDifference]
        let minHeight = heightArray.min()

        if expandedDifference == minHeight {
            expansionState = .expanded
            animateTopConstraint(constant: expandedTopConstraint, velocity: velocity)
        } else if haftDifference == minHeight {
            expansionState = .haft
            animateTopConstraint(constant: haftTopConstraint, velocity: velocity)
        } else {
            expansionState = .compressed
            animateTopConstraint(constant: compressedTopConstraint, velocity: velocity)
        }
    }

    // Hàm này để xử lí khi người dùng kéo thả view với tốc độ cao,
    // nó sẽ xác định và chuyển tới trạng thái liền kề theo hướng phía dưới
    func dragDown(highVelocity velocity: CGPoint) {
        // Handle High Velocity Pan Gesture

        switch expansionState {
        case .compressed, .haft:
            expansionState = .compressed
            animateTopConstraint(constant: topViewContainer(forState: .compressed), velocity: velocity)
        case .expanded:
            expansionState = .haft
            animateTopConstraint(constant: topViewContainer(forState: .haft), velocity: velocity)
        }
    }

    // Hàm này để xử lí khi người dùng kéo thả view với tốc độ cao,
    // nó sẽ xác định và chuyển tới trạng thái liền kề theo hướng phía trên
    func dragUp(highVelocity velocity: CGPoint) {
        // Handle high Velocity Pan Gesture
        switch expansionState {
        case .compressed:
            expansionState = .haft
            animateTopConstraint(constant: topViewContainer(forState: .haft), velocity: velocity)
        case .haft, .expanded:
            expansionState = .expanded
            animateTopConstraint(constant: topViewContainer(forState: .expanded), velocity: velocity)
        }
    }
}

Và đây là kết quả khi chúng ta build app lên:

Vậy là xong!
Ngoài ra các bạn có thể chỉnh sửa lại view sao cho phù hợp với ứng dụng đang làm 😀


Cảm ơn mọi người đã theo dõi bài viết!
Mọi ý kiến đóng góp mọi người hãy comment xuống phía dưới để mình có thể thay đổi cho bài biết được tốt hơn. :v

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.