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


この記事は iOS2 Advent Calendar 2017 23日目の記事です。

About

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

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

リポジトリはこちらです(Carthageで公開しています)。
mii-chan/MIIScrollableViews

*上は、このライブラリのRxSwift Extensionであるmii-chan/RxMIIScrollableViewsのデモ動画です。

前提

  • UIScrollViewに表示されているUIViewを横スクロールでページングする処理に対応しています(ScrollViewのisPagingEnabletrueになっている状態)。

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

主な課題

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

1. UIScrollViewにUIViewを動的に追加したりするのが面倒

通常のやり方

まずは通常のやり方について簡単に見ていきます。
*下記のコードを実際に試される場合は、適宜ViewのbackgroundColorなどを変更してください

UIViewを一つ追加

// 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を追加する場合を考えます。

  1. Viewのframeを設定(origin.xScrollViewのwidth * 1(既存のViewの数))

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

  3. ViewをScrollViewにaddSubView

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

// 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を挿入する場合を考えます。

  1. Viewのframeを設定(origin.xScrollViewの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のsetContentOffsetcontentOffsetのX座標の値を、ScrollViewのwidth * CGFloat(1)に変更(追加したViewまで移動)

// 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を一つ追加

let view = UIView()
self.scrollableViews.append(view)

UIViewを既存のUIViewの最後に追加し、追加したUIViewに移動

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

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

UIScrollViewにUIViewを既存のUIViewの間に追加し、追加したUIViewに移動

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

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

  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にUITapGestureRecognizerUIPinchGestureRecognizeraddGestureRecognizerで付与する必要があります。Viewの数が増えるとその都度Gestureを付与するのが煩わしかったり、ScrollViewに追加されているViewのうち、左から何番目のViewに対してそのGestureが実行されたのかを把握することが難しいといった課題がありました。

MIIScrollableViewsを使った場合

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

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

self.scrollableViews.shouldAddTapGesture = true

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

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

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

おまけ

mii-chan/RxMIIScrollableViewsというRxSwift Extensionも作りました。

まとめ

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

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

参考

UIScrollView
https://developer.apple.com/documentation/uikit/uiscrollview

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

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