10 年もののタブ並び替えライブラリを 3 回叩いて撤退 — メモ帳を UIKit Drag & Drop に乗り換えた話

| 開発記録 | メモ帳

タグ: #iOS #UIKit #Swift #リファクタリング #UITableView

iOS 版メモ帳のタブ並び替えがちらつく。10 年使ってきた BVReorderTableView を同じ日に 3 回パッチしても直らず、結局ライブラリを捨てました。UIKit 標準の UITableViewDragDelegate / UITableViewDropDelegate へ乗り換えるまでの、撤退判断と実装の勘所です。

なぜ 10 年もののライブラリを捨てたか

メモ帳アプリのタブ切り替えには、「フォルダを並び替えて自分好みの順にする」機能があります。iOS 版はこれを、10 年以上前から BVReorderTableView という OSS で実現していました。当時の UITableView には長押しドラッグ並び替えの標準 API がなく、このライブラリが事実上の選択肢でした。

ところが 2025 年末、並び替え時のちらつきや表示位置のずれの指摘が増えました。実機(iOS 17)でも再現します。すぐ直せると思って着手しました。結果は逆でした。同じ日に 3 回パッチしても解消できず、最終的にライブラリを捨てて UIKit 標準の Drag & Drop に乗り換えることになりました。

失敗した 3 回の記録と、乗り換え後の実装ポイントを残します。

パッチ 1: セクションヘッダーの再描画を止めにいく

最初の症状はこうでした。ドラッグを開始した瞬間に、セクションヘッダーの文字が一瞬消えて再描画される。

titleForHeaderInSection を使うと、UITableView 側がヘッダーラベルの UILabel を毎回作り直します。ライブラリがドラッグ中に deleteRows / insertRows を呼ぶたび、ヘッダーも「レイアウトが変わったかもしれない」扱いで作り直される。それが原因でした。

対策として viewForHeaderInSection に切り替えました。固定サイズの UIView + UILabel を自前で持ちます。

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    let headerView = UIView()
    headerView.backgroundColor = .systemGroupedBackground

    let label = UILabel()
    label.text = "並び替えたい順にドラッグしてください"
    label.font = .systemFont(ofSize: 13)
    label.textColor = .secondaryLabel
    label.translatesAutoresizingMaskIntoConstraints = false

    headerView.addSubview(label)
    NSLayoutConstraint.activate([
        label.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16),
        label.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
        label.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
    ])
    return headerView
}

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return 44
}

ヘッダーの文字が消える現象は治まりました。でも本題の「テーブル全体が上下にシフトする」問題は残ります。

パッチ 2: スクロール位置を退避して復元する

次に isDraggingRow フラグと savedContentOffset を用意しました。ドラッグ開始時にスクロール位置を退避し、scrollViewDidScroll で予期しないシフトを検出して復元する。そういう追加対策です。

private var isDraggingRow = false
private var savedContentOffset: CGPoint = .zero

// saveObjectAndInsertBlankRow(at:) の入口で
isDraggingRow = true
savedContentOffset = tableView.contentOffset

// finishReordering(with:at:) の出口で
isDraggingRow = false

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard isDraggingRow else { return }
    if scrollView.contentOffset != savedContentOffset {
        scrollView.setContentOffset(savedContentOffset, animated: false)
    }
}

あわせて iOS 15 で導入された sectionHeaderTopPadding を 0 に指定しました。推定高さ(estimatedRowHeight など)にも明示値を入れます。

if #available(iOS 15.0, *) {
    tableView.sectionHeaderTopPadding = 0
}
tableView.estimatedRowHeight = 50.0
tableView.estimatedSectionHeaderHeight = 44.0

これでレイアウトが安定するはず。そう思っていました。今度はドラッグ中に セルが一瞬消える という別の不具合を招きます。scrollViewDidScroll の中で setContentOffset を叩くと、ライブラリ側が管理する一時的な「空セル」の描画とレースを起こしていたのです(直すたびに新しい穴を開けていました)。

パッチ 3: ライブラリの中に手を入れる(そして撤退)

原因はもっと根っこにある。そう感じて BVReorderTableView のソースを読みました。updateCurrentLocation メソッドで、deleteRows(at:with:) と insertRows(at:with:) に UITableViewRowAnimationAutomatic が渡されています。これがドラッグ中の再レイアウトを誘発している疑いでした。

Pods 配下のファイルを直接編集し、.automatic を .none に置換しました。あわせて、パッチ 2 で入れた contentOffset 復元ロジックは害のほうが大きいと判断して revert しました。

// BVReorderTableView.m (Pods 内部を直接編集)
- (void)updateCurrentLocation:(UILongPressGestureRecognizer *)gesture {
    // ...省略...
    [self beginUpdates];
    [self deleteRowsAtIndexPaths:@[indexPath]
                withRowAnimation:UITableViewRowAnimationNone];  // .automatic から変更
    [self insertRowsAtIndexPaths:@[newIndexPath]
                withRowAnimation:UITableViewRowAnimationNone];  // .automatic から変更
    // ...
    [self endUpdates];
}

結果は「ちらつきは減ったが完全には消えない」でした。しかも Pods ディレクトリを直接編集しているので、pod install が走ると差分が消えます。post_install フックで再適用する運用は QR リーダー側で既に導入していました。ただ、10 年前のライブラリのために同じ運用を増やすのは筋が悪い。ここで ライブラリ側を叩く方針を撤回 しました。

パッチ 4: UIKit 標準 Drag & Drop へ乗り換える

UIKit には iOS 11 から UITableViewDragDelegate と UITableViewDropDelegate が入っています。UITableView.dragInteractionEnabled = true を設定するだけで、長押し検出と半透明プレビュー生成はフレームワークが担当します。10 年前の OSS が担っていた仕事の大半は、もう今の UIKit に含まれていました。

UITableView に一本化した後の初期化はこれだけです。

self.tableView = UITableView(frame: parent!.view.bounds, style: .grouped)
self.tableView.delegate = self
self.tableView.dataSource = self

if #available(iOS 15.0, *) {
    tableView.sectionHeaderTopPadding = 0
}
// 固定高さを明示して推定高さを無効化する。
// ドラッグ中に UITableView がレイアウトを再計算するのを止めるのが目的。
tableView.rowHeight = 50
tableView.sectionHeaderHeight = 44
tableView.sectionFooterHeight = 0
tableView.estimatedRowHeight = 0
tableView.estimatedSectionHeaderHeight = 0
tableView.estimatedSectionFooterHeight = 0

if #available(iOS 11.0, *) {
    tableView.dragInteractionEnabled = true
    tableView.dragDelegate = self
    tableView.dropDelegate = self
}

estimatedRowHeight = 0 にしているのがポイントです。この画面のように行高さが固定なら、推定値との差分でドラッグ中に再レイアウトが走るのを避けやすくなります。パッチ 1 〜 3 で戦っていた「ヘッダー再描画」「スクロール位置シフト」の一因は、この推定値と実測のズレだったと考えています。

ドラッグ側は最小限で書けます。

@available(iOS 11.0, *)
extension SettingTabViewController: UITableViewDragDelegate {
    func tableView(_ tableView: UITableView,
                   itemsForBeginning session: UIDragSession,
                   at indexPath: IndexPath) -> [UIDragItem] {
        let folder = folders[indexPath.row]
        let itemProvider = NSItemProvider(object: "\(folder.id)" as NSString)
        let dragItem = UIDragItem(itemProvider: itemProvider)
        dragItem.localObject = folder
        return [dragItem]
    }
}

ドロップ側は「自分自身のドラッグセッションだけ受け付ける」形にします。アプリ外からのドロップを弾くためです。

@available(iOS 11.0, *)
extension SettingTabViewController: UITableViewDropDelegate {
    func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
        return session.localDragSession != nil
    }

    func tableView(_ tableView: UITableView,
                   dropSessionDidUpdate session: UIDropSession,
                   withDestinationIndexPath destinationIndexPath: IndexPath?)
        -> UITableViewDropProposal {
        let op: UIDropOperation = session.localDragSession != nil ? .move : .forbidden
        return UITableViewDropProposal(operation: op, intent: .insertAtDestinationIndexPath)
    }

    func tableView(_ tableView: UITableView,
                   performDropWith coordinator: UITableViewDropCoordinator) {
        let destinationIndexPath = coordinator.destinationIndexPath
            ?? IndexPath(row: folders.count, section: 0)

        for item in coordinator.items {
            guard let sourceIndexPath = item.sourceIndexPath else { continue }

            tableView.performBatchUpdates({
                let moved = folders.remove(at: sourceIndexPath.row)
                folders.insert(moved, at: destinationIndexPath.row)
                tableView.deleteRows(at: [sourceIndexPath], with: .automatic)
                tableView.insertRows(at: [destinationIndexPath], with: .automatic)
            })

            coordinator.drop(item.dragItem, toRowAt: destinationIndexPath)

            updateSortOrder()
            NotificationCenter.default.post(name: DidUpdateFolders, object: nil)
        }
    }
}

.insertAtDestinationIndexPath を提案すると、destinationIndexPath は「削除後の配列に対して挿入したい位置」を指す座標として渡ってきます。そのため performBatchUpdates の中で remove → insert → deleteRows → insertRows を同時に行います。これで UITableView 側とモデルが同じ 1 回の更新で同期し、末尾へのドロップも含めて追加補正は不要です。実アプリでは、セクション末尾へのドロップ許容や sourceIndexPath == destinationIndexPath の早期リターンなど、細部を足すことがあります。

教訓

  • 10 年前の UIKit 前提の OSS は、今の UIKit と組み合わせると内部で二重に仕事をしていることがある。ちらつきの一因は、ライブラリと UITableView のアニメーションの競合だと考えています。
  • ライブラリ内部を直接パッチする方針は最終手段。post_install で再適用する運用は、増やすたびに保守コストが上がる。標準 API に代替があるなら乗り換えたほうが長期的には安い。
  • iOS 11 以降なら UITableViewDragDelegate / UITableViewDropDelegate が事実上のデフォルト。長押し検出・半透明プレビュー・スクロール追従を全部フレームワークに任せられます。
  • estimatedRowHeight = 0 と sectionHeaderTopPadding = 0 の 2 行は、この画面のように行高さ・ヘッダー高さが固定な UI で位置変動を抑えたいときに効きます(Drag & Drop の必須設定ではありません)。推定値が残っていると、ドラッグ中の再レイアウトで実測とズレる場合があります。

3 回パッチした半日は、損失に見えます。でも「なぜライブラリ内部のアニメーションが問題なのか」を理解しきったので、乗り換え後の設定値を全部説明できる状態になりました。撤退の判断はもう一つ早くてよかった。そこは正直、反省しています。