UIScrollViewに追加するUIViewたちを配列ライクに操作したい！

この記事は [iOS2 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/ios2) 23日目の記事です。

# About
最近UIScrollViewにUIImageViewを複数追加して、横スクロールでその画像をページングする処理を書いていたのですが、新しい画像を既存画像の間に動的に追加する場面などがあり、だんだんと処理を書くのが面倒になってきたので、UIScrollViewに追加するUIViewたちを配列ライクに操作できるライブラリを作りました。

また、追加した各UIViewにタップ・ダブルタップなどのGestureをその都度`addGestureRecognizer`で付与したり、Gestureが実行されたViewの相対的な位置(ScrollViewに追加されているViewのうち、左から何番目のViewに対してそのGestureが実行されたか)を取得するのもつらかったので、UIScrollViewに追加したUIViewのGestureを一括管理する機能も作りました。

リポジトリはこちらです(Carthageで公開しています)。
[mii-chan/MIIScrollableViews](https://github.com/mii-chan/MIIScrollableViews)

![demo.gif](https://qiita-image-store.s3.amazonaws.com/0/172234/51274ebf-6d5f-d8f1-458e-a6f0a2c59300.gif)

＊上は、このライブラリのRxSwift Extensionである[mii-chan/RxMIIScrollableViews](https://github.com/mii-chan/RxMIIScrollableViews)のデモ動画です。


# 前提
* UIScrollViewに表示されているUIViewを横スクロールでページングする処理に対応しています(ScrollViewの`isPagingEnable`が`true`になっている状態)。

* 各UIViewのwidth・heightは、UIScrollViewのwidth・heightと等しいです(1ページ1UIView)。

# 主な課題
1. UIScrollViewにUIViewを動的に追加したりするのが面倒
2. 追加したUIViewにGestureをその都度付与するのがつらい & Gestureが実行されたUIViewの相対的位置の把握が困難

# 1. UIScrollViewにUIViewを動的に追加したりするのが面倒
## 通常のやり方
まずは通常のやり方について簡単に見ていきます。
＊下記のコードを実際に試される場合は、適宜Viewの`backgroundColor`などを変更してください

### UIViewを一つ追加

![image1.png](https://qiita-image-store.s3.amazonaws.com/0/172234/dda1327d-ed93-be20-311c-620219157b0a.png)


```swift
// 1. Viewのframeを設定
let view = UIView()
view.frame = self.scrollView.bounds

// 2. ViewをScrollViewに`addSubView`        
self.scrollView.addSubview(view)
```

### UIViewを既存のUIViewの最後に追加し、追加したUIViewに移動
例として、ScrollViewに追加されたViewが既に1つあり、その後ろに新しくViewを追加する場合を考えます。

![image2.png](https://qiita-image-store.s3.amazonaws.com/0/172234/b00d26dc-af56-2086-c083-272dc2f610a8.png)


1. Viewの`frame`を設定(`origin.x`は`ScrollViewのwidth * 1(既存のViewの数)`)

2. ScrollViewの`contentSize.width`を、`ScrollViewのwidth * 2(追加後のViewの数)`にする

3. ViewをScrollViewに`addSubView`

4. ScrollViewの`setContentOffset`で`contentOffset`のX座標の値を、`ScrollViewのwidth * 1(既存のViewの数)`に変更(追加したViewまで移動)

```swift
// 1. Viewの`frame`を設定
let lastView = UIView()
lastView.frame = self.scrollView.bounds
lastView.frame.origin.x = self.scrollView.frame.width * CGFloat(1)
        
// 2. ScrollViewの`contentSize.width`の変更
self.scrollView.contentSize.width = self.scrollView.frame.width * CGFloat(2)
        
        
// 3. ViewをScrollViewに`addSubView`
self.scrollView.addSubview(lastView)
        
        
// 4. 追加したViewまで移動
self.scrollView.setContentOffset(CGPoint(x:self.scrollView.frame.width * CGFloat(1), y: self.scrollView.bounds.origin.y), animated: true)
```

### UIScrollViewにUIViewを既存のUIViewの間に追加し、追加したUIViewに移動
例として、ScrollViewに追加されたViewが既に2つあり、左から2番目の位置(Viewを配列と見るとindexが1の場所)に新しくViewを挿入する場合を考えます。

![image3.png](https://qiita-image-store.s3.amazonaws.com/0/172234/7739e3b2-df33-f02d-bb48-08d813baadb1.png)


1. Viewのframeを設定(`origin.x`は`ScrollViewのwidth * CGFloat(1)`)

2. ScrollViewの`contentSize.width`を、`ScrollViewのwidth * 3(追加後のViewの数)`にする

3. ViewをScrollViewに`addSubView`

4. 追加前の2つのViewのうち、左から2つ目のViewの`frame.origin.x`を、ScrollViewのwidth(=追加したUIViewのwidth)分だけ右に移動させる

5. ScrollViewの`setContentOffset`で`contentOffset`のX座標の値を、`ScrollViewのwidth * CGFloat(1)`に変更(追加したViewまで移動)

```swift
// 1. Viewの`frame`を設定
let middleView = UIView()
middleView.frame = self.scrollView.bounds
middleView.frame.origin.x = self.scrollView.frame.width * CGFloat(1)

// 2. ScrollViewの`contentSize.width`の変更
self.scrollView.contentSize.width = self.scrollView.frame.width * CGFloat(3)

// 3. ViewをScrollViewに`addSubView`
self.scrollView.addSubview(middleView)
        
// 4. 追加前の2つのViewのうち、左から2つ目のViewの`frame.origin.x`の変更
lastView.frame.origin.x += self.scrollView.frame.width
        
// 5. 追加したViewまで移動
self.scrollView.setContentOffset(CGPoint(x: self.scrollView.frame.width * CGFloat(1), y: self.scrollView.bounds.origin.y), animated: true)
```

だんだん面倒になってきました...

## MIIScrollableViewsを使った場合
UIScrollViewに追加するUIViewたちを配列ライクに操作するメソッドを提供することで、上記の煩雑さの解消を試みました。

### UIViewを一つ追加
```swift
let view = UIView()
self.scrollableViews.append(view)
```

### UIViewを既存のUIViewの最後に追加し、追加したUIViewに移動
先程と同じく、ScrollViewに追加されたViewが既に1つあり、その後ろに新しくViewを追加する場合を考えると。。。

```swift
let lastView = UIView()
self.scrollableViews.append(lastView)
```

### UIScrollViewにUIViewを既存のUIViewの間に追加し、追加したUIViewに移動
これも先程と同じく、ScrollViewに追加されたViewが既に2つあり、左から2番目の位置(Viewを配列と見るとindexが1の場所)に新しくViewを挿入する場合を考えると。。。

```swift
let middleView = UIView()
self.scrollableViews.insert(middleView, at: 1)
```

:tada::tada::tada:


1. Viewのframeの設定
2. ScrollViewの`contentSize`の拡張
3. ScrollViewへの`addSubView`
4. 既存Viewの`frame.origin.x`の変更
5. 追加したViewへの移動

をライブラリ内部で行うようにしました。(5.の「追加したViewへの移動」をさせたくない場合は、`shouldMoveWhenAdding`フラグを`false`にしてください)

配列ライクな操作を意識していることから、`append(contentsOf:)`や`remove(at:)`、`index(of:)`などのメソッドも用意してあります。index指定によるViewの参照・変更もできます。


# 2. 追加したUIViewにGestureをその都度付与するのがつらい & Gestureが実行されたUIViewの相対的位置の把握が困難
例えば、UIImageViewにダブルタップやピンチイン・アウトのGestureを付ける場合は、対象のImageViewに`UITapGestureRecognizer`や`UIPinchGestureRecognizer`を`addGestureRecognizer`で付与する必要があります。Viewの数が増えるとその都度Gestureを付与するのが煩わしかったり、ScrollViewに追加されているViewのうち、左から何番目のViewに対してそのGestureが実行されたのかを把握することが難しいといった課題がありました。

## MIIScrollableViewsを使った場合
各UIViewのGestureを一括で管理できるようにしました。現在タップ、ダブルタップ、ドラッグ、ピンチイン・アウト、長押しをサポートしています。

`shouldAdd<GestureName>Gesture`フラグを`true`にすることで、全てのViewに対してそのGestureを付与することができます。

```swift
self.scrollableViews.shouldAddTapGesture = true
```

Gestureが許可された状態でそのGestureがViewに対して行われると
、対応するDelegateメソッドが呼ばれるようになっています。

```swift
func didTap(view: UIView, index: Int, gesture: UITapGestureRecognizer) {
    // do something
}
```

引数にはGestureが行われたViewやそのindexなども渡ってくるため、例えばそれを使って処理を分岐し、最後のViewにだけ違う処理を行わせたりすることもできます。

# おまけ
[mii-chan/RxMIIScrollableViews](https://github.com/mii-chan/RxMIIScrollableViews)というRxSwift Extensionも作りました。

# まとめ
引き続き機能等を拡充して参ります。
Carthageで公開していますので、ぜひ使ってみてください〜

(ご意見・ご要望等もお待ちしております:bow:)

# 参考
UIScrollView
https://developer.apple.com/documentation/uikit/uiscrollview

Array
https://developer.apple.com/documentation/swift/array

UIGestureRecognizer
https://developer.apple.com/documentation/uikit/uigesturerecognizer