11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Apple純正メールアプリのようなロングプレスによる複数選択を実装する

Posted at

やりたいこと

標準メールアプリのような長押しでドラッグすると
複数選択ができるUIを実現したい

アプローチ① 提供されているAPIを使う

方針

iOS13以降ではTableView、CollectionViewに2本指タップを検出し複数選択を実現するためのAPIが提供されています。
Selecting Multiple Items with a Two-Finger Pan Gesture

上記に書いてある方法で簡単に

  • 複数選択のUI
  • 選択されたセルの管理
  • 画面の端までスクロールしたときの自動スクロール

を実現できます。
なお、このアプローチの場合2本指タップをトリガーに複数選択が始まります。

実装

DefaultSmoothSelectableViewController.swift

import UIKit

final class DefaultSmoothSelectableViewController: UIViewController {
    
    @IBOutlet private weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 編集中の複数選択を許可設定を追加
        self.tableView.allowsMultipleSelectionDuringEditing = true
    }
}

extension DefaultSmoothSelectableViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "SmoothSelectableCell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.row + 1)番目のセル"
        return cell
    }
}

extension DefaultSmoothSelectableViewController: UITableViewDelegate {

    /// 2本指のタップを検出するとこのメソッドが呼ばれ複数選択をサポートしているかを確認する
    func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
        return true
    }

    // 2本指タップによる複数選択が開始したら呼ばれる
    func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) {
        self.setEditing(true, animated: true)
    }
}

動き

ios13.gif

アプローチ② 自作する

ただiOS12以下だと上記のアプローチは使えないので自作する必要があります。

実現したい動き

  • ロングプレスをトリガーに複数選択を開始する
  • ロングプレスを開始したセルに応じて、選択状態にするか未選択状態にするかを決定する
    • 例えば最初のセルが未選択状態の場合、ロングプレス範囲中の未選択状態のセルだけ選択状態に移行する
  • ロングプレスが解除されるまではスクロール範囲応じて随時選択状態が変更される
    • 例えば1番目から10番目までスクロールして選択状態に更新している最中に、そのまま上にドラッグし7番目まで戻した場合、8~10番目のセルは未選択状態になる

※アプローチ①とは違い、一本指のロングプレスをトリガーにしています
※画面の端に滞在したときに自動スクロールする機能については管理が煩雑で一旦諦めました(iOS13以上のAppleが提供しているAPIを使えばデフォルトで実現できます)

方針

以下の方針でやってみました。

  • UILongPressGestureRecognizerを内包するTableViewのクラスを作成しロングプレスを検知する
  • ロングプレスの開始、更新、終了を検知してDelegate経由でViewControllerに通知する
    • ロングプレスの状態を示すSmoothSelectalbeTableViewStateを定義して、連想値としてIndexPathとセットで渡す

実装

ロングプレスの状態定義

SmoothSelectalbeTableViewState.swift
enum SmoothSelectalbeTableViewState {
    case began(IndexPath) // ロングプレスの開始
    case changed([IndexPath]) // ロングプレスされたセルの更新
    case ended(IndexPath) // ロングプレスの終了
}

ViewController側に伝えるロングプレスを上記のように定義しました。
.changedはロングプレスされたセルが変更されたときだけ通知されるようにします。

Delegateの定義

SmoothSelectableTableViewDelegate.swift
protocol SmoothSelectableTableViewDelegate: AnyObject {
    func smoothSelectableTableView(
        _ tableView: SmoothSelectableTableView,
        didUpdateLongPressState state: SmoothSelectalbeTableViewState
    )
}

ViewController側に通知されるインターフェースです。
SmoothSelectalbeTableViewStateが更新されたタイミングでdelegateから通知されます。

カスタムTableView

SmoothSelectableTableView.swift
import UIKit

final class SmoothSelectableTableView: UITableView, UIGestureRecognizerDelegate {

    private var longPressRecognizer: UILongPressGestureRecognizer?
    private var selectedIndexPaths = [IndexPath]()
    private weak var smoothSelectionDelegate: SmoothSelectableTableViewDelegate?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    func setupLongPress(_ delegate: SmoothSelectableTableViewDelegate?, allowableMovement: CGFloat = 15, minimumPressDuration: TimeInterval = 0.2) {
        self.smoothSelectionDelegate = delegate
        self.setupLongPressGestureRecognizer(allowableMovement: allowableMovement, minimumPressDuration: minimumPressDuration)
    }

    private func setupLongPressGestureRecognizer(allowableMovement: CGFloat, minimumPressDuration: TimeInterval) {
        self.longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longTapped))
        self.longPressRecognizer?.allowableMovement = allowableMovement
        self.longPressRecognizer?.minimumPressDuration = minimumPressDuration
        self.addGestureRecognizer(self.longPressRecognizer!)
        self.longPressRecognizer!.delegate = self
    }

    @objc
    func longTapped(recognizer: UILongPressGestureRecognizer) {
        let currentPoint = self.longPressRecognizer!.location(in: self)
        guard let currentIndexPath = self.indexPathForRow(at: currentPoint) else {
            return
        }
        switch recognizer.state {
        case .began:
            self.selectedIndexPaths = [currentIndexPath]
            self.smoothSelectionDelegate?.smoothSelectableTableView(self, didUpdateLongPressState: .began(currentIndexPath))
        case .changed:
            guard let beganIndexPath = self.selectedIndexPaths.first,
                  currentIndexPath.section == beganIndexPath.section else {
                return
            }
            let sorted = [beganIndexPath.row, currentIndexPath.row].sorted()
            let array = Array(sorted[0]...sorted[1])
            var indexPaths = [IndexPath]()
            array.enumerated().forEach { args in
                // ロングプレスされている順に詰め直す
                let row = beganIndexPath.row < currentIndexPath.row ? args.element : beganIndexPath.row - args.offset
                indexPaths.append(IndexPath(row: row, section: beganIndexPath.section))
            }
            if indexPaths != self.selectedIndexPaths {
                // 差分があるときだけ更新通知を送る
                self.smoothSelectionDelegate?.smoothSelectableTableView(self, didUpdateLongPressState: .changed(indexPaths))
            }
            self.selectedIndexPaths = indexPaths
        case .ended:
            self.selectedIndexPaths = []
            self.smoothSelectionDelegate?.smoothSelectableTableView(self, didUpdateLongPressState: .ended(currentIndexPath))
        default:
            break
        }
    }
}

UILongPressGestureRecognizerのセットアップはsetupLongPressGestureRecognizer(allowableMovement:minimumPressDuration:)で行います。
ここでは

  • ロングプレス開始するまでの時間
  • Gestureのdelegate設定
    などを実行します。

またロングプレスされたら、最初の地点とドラッグされている地点からロングプレス対象内のセルのインデックスを計算して、Delegate経由で通知します。

利用側

SmoothSelectableViewController.swift
import UIKit

final class SmoothSelectableViewController: UIViewController {
    
    @IBOutlet private weak var tableView: SmoothSelectableTableView!
    /// 全体の選択中のセルのインデックス
    private var selectedIndex: Set<IndexPath> = []
    /// 現在ロングプレスされているセルのインデックス
    /// ロングプレスが終了するまでは変更するので全体とは別で管理する
    private var longPreesedIndex: Set<IndexPath> = []
    /// ロングプレス時に選択状態にするか非選択状態にするか判定するフラグ
    /// ロングプレス開始セルの状況に応じて値がきまる
    private var willSelect: Bool = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.tableView.setupLongPress(self)
    }
}

extension SmoothSelectableViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "SmoothSelectableCell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.row + 1)番目のセル"
        // 選択状態にする場合全体との和を取り、非選択状態にする場合全体からの差を取る
        let selectedAllIndex = self.willSelect ? self.selectedIndex.union(self.longPreesedIndex) : self.selectedIndex.subtracting(self.longPreesedIndex)
        let isSelected = !selectedAllIndex.filter { $0 == indexPath }.isEmpty
        cell.backgroundColor = isSelected ? .cyan : .white
        return cell
    }
}

extension SmoothSelectableViewController: SmoothSelectableTableViewDelegate {
 
    func smoothSelectableTableView(_ tableView: SmoothSelectableTableView, didUpdateLongPressState state: SmoothSelectalbeTableViewState) {
        switch state {
        case .began(let indexPath):
            self.willSelect = !self.selectedIndex.contains(indexPath)
            self.updateSelectedIndex(indexPath)
        case .changed(let indexPaths):
            self.longPreesedIndex = []
            indexPaths.forEach { indexPath in
                self.updateSelectedIndex(indexPath)
            }
        case .ended(let indexPath):
            self.updateSelectedIndex(indexPath)
            // ロングプレスを終了するときに全体の選択済みインデックスを更新する
            self.selectedIndex = self.willSelect ? self.selectedIndex.union(self.longPreesedIndex) : self.selectedIndex.subtracting(self.longPreesedIndex)
            self.longPreesedIndex = []
        }
    }
}

extension SmoothSelectableViewController {

    private func updateSelectedIndex(_ index: IndexPath) {
        self.longPreesedIndex.insert(index)
        self.tableView.reloadData()
    }
}

動き

未選択のセルは背景が白に、選択済みのセルは背景を水色になっています。

さいごに

TableView,CollectionViewのAPIを使えば、
煩雑な状態管理や、画面の端までドラッグしたときの自動スクロールをやってくれます。
Appleのメールアプリでも2本指スワイプでの複数選択が導入されていたり、
そのものズバリなドキュメントも出ていることから、こちらを推奨しているように見受けられます。
サポート対象がiOS13以上であればアプローチ①を使うのがいいかと思います。

11
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?