Swipe to right trên tableviewcell để show delete mà không sử dụng scrollview

Apple đã giới thiệu UITableViewRowAction cho phép chúng ta thêm nhiều action trên tableviewcell. Giống như Mail App, bạn chỉ việc swipe to left để show nhiều actions. Nhưng các view action này đều nằm bên phải của cell, vì vậy khi UI/UX đòi hỏi chúng ta phải custom action nằm bên trái của cell và phải swipe to right để show các action đó ra. Topic này sẽ hướng dẫn các bạn thực hiện điều đó.

Tạo các subview và pan gesture

Đầu tiên, chúng ta tạo 1 button delete nằm ở bên trái của cell Sau đó, chúng ta thêm 1 view đè lên trên button delete và set constraints cho nó Build và chạy app Để custom swipe to right chúng ta cần tạo 1 pan gesture và link 2 NSLayoutConstraint tương ứng với 2 contrastraint left và right của view chúng ta vừa thêm bên trên.

    private var panRecognizer : UIPanGestureRecognizer!
    private var panStartPoint : CGPoint!
    private var startingLeftLayoutConstraintConstant : CGFloat = 0.0
    @IBOutlet weak var contentViewRightConstraint: NSLayoutConstraint!
    @IBOutlet weak var contentViewLeftConstraint: NSLayoutConstraint!

Setup pan gesture và hàm của nó

    override func awakeFromNib() {
        super.awakeFromNib()
        
        self.panRecognizer = UIPanGestureRecognizer.init(target: self, action: #selector(ItemCardCell.panThisCell(recognizer:)))
        self.panRecognizer.delegate = self
        self.holderView.addGestureRecognizer(self.panRecognizer)
    }
    func panThisCell(recognizer:UIPanGestureRecognizer) {
        switch (recognizer.state) {
            case .began:
                self.panStartPoint = recognizer.translation(in: self.holderView)
                self.startingLeftLayoutConstraintConstant = self.contentViewLeftConstraint.constant
                break;
            case .changed:
                let currentPoint = recognizer.translation(in: self.holderView)
                let deltaX = currentPoint.x - self.panStartPoint.x
                break;
            case .ended:
                break;
            case .cancelled:
                break;
            default:
                break;
            }
    }

Đây là hàm được gọi khi ta thực hiện 1 pan gesture. Build app, đưa ngón tay lên cell, kéo qua kéo lại và log ra các giá trị được lưu bên trên. Bạn sẽ thấy các con số tăng/giảm khi ta thực hiện.

Xử lý pan gesture

Đầu tiên chúng ta cần xác định khoảng cách của contentView cách ra khi ta thực hiện swipe. Ở đây sẽ bằng với chiều dài của button delete.

    private func buttonTotalWidth() -> CGFloat {
        return self.deleteButton.frame.maxX
    }
    private func resetContraintContstantsToZero(animated:Bool, notifyDelegateDidClose notifyDelegate:Bool) {
    }
    private func setContraintToShowDeleteButton(animated:Bool, notifyDelegateDidOpen notifyDelegate:Bool) {
    }

Tiếp theo, chúng ta cần xử lý khi gesture recognizer thay đổi.

  1. Xác định pan trái hay phải
  2. Nhận biết contentView đang đóng hay mở
  3. Tùy vào khoảng cách và pan trái/phải ta sẽ đóng/mở contentView.
    case .changed:
                let currentPoint = recognizer.translation(in: self.holderView)
                let deltaX = currentPoint.x - self.panStartPoint.x
                var panningRight = false
                if self.panStartPoint.x < currentPoint.x {
                    panningRight = true
                }
                if self.startingLeftLayoutConstraintConstant == 0.0 {
                    //The cell was closed and is now opening
                    if panningRight {
                        let constant = min(deltaX,self.buttonTotalWidth())
                        if constant == self.buttonTotalWidth() {
                            self.setContraintToShowDeleteButton(animated: true, notifyDelegateDidOpen: false)
                        }
                        else {
                            self.contentViewLeftConstraint.constant = constant
                        }
                    }
                    else {
                        let constant = min(-deltaX, 0)
                        if constant == 0 {
                            self.resetContraintContstantsToZero(animated: true, notifyDelegateDidClose: false)
                        }
                        else {
                            self.contentViewLeftConstraint.constant = constant
                        }
                    }
                }
                break;

Ở bên trên là trường hợp contentView đang đóng. Với trường hợp, contentView đã mở ra ta cũng thực hiện tương tự

        else {
                    //The cell was at least partially open.
                    let adjustment = self.startingLeftLayoutConstraintConstant + deltaX;
                    if (panningRight) {
                        let constant = min(adjustment, self.buttonTotalWidth());
                        if (constant == self.buttonTotalWidth()) {
                            self.setContraintToShowDeleteButton(animated: true, notifyDelegateDidOpen: false)
                        }
                        else {
                            self.contentViewLeftConstraint.constant = constant;
                        }
                    } else {
                        let constant = max(adjustment, 0);
                        if (constant == 0) {
                            self.resetContraintContstantsToZero(animated: true, notifyDelegateDidClose: false)
                            
                        } else {
                            self.contentViewLeftConstraint.constant = constant
                        }
                    }
                }
                self.contentViewRightConstraint.constant = -self.contentViewLeftConstraint.constant;

Animation

Chúng ta sẽ thực hiện các hàm đóng/mở contentView và animation tương ứng

    private func updateConstraintsIfNeeded(animated:Bool, completion: ((Bool) -> Swift.Void)? = nil) {
        
        var duration = 0.0
        if animated {
            duration = 0.1
        }
        
        UIView.animate(withDuration: duration, animations: { 
            self.layoutIfNeeded()
        }, completion: completion)
    }

Để làm cho contentView chuyển động mượt mà, chúng ta cần tạo cho nó 1 bounce khi nó animation.

    private let kBounceValue : CGFloat = 20.0 

Chúng ta sẽ xử lý khi contentView được mở ra

  1. Nếu contentView đang mở ta sẽ không làm gì cả
  2. Set contraint cho left/right của contentView bằng tổng chiều dài của button và có thêm bounce. Nó sẽ làm contentView pull qua 1 chút khi ta thực hiện 1 animation
  3. Sau khi animation đầu tiên kết thúc, ta gọi thêm 1 animation để cho contentView trở lại đúng vị trí sau khi bounce
  4. Sau đó ta reset lại giá trị cho starting contraint
    private func setContraintToShowDeleteButton(animated:Bool, notifyDelegateDidOpen notifyDelegate:Bool) {
        if notifyDelegate {
            delegate?.cellDidOpen?()
        }
        
        if (self.startingLeftLayoutConstraintConstant == self.buttonTotalWidth() &&
            self.contentViewLeftConstraint.constant == self.buttonTotalWidth()) {
            return;
        }
        
        self.contentViewLeftConstraint.constant = self.buttonTotalWidth() + kBounceValue;
        self.contentViewRightConstraint.constant = -self.buttonTotalWidth() - kBounceValue;
        
        self.updateConstraintsIfNeeded(animated: animated) { (finished) in
            self.contentViewLeftConstraint.constant = self.buttonTotalWidth();
            self.contentViewRightConstraint.constant = -self.buttonTotalWidth();
            
            self.updateConstraintsIfNeeded(animated: animated, completion: { (finished) in
                self.startingLeftLayoutConstraintConstant = self.contentViewLeftConstraint.constant;
            })
        }
    }

Tương tự, ta cũng thực hiện khi contentView đóng lại

    private func resetContraintContstantsToZero(animated:Bool, notifyDelegateDidClose notifyDelegate:Bool) {
        if notifyDelegate {
            delegate?.cellDidClose?()
        }
        
        if (self.startingLeftLayoutConstraintConstant == 0.0 &&
            self.contentViewLeftConstraint.constant == 0.0) {
            //Already all the way closed, no bounce necessary
            return;
        }
        
        self.contentViewLeftConstraint.constant = -kBounceValue;
        self.contentViewRightConstraint.constant = kBounceValue;
        
        self.updateConstraintsIfNeeded(animated: animated) { (finished) in
            self.contentViewLeftConstraint.constant = 0.0;
            self.contentViewRightConstraint.constant = 0.0;
            
            self.updateConstraintsIfNeeded(animated: animated, completion: { (finished) in
                self.startingLeftLayoutConstraintConstant = self.contentViewLeftConstraint.constant;
            })
        }
    }

Run app và drag cell, chúng ta sẽ thấy 1 bounce action khi đóng/mở cell. Tuy nhiên, ta cần xử lý hoàn chỉnh việc đóng/mở cell bằng cách sau

    case .ended:
                //We were opening
                let halfOfButton = self.buttonTotalWidth() / 2;
                if (self.contentViewLeftConstraint.constant >= halfOfButton) { //3
                    self.setContraintToShowDeleteButton(animated: true, notifyDelegateDidOpen: true)
                } else {
                    self.resetContraintContstantsToZero(animated: true, notifyDelegateDidClose: true)
                }
                break;
    case .cancelled:
                if (self.startingLeftLayoutConstraintConstant == 0.0) {
                    self.resetContraintContstantsToZero(animated: true, notifyDelegateDidClose: true)
                } else {
                    //We were open - reset to the open state
                    self.setContraintToShowDeleteButton(animated: true, notifyDelegateDidOpen: true)
                }
                break; 

Run app và scroll tableview chúng ta sẽ thấy bug xảy ra do việc reuse cell. Để fix điều này ta cần xử lý ở hàm prepareForReuse

    override func prepareForReuse() {
        super.prepareForReuse()
        self.resetContraintContstantsToZero(animated: false, notifyDelegateDidClose: false)
    }

Chúng ta đã hoàn tất việc xử lý show action khi swipe qua phải. Ngoài ra, bạn có thể lưu lại cell đã đóng/mở bằng cách xử lý các delegate gọi ra hoặc custom thêm các view action khác. Hi vọng Apple sẽ sớm cập nhật tính năng này cho các bản iOS về sau.