StackView in a ScrollView

どうも、iOSアプリ開発をしております[Ryu1](https://github.com/fukami421)です。
最近**ふかみん**というあだ名を付けてもらって少し気に入ってます。
今回は、UIScrollViewにUIStackViewを配置して、スクロールする方法を紹介します。

## 何故この記事を書くか
UIScrollViewにUIStackViewを配置してスクロールする方法を紹介している記事は既にいくつかあるものの、

- Xcode11で追加されたframe and content layout guidesと合わせて書いてある記事が少ない
- ScrollViewのContent sizeをわかりやすく解説している記事が少なかった。

などといった理由で、本記事を書くことにしました。

## 環境
- Xcode Version 11.3.1 
- Swift5

## 実現したいこと
UIScrollViewにUIStackViewを重ねて、UIStackViewにUIViewを5つ程配置してスクロールする。

↓こんな感じ。
![scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/a674313b-4455-1287-ca86-3b7a8b7bbc20.gif)

## 必要となる知識
### frame layout guides
[Appleのドキュメント](https://developer.apple.com/documentation/uikit/uiscrollview/2865772-framelayoutguide)に以下のように記載されている通り、
>Use this layout guide when you want to create Auto Layout constraints that explicitly involve the frame rectangle of the scroll view itself, as opposed to its content rectangle.

と書かれているので、ScrollView自体の形を指定する制約を設定する際に、このlayout guideを用います。

### content layout guides
同様に、[Appleのドキュメント](https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide)に以下のように記載されている通り、
>Use this layout guide when you want to create Auto Layout constraints related to the content area of a scroll view.

ScrollViewの中のContent area(今回で言うとStackView)に関する制約を設定する際に、このlayout guideを用います。

### frame and content layout guides
上記の

- frame layout guides
- content layout guides

は、あくまでlayoutを組むのを手助けするものであり、必ずしも必要なものではありません。
実装の欄で制約の組み方を見ていただければ、これらがなくとも実装可能であることが確認できると思います。
これらを使うかどうかは、以下のチェック項目で選択できます。
<img width="255" alt="スクリーンショット 2020-02-18 9.49.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/15c38ca1-b00d-cd2c-e493-14c538b596a9.png">

### ScrollViewのcontent size
今回のように、ScrollViewのContent ViewをAuto layoutで組む場合は、`Content size`を設定してあげなければなりません。これは、Scrollable Areaの範囲を設定するためで、Scroll Viewの制約がなかなか定まらないのはこのためです。
Content SizeがScroll Viewのframe sizeよりも大きい場合のみScrollできます。

また、Auto layoutとCodeで組む場合で、それぞれContent sizeの設定の仕方が異なります。
##### Auto layoutの場合
以下の2点を設定する必要があります。
- Content View(今回でいうStack View)のwidthとheightの**両方**
- Content View(今回でいうStack View)の4辺をScroll Viewに合わせる

##### Codeの場合

```Swift
scrollview.contentSize = CGSize(width: 640, height: 800)
```

### intrinsic content sizeとは
**intrinsic**とは、**固有の**という意味で、[intrinsic content size](https://developer.apple.com/documentation/uikit/uiview/1622600-intrinsiccontentsize)は、Viewのコンテンツを表示するための最低のサイズを表しています。
UIViewのプロパティである`intrinsicContentSize`は読み取り専用ですが、UIViewのサブクラスで、以下のように、`intrinsicContentSize`をオーバーライドすることで`intrinsicContentSize`を変更することもできます。

```swift
class ExampleView: UIView {
    
    public override func awakeFromNib() {
        super.awakeFromNib()
    }

    override var intrinsicContentSize: CGSize {
        CGSize(width: 170, height: 230)
    }
```

また、XibやStoryBoardでも以下のように、Intrinsic SizeをDefaultからPlaceholderにして設定することができます。またこれは、編集時のみ適応されるものであり、ランタイムには無視されるため、中身の大きさによってそのViewのサイズが変更される場合に、使い勝手がいいです。
<img width="538" alt="スクリーンショット 2020-02-17 23.38.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/19d7fdc4-9291-1100-d300-4cc14a242f88.png">

## 実装
### 0. ファイル生成
今回は画面繊維や細かいViewを使う必要はないので、
`New file...` → `Cocoa Touch Class` → `UIViewController`を継承した.swiftファイルと`Also create XIB file`を選択して、xibファイルを生成。
つまり、今回主に扱うファイルは２つです。

### 1. xibでUIを組む。
#### 1.1 UIScrollView
まずはScrollViewを配置します。
[AppleのAuto Layout Guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html#//apple_ref/doc/uid/TP40010853-CH24-SW1)にも以下のように記載されている通り、特別なことは何もせずに、通常通り制約を付けます。
>Any constraints between the scroll view and objects outside the scroll view attach to the scroll view’s frame, just as with any other view.

以下のように制約を組みます。
<img width="562" alt="スクリーンショット 2020-02-17 23.28.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/3a88b77f-52dd-f904-64c3-7fd9faa96b43.png">
<img width="345" alt="スクリーンショット 2020-02-17 23.29.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/50d294d3-f7b1-7769-9851-e22e03747e65.png">

ここで、ScorllViewの中に`Content Layout Guide`と`Frame Layout Guide`というものに目が行くかと思いますが、一旦無視します。
また、この時点で制約が足りないと言われていますが、それも無視します。(これは、
#### 1.2 UIStackView
まずは、UIStackViewをUIScrollViewの上に配置します。
Distributionなどの設定は以下の通りです。
<img width="413" alt="スクリーンショット 2020-02-17 23.39.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/9ea44cd0-6dc2-7321-3cce-2c556d295fd6.png">

##### 1.2.1 Frame Layout Guide
その後、[上の説明](https://qiita.com/ryu1_f/private/94e0e8ced1067ee3eedb#frame-layout-guides)で記載した通り、`frame layout guides`を用いてScrollView自体の形に関する制約を付けます。
以下のように、`StackView`と`Frame Layout Guide`を同時に選択した状態で、`Add New Constraints`を押し、`Equal Heights`を設定します。
<img width="755" alt="スクリーンショット 2020-02-17 23.58.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/b576c465-9e12-4480-763e-f0d0c70d2589.png">
##### 1.2.2 Content Layout Guide
[上の説明](https://qiita.com/ryu1_f/private/94e0e8ced1067ee3eedb#content-layout-guides)で記載した通り、`content layout guides`を用いてStackViewに関する制約を付けます。
![content.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/6d62b84f-24a6-86cf-20e6-e8fc6a866db4.gif)
上の操作によって、下のような制約を付けます。(多少値を修正する必要あり)
<img width="255" alt="スクリーンショット 2020-02-18 0.58.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/9d9aed16-ee01-0d9c-76c9-f5fe93f4d732.png">

##### 1.2.3 Scroll Viewの横幅の設定
この状態では、以下のような赤文字が出てしまいます。
<img width="618" alt="スクリーンショット 2020-02-17 23.35.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/8a34c300-a2fe-5718-49a2-4633f9e0d0f0.png">
Scroll Viewの横幅(つまり、その子であるStack Viewの横幅)に関する制約が不足しているそうです。ここで、StackViewの横幅を設定する必要があるのですが、**注意**が必要で、Scroll Viewの横幅を単純にSuper Viewとイコール関係で追加してしまっては、`Priority`が、Stack Viewに配置するUIViewの大きさの設定の`Priority`よりも下がらないままStackViewの横幅が設定されてしまいます。
その結果、

- StackViewの横幅を縛ってしまうので、Scrollしない
- UIViewの大きさがおかしくなる
という事態が発生してしまいます。

よって、ここで設定すべきは、Stack Viewの**intrinsic content size**です。
##### 1.2.4 intrinsic content sizeの設定
intrinsic content sizeの説明を見てみると、
>Setting a design time intrinsic content size only affects a view while editing in Interface Builder. The view will not have this intrinsic content size at runtime.

と書かれてており、ここで設定したサイズは、IBの編集中にのみ適応されるもので、ランタイムはここでなされた設定は無視されます。
<img width="538" alt="スクリーンショット 2020-02-17 23.38.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/19d7fdc4-9291-1100-d300-4cc14a242f88.png">
横幅のみでいいので、Heightは`None`にチェックを入れます。Widthの値は何でも良いので、0を入れておきます。
これによって、StackViewの横幅を縛ることなく、StackViewに配置されたUIViewによってStack Viewの横幅を決めれることで、適切にスクロールさせることができます。

また、`intrinsic content size`を使わずに、Stack Viewのwidth >= 0というように設定し、priorityを下げる、という方法でもうまくいきますが、Stack ViewにStackするコンテンツによって上書きされてしまうので、無駄な制約をつけることになってしまうかつ、ランタイム中に使わない制約を付けてしまうという意味でもあまりいい手法とは言えません。よって、今回は`intrinsic content size`を使って、暫定的な幅を設定してあげるのが良さそうです。

### 3. IBとコードを繋げる
ここに関する説明は特にいらないと思うので、割愛します。

```Swift
import UIKit

class ScrollStackViewController: UIViewController {

    @IBOutlet weak var stackView: UIStackView!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        prepareForStackView()
    }
    
    // Stack Viewと同じのheightのUIViewを5つStack Viewに配置する
    private func prepareForStackView() {
        let stackedViewSize = stackView.bounds.height

        for _ in 1..<5 {
            let stackedView = UIView()
            stackedView.backgroundColor = .black
            stackedView.widthAnchor.constraint(equalToConstant: stackedViewSize).isActive = true
            stackedView.heightAnchor.constraint(equalToConstant: stackedViewSize).isActive = true
            stackView.addArrangedSubview(stackedView)
        }
    }
}
```

## 完成
以上の手順を踏むと、冒頭で掲載した通りの、**Stack ViewをScrollする**というViewを実装できます。
![scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332582/54348b8f-9d02-4ed8-cc53-71e388ee8179.gif)

## 参考文献
- https://blog.alltheflow.com/scrollable-uistackview/
- https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html#//apple_ref/doc/uid/TP40010853-CH24-SW1
- https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide
- https://riptutorial.com/ios/example/5109/scroll-view-content-size