やりたいこと
標準メールアプリのような長押しでドラッグすると
複数選択ができるUIを実現したい
アプローチ① 提供されているAPIを使う
方針
iOS13以降ではTableView、CollectionViewに2本指タップを検出し複数選択を実現するためのAPIが提供されています。
Selecting Multiple Items with a Two-Finger Pan Gesture
上記に書いてある方法で簡単に
- 複数選択のUI
- 選択されたセルの管理
- 画面の端までスクロールしたときの自動スクロール
を実現できます。
なお、このアプローチの場合2本指タップをトリガーに複数選択が始まります。
実装
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)
}
}
動き
アプローチ② 自作する
ただiOS12以下だと上記のアプローチは使えないので自作する必要があります。
実現したい動き
- ロングプレスをトリガーに複数選択を開始する
- ロングプレスを開始したセルに応じて、選択状態にするか未選択状態にするかを決定する
- 例えば最初のセルが未選択状態の場合、ロングプレス範囲中の未選択状態のセルだけ選択状態に移行する
- ロングプレスが解除されるまではスクロール範囲応じて随時選択状態が変更される
- 例えば1番目から10番目までスクロールして選択状態に更新している最中に、そのまま上にドラッグし7番目まで戻した場合、8~10番目のセルは未選択状態になる
※アプローチ①とは違い、一本指のロングプレスをトリガーにしています
※画面の端に滞在したときに自動スクロールする機能については管理が煩雑で一旦諦めました(iOS13以上のAppleが提供しているAPIを使えばデフォルトで実現できます)
方針
以下の方針でやってみました。
-
UILongPressGestureRecognizer
を内包するTableViewのクラスを作成しロングプレスを検知する - ロングプレスの開始、更新、終了を検知してDelegate経由でViewControllerに通知する
- ロングプレスの状態を示す
SmoothSelectalbeTableViewState
を定義して、連想値としてIndexPathとセットで渡す
- ロングプレスの状態を示す
実装
ロングプレスの状態定義
enum SmoothSelectalbeTableViewState {
case began(IndexPath) // ロングプレスの開始
case changed([IndexPath]) // ロングプレスされたセルの更新
case ended(IndexPath) // ロングプレスの終了
}
ViewController側に伝えるロングプレスを上記のように定義しました。
.changed
はロングプレスされたセルが変更されたときだけ通知されるようにします。
Delegateの定義
protocol SmoothSelectableTableViewDelegate: AnyObject {
func smoothSelectableTableView(
_ tableView: SmoothSelectableTableView,
didUpdateLongPressState state: SmoothSelectalbeTableViewState
)
}
ViewController側に通知されるインターフェースです。
SmoothSelectalbeTableViewState
が更新されたタイミングでdelegateから通知されます。
カスタムTableView
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経由で通知します。
利用側
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以上であればアプローチ①を使うのがいいかと思います。