Edited at

CollectionViewでセルの範囲選択処理を書いてみた(致命的な課題が残)

More than 1 year has passed since last update.

範囲選択.gif

水色→未選択

赤色→選択済

「やった、範囲選択できるようになった!」と満足してしまって、

しばらく手をつけない気がするので実装できた分だけまとめておく。

現在のソース全部は以下にございます。

https://github.com/m-yamada1992/DragSelection


実用に致命的な課題


  • タッチイベントが競合してスクロールが効かない

 →ViewController側でタッチイベントを何とかできないかと試したが、

  そうするとCollectionView上のタッチ先の座標が拾う方法が見つからず一旦放置


  • スクロールした先の座標の計算を考慮してない

 →現在の画面上の座標からスクロールした分の高さを引く云々が必要だが

  上記どうやってスクロールさせる問題を解決するまで放置の見込み


  • カラムが4列の場合しか考慮してない

 →セルサイズ決めうち実装。縦横回転・iPhone/iPadなどなど

  表示先の画面サイズにより列数が変動する場合に関しての処理を実装してない

  (セルを画面上に生成後にサイズを保持する対応をすれば問題ない気がする)


  • セクション混じりの場合の範囲内セル取得を考慮してない

 →今のままでいけるかなという気はしているが、試してないので要検証


  • タッチ範囲を可視化している矩形と実際に選択される座標がずれてる

 →描画自体がずれてるのか、選択ステータス変更しにいってるセルの座標計算が誤りなのか・・・


実装詳細

環境:Xcode8.2 / Swift3.0

要な部分を抜粋。


UIViewController


ViewController.swift

class ViewController: UIViewController {

/// コレクションビュー
@IBOutlet weak var _collectionView: DraggableCollectionView!

/// 選択済セルのインデックス
lazy var selectedIndices: Array<Int> = {
return Array<Int>()
}()

/// セル1個当たりの横幅(セル自体は正方形)
var cellWidth: CGFloat!

/// 選択範囲を表す矩形
var selectedRangeRect: UIView?

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~いろいろ省略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

// MARK: Self
/**
選択を開始したセルの選択ステータスを変更する
- paremeter beganPoint: 選択を開始した座標
*/

func selectTouchBeganCell(beganPoint: CGPoint?) {
if let indexPath = self._collectionView.indexPathForItem(at: beganPoint!) {
self._collectionView.previewRangeSelectedIndexPathes.append(indexPath)
self.selectedCell(self._collectionView, didSelectItemAt: indexPath)
}
}

/**
移動した座標間に含まれるセルの選択ステータスを変更する
- paremeter beganPoint: 選択を開始した座標
- parameter movedPoint: 選択範囲の終端の座標
*/

func selectTouchRangeCells(beganPoint: CGPoint?, movedPoint: CGPoint?) {
// 座標内に含まれるセルを取得する
if beganPoint != nil && movedPoint != nil {
if self._collectionView.indexPathForItem(at: movedPoint!) != nil {
// 4方向の座標を取得
let topPoint = min(beganPoint!.y, movedPoint!.y)
let underPoint = max(beganPoint!.y, movedPoint!.y)
let leftPoint = min(beganPoint!.x, movedPoint!.x)
let rightPoint = max(beganPoint!.x, movedPoint!.x)
// ナビゲーションバーの高さも鑑みて矩形を生成
let navigationBarHeight = self.navigationController?.navigationBar.frame.height ?? 0
let rect = CGRect(
x: leftPoint,
y: topPoint + navigationBarHeight,
width: rightPoint - leftPoint,
height: underPoint - topPoint + navigationBarHeight
)
// 現在矩形を表示中の場合は表示を削除し、描画
self.selectedRangeRect?.removeFromSuperview()
self.selectedRangeRect = UIView(frame: rect)
self.selectedRangeRect?.backgroundColor = UIColor.purple
self.selectedRangeRect?.alpha = 0.5
self.view.addSubview(self.selectedRangeRect!)

// 1セル辺りのサイズを取得して等間隔に座標指定して、タッチ開始〜タッチ中終端の座標内に含まれるセルの選択ステータスを変更する
// 選択範囲内のセルのインデックスをそれぞれ取得する
var selectedIndexPathes = Array<IndexPath>()
let previewSelectedIndexPathes = self._collectionView.previewRangeSelectedIndexPathes
// 行
var y = topPoint
while y <= underPoint {
// 列
var x = leftPoint
while x <= rightPoint {
if let selectedIndexPath = self._collectionView.indexPathForItem(at: CGPoint(x: x, y: y)) {
// 前回選択ステータスを切り替えたばかりのセルでない場合、選択イベントを呼び出す
var nonSelect = true
if previewSelectedIndexPathes.count > 0 {
for selected in previewSelectedIndexPathes {
if selected == selectedIndexPath {
nonSelect = false
break
}
}
}
if nonSelect {
self.selectedCell(self._collectionView, didSelectItemAt: selectedIndexPath)
}
selectedIndexPathes.append(selectedIndexPath)
}
// 次の列のセルへ
x += self.cellWidth
}
// 次の行のセルへ
y += self.cellWidth
}
// 前回の範囲選択で選択され、今回分で範囲外になったセルのステータスを変更する
for previewSelected in previewSelectedIndexPathes {
if !selectedIndexPathes.contains(previewSelected) {
self.selectedCell(self._collectionView, didSelectItemAt: previewSelected)
}
}
// 今回選択範囲に含むセルの情報を保持する
self._collectionView.previewRangeSelectedIndexPathes = selectedIndexPathes
}
}
}

/**
セルの選択が終了時の処理
*/

func selectTouchEndedCells() {
// 範囲表示をしている場合は、表示を削除
self.selectedRangeRect?.removeFromSuperview()
self.selectedRangeRect = nil
}

/**
セル選択時の処理

- parameter collectionView
- parameter didSelectItemAtIndexPath indexPath
*/
func selectedCell(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let cell = collectionView.cellForItem(at: indexPath) {
let customCell = cell as! Cell
customCell.selectedCell = !customCell.selectedCell
// 選択ステータスを切り替える
if customCell.selectedCell {
// 未選択→選択
// 選択中インデックス情報にこのセルのインデックス情報を追加
self.selectedIndices.append(indexPath.row)
} else {
// 選択→未選択
// 選択中インデックス情報からこのセルのインデックス情報を削除
for (index, selected) in self.selectedIndices.enumerated() {
if selected == indexPath.row {
self.selectedIndices.remove(at: index)
break
}
}
}
}

}
}

// MARK: CollectionViewDelegate

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource {

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~いろいろ省略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

/**
(delegate)セル描画時の処理

- parameter collectionView
- parameter cellForItemAtIndexPath indexPath

- returns UICollectionViewCell: 表示するセル
*/
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
// 選択済みセルのインデックス情報にこのセルのインデックス情報が含まれている場合、選択状態にする
for selected in self.selectedIndices {
if selected == indexPath.row {
cell.selectedCell = true
break
} else {
cell.selectedCell = false
}
}
return cell
}
}


ドラッグ(パン動作)中に選択範囲を可視化した際の四隅の座標と

選択ステータスを切り替える先の座標がどうもずれている気がする。

ドラッグ&ドロップモードのときだけナビゲーションバーが非表示になるUIも

見たことあるから、ドラッグ中はナビゲーションバーとかツールバーとかの領域を

考慮しないくて良い作りにしたほうがスマートでいろいろうまくいくのかも・・・


UICollectionView


DraggableCollectionView.swift

class DraggableCollectionView : UICollectionView {

var touchesBeganPoint: CGPoint?
var touchesMovedPoint: CGPoint?

/// 選択範囲に含んでいるセルのインデックス情報
var parentViewController: ViewController!
lazy var previewRangeSelectedIndexPathes: Array<IndexPath> = {
return Array<IndexPath>()
}()

// MARK: override
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)

self.touchesBeganPoint = touches.first?.location(in: self)

// タップした座標のセルの選択ステータスを変更する
self.parentViewController.selectTouchBeganCell(beganPoint: self.touchesBeganPoint)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
// タッチしたまま指の移動を検知した場合、スクロールを不可にする
// ただし、現在の座標が画面最上部・最下部の座標の場合はスクロール可能にする
if let locationPoint = touches.first?.location(in: self) {
self.touchesMovedPoint = touches.first?.location(in: self)
if (locationPoint.y - self.contentOffset.y) > 10 && locationPoint.y < self.bounds.size.height - 10 {
self.isScrollEnabled = false
}
} else {
self.isScrollEnabled = true
}

// タップした範囲内のセルの選択ステータスを変更する
self.parentViewController.selectTouchRangeCells(beganPoint: self.touchesBeganPoint, movedPoint: self.touchesMovedPoint)
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)

self.touchesBeganPoint = nil
self.touchesMovedPoint = nil
self.previewRangeSelectedIndexPathes.removeAll()

// タッチイベントが終了したので、スクロールを可能に戻す
self.isScrollEnabled = true

// 範囲選択表示を削除する
self.parentViewController.selectTouchEndedCells()
}
}


ViewController側でUIPanGestureRecognizerを使ってみたらCollectionView内の

座標がすんなり拾えずに反射的にこっちに座標取得イベント放り込んじゃたけど、

こっちの方法だと永遠にスクロールが叶わない気がする。

今考えるとViewController側でUIPanGestureRecognizer利用が大前提で

何とかしたほうが課題がばっさばっさ解決できる予感。


追記

dokubekoさんがこの記事の課題をごっそり解決、

且つ処理にいろいろ無駄のないバージョンを投稿してくださいました。

http://qiita.com/dokubeko/items/ec9b770f8a6c1dc32c6e

UIGestureRecognizerの取り扱いはもちろん、

配列の取り扱いも大変勉強になりました。ありがとうございます!