CollectionViewで並び替えのアニメーションを作る


はじめに

コンテンツがタイル状に並んでいる画面での並び順変更にて
タイルが移動するアニメーションを実装する必要ができたのですが、
色々調べてみてもセクションヘッダーを含めてアニメーションする方法が見当たりませんでした。
そのため試行錯誤しつつ自力で実装するはめに...
まだまだ微妙な部分もありますが、とりあえず形になったため知見を残す意味も込めて記事にしました。
マサカリ大歓迎です!

イメージ

画面の構成

並び順を切り替えるためのSegmentedControl
タイルを表示するためのCollectionViewController

実現方法

データの持ち回り方

// セクション情報
struct Section {
    let title: String?
    let students: [Student]
}

// セクション内のタイル情報
struct Student {
    let id: Int
    let name: String
    let birthday: Date
    let height: Int
}

// 実データ
var sections: [Section]?

アニメーションのさせ方

アニメーションの実現にはperformBatchUpdatesを利用します。
このメソッドは、ブロック内に記述されたinsert,delete,updateのアニメーションを同時に実行してくれるメソッドです。
上記メソッドのブロック内で、並び替え前のIndexPathと並び替え後のIndexPathをマッチングさせ、moveItem(at: to:)で移動させます。
※ この方法ではセクション数の可変がとても難しいので、後述の一工夫が必要になります。

let newSections = 並び替え後のデータ

collectionView.performBatchUpdates({
    並び替え前のIndexPathと並び替え後のIndexPathをマッチングさせる処理
    マッチしたアイテムの分だけ繰り返し {
        collectionView.moveItem(at: 並び替え前のIndexPath, to: 並び替え後のIndexPath)
    }
    self.sections = newSections // 1.
}, completion: { success in
    self.collectionView.reloadSections(IndexSet(integersIn: 0..<self.collectionView.numberOfSections) // 2.
})
  1. performBatchUpdates実施後にCollectionViewの再描画処理が実行されるため、データを更新してこかないとセクション数やセクション内のアイテム数の処理で失敗します。
  2. セクションやアイテムの表示内容の更新がある場合、performBatchUpdates内で実行するとアニメーションが実行されなくなるため、完了後に実施する必要があります

セッション数を可変させるには

アニメーションのさせ方で軽く触れましたが、上記の方法ではセッション数の可変が難しいです。

なぜかというと、performBatchUpdatesの特徴として、移動させる対象のIndexPathと削除する対象のIndexPathが一致する場合にエラーになるといった特徴があるからです。
この特徴により、セッションを減らそうとするとmoveItemとdeleteSectionのIndexPathが被ってしまいエラーになってしまいます。

そのため、そもそもセッション数を可変にすることを諦めます。
どういうことかと言いますと、セッション数が減少する場合は空のセッションを末尾に追加しそのセッションヘッダーを非表示(サイズ0)にすることで擬似的にセッション数が減った状態を作り出す方法をとります。

let newSections = self.newSections

// これを追加する
UIView.performWithoutAnimation {
    collectionView.performBatchUpdates({
        if collectionView.numberOfSections < newSections.count {
            for count in collectionView.numberOfSections..<newSections.count {
                collectionView.insertSections(IndexSet(integer: count))
                sections?.append(Section(title: nil, students: []))
            }
        }
    }, completion: nil)
}

collectionView.performBatchUpdates({
    並び替え前のIndexPathと並び替え後のIndexPathをマッチングさせる処理
    マッチしたアイテムの分だけ繰り返し {
        collectionView.moveItem(at: 並び替え前のIndexPath, to: 並び替え後のIndexPath)
    }
    self.sections = newSections
}, completion: { success in
    self.collectionView.reloadSections(IndexSet(integersIn: 0..<self.collectionView.numberOfSections)
})

これで擬似的にセクション数の可変が実現できました。

さいごに

意外と要件として出てきそうで情報が全然なかったのでなんとか作ってみましたが、
まだまだ荒削りな方法なのでいただいたマサカリを元にブラッシュアップしていけたらと思っています!

参考

【Swift】CollectionViewを再理解する
公式リファレンス

コード

検証用に書いたコードなので割と適当ですがそれでもよければ
https://github.com/rihitenLab/performBatchUpdatesExample