前書き
こちらの記事を参考にCollectionViewCellでセルの範囲選択処理を実装しようと思ったのですが、以下のような不都合な点がいくつかあったので、こちらの記事も参考にしつつ改造してみました。
-
元ネタの記事にそもそも「実用に致命的な課題」という部分があり解決されずに放置されていた(多分)。
(今回は特に以下の課題を解決しました。)- タッチイベントが競合してスクロールが効かない
- スクロールした先の座標の計算を考慮してない
- カラムが4列の場合しか考慮してない
- 実装されていた処理がAppleの写真等を範囲選択する場合の処理と挙動が違っていました。
- 記事が古く現時点(2022年9月時点)のSwiftに対応していない。
完成イメージ
あとで編集して上げたいと思います。
本題
現在のソース全部はこちらにございます。
ViewController内の以下の部分を設定したら使えるようになるはずです。
class ViewController: UIViewController {
// 特に設定する部分
var data = [String]()
let numOfRow:CGFloat = 5
@IBOutlet var collectionView: CollectionView!
// -----省略------
for i in 1...150{
data.append( String(i) )
}
// -----省略------
}
data変数に表示させたいデータをまとめたデータを入れてください。
numOfRow変数にカラムの列数を入れてください。
40行目付近のループを消してください。
もう少し詳しく解説
主要な部分を抜粋して説明していきます。
CollwctionView
まず、オリジナルのdelegateを追加して、touchesBegan、touchesMoved、touchesEndedのイベントをViewControllerに送ります。
touchsもViewControllerに送ることで、「スクロールした先の座標の計算を考慮してない」の部分を解決しました。
protocol AdditionalDelegate{
func selectTouchBeganCell(touches: Set<UITouch>, beganPoint: CGPoint?)
func selectTouchRangeCells(touches: Set<UITouch>, beganPoint: CGPoint?, movedPoint: CGPoint?)
func selectTouchEndedCells()
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
self.touchesBeganPoint = touches.first?.location(in: self)
// タップした座標のセルの選択ステータスを変更する
self.additionalDelegate?.selectTouchBeganCell(touches: touches, beganPoint: 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 = locationPoint
}
// タップした範囲内のセルの選択ステータスを変更する
self.additionalDelegate?.selectTouchRangeCells(touches: touches, beganPoint: touchesBeganPoint, movedPoint: 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.additionalDelegate?.selectTouchEndedCells()
}
ViewController
続いて、ViewControllerでdelegateの処理部分と、それに必要な設定をします。
まずはdelegateの処理部分です。
後述する自動スクロールの発火もこの部分で行います。
extension ViewController: AdditionalDelegate{
func selectTouchEndedCells() {
DispatchQueue.main.async {
self.shouldFinish = true
self.stopAutoScrollIfNeeded()
}
}
func selectTouchBeganCell(touches: Set<UITouch>, beganPoint: CGPoint?) {
if let indexPath = self.collectionView.indexPathForItem(at: beganPoint!) {
self.collectionView.previewRangeSelectedIndexPathes.append(indexPath)
updateCollectionView(indexPath)
cachedSelectedIndices = selectedIndices
}
}
/**
移動した座標間に含まれるセルの選択ステータスを変更する
- paremeter beganPoint: 選択を開始した座標
- parameter movedPoint: 選択範囲の終端の座標
*/
func selectTouchRangeCells(touches: Set<UITouch>, beganPoint: CGPoint?, movedPoint: CGPoint?) {
if let locationPoint = touches.first?.location(in: self.view){
let timeInterval = 0.1
if(locationPoint.y <= scrollStartHeightTop){
// 上にスクロールさせる
collectionView.isScrollEnabled = true
startAutoScroll(duration: timeInterval, direction: "up")
} else if(scrollStartHeightBottom <= locationPoint.y){
// 下にスクロールさせる
collectionView.isScrollEnabled = true
startAutoScroll(duration: timeInterval, direction: "down")
} else{
collectionView.isScrollEnabled = false
stopAutoScrollIfNeeded()
}
}
// 座標内に含まれるセルを取得する
if let beganPoint = beganPoint, let movedPoint = movedPoint{
if let beganIndexPath = collectionView.indexPathForItem(at: beganPoint), let movedIndexPath = collectionView.indexPathForItem(at: movedPoint){
updateSelectedIndices = []
for item in min(beganIndexPath.item, movedIndexPath.item)...max(beganIndexPath.item, movedIndexPath.item){
updateSelectedIndices.append(item)
}
selectedIndices = []
if(updateBooleanValue){
for i in cachedSelectedIndices{
selectedIndices.append(i)
}
for i in updateSelectedIndices {
if( !selectedIndices.contains(i) ){
selectedIndices.append(i)
}
}
} else{
for i in cachedSelectedIndices{
if( !updateSelectedIndices.contains(i) ){
selectedIndices.append(i)
}
}
}
collectionView.reloadData()
}
}
}
}
次に、「タッチイベントが競合してスクロールが効かない」を解決させるために上の方、または下の方に指が移動した場合に自動的にスクロールさせるために必要な設定を書いていきます。こちらの記事を参考に自動スクロールを実装しました。この辺はテキトーに改造したので、もっと効率の良い方があるような気がします。
extension ViewController{
// 自動スクロールを開始する
private func startAutoScroll(duration: TimeInterval, direction: String) {
if isNeedToStartTimer {
// 表示されているCollectionViewのOffsetを取得
var currentOffsetY = collectionView.contentOffset.y
// 自動スクロールを終了させるかどうか
shouldFinish = false
autoScrollTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: true, block: { [weak self] (_) in
guard let self = self else { return }
DispatchQueue.main.async {
// item2つ分ずつスクロールさせる
switch direction {
case "up":
currentOffsetY = (currentOffsetY - 10 < 0) ? 0 : currentOffsetY - 10
self.shouldFinish = currentOffsetY == 0
case "down":
let highLimit = self.collectionView.contentSize.height - self.collectionView.bounds.size.height
currentOffsetY = (currentOffsetY + 10 > highLimit) ? highLimit : currentOffsetY + 10
self.shouldFinish = currentOffsetY == highLimit
default: break
}
UIView.animate(withDuration: duration, animations: {
self.collectionView.setContentOffset(CGPoint(x: 0, y: currentOffsetY), animated: false)
}, completion: { _ in
if self.shouldFinish { self.stopAutoScrollIfNeeded() }
})
}
})
}
isNeedToStartTimer = false
}
// 自動スクロールを停止する
private func stopAutoScrollIfNeeded() {
DispatchQueue.main.async {
self.isNeedToStartTimer = true
self.shouldFinish = true
self.view.layer.removeAllAnimations()
self.autoScrollTimer.invalidate()
}
}
}
最後に、ViewDidAppearで画面等のサイズを取得し、
スクロールを開始する高さの設定とセルサイズの設定をします。
scrollStartHeightTop変数とscrollStartHeightBottom変数がスクロールを開始する高さで、cellSize変数がセルの大きさの変数です。
override func viewDidAppear(_ animated: Bool) {
scrollStartHeightTop = self.view.safeAreaInsets.top + 50
scrollStartHeightBottom = scrollStartHeightTop + collectionView.frame.height - 100
let width = ( self.view.frame.width - 9 * (numOfRow + 1) ) / numOfRow
cellSize = CGSize(width: width, height: width)
collectionView.reloadData()
}