Customize di chuyển table view cell trong iOS

Introduction

Trong bài viết này, chúng ta sẽ tìm hiểu cách customize giao diện editing của tableView. Cụ thể là thao tác kéo thả, thay đổi vị trí các cell.

Default Implement

Trước tiên, hãy điểm lại sơ qua cách cài đặt việc reoder cell trong tableView mặc định của Apple.

Tạo một tableView đơn giản, hiện thị list các contact như sau:

Model Contact.swift

class Contact {
    
    var avatarImage: String
    var fullName: String
    var phoneNumber: String
    
    init(avatarImage: String, fullName: String, phoneNumber: String) {
        self.avatarImage = avatarImage
        self.fullName = fullName
        self.phoneNumber = phoneNumber
    }
    
}

Main.storyboard

ContactsViewController.swift

class ContactsViewController: UIViewController {

    @IBOutlet weak var contactsTableView: UITableView!
    private var contacts = [Contact]()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        createContacts()
    }
    
    private func createContacts() {
        contacts = [
            Contact(avatarImage: "taylor_swift", fullName: "Taylor Swift", phoneNumber: "+8006546748"),
            Contact(avatarImage: "ed_sheeran", fullName: "Ed Sheeran", phoneNumber: "+8784165786"),
            Contact(avatarImage: "jisoo", fullName: "JISOO", phoneNumber: "+82546578550"),
            Contact(avatarImage: "bruno_mars", fullName: "Bruno Mars", phoneNumber: "+80021024152"),
            Contact(avatarImage: "zayn", fullName: "Zayn", phoneNumber: "+84114578778")
        ]
    }
    
    @IBAction private func reloadButtonTapped(_ sender: Any) {
        contactsTableView.reloadData()
    }
    
}

extension ContactsViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return contacts.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath) as? ContactCell else {
            return UITableViewCell()
        }
        cell.contact = contacts[indexPath.row]
        return cell
    }
    
}

Kết quả:

Để có thể di chuyển các cell bằng cách kéo thả, chúng ta cần enable editing mode của tableView bằng function setEditing(Bool, animated: Bool).

    @IBAction func editButtonTapped(_ sender: UIBarButtonItem) {
        if contactsTableView.isEditing {
            sender.title = "Edit"
            // Tắt editing mode
            contactsTableView.setEditing(false, animated: true)
        } else {
            sender.title = "Done"
            // Bật editing mode
            contactsTableView.setEditing(true, animated: true)
        }
    }

Và implement 2 function của UITableViewDelegate:

  • func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool: cho biết row nào có thể move, row nào không.
  • func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath): Xử lý di chuyển, đổi vị trí dataSource.
extension ContactsViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
        return true
    }
    
    func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        // Đổi vị trí của 2 phần tử contact trong mảng dataSource contacts
        contacts.insert(contacts.remove(at: sourceIndexPath.row), at: destinationIndexPath.row)
    }
    
}

Kết quả:

Customize moving cell

Việc implement moving cell mặc định rất dễ phải không. Tuy nhiên đôi khi theo requirement của khách hàng, chúng ta phải customize một giao diện khác.

Ví dụ đổi nút hình 3 sọc nằm ngang kia thành một hình khác... Lúc này có thể thấy rằng nút move đó thuộc private API của Apple, không được hỗ trợ một cách chính thức.

Một cách tip-trick để thỏa mãn được yêu cầu này là lọc, tìm trong cell một control có tên là UITableViewCellReorderControl.

override func setEditing(editing: Bool, animated: Bool) {
    super.setEditing(editing, animated: animated)
    if (editing) {
        for view in subviews as [UIView] {
            if view.dynamicType.description().rangeOfString("Reorder") != nil {
                for subview in view.subviews as [UIImageView] {
                    if subview.isKindOfClass(UIImageView) {
                        subview.image = UIImage(named: "yourimage.png")
                    }
                }
            }
        }
    }
}

Tuy nhiên đây không phải một cách dài hạn, vì sau các bản cập nhật iOS, nút này có thể bị Apple đổi tên, thay đổi cấu trúc...

Để có thể kéo thả, di chuyển cell trong tableView mà không cần dùng đến editing mode của Apple. Chúng ta có thể làm theo các bước chính sau:

  1. Thêm một UILongGestureRecognizer vào tableView hoặc vào một UIImageView trong cell tùy ý.
  2. Khi người dùng nhấn giữ vào imageView hoặc vào một cell, chúng ta sẽ lấy được point chạm vào.
  3. Từ đó sẽ lấy được indexPath của cell vừa được nhấn giữ.
  4. Tạo một view clone, ảnh snapshot từ cell đó và ẩn cell đi, hiện snapshot view lên.
  5. Khi người dùng tiếp tục nhấn giữ di chuyển, di chuyển đến đâu, lấy được indexPath đó, thực hiện đổi chỗ 2 cell với indexPath hiện tại và indexPath lúc bắt đầu nhấn giữ.
  6. Khi người dùng thả tay, long press gesture sẽ có state cancel hoặc finish thì ẩn snapshot view và cho hiện lại cell bị ẩn.

Add Long Press Gesture

Thêm một UIImageView với ảnh bất kỳ vào ContactCell và tạo @IBOutlet cho nó.

ContactCell.swift

    @IBOutlet private weak var flowerImageView: UIImageView!

Thêm @IBAction và closure để handle khi long press gesture được recognized.

ContactCell.swift

    var handleLongPressGesture: (UILongPressGestureRecognizer) -> Void = { _ in }
    
        override func awakeFromNib() {
        super.awakeFromNib()
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureRecognized(_:)))
        flowerImageView.isUserInteractionEnabled = true
        flowerImageView.addGestureRecognizer(longPressGesture)
    }
    
    @objc private func longPressGestureRecognized(_ gesture: UILongPressGestureRecognizer) {
        handleLongPressGesture(gesture)
    }

Trong ContactsViewController.swift, implement thêm closure ở cellForRowAt:

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath) as? ContactCell else {
            return UITableViewCell()
        }
        cell.contact = contacts[indexPath.row]
        cell.handleLongPressGesture = { [weak self] gesture in
            self?.handleLongPressGesture(gesture)
        }
        return cell
    }

Và function handleLongPressGesture(_ gesture: UILongPressGestureRecognizer).

    private func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        // Handle long press gesture here
    }

Handle Long Press Gesture

Trong function handleLongPressGesture(_ gesture: UILongPressGestureRecognizer), thực hiện lấy point locationInView và tính indexPath.

    private func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        let locationInView = gesture.location(in: contactsTableView)
        let indexPath = contactsTableView.indexPathForRow(at: locationInView)
        switch gesture.state {
        case .began: break
            // Handle .began state
        case .changed: break
            // Handle .changed state
        default: break
            // Handle .cancel, .ended ... state
        }
    }

Thêm 4 variable sau để lưu trạng thái trong khi xử lý gesture.

    private var currentIndexPath: IndexPath? // Lưu indexPath lúc bắt đầu nhấn giữ và sau khi swap
    private var snapShotView: UIView? // Snapshot view của cell được chọn
    private var isCellAnimating = false // Flag animate cell
    private var isCellNeedToShow = false // Flag ẩn hiện cell

Create snapshot view from cell

Tạo một file extention UIView+Extensions.swift, và thêm function sau để tạo snapshot view từ một view.

Snapshot view là một view "ảnh chụp lại" của một view khác.

UIView+Extensions.swift

import UIKit

extension UIView {
    
    func makeSnapshot() -> UIView {
        UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() else {
            UIGraphicsEndImageContext()
            return UIView()
        }
        layer.render(in: context)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        let snapshot = UIImageView(image: image)
        // Tạo style cho snapshot view nổi bật hơn
        snapshot.layer.masksToBounds = false
        snapshot.layer.cornerRadius = 0.0
        snapshot.layer.shadowOffset = CGSize(width: -5.0, height: 0.0)
        snapshot.layer.shadowRadius = 5.0
        snapshot.layer.shadowOpacity = 0.4
        return snapshot
    }

}

Handle .began state

    private func beginDragging(at indexPath: IndexPath?, locationInView: CGPoint) {
        self.currentIndexPath = indexPath
        guard let indexPath = indexPath, let cell = contactsTableView.cellForRow(at: indexPath) else {
            return
        }
        var center = cell.center
        // Tạo snapshot view từ cell lấy được
        let snapShotView = cell.makeSnapshot()
        self.snapShotView = snapShotView
        snapShotView.center = center
        snapShotView.alpha = 0.0
        contactsTableView.addSubview(snapShotView)
        // Animate hiện snapshot view lên và ẩn cell đi
        UIView.animate(withDuration: 0.25, animations: {
            center.y = locationInView.y
            self.isCellAnimating = true
            snapShotView.center = center
            snapShotView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
            snapShotView.alpha = 0.98
            cell.alpha = 0.0
        }, completion: { success in
            if success {
                self.isCellAnimating = false
                if self.isCellNeedToShow {
                    self.isCellNeedToShow = false
                    UIView.animate(withDuration: 0.25, animations: {
                        cell.alpha = 1.0
                    })
                } else {
                    cell.isHidden = true
                }
            }
        })
    }

Handle .changed state

    private func swapCellIfNeeded(at indexPath: IndexPath?, locationInView: CGPoint) {
        // Nếu nhấn giữ ra ngoài tableView, indexPath = nil -> huỷ handle
        guard let indexPath = indexPath,
              let snapShotView = self.snapShotView, let currentIndexPath = self.currentIndexPath else {
            return
        }
        // Di chuyển snapshot view theo point detect được
        var center = snapShotView.center
        center.y = locationInView.y
        snapShotView.center = center
        // Khi detect được indexPath mới mà khác với indexPath ban đầu
        // thực hiện đổi chỗ 2 cell và gán lại currentIndexPath
        if indexPath != currentIndexPath {
            contacts.insert(contacts.remove(at: currentIndexPath.row), at: indexPath.row)
            contactsTableView.moveRow(at: currentIndexPath, to: indexPath)
            self.currentIndexPath = indexPath
        }
    }

Handle other states

Khi gesture kết thúc hoặc bị cancel, animate hiện lại cell và ẩn snapshot view, remove khỏi tableView.

    private func cancelDragging() {
        guard let currentIndexPath = self.currentIndexPath,
              let cell = contactsTableView.cellForRow(at: currentIndexPath) else {
            return
        }
        if isCellAnimating {
            isCellNeedToShow = true
        } else {
            cell.isHidden = false
            cell.alpha = 0.0
        }
        UIView.animate(withDuration: 0.25, animations: {
            self.snapShotView?.center = cell.center
            self.snapShotView?.transform = .identity
            self.snapShotView?.alpha = 0.0
            cell.alpha = 1.0
        }, completion: { success in
            guard success else {
                return
            }
            self.currentIndexPath = nil
            self.snapShotView?.removeFromSuperview()
            self.snapShotView = nil
        })
    }

Cuối cùng, sửa lại function handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) tương ứng.

    private func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        let locationInView = gesture.location(in: contactsTableView)
        let indexPath = contactsTableView.indexPathForRow(at: locationInView)
        switch gesture.state {
        case .began:
            beginDragging(at: indexPath, locationInView: locationInView)
        case .changed:
            swapCellIfNeeded(at: indexPath, locationInView: locationInView)
        default:
            cancelDragging()
        }
    }

Result

Chúng ta có thể thực hiện thao tác, nhấn giữ một nút bất kỳ mà không cần dùng editing mode có sẵn, cứng nhắc của Apple.

Link github demo: https://github.com/oNguyenXuanThanh/StudyReport112018