【Swift】ひとつの画面に複数のUICollectionViewやUITableViewを実装してみた【StackView】

どうも。Reoです。

今回は、一つの画面に複数の UICollectionView と UITableView を実装してみたお話です。

めちゃくちゃよくあるレイアウトですよね!

数年 Swift をやっていて今更こんな話?って気もしますが、気にしないで書いていこうと思います。

 

環境

記事を書いてる時点の環境です。

  • Xcode 11.1
  • Swift 5.1
  • iOS 13.1.3

GitHub にサンプルリポジトリを作りました。

uruly/MultipleCollectionView: Set multiple UICollectionView or UITableView in UIViewController.

 

つくるもの

つくったもの

こんな感じのもの。

四角の部分とリストの部分の個数は決まっておらず、可変です。

個数が少ない時

例えば、レシピアプリだったら。

  • ヘッダーの画像部分 →「今日のオススメレシピ」
  • 四角の部分 →「人気の検索ワード」
  • 下部のリスト →「レシピ一覧」

だったり。おっ、ありそう。

 

実装方法を考える

さて。ここが一番大切ですね。

先にそれぞれのパーツの実装を考えてみます。

  • ヘッダーの画像部分 → UIImageView
  • 四角の部分 → UICollectionView
  • 下部のリスト → UITableView

としました。ここはもうなんでも大丈夫です。

 

これらをどうやって一緒にスクロールさせるか

いくつかの方法を考えてみました。

  1. UIScrollView + UIStackView に設置
  2. UIScrollView に設置
  3. UICollectionView のセルに設置
  4. UITableView のセルに設置

まぁ色々とあると思います。4は論外なのに、何故自分はこう似たようなやつで4を選んだのか、本当に謎。ここだけの話、つい最近やってしまったんですわ。(だからこそ記事を書いてる)

 

今回は 1 の方法をとります。

その他の方法のデメリットは

  • 2 は StackView を使わない分、StackView がよしなにしてくれる AutoLayout 等の設定が面倒。とても。
  • 3 は UICollectionView と UICollectionView or UITableView の入れ子になるので、複雑になる。
  • 4 は UITableView と UICollectionView or UITableView の入れ子になるのでry

です。

自分は今までだったら、確実に 3 の方法を取っていましたね。ダッテ UICollectionView が一番使いなれてるんだもの。。。

 

あとは、ContainerView を使って、UICollectionView と UITableView の部分を別の ViewController に分けます。

 

簡単にまとめるとこんな感じ。 歪んでるのはご愛嬌で許して…

ContainerView ってしたけど、childViewController を使うよってことです。

Storyboard 上の ContainerView って使ったことないや…

 

用意したファイル

UIViewController のファイルを3つ分。

UIAppDelegate.swift にて


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let window = UIWindow()
        window.rootViewController = MainViewController()
        window.makeKeyAndVisible()
        self.window = window

        return true
    }

で準備はおk。 Scene?知らない子ですね。

全部やってたら朝になっちゃうので、とりあえず色々端折るかもしれませんが許してください。

 

MainViewController.xib に配置する

まず、 UIScrollView を全面に配置します。

top, left, bottom, right に 0 を指定します。 Scrollable Content Size Ambiguity がほげほげってエラーが出たら、 Content Layout Guides のチェックを外してやるとエラーが消えます。

私が参考にした記事がちょっと見当たらないので、見つけ次第リンク貼っておきます。

 

この上に StackView をおいて、Distribution を Equal Spacing にしておきます。

とりあえず、StackViewの中に高さを指定した UIImageView をぶち込んでおきました。これでヘッダー部分は完成。

 

次!

TableViewController と CollectionViewController

これは割愛しますね。

  • UIViewController に UICollectionView を配置した CollectionViewController
  • UIViewController に UITableView を配置した TableViewController

をそれぞれ用意してます。

TableViewController.swift の方のここまでのコードだけ貼っておきます。


import UIKit

final class TableViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: reuseIdentifier)
            tableView.delegate = self
            tableView.dataSource = self
        }
    }

    private let reuseIdentifier = "cell"
    private var data: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        data = Array(repeating: "hoge", count: 20)
    }
}

// MARK: - TableViewDelegate

extension TableViewController: UITableViewDelegate {}

// MARK: - UITableViewDataSource

extension TableViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath)
        cell.textLabel?.text = data[indexPath.row]
        return cell
    }
}

本当にただ tableViewを表示してるだけです。

 

StackView にぶちこむ

疲れてきてなんかないですよ。

作った TableViewController と CollectionViewController を MainViewController に設置します。


    override func viewDidLoad() {
        super.viewDidLoad()
        setupChildren()
    }

    private func setupChildren() {
        // CollectionViewController
        let collectionViewController = CollectionViewController()
        addChild(collectionViewController)
        stackView.addArrangedSubview(collectionViewController.view)
        collectionViewController.didMove(toParent: self)

        // TableViewController
        let tableViewController = TableViewController()
        addChild(tableViewController)
        stackView.addArrangedSubview(tableViewController.view)
        tableViewController.didMove(toParent: self)
    }

 

それぞれの view を stackView にぶち込んでいます。

MainViewController.swift に書くことは以上です。スッキリ!

collectionView の部分と tableView の部分を、UIViewController として切り出さなかった場合、それぞれの delegate や dataSource もMainViewController に書くことになってしまいます。

そうすると、それだけで FatViewController になっちゃいます。

 

ここだけの話、まさにこんな形の構成の画面で、そのまま1つの ViewController で作ってました。5日前くらいの自分が。やっぱりリファクタリングすべきかなぁって気がしてきました。

 

UICollectionView の全体を表示する

さて、ここが本題です。

UICollectionView を設置したはいいけれど、ここに入るセルの数はまだ決まっていません。

つまり、高さがわからない。

高さを固定してスクロールすればいいんじゃないの?ということですが、全体のスクロールと UICollectionView のスクロールと2つある状態になってしまいます。カルーセルならともかく、縦スクロールが重複するのはなかなか厳しい。使いづらそう。

 

セルの数に合わせて、高さを決めてほしい!
を実装していきます。

端折りましたが、現在の collectionView の AutoLayout は top, left, bottom, right が 0 になっている状態です。

 

ここに、高さを指定します。数値は適当で大丈夫です。

view の高さと違うものを指定するとエラーが出ますが、大丈夫です。

Priority を 下げておきます。(今回は High にしました。 999でも大丈夫です。)

この高さを CollectionViewController と接続します。


@IBOutlet private weak var collectionViewConstraintHeight: NSLayoutConstraint!

 

ここまでは TableViewController も同様にしてください。

 

viewDidLayoutSubviews を override して以下のように書きます。

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // Set collectionView height to content size height.
        if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
            collectionViewConstraintHeight.constant = layout.collectionViewContentSize.height
            view.layoutIfNeeded()
            view.frame.size.height = layout.collectionViewContentSize.height
        }
    }

 

ポイントは以下の通り。

  • viewDidLayoutSubviews で呼ぶ。 viewWillLayoutSubviews ではダメ。
  • collectionViewLayout の collectionViewContentSize を呼ぶ。collectionView.contentSize.height ではダメ。
  • view.layoutIfNeeded() を呼んでから view の frame を呼ぶ

です。

今回はセルの高さをいじっていないので、view.layoutIfNeeded() を呼ばなくてもちゃんと動きますが、変更している場合は崩れることがあります。もしくは AutoLayout の Warning が大量にログに流れます。

 

これで collectionView のセルの数を変えても、全体が表示されているはずです!

 

UITableView の全体を表示する

次は UITableView です。

先ほどと同様に、priority を下げた tableView の高さを接続しておきます。


@IBOutlet weak var tableViewConstraintHeight: NSLayoutConstraint!

そして、viewDidLayoutSubviews に以下のように書きます。

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // Set tableView height to content size height.
        tableView.layoutIfNeeded()
        tableViewConstraintHeight.constant = tableView.contentSize.height
        view.frame.size.height = tableView.contentSize.height
    }

こうです。

tableView でのポイントは

  • tableView.layoutIfNeeded() を呼んでから高さを設定する。
  • view の高さも変更する

です。

tableView の場合は tableView.contentSize.height でいいので、若干ややこしいですよね。

tableView は、セルの高さをいじっている場合には tableView.layoutIfNeeded() を呼んでから、tableView.contentSize.height を取得しないと正しい値が取れません。

 

collectionView と同様に view.layoutIfNeeded() 呼ぶ必要があるのか、ちょっと微妙なところですが、高さを変えてても特に支障はなさそうなので、呼ばなくてもいいハズ。

 

これで完成です!

 

全体のコード

冒頭でも書きましたが、GitHub に今回作ったサンプルを公開してあります。

uruly/MultipleCollectionView: Set multiple UICollectionView or UITableView in UIViewController.

 

おわりに

久々にゴッツリ書いた気がしますね。サンプルのコードを書くところから考えると3時間以上はかかってる気がします。

最近は iOS のお仕事をさせてもらっていて、こういう構成の画面の実装になり、昨日ようやくこの実装にたどり着きました。数年書いててもこんなレベルだよ!!!

 

流石に StackView は使い慣れたんですけど、ContainerView はあんまり使ってきてないんですよね。StoryBoard 上ではマジで使ったことがないくらいなので。

UICollectionView の入れ子とか、少し複雑でも動くものは作れますし、こんな複雑なの作れてる俺スゴイ!ぐらいの気持ちだった時もあるんですけど、そうじゃないですよね。

複雑に見えるものを以下に簡潔に実装するか、そっちの方がよっぽど知識がないと難しい。

最近はなんだかそういうことを実感してます。

 

今回実装したものを応用すればもうなんでも作れちゃうと思います。

例えば、UIView + UIView + UITableView という形でも、UITableViewHeaderFooterView で無理に作らずに済みます。

UITableView + UITableView をした時に、スクロールの同期ってどうしたらいいんだ!なんてことも考えなくてよくなります。(これ昔めっちゃ考えてました。)

 

実はこれよりもいい実装もあるのかもしれません。設計は本当に面白く、難しいですね。

職場の方に教えてもらった MicroViewControllerで無限にスケールするiOS開発 の話もとても面白かったです。

このお話は、実は 「iOSアプリ設計パターン入門」にもチラッと出てくるんですよね。だからちゃんと調べてれば知ってたハズなのに…勉強不足を感じまし…た…

 

 

それではこの辺で。おやすみなさい。

 

Comments...

2020/01/04 10:01

メインストーリーボードを使わずにxibを使っているのには何か理由があるのでしょうか?

アバター
from.ぶち
    2020/01/04 10:01

    コメントありがとうございます!
    私の場合は、複数人でプロジェクトを進めていて、Main.storyboard を利用すると1つのファイルを複数人で触ることになるので、Gitで管理をした場合にコンフリクトをしてしまって解消するのが大変になってしまうので、xibを利用するようにしています。
    StoryBoardは画面数が多いとゴチャゴチャしてしまうのもあり、個人制作の場合でも私は完全にStoryBoardを使わないxib派ですね。

    アバター
    from.Reo(管理人)
2020/01/12 03:01

返信ありがとうございます!
まだまだ初心者で使いこなせるか分かりませんが自分もxib少しづつ使ってみます!

アバター
from.ぶち
2020/07/26 03:07

いつも参考にさせて頂いております!
まだサイトの保守をされておりましたら、教えて頂きたい事がございましたのでコメントをさせて頂きます。
tableView のセルを、API通信で取得したデータ数生成したいと思い、TableViewController の viewDidLoad で API通信を行い、tableView.reloadData() を行いましたが、セルの生成が行われませんでした。

目的の動作の実現のための注意点などございましたら、教えて頂けないでしょうか?

アバター
from.小林大希
    2020/07/27 12:07

    こんにちは、コメントありがとうございます!
    こちらで適当にAPIを叩いてやってみたところ同じ現象になりました。
    セルの生成が行われていない、というよりデータ取得後の TableViewController の部分の高さが0の状態になっているんですね。
    なので、高さの再設定をしてあげないとダメになります。

    GistにTableViewControllerの部分のサンプルをあげてみましたので、参考になれば幸いです!
    (APIの部分は、Qiitaの記事「iOS開発: 再入門 apiを叩いてtableViewに表示する (Qiita編)」のコードを流用させていただきました。)

    アバター
    from.Reo(管理人)
2020/07/27 07:07

返信ありがとうございます!
サンプルコードまで書いて頂き本当に助かりました。
参考にさせて頂きます!

アバター
from.小林大希
コメントは認証制です。詳しくは下記の注意をお読みください。お気軽にコメントお願いします!

Write a Comment

コメント時の注意

「Twitter」「Facebook」「Google+」「WordPress」のいずれかのアカウントをお持ちの方は各アカウントと連携することでコメントできます。 コメントしたことはSNSに流れませんので、アカウントをお持ちの方はこちらの方法でコメントを投稿して下さると嬉しいです。 アカウントをお持ちでない方はメールアドレスで投稿することができます。 初回コメント時は承認後に表示されます。

Related Memo...

記事を書くほどでもないけれどメモっておきたいこと

テスト投稿。

例えばiphone7 の画面サイズ

750 × 1334
半分375 × 667

iOS

UITableView.RowAnimation の .none はアニメーションするよ

UITableView.RowAnimation の .none はアニメーションがnoneなわけじゃなく、デフォルトの設定を使うよという意味らしい。

The inserted or deleted rows use the default animations.

なのでアニメーションしちゃう。今更の気づき。

 

iOS

UINavigationController + UIScrollView の組み合わせで使っている時に謎の余白ができる時

UINavigationController + UIScrollView の組み合わせで使っていて、UIScrollView 上に AutoLayout で上下左右0で View を設置しているのに、30px程度上にずれてしまうとき。

`navigationController.navigationBar.isTranslucent = false` にすると直るかもしれない。

ScrollView上のコンテンツとNavigationBarの重なっているところが透過していたら多分これで直せるはず。

通常のターゲットではちゃんと動いているのに、iOSSnapshotTestCase を用いたテストでだけこの対応が必要なのよくわからないけれど。。。

iOS
more