post Image
FacebookやTwitterのアプリで気になった表現を自分なりにトレースした際の実装ポイントまとめ(タイルレイアウトがサムネイル画像の枚数に応じて変わる表現)

1. はじめに

皆様お疲れ様です。AdventCalendarの20日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

今週末はいよいよクリスマスに差し掛かり、私のお世話になっている現場の周囲や帰り道でもクリスマスデコレーションやクリスマスソングに触れることが多くなり、「今年も終わりか…」と少し切なさも覚える今日この頃です。

Qiitaに投稿している内容も元々UI周辺の実装に関するものが多くを占めることもあり、今回はまたUIまわりのサンプルに関する実装の解説を行っていきたいと思います。

今回は普段からよくチェックしているFacebookやTwitterでよく見かけるようなUITableView内の写真配置に関するレイアウトの実装で、画像の枚数に合わせて写真の形が変化して配置されるようなレイアウトを実装する際の解説を中心に行っていきたいと思います。

この実装ではStoryboard上で設定するAutoLayoutとコードによる制約を変更する処理を組み合わせて実現する手法をとっているので、コードだけだと読み取りにくい部分があるかと思いますので、図解等もできるだけ交えてみました。

2. Facebookのようなタイル状の画像レイアウトを作成するサンプルについて

今回のサンプルに関しては、主に下記の3つの機能を実装しています。

  • サムネイル画像の一覧表示の際に画像の枚数に応じてタイル表示部分の大きさを変更するレイアウトと画面遷移関連(今回のメイン部分)
  • 自分のフォトライブラリーに登録されている画像の一覧を取得してUICollectionViewに表示する
  • 横方向にカード型のUICollectionViewを配置しスクロールすると表示対象のセルが中央に表示される

UIの表現部分に関しては、FacebookやTwitterのアプリに少し近い形になるように、今回は最低限のUIまわりの部分に関しては自分なりではありますが実装してみました。

サンプルの全体的な動き:

ezgif.com-414b8df9bc.gif

各々のセルに表示する画像の枚数に合わせて下記のようにレイアウトが様々に変化するような形にしています。また、画像のタップと「他○件」のボタンのタップで遷移する画面が異なるようにしています。

画面キャプチャその1:

sample_capture1.jpg

画面キャプチャその2:

sample_capture2.jpg

環境やバージョンについて:

  • Xcode8.2
  • Swift3.0
  • MacOS Sierra (Ver10.12.2)

※1. こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

※2. サンプルデータの設定に関しては、このプロジェクト内のSample.swift及びSampleMock.swiftにデータ定義を記載しておりますので適宜中身の画像やデータ定義等を変更してお試し頂ければ幸いです。

3. 1枚のセルと6枚のUIImageViewとAutoLayoutを利用して、タイルレイアウトがサムネイル画像の枚数に応じて変わる表現部分の実装ポイント

今回はFacebookやTwitterで見かけるような画像サムネイルをタイル状に並べて、かつ画像サムネイルの枚数に応じてタイルの形状が合わせて変わるような形のデザインを実装していきますが、今回は前提として下記のように設定をしました。

画像サムネイルの配置に関する前提:

  • 画像同士の余白は0にする
  • 最大6枚までをセル内に表示し6枚を超えた枚数の場合は6枚目の画像の上にボタンを乗せる
  • コンテンツ全体のセルに関しては1枚だけ使用する

このようなデザインの画像サムネイルの配置を行う場合に関しては、AutoLayoutを使用するのですが、画像サムネイルの枚数に応じて各々の画像のサムネイルの幅や高さを調整するのでInterfaceBuilderの設定とコードによるAutoLayoutの制御をうまく組み合わせて実装するような形になります。

★3-1. 6枚のUIImageViewを配置してAutoLayoutで距離とサイズに関する制約をつける

まずはUITableViewCellクラスを継承したクラスと対応するXibファイルも一緒に配置します。今回は画像だけではなくダミーのデータを入れるので、下記のような形でサムネイルを表示するためのUIImageViewと一緒にその他のUIパーツを配置していきます。

MainContentsCell.xibへのUIパーツの配置図:

cell_setting.png

※ 他のパーツ(一番下の「コメントする」・「Social共有」・「その他」のボタン)に関しましては、見た目のためだけにダミーで配置しています。

今回のメインで色々と処理を加えていくUIImageViewに関してはこの状態でのXibの幅が320なので、今回は左右に15のマージンを開けたいのでこの段階では幅と高さを145の状態で3行×2列で隙間なく配置していきます。

Xib内での画像の配置のイメージ図:

IMG_0346.JPG

その後にそれぞれのUI要素に対して制約をつけていきます。
ここでポイントとなるのは、UIImageViewに関しての制約の設定のしかたになるのですが、下記の図は6つそれぞれのUIImageViewに設定する制約をまとめた図になります。

セルに配置した画像(UIImageView)に関する制約:

IMG_0351.JPG

この図に書いてあるそれぞれの制約をまとめると、

  • contentsImage1 (1番目のUIImageView) → 「左:15, 上:7, 幅:145, 高さ:145」
  • contentsImage2 (2番目のUIImageView) → 「左:0, 右:15, 上:7, 幅:145, 高さ:145」
  • contentsImage3 (3番目のUIImageView) → 「左:15, 上:0, 幅:145, 高さ:145」
  • contentsImage4 (4番目のUIImageView) → 「左:0, 右:15, 上:0, 幅:145, 高さ:145」
  • contentsImage5 (5番目のUIImageView) → 「左:15, 上:0, 下:8, 幅:145, 高さ:145」
  • contentsImage6 (6番目のUIImageView) → 「左:0, 右:15, 上:0, 下:8, 幅:145, 高さ:145」

という形になります。そしてそれぞれのUIImageViewに対しての追加設定として、

  • 「UIImageViewのタグの設定(上記の番号に対応する値)」
  • 「Content Modeの設定(= Aspect Fill)」
  • 「Clip to Boundsの部分にチェックを入れる」

を行って、InterfaceBuilder上での準備は完了です。次にここで設定した制約をコードで変更できるように設定をしていきます。

★3-2. 幅と高さの制約をそれぞれOutletで接続して制約の優先度(priority)を下げる

上記の図解のようにInterfaceBuilder上でのAutoLayoutの設定が完了したら、次は配置したUIImageViewの幅と高さをそれぞれOutlet接続をします(6×2=12個)。
また、Outlet接続をした変数はこのクラス内でしか変更ができないようにfileprivateにしておきます。

MainContentsCell.swift
//それぞれ画像の制約に関する設定(Width/Height)
@IBOutlet weak fileprivate var contentImage1Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage1Height: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage2Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage2Height: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage3Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage3Height: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage4Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage4Height: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage5Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage5Height: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage6Width: NSLayoutConstraint!
@IBOutlet weak fileprivate var contentImage6Height: NSLayoutConstraint!

ただ、このままサイズの計算出した値を設定してしまうとAutoLayoutの制約に関する警告が出てしまうので、このそれぞれのUIImageViewの幅と高さの制約の優先度を1000から750へ変更してAutoLayoutの優先度を下げます。
今回は優先度の変更の対象となる制約が多いので下記のように1度各々の値を配列に代入して下記のような形でループを回して処理をすると良いかと思います。

MainContentsCell.swift
//配置しているUIImageViewの幅と高さの制約の優先度を下げる
fileprivate func downConstraintPriority() {

    let targetConstraintSet: [NSLayoutConstraint] = [
        contentImage1Width,
        contentImage1Height,
        contentImage2Width,
        contentImage2Height,
        contentImage3Width,
        contentImage3Height,
        contentImage4Width,
        contentImage4Height,
        contentImage5Width,
        contentImage5Height,
        contentImage6Width,
        contentImage6Height
    ]

    for (_, targetConstraint) in targetConstraintSet.enumerated() {
        targetConstraint.priority = UILayoutPriority(750)
    }
}

今回のようにAutoLayoutの制約を変更する際に「InterfaceBuilderで制御する場合」と「コードで制御する場合」が混在してしまうような場合に関しては、あくまで私がやっている設定の切り分けの基準にはなるのですが、

  • UIImageViewのタグの値や間隔などのケースに応じた処理の中で変更の必要がない部分は、InterfaceBuilderで設定を行う。
  • 幅や高さの制約や優先度などのケースに応じた処理の中で変更の可能性がある部分は、コードで設定を行う。

という風にしています。

★3-3. 画像の枚数に応じた幅と高さを設定する

ここまでで、画像サムネイルを格納するための準備は整ったので、次は実際の大きさを設定していこうと思います。
大きさに関しては、InterfaceBuilder上の見た目には幅(width: 145)・高さ(height: 145)となっていますが、前述のようにそれぞれの制約をOutlet接続しているので、幅と高さの制約のconstantプロパティに対し、計算で算出した値を代入た後にself.layoutIfNeeded()をすることで幅と高さを決定するような処理を行います。

そのためにまずは、表示しているデバイスの横幅を下記のような構造体を定義して取得できるようにします。

MainContentsCell.swift
//デバイスの横幅を取得するための構造体
struct DeviceSize {

    //デバイスの画面の横サイズを取得
    static func screenWidth() -> CGFloat {
        return UIScreen.main.bounds.size.width
    }
}

今回は左右のマージンを15ずつ取っているので、デバイスの幅から15×2=30を差し引いたものが「サムネイル全体の表示エリアの幅」になるのでこの値を超えないように、値を決定するようにします。また今回のセルは高さが動的に変更されるように、UITableViewを配置しているViewController側でも下記の設定を忘れずに行って下さい。

ViewController.swift
//セルの高さの予測値を設定する(高さが可変になる場合のセルが存在する場合)
contentsTableView.rowHeight = UITableViewAutomaticDimension
contentsTableView.estimatedRowHeight = 10000

そして、下記の図のような幅と高さのデザインになるように幅と高さの制約を計算によって決定させます。

画像が1枚の場合と2枚の場合:

IMG_0348.JPG

画像が3枚の場合と4枚の場合:

IMG_0349.JPG

画像が5枚の場合と6枚以上の場合:

IMG_0350.JPG

そして画像サムネイルの個数に合わせてそれぞれのUIImageViewの幅と高さを決定するメソッドは下記のようになります。

MainContentsCell.swift
//AutoLayoutの制約を画像枚数に応じて設定するメソッド
fileprivate func setLayoutConstraintSetting(count: Int) {

    //もっと見るボタンに関しては6枚より多い場合に表示するので初期状態では無効化
    moreButton.isEnabled = false
    moreButton.alpha     = 0
    moreCount.isEnabled  = false
    moreCount.alpha      = 0

    //各配置済のImageViewのConstraintにおいて、幅と高さの優先度を下げる
    downConstraintPriority()

    //画像サムネイルの枚数に合わせてそれぞれのUIImageViewの幅と高さを決定する
    switch count {

    case 0:

        let mainRectWidth: CGFloat   = CGFloat(DeviceSize.screenWidth() - 30)

        contentImage1Height.constant = 0
        contentImage1Width.constant  = mainRectWidth

        contentImage2Height.constant = 0
        contentImage2Width.constant  = 0

        contentImage3Height.constant = 0
        contentImage3Width.constant  = contentImage1Width.constant

        contentImage4Height.constant = 0
        contentImage4Width.constant  = 0

        contentImage5Height.constant = 0
        contentImage5Width.constant  = contentImage1Width.constant

        contentImage6Height.constant = 0
        contentImage6Width.constant  = 0

        self.layoutIfNeeded()

    case 1:

        let mainRectWidth: CGFloat   = CGFloat(DeviceSize.screenWidth() - 30)
        let mainRectHeight: CGFloat  = 150

        contentImage1Height.constant = mainRectHeight
        contentImage1Width.constant  = mainRectWidth

        contentImage2Height.constant = mainRectHeight
        contentImage2Width.constant  = 0

        contentImage3Height.constant = 0
        contentImage3Width.constant  = mainRectWidth

        contentImage4Height.constant = 0
        contentImage4Width.constant  = 0

        contentImage5Height.constant = contentImage3Height.constant
        contentImage5Width.constant  = contentImage3Width.constant

        contentImage6Height.constant = contentImage4Height.constant
        contentImage6Width.constant  = contentImage4Width.constant

        self.layoutIfNeeded()

    case 2:

        let mainRect: CGFloat        = CGFloat(DeviceSize.screenWidth() - 30) / 2

        contentImage1Height.constant = mainRect
        contentImage1Width.constant  = mainRect

        contentImage2Height.constant = mainRect
        contentImage2Width.constant  = mainRect

        contentImage3Height.constant = 0
        contentImage3Width.constant  = mainRect

        contentImage4Height.constant = 0
        contentImage4Width.constant  = mainRect

        contentImage5Height.constant = contentImage3Height.constant
        contentImage5Width.constant  = contentImage3Width.constant

        contentImage6Height.constant = contentImage4Height.constant
        contentImage6Width.constant  = contentImage4Width.constant

        self.layoutIfNeeded()

    case 3:

        let subRect: CGFloat         = 100
        let mainRectWidth: CGFloat   = CGFloat(DeviceSize.screenWidth() - subRect - 30)
        let mainRectHeight: CGFloat  = CGFloat(subRect * 2)

        contentImage1Height.constant = subRect
        contentImage1Width.constant  = subRect

        contentImage2Height.constant = mainRectHeight
        contentImage2Width.constant  = mainRectWidth

        contentImage3Height.constant = subRect
        contentImage3Width.constant  = subRect

        contentImage4Height.constant = 0
        contentImage4Width.constant  = mainRectWidth

        contentImage5Height.constant = 0
        contentImage5Width.constant  = subRect

        contentImage6Height.constant = 0
        contentImage6Width.constant  = mainRectWidth

        self.layoutIfNeeded()

    case 4:

        let subRect: CGFloat         = 140
        let mainRectWidth: CGFloat   = CGFloat(DeviceSize.screenWidth() - subRect - 30)
        let mainRectHeight: CGFloat  = subRect

        contentImage1Height.constant = mainRectHeight
        contentImage1Width.constant  = mainRectWidth

        contentImage2Height.constant = subRect
        contentImage2Width.constant  = subRect

        contentImage3Height.constant = subRect
        contentImage3Width.constant  = subRect

        contentImage4Height.constant = mainRectHeight
        contentImage4Width.constant  = mainRectWidth

        contentImage5Height.constant = 0
        contentImage5Width.constant  = subRect

        contentImage6Height.constant = 0
        contentImage6Width.constant  = mainRectWidth

        self.layoutIfNeeded()

    case 5:

        let photoAreaWidth: CGFloat  = CGFloat(DeviceSize.screenWidth() - 30)
        let subRect: CGFloat         = 100
        let mainRectWidth: CGFloat   = photoAreaWidth - subRect
        let mainRectHeight: CGFloat  = (subRect * 3) / 2

        contentImage1Height.constant = subRect
        contentImage1Width.constant  = subRect

        contentImage2Height.constant = mainRectHeight
        contentImage2Width.constant  = mainRectWidth

        contentImage3Height.constant = subRect
        contentImage3Width.constant  = subRect

        contentImage4Height.constant = mainRectHeight
        contentImage4Width.constant  = mainRectWidth

        contentImage5Height.constant = subRect
        contentImage5Width.constant  = subRect

        contentImage6Height.constant = 0
        contentImage6Width.constant  = mainRectWidth

        self.layoutIfNeeded()

    default:

        let subRect: CGFloat         = 140
        let mainRectWidth: CGFloat   = CGFloat(DeviceSize.screenWidth() - subRect - 30)
        let mainRectHeight: CGFloat  = subRect
        let halfRect: CGFloat        = CGFloat(DeviceSize.screenWidth() - 30) / 2

        contentImage1Height.constant = subRect
        contentImage1Width.constant  = subRect

        contentImage2Height.constant = mainRectHeight
        contentImage2Width.constant  = mainRectWidth

        contentImage3Height.constant = halfRect
        contentImage3Width.constant  = halfRect

        contentImage4Height.constant = halfRect
        contentImage4Width.constant  = halfRect

        contentImage5Height.constant = mainRectHeight
        contentImage5Width.constant  = mainRectWidth

        contentImage6Height.constant = subRect
        contentImage6Width.constant  = subRect

        self.layoutIfNeeded()

        //ボタンを有効化する
        if count > ImageConfig.maxPhotoCount {
            moreButton.isEnabled = true
            moreButton.alpha     = 0.45
            moreCount.isEnabled  = true
            moreCount.alpha      = 1
            let otherPhotoCount  = count - ImageConfig.maxPhotoCount
            moreCount.text       = "他\(otherPhotoCount)件"
        }
    }
}

一見するとSwitch文の中の処理が多いので面食らうかもしれませんが、行っていることはそれぞれのUIImageViewの幅と高さを計算で算出しているだけなので、UIImageViewのマージンの制約や全体幅を超えない範囲で、この計算のパターンを色々変化させてまた違ったレイアウトを作ってみても面白いかなと思います。

またサムネイル画像の枚数が6枚より多い場合には6枚目の画像と同じ大きさに設定したボタンを表示させるようにしています(default:の一番最後の部分になります)。

★3-4. UIImage型の配列を引数にとるメソッドに処理をまとめる

ここまでは上記の前提を満たすためのポイントとなる設定や処理に関する部分をピックアップして見てきました。
UITableViewが配置されているViewController.swiftにて今回は画像サムネイルのデータが[UIImage]型で取得できるような想定でいるため、[UIImage]型の引数を取った上で上記の処理をひとまとめで行えるメソッドを作成します。また今回はサムネイル画像が格納されるUIImageViewのタップした際の処理も考慮するために、TapGestureRecognizerも付与する形になります。

これを踏まえた上で全体の処理を作成すると下記のような形になります。
※ 優先度の決定に関してはsetLayoutConstraintSetting(count: images.count)内でdownConstraintPriority()を実行するようにしています。

MainContentsCell.swift
//該当番号のImageView(サムネイル)のセットを行う
func setImageViews(images: [UIImage]) {

    //画像枚数に応じて制約を決定する
    setLayoutConstraintSetting(count: images.count)

    /**
     * InterfaceBuilderで各サムネイルに対して下記のようにタグを設置する
     *
     * contentsImage1.tag = 1
     * contentsImage2.tag = 2
     * contentsImage3.tag = 3
     * contentsImage4.tag = 4
     * contentsImage5.tag = 5
     * contentsImage6.tag = 6
     */
    let targetImageViewSet: [UIImageView] = [
        contentsImage1,
        contentsImage2,
        contentsImage3,
        contentsImage4,
        contentsImage5,
        contentsImage6
    ]

    //12/21:【修正】全てのサムネイル格納用UIImageViewのUIimageプロパティに画像をセットする前にnilを入れて初期化する
    for (_, contentImageView) in targetImageViewSet.enumerated() {
        contentImageView.image = nil
    }

    //渡された画像をUIImageViewに入れてTapGestureを付与する
    for (index, targetImage) in images.enumerated() {

        if index < ImageConfig.maxPhotoCount {
            let imageTap = UITapGestureRecognizer(target: self, action: #selector(MainContentsCell.tapGesture(sender:)))
            targetImageViewSet[index].image = targetImage
            targetImageViewSet[index].addGestureRecognizer(imageTap)
        }
    }
}

ここまでの流れを見ると、「InterfaceBuilder上での配置したUIImageViewへの制約の設定の仕方」「計算による幅と高さの制約を決める部分のロジック」の2点が大きなポイントになるかなと思います。

InterfaceBuilderで設定した制約を更にコードで動的に設定し直すことによって、ただ決まった位置や方向に配置するだけではなく、うまく活用すれば見せ方の工夫ができるので、このような表現方法や知見は今後更に深めて更により良いUI表現ができるように、深堀りしたいと思っています。

4. 6枚より多い数のサムネイル画像があった場合に6枚目のUIImageViewの上にボタンとラベルを重ねる部分の実装ポイント

UIImageViewの配置と制約の設定が終わったら次に、6枚より多い数のサムネイル画像がある場合の考慮を行っていきます。
6枚目のUIImageViewの上にボタンとラベルを重ねて表示したいわけですが、今回のXibファイルの場合、本文部分(UITextFieldで作成してかつ高さが文字数に応じて変化する)とUIImageViewの高さが変化するので、6枚目の画像を入れるUIImageViewの位置が常に決まった位置にあるわけとは限りません。

このような場合に関してもInterfaceBuilderを使って、ボタンの制約を6枚目のUIImageViewと同じ位置に来るように設定をし、その後表示されていないサムネイル画像の個数を表示するラベルの中心をボタンの中心に合わせるような制約を設定することで対応します。イメージとしては下記のような形で行う感じになります。

6枚目の画像の上にボタンと残り枚数のラベルを乗せる:

IMG_0347.JPG

まずは「ボタンの制約を6枚目のUIImageViewと同じ位置に来る」という制約を設定する部分に関しての手順を説明します。具体的な手順としては、

  1. 幅・高さ共に145の黒色のUIButtonをXib上の任意の場所に配置する
  2. 次にUIButtonを6枚目のUIImageViewに向けて「Control + ドラッグ」をする
  3. 下記のようなポップアップが表示されるので「Center Vertically」・「Center Horizontally」・「Equal Width」・「Equal Height」を選択する
  4. 制約のずれを直す

という流れで設定を行います。

InterfaceBuilder上の図 (UIButtonの位置と大きさを6枚目のUIImageViewに合わせる):

button_constraint.png

次に「表示されていないサムネイル画像の個数を表示するラベルの中心をボタンの中心に合わせる」という制約を設定する部分に関しての手順を説明します。具体的な手順としては、

  1. 任意の幅・高さのUILabelをXib上の任意の場所に配置する
  2. 次にUILabelを上記で設定したUIButtonに向けて「Control + ドラッグ」をする
  3. 下記のようなポップアップが表示されるので「Center Vertically」・「Center Horizontally」を選択する
  4. UILabelの幅と高さに関する制約をつけた後に制約のずれを直す

という流れで設定を行います。

InterfaceBuilder上の図 (UILabelの中心をUIButtonの中心に合わせる):

label_constraint.png

このように高さが動的に変わってしまうようなレイアウトを作成するような場合においても、既に位置や大きさに関する制約が設定されたUI要素に対しての制約に合わせることができるので、複数のUIパーツを重ねる・位置の関連付けを合わせるような場合には有効活用ができるのではないかと思います。

5. 画像サムネイルに付与したTapGestureRecognizerとボタンアクション時の遷移部分の実装ポイント

今回はTapGestureが発動したタイミングで実行されるのメソッドやセルに配置したボタンのアクションに関しては、今回の実装ではUITableViewCellクラス側に記述しています。

この形を維持した状態でUITableViewCell内の画面遷移を行うようにする実装を考えないといけないので、この点については注意が必要になる部分かと思います。

今回の画面の遷移に関する設計をおさらいしてまとめると下記のようになります。

前提:

セルのXibに配置したUIImageViewをタップした時(TapGestureRecognizer)場合と6枚以上の画像があった場合に6枚目のUIImageViewの上に配置されているボタンをタップした時で遷移する画面が異なるような設計にしています。

各々の画像サムネイル(UIImageView)をタップした場合:

  • ポップアップで画像の一覧を横スクロールで並べた画面に表示する(初期位置はタップした画像に対応している)

他○枚のボタンをタップした場合:

  • Pushで画像の詳細画面(画像の一覧がUITableViewで表示される画面)に表示し初期位置を6枚目からにする

このように遷移する画面&遷移の方法も全く異なる形になるので、この点に留意してUITableViewCell側の遷移に関するメソッドの切り分けをしなければいけません。

※今回紹介する実装を使用しないのであれば、GestureRecognizerやボタンアクション部分に関してはUIViewController側に持たせておくように実装しても良いかと思います。

今回の処理ではUITableViewCell側にクロージャーの変数var transitionClosure: ((Int?) -> ())?を用意しておき、クロージャー内の処理の詳細な内容に関しては、表示しているUITableViewにおけるセルの内容を表示するタイミングでクロージャー内の処理を記載しておくようにします。

ViewController.swift
//テーブルビューのセル設定を行う
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    switch indexPath.section {

    case 0:
        ・・・(省略)・・・

    case 1:
        ・・・(省略)・・・

    case 2:
        ・・・(省略)・・・

        //MainContentsCell側に設定したクロージャーの内部の処理を記載する
        cell.transitionClosure = { [weak self] num in

            if num != nil {

                //MainContentsCell側の画像がタップがされた場合は該当画像の表示をポップアップで行う
                let toVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "ImageController") as! ImageController
                toVC.targetImageList = imageList
                toVC.targetImageCount = num!
                self?.present(toVC, animated: false, completion: nil)

            } else {

                //MainContentsCell側のボタンがタップがされた場合は画像一覧の表示を行う
                let toVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "DetailController") as! DetailController
                toVC.targetImageList = imageList
                self?.navigationController?.pushViewController(toVC, animated: true)
            }

        }

        ・・・(省略)・・・

    default:
        ・・・(省略)・・・
    }
}

こうすることでUITableViewCell側で用意したUIImageViewをタップ時に発動されるメソッドないしは6枚以上の画像がある場合に表示されるボタンのアクション内でこのクロージャーの変数を実行するようにすれば準備は完了です。

またクロージャー変数var transitionClosure: ((Int?) -> ())?については引数には、レイアウトが変わるUIImageViewへInterfaceBuilderで設定した「タグ番号」が入るようにします。このタグ番号はポップアップによる画像表示画面において最初の表示時にどの画像を表示するかを決定するために使用しています。

UIImageViewに付与したTapGestureが発動した際に呼び出されるメソッドにはこのクロージャー変数の引数内にUIImageViewに設定したタグの値を設定して呼び出すようにし、また引数がnilの場合には、詳細画面への遷移が行われるようにしておき、ボタンアクションにはこのクロージャー変数の引数内にnilを設定して呼び出すようにします。

上記の説明を踏まえてコードにまとめると、MainContentsCell.swift側の処理は下記のような形になります。

MainContentsCell.swift
//「もっと他の画像を見る」ボタンのアクション
@IBAction func moreImageAction(_ sender: UIButton) {
    transitionClosure!(nil)
}

//サムネイル画像のTapGesture発動時に実行されるメソッド
func tapGesture(sender: UITapGestureRecognizer) {
    let targetNumber: Int = (sender.view?.tag)!
    transitionClosure!(targetNumber)
}

※ポップアップでの画像表示画面の詳細に関しては、このプロジェクト内のImageController.swift内で実装されている処理を参照して頂けますと幸いです。

6. このサンプルで使用しているその他の表現に関して軽く紹介

今回のサンプル内では、Facebookに似たUIの実装で画像をタイル状に並べて表示するレイアウトを行う部分に関しての解説に重点を置いて解説を行いましたが、その他にもUITableViewCellとUICollectionViewを組み合わせて作成しているように見受けられる部分があったので、見よう見真似ではありますが自分なりに実装を行ってみましたので、この部分も軽くではありますが紹介できればと思います。

★6-1. 自分のカメラロールにある写真の一覧を取得する部分のUIに関して

カメラロールからの画像を取得する際には、Photos.frameworkを使用しています。

この中で行っている処理の手順をざっくりまとめると下記のようになります。

  1. まずはPHAssetクラスを使用してフォトライブラリ側の画像を取得する(取得時はスレッドを利用)
  2. 取得した画像(PHASeet型)を配列に格納しUICollectionViewをリロード
  3. UICollectionViewCell内に配置したUIImageViewに表示させるタイミングでPHAsset型のデータをUIImage型に変換する
  4. 画像の読み込み直しができるようにボタンアクションに1. と 2. の処理を記載する

そして以上の部分をコードにまとめると下記のような形になります。

PhotoLibraryCell.swift
//このコレクションビューのセル内へ写真の配置を行う際の処理
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoCell", for: indexPath) as? PhotoCell

    //cellへの画像表示
    cell?.photoImageView.image = convertAssetThumbnail(asset: photoAssetLists[indexPath.row], rectSize: 100)

    UIView.animate(withDuration: 0.28, delay: 0.0, options: UIViewAnimationOptions.curveEaseOut, animations:{
            cell?.photoImageView.alpha = 1
        }, completion: nil)

    return cell!
}

//画像を同期するアクション
@IBAction func photoSyncAction(_ sender: UIButton) {

    //画像のアセットリストを一旦クリアして再度写真データを読み込む
    photoAssetLists.removeAll()
    dispatchPhotoLibraryAndReload()
}

//フォトライブラリを非同期で読み込む処理
fileprivate func dispatchPhotoLibraryAndReload() {

    //データの取得はサブスレッドで行う
    DispatchQueue.global().async {
        self.photoCollectionView.isUserInteractionEnabled = false
        self.getPHAssetsForImageLibrary()

        //コレクションビューのリロードはメインスレッドで行う
        DispatchQueue.main.async {
            self.photoCollectionView.isUserInteractionEnabled = true
            self.photoCollectionView.reloadData()
        }
    }
}

//PHAssetクラスを使用して画像を取得する
fileprivate func getPHAssetsForImageLibrary() {

    //データの並べ替え条件
    let options = PHFetchOptions()
    options.sortDescriptors = [
        NSSortDescriptor(key: "creationDate", ascending: true)
    ]

    //Photoライブラリから画像を取得する
    let assets: PHFetchResult = PHAsset.fetchAssets(with: .image, options: options)
    assets.enumerateObjects( { (asset, index, stop) -> Void in
        self.photoAssetLists.append(asset as PHAsset)
    })
    photoAssetLists.reverse()
}

//PHAsset型のデータを表示用に変換する
fileprivate func convertAssetThumbnail(asset: PHAsset, rectSize: Int) -> UIImage {
    let manager = PHImageManager.default()
    let option = PHImageRequestOptions()
    var thumbnail = UIImage()
    option.isSynchronous = true
    manager.requestImage(for: asset, targetSize: CGSize(width: rectSize, height: rectSize), contentMode: .aspectFill, options: option, resultHandler: {(result, info) -> Void in
        thumbnail = result!
    })
    return thumbnail
}

表示する際のポイントとしては、「表示の前にPHAsset型で抜き出した画像データをUIImage型に変換する」処理の部分になるかと思います。また、この部分の処理に関しては下記のリンクで掲載されている実装

さらにこの部分の処理を応用してアレンジをすることで、下記の記事で行った実装のように「画像の選択フォーム」のように活用することもできます。

★6-2. 横方向に配置したUICollectionViewを動かすと、表示対象のセルが中央に来る部分のUIに関して

この部分に関してはUICollectionViewFlowLayoutクラスを利用していきます。今回はこのクラスを継承したサブクラスを作成(CenterCellCollectionViewFlowLayoutクラス)を作成し、さらにそのクラス内でtargetContentOffsetメソッドをオーバーライドして、UICollectionView内の横方向に配置されているセルをスクロールした際に、スクロールの早さから推測される停止位置を予測するメソッドになります。

※具体的な処理に関しては、このクラス内のコメントを参照してください。

このクラスの参考元は下記になります。このリポジトリに入っているクラスファイルは、実装を読み解いて自分なりに処理に関するコメントをつけたり一部修正を加えたものになります。

今回に関してはレイアウトを近い部分まで再現してみるというところまでで終わっている状態ではありますが、実際は画面の遷移やリンク先の表示等を行っているような部分ですので、続きの実装に関しては、以降もしっかりと調べた上で取り組んでいければと思います。

7. まとめ

今回の方法では、「セル内のUIImageViewに対して、Storyboard上で制約を設定した上で、幅(width)と高さ(height)の制約に関しては優先順位を下げてコードで制御する」というちょっと強引な手法でタイル状のレイアウトを実現してみました。

この部分の表現に関しては、UIStackViewを用いる方法をはじめ、セルの中にUICollectionViewを配置して画像の枚数に合わせてセクションやアイテム数を変更る方法、画像枚数に合わせてセルを作成する方法等、様々な表現方法があるかと思います。

(実装方法の選択に関しましては、作成しているアプリに合わせて適切な実装を選択する形になると思いますので、このような方法もあるのかという認識でも問題ないと感じています)

また今回の方法に関しては、配置したUIImageView間の隙間を考慮しなければならない場合には、UIImageViewの間にさらに隙間用のUIViewをはさむ、ないしはXib上でUIImageViewの配置をして制約を設定する際にマージンを設定しサイズ計算ロジック内でマージンを考慮する等の手間が必要になってしまいます。

今回の手法による実装で個人的に感じたメリット・デメリットに関してざっくりとまとめてみると、

メリット:

  • セルを1枚だけ済ませることができる
  • 幅(width)と高さ(height)の値さえ計算で決めてしまえば画像の大きさが決められる

デメリット:

  • 画像の枚数分だけプロパティが増えてしまう
  • 画像の大きさを決める計算ロジックを画像の枚数に応じて算出しているので処理が長くなる

という点が挙げられるかなと思います。

写真を生かしたレイアウトやUIを作成する際には、どのような配置や遷移・アニメーションで見せるかという部分が一番大変な部分でもあり、また楽しい部分ではあるかと感じています。AutoLayoutとコードを併用する形でなかなか整理ができきれていない書き方にもしかしたらなっているのかも知れませんが、UI構築の参考の一助となれば嬉しく思います。

そして私自身も、複雑な処理を組み合わせたコードであっても整理や綺麗な設計・書き方を常に意識することを忘れずにこれからも精進をしていければと強く感じる次第です。


『 Swift 』Article List