AutoReselect of UITableView


概要

Apple純正のNote(メモ)アプリを見ると、UISplitViewControllerを利用したMaster-DatailアプリケーションでUITableViewにおいて削除/挿入/移動が生じた際に、その後適切なセルが再選択され、secondaryViewControllerが対応したデータにreplaceされます。まるで標準挙動かのような自然な動きに感じますが、これはAppleNoteチームの独自実装のため、私たちも自前で実装するしかありません。この記事では、UITableView編集後の再選択に対する知見と参考の実装を共有します。

目次

  1. APIの分類
  2. UITableViewのselectionとEditModeに関する考慮点
  3. 方針の検討

APIの分類

基本的にAPIはEditMode用、非EditModeの二種類が必要になります。

そもそも再選択とはどのようなものかというと、基本的には「選択中のセル削除されたとき」に、現在のテーブルの位置関係から、次の最も妥当なセルを選択するというものです。

ただし、EditModeによってその扱いが変わるため、それぞれなぜ必要なのかについて解説していきます。

非EditMode

EditModeではないとき、TableViewで気にすべきなのは「移動」「削除」の2つの更新です。削除はイメージ通りですが、移動はinsertRowsdeleteRowsの組み合わせから実現されるため、選択中のセルが削除されることがあり、その場合に再選択が必要になります。

EditMode

EditModeに入ってしまうとシステム側でUITableViewのselectedRowIndexPathsが一旦クリアされてしまうため、非EditModeの「削除」「移動」だけではなく全てのイベントにおいて再選択が必要になります。これについては次の章で詳しく解説します。

UITableViewのselectionとEditModeに関する考慮点

UITableViewではsetEditing(true, animated: animated)が呼ばれた際にセレクションをクリアする挙動がありますこれはEditModeを抜けても復帰されない上に、モード中のselectRowの実行は無視されます。

そのため、どのような編集アクションが実行されようと、また何も実行されない場合でも、一度EditModeに入った場合は抜けた後に再選択をさせる必要があります。

クリアの挙動が実装されているのは、以下のような理由のためではないかと推察しています。

  • UITableViewallowsMultipleSelectiontrueにすることでeditMode中に複数選択が可能であり、indexPathForSelectedRowsはそれらの格納に利用されるため

サンプルコード

以下のようなコードを書いて実行した際、ViewControllerのコメント該当部分をコメントアウトするとindexPathForSelectedRowが得られますが、tableViewがeditModeに入ってしまうとindexPathForSelectedRownilになってしまう、つまりセレクションがクリアされます。

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.delegate = self

        navigationItem.rightBarButtonItem = editButtonItem

    }

    override func setEditing(_ editing: Bool, animated: Bool) {
        print("before: \(tableView.indexPathForSelectedRow)")

        super.setEditing(editing, animated: animated)

        // 以下の行をコメントアウトすると、セレクションが残ります
        tableView.setEditing(editing, animated: animated)

        print("after: \(tableView.indexPathForSelectedRow)")
    }

}

extension ViewController : UITableViewDataSource {

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        return cell
    }


}


extension ViewController : UITableViewDelegate {

    func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
        return .delete
    }

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {

    }

    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        return true
    }

    func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
        setEditing(true, animated: false)
    }

    func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
        setEditing(false, animated: false)
    }

}

ここでは「EditMode経由だとそれまでの選択がクリアされてされてしまうので、適切な選択をさせるならsetEditing(false)のタイミングでselectRowを実行する必要があるだろう」と考えることが出来ます。

方針の検討

ここで基本的な対応の方針を整理します。

上で説明した通り、EditModeを経由すると選択がクリアされてしまい、途中の適応は無視されるため、モードを抜けるタイミングで適応させる必要があります。加えて、通常操作によるセルの更新の場合にも再選択をさせる必要があります。まとめると

  1. setEditingoverrideして呼ぶ、EditMode用の変更を内部でpendingさせて、抜けるタイミングで最後の状態の再選択を適応できるAPI
  2. 通常変更の差分適応をする際に再選択を行う、非EditMode用の即時再選択を適応できるAPI

の2種類のAPIに対応する必要があります。

API設計

これに対して、このようなAPIを提案します。

  1. reselect()
  2. setReselect()
  3. commit()

1つ目は即時実行用、2つ目と3つ目はEditMode中の複数の変更に対応するためのAPIになります。

実装

こちらに実装しました。

内部的にpendingItemForSelectedRowというぶら下がったプロパティを持つことで、EditMode中の複数の変更でも再選択のための適切なIndexPathを計算・保持し続けることができます。

reselectでは、以下のように内部的にはsetReselectを上手く呼ぶことで無駄な実装を省くことに成功しました。

public func reselect(deletedIndexPaths: [IndexPath]? = nil) {
    refresh() // 内部変数のリフレッシュ
    setReselect(deletedIndexPaths: deletedIndexPaths)
    commit()
}

サンプル

利用サンプルは後日、github側に追加して解説を入れます。

まとめ

「適切なセルの再選択」というのは、UITableViewの標準挙動で提供されていそうなものですが、実現されていません。

そのため、この記事では

  • EditMode
  • 非EditMode

用に分けて理想挙動を解説し、EditMode時のselectionの考慮点について述べました。
その後、APIのインターフェースについて提案しつつ、筆者の実装例とサンプルを示しました。

展望としては、記事化とOSS化によって筆者が未考慮の事項がコメントやPRで集まり、さらに良い再選択のロジックについて議論・構築が進めば良いなと考えております。

参照