はじめに
こんにちはsuzukiです。3月末でXcode11対応が必須になり、慌てて対応しました。昨年も慌てて対応した気がします。
Xcode11対応前から発生したのですが、iOS13でUICollectionViewのCellの入れ替え中を行うとカクつきが起きたため、対処療法ですが対応したのでご紹介いたします。おまけでその他のiOS13対応で一度対応が漏れた内容を記述しております。
発生内容
iOS13端末でのみGifの通りなのですがドラッグしている“7”が一瞬カクついています。条件としてはドラッグを開始した場所をドラッグ中に通過することです。
UICollectionViewの実装
Gifと同様の事象が起きるCollectionViewのコードです。
StoryBoardはよしなに設定してください。下記を忘れなければだいたい動きます。
・CollectionViewの配置とデータソース類の関連付け
・CustomCellのIDの設定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | import UIKit class ViewController: UIViewController { //表示用のデータ var arrayData:[Int] = [] @IBOutlet weak var collectionView: UICollectionView! override func viewDidLoad() { super.viewDidLoad() //デフォルトの値を設定 SavedDataManager.setInitialData() //保存されている値の取得 arrayData = SavedDataManager.getArrayData() //FlowLayoutの設定 let customLayout = CustomCollectionViewFlowLayout() collectionView.collectionViewLayout = customLayout // ロングタップジェスチャーを付与 addLongTapGesture() } // 長押しでCollectionViewのセル移動処理を行う func addLongTapGesture() { let longTapGesture = UILongPressGestureRecognizer(target: self, action: #selector(longTap(gesture:))) collectionView.addGestureRecognizer(longTapGesture) } @objc func longTap(gesture: UILongPressGestureRecognizer) { switch gesture.state { // ロングタップの開始時 case .began: guard let selectedIndexPath = collectionView.indexPathForItem(at: gesture.location(in: collectionView)) else { break } collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) // セルの移動中 case .changed: collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view)) // セルの移動完了時 case .ended: collectionView.endInteractiveMovement() default: collectionView.cancelInteractiveMovement() } } //データの更新処理 private func updateArrayData(sourceIndex: Int, destinationIndex: Int) { let n = arrayData.remove(at: sourceIndex) arrayData.insert(n, at: destinationIndex) SavedDataManager.setArrayData(arrayData) } } //Delegate関連 extension ViewController:UICollectionViewDataSource,UICollectionViewDelegate{ //いつもの必須デリゲート func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return arrayData.count } //いつもの必須デリゲート func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) if let cell = cell as? CustomCell { cell.setupCell(title: arrayData[indexPath.row]) } return cell } // 並び替えを可とする func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { return true } //セルの入れ替え時の動作Endを呼ばれた際に呼ばれる。 func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { //VisibleCellのデータ確認 ここで取得できるvisibleCellsはOSで挙動が異なる //iOS10は選択しているCellが含まれた値が取得されるが、それ以降は選択しているCellが含まれない。 print(self.collectionView.visibleCells.count) //データの保存処理を呼び出し updateArrayData(sourceIndex: sourceIndexPath.item, destinationIndex: destinationIndexPath.item) } } //データの保存処理の管理方法 class SavedDataManager { //初期データ static func setInitialData(){ UserDefaults.standard.register(defaults: ["KeyOrderList" :[1,2,3,4,5,6,7,8,9]]) } static func setArrayData(_ arrayData:[Int]){ UserDefaults.standard.set(arrayData, forKey: "KeyOrderList") } static func getArrayData() -> [Int]{ return UserDefaults.standard.array(forKey: "KeyOrderList") as! [Int] } } //テスト用Cell class CustomCell: UICollectionViewCell { @IBOutlet weak var title: UILabel! var order = 0 func setupCell(title : Int) { self.order = title self.title.text = "\(title)" self.backgroundColor = .lightGray } } |
カクつきを抑える
この問題への対応を調査したところ、Xcodeのバージョンに関係なく、iOS 13で発生することが分かりました。
そこで、以下の対応方針でコードを修正していきます。
①ドラッグ中に問題が発生する入れ替え処理とアニメーションを行わせない。
②ドラッグ終了時に元の位置に戻すため止めていた入れ替え処理とアニメーションを行う。
Cellの入れ替えが発生する際の起点
処理をメインで追加するのが下記のデリゲートです。
1 2 | func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath{ } |
簡単に説明ですが、updateInteractiveMovementTargetPositionを読んだ際に呼ばれるメソッドです。
実際にセルが入れ替わる際には下記のように取得できます。
・originalIndexPathで元のIndexPath
・proposedIndexPathで次のIndexPath
・originalIndexPathとproposedIndexPathが異なる際にproposedIndexPathを返却するとCellの入れ替えとアニメーションが発生します。
修正内容
ドラッグ中に問題が発生する入れ替え処理とアニメーションを行わせないという処理のため
・ドラッグし始めた際のIndexPathを取得
・ドラッグが完了した際のフラグを設定
上記を管理するためインスタンス変数を追加します。
1 2 3 4 | //セルのドラッグ開始時のIndex var startIndex:IndexPath? //ドラッグの完了が呼ばれた際にアニメーションを強制するためのフラグ var isForceAnimationFlag = false |
実際に値を入れるのは下記の関数の中で行いました。ドラッグ中にユーザがタブを切り替えた場合も考慮し、viewWillAppearでfalseを代入します。記事後半で、コード全体を掲載するので、そちらも確認してみてください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | @objc func longTap(gesture: UILongPressGestureRecognizer) { switch gesture.state { // ロングタップの開始時 case .began: guard let selectedIndexPath = collectionView.indexPathForItem(at: gesture.location(in: collectionView)) else { break } //iOS13の場合だけユーザーが指を離したタイミングで一度更新を行う if #available(iOS 13.0, *) { //Indexの保存 startIndex = selectedIndexPath //アニメーション強制フラグをFalseに isForceAnimationFlag = false } collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) // セルの移動中 case .changed: collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view)) // セルの移動完了時 case .ended: //iOS13の場合だけユーザーが指を離したタイミングで一度更新を行う if #available(iOS 13.0, *) { isForceAnimationFlag = true collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view)) } collectionView.endInteractiveMovement() default: collectionView.cancelInteractiveMovement() } } |
セルの入れ替え処理のデリゲートに下記のように記述しました。
1 2 3 4 5 6 7 8 9 10 11 | //セルの入れ替えを行う処理originalIndexPathを返却で入れ替え処理を行わない。 func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { //iOS13だけ選択した最初のセルと入れ替えを行うセルが同じだった場合は入れ替え処理を行わない。 if #available(iOS 13.0, *) { if proposedIndexPath == startIndex , !isForceAnimationFlag{ isForceAnimationFlag = false return originalIndexPath } } return proposedIndexPath } |
動作確認
このような動きになりました。最初のカクつきは抑えれましたが既存のOSとは動きに少し差があります。
修正後のコードを記述します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | class ViewController: UIViewController { //表示用のデータ var arrayData:[Int] = [] //セルのドラッグ開始時のIndex var startIndex:IndexPath? //ドラッグの完了が呼ばれた際にアニメーションを強制するためのフラグ var isForceAnimationFlag = false @IBOutlet weak var collectionView: UICollectionView! override func viewDidLoad() { super.viewDidLoad() //デフォルトの値を設定 SavedDataManager.setInitialData() //保存されている値の取得 arrayData = SavedDataManager.getArrayData() //FlowLayoutの設定 let customLayout = CustomCollectionViewFlowLayout() collectionView.collectionViewLayout = customLayout // ロングタップジェスチャーを付与 addLongTapGesture() } override func viewWillAppear(_ animated: Bool) { //Endが呼ばれる前にタブの切り替え等を行いUICollectionViewが不自然な状態になるのを防ぐ collectionView.reloadData() if #available(iOS 13.0, *) { startIndex = nil isForceAnimationFlag = false } } // 長押しでCollectionViewのセル移動処理を行う func addLongTapGesture() { let longTapGesture = UILongPressGestureRecognizer(target: self, action: #selector(longTap(gesture:))) collectionView.addGestureRecognizer(longTapGesture) } @objc func longTap(gesture: UILongPressGestureRecognizer) { switch gesture.state { // ロングタップの開始時 case .began: guard let selectedIndexPath = collectionView.indexPathForItem(at: gesture.location(in: collectionView)) else { break } //iOS13の場合だけユーザーが指を離したタイミングで一度更新を行う if #available(iOS 13.0, *) { //Indexの保存 startIndex = selectedIndexPath //アニメーション強制フラグをFalseに isForceAnimationFlag = false } collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) // セルの移動中 case .changed: collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view)) // セルの移動完了時 case .ended: //iOS13の場合だけユーザーが指を離したタイミングで一度更新を行う if #available(iOS 13.0, *) { isForceAnimationFlag = true collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view)) } collectionView.endInteractiveMovement() default: collectionView.cancelInteractiveMovement() } } } //Delegate関連 extension ViewController:UICollectionViewDataSource,UICollectionViewDelegate{ //いつもの必須デリゲート func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return arrayData.count } //いつもの必須デリゲート func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) if let cell = cell as? CustomCell { cell.setupCell(title: arrayData[indexPath.row]) } return cell } // 並び替えを可とする func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { return true } //セルの入れ替えを行う処理originalIndexPathを返却で入れ替えを行わない。 func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { //iOS13だけ選択した最初のセルと入れ替えを行うセルが同じだった場合は入れ替え処理を行わない。 if #available(iOS 13.0, *) { if proposedIndexPath == startIndex , !isForceAnimationFlag{ isForceAnimationFlag = false return originalIndexPath } } return proposedIndexPath } //セルの入れ替え時の動作Endを呼ばれた際に呼ばれる。 func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { //VisibleCellのデータ確認 ここで取得できるvisibleCellsはOSで挙動が異なる //iOS10は選択しているセルが含まれた値が取得されるが、それ以降は選択しているセルが含まれない。 print(self.collectionView.visibleCells.count) //データの保存処理を呼び出し updateArrayData(sourceIndex: sourceIndexPath.item, destinationIndex: destinationIndexPath.item) } //データの更新処理 private func updateArrayData(sourceIndex: Int, destinationIndex: Int) { let n = arrayData.remove(at: sourceIndex) arrayData.insert(n, at: destinationIndex) SavedDataManager.setArrayData(arrayData) } } //データの保存処理の管理方法 class SavedDataManager { //初期データ static func setInitialData(){ UserDefaults.standard.register(defaults: ["KeyOrderList" :[1,2,3,4,5,6,7,8,9]]) } static func setArrayData(_ arrayData:[Int]){ UserDefaults.standard.set(arrayData, forKey: "KeyOrderList") } static func getArrayData() -> [Int]{ return UserDefaults.standard.array(forKey: "KeyOrderList") as! [Int] } } //テスト用Cell class CustomCell: UICollectionViewCell { @IBOutlet weak var title: UILabel! var order = 0 func setupCell(title : Int) { self.order = title self.title.text = "\(title)" self.backgroundColor = .lightGray } } |
おまけ
・UITableViewCellのタップ時の色の変更
iOS 13 Release Notesで記述されているのですが下記をXcode11対応で合わせて行いました。
iOS13以前はCellをタップ際にcontentViewのsubView全体の色を変更していました。
iOS13以降はcontentViewのみ色の変更をします。
subViewにcontentsViewと同じ色を設定している時などは、設定を見直しましょう。
・UITableViewCellのSetHighlightの呼ばれるタイミング
UITableViewCellの
func setHighlighted(_ highlighted: Bool, animated: Bool)
の呼ばれるタイミングが変更されました。
上記箇所でselfのサイズを取得すると
iOS13以前はオートレイアウトの情報をもとに画面に表示されるサイズが取得。
iOS13以降は配置前のxibファイルに設定されているサイズが取得される。
という違いがありました。私の場合上記箇所でLazyでサイズ取得を行なっていたため、想定と違うサイズで不具合が起きました。
・iOS13のUICollectionViewの新機能
こちらのサイトがUICollectionViewのWWDCをもとにわかりやすくまとめてくれてます。
WWDCそのもののリンクはこちらです。
Advances in Collection View Layout
https://developer.apple.com/videos/play/wwdc2019/215
Advances in UI Data Sources
https://developer.apple.com/videos/play/wwdc2019/220/
さいごに
UICollectionViewはiOS11,13で大きな変更が続いています。想像しないような問題も発生しますが、入れ替えのアニメーションを自分で開発するのは大変なので使うこともよくあるかと思います。しっかりと付き合っていきたいですね。