テクノロジー

iOSのCompositionalLayoutによるカスタムレイアウト実現と高速化 〜 PayPayフリマのグリッドレイアウトUI

こんにちは、PayPayフリマでiOS開発を担当している新妻です。PayPayフリマYahoo! JAPAN Tech Blogの第三弾となります!

今回は、PayPayフリマのUI事情についてお話しします。PayPayフリマでは、iOSのモダンなUI技術を積極的に採用しており、複雑なレイアウトにも対応できる環境が整っています。
この記事では、PayPayフリマの新機能である「投稿」システムの開発において、「CompositionalLayout」を活用した事例をご紹介します。

PayPayフリマの投稿機能とは?

PayPayフリマは、誰でも気軽に、安心して個人間取引ができるフリマアプリです。(PayPayアプリの中からも使えます)
この中の「投稿」機能をご存じでしょうか?

PayPayフリマの投稿機能

投稿機能は出品者の「売りたい」気持ちや、購入者の「この商品が欲しい!」といった気持ちを気軽に発信できるPayPayフリマ独自の機能です。

投稿機能では、最近買って良かったもの、予想以上に早く売れてびっくりしたもの、出品しようか迷っているもの、ほしいけど探しても見つからないものなどさまざまな内容を気軽に投稿でき、その投稿をPayPayフリマアプリを使っているユーザーが見ることができます。投稿した内容はPayPayフリマ内で閲覧でき、投稿に対してコメントができるようになっています。ユーザ同士でコミュニケーションをとることにより、出品者と購入者の新しいつながりが生まれます。

PayPayフリマはソーシャルEC化を目指しており、その軸となる要素でもあります。

投稿一覧画面の課題:高さがバラバラな要素を並べたい(Masonryレイアウト)

新機能の実装にあたって新しい画面が必要になるわけですが、投稿を一覧で見るタイムラインの画面要件に課題がありました。タイムラインの画面は、ながら見に特化するため、投稿のサイズを固定にせず、レイアウト上の区切りを無くしたデザインになっており、これは通常のCollectionViewでは容易に実装できませんでした。

投稿タブのレイアウト

さらに、投稿の種類は3つあり、それぞれ異なる表示になります。

投稿の種類

バナーやお知らせ枠などの別の要素も同じ画面に表示されるため、対応策を考える必要がありました。

投稿タブのバナー

CompositionalLayout の採用

PayPayフリマチームはこの画面を実装するにあたって、以下の実装方法を候補に挙げました。

  1. CollectionView のカスタムレイアウトを利用して実装
  2. CompositionalLayout を利用して実装

1.UICollectionViewLayout を継承したカスタムクラスを独自実装して、複雑なレイアウトを実装する以前からよく知られたやり方です。ただし、ビューのサイズや位置を計算しなければいけないのはもちろん、スクロール位置によるビューの出し分けや、レイアウトに必要なデータの管理など開発コストとしては非常に重く、今後運用していく面でも難しい実装は避けたい気持ちがありました。

2. のCompositionalLayoutは、WWDC2019で発表された複雑なレイアウトをシンプルに実装するための新しい考え方です。iOS13以降で利用でき、App Storeの画面にも使われています。

App Storeのレイアウト

出典:Advances in Collection View Layout(PDF)

当時の発表資料でもCompositionalLayoutの特徴を

  • Composable
  • Flexible
  • Fast

と表現しており、さまざまなレイアウトに対して柔軟に対応できることがうかがえます。

CompositonalLayoutの導入ははじめてでしたが、比較的新しいレイアウト技術であり、チーム内のモチベーションも高かったため、CompositionalLayoutによる実装を採用しました。

CompositionalLayoutの特徴

PayPayフリマでの実装方法の説明の前に、CompositionalLayoutの基本的な知識をおさらいしておきます。CompositionalLayoutには4つの要素が存在しています。

  • Item
  • Group
  • Section
  • Layout

Item (NSCollectionLayoutItem)

CompositionalLayoutにおける最小要素になります。これはCollectionViewのCellの領域に相当します。

let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize)

Group (NSCollectionLayoutGroup)

複数のItemを持ち、それらのレイアウトを決める要素です。
縦方向と横方向に並べられる他、独自でレイアウトを定義もできます。

let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: NSCollectionLayoutSize,
        subitems: [NSCollectionLayoutItem]
)

Section (NSCollectionLayoutSection)

CollectionViewにおけるSectionと同じ要素です。
Groupを持ち、CompositionalLayoutにおけるレイアウトの単位になります。

let section = NSCollectionLayoutSection(group: NSCollectionLayoutGroup)

Layout (UICollectionViewCompositionalLayout)

複数のSectionを持ち、全体的なレイアウトを決定します。

let layout = UICollectionViewCompositionalLayout {
    (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
    switch sectionIndex {
    case 0:
        // ...
        return NSCollectionLayoutSection(group: group)
    case 1:
        // ...
        return NSCollectionLayoutSection(group: group)
    }
}

これらがCompositionalLayoutを構成する4つの要素です。

以下の画像の例では、Layoutの中に2つのSectionが用意されています。
2つのSectionはそれぞれ異なったレイアウトになっており、1つは横スクロールができるレイアウト、もう1つが縦に要素を並べたレイアウトになっています。このようにSectionごとにレイアウトを定義できるため、複雑な画面でも簡単に作成できるわけです。

CompositionalLayoutの例

CompositionalLayoutは非常に柔軟なため、画像のようなレイアウト以外にもさまざまなレイアウトに対応できます。「どんなレイアウトが作れるんだろう?」と気になる方は、Apple公式よりサンプルプロジェクトが公開されているため、そちらで試してみることをおすすめします。

Appleの公式サンプルプロジェクト(外部サイト)

PayPayフリマにおけるレイアウト構築

では、PayPayフリマの投稿一覧のレイアウトをCompositionalLayoutでどのように実装しているのか紹介していきます。

はじめにSectionですが、タイムライン投稿画面にはバナー画像や緊急お知らせ枠など投稿以外の情報も表示する必要があるため、Sectionは項目ごとに分けて実装しています。

投稿画面のセクション

// Sectionはenumで定義
enum HomeMessageSection: Int, CaseIterable {
    case troubleReport
    case banner
    case message
}

class HomeMessageViewController {
    @IBOutlet weak var collectionView: UICollectionView! {
    didSet {
        collectionView.collectionViewLayout = createLayout()
            // ...
        }
    }

    // ...

    private func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { [weak self] (section, environment) -> NSCollectionLayoutSection? in
            guard let self = self else { return nil }
            switch HomeMessageSection(rawValue: section) {
            case .troubleReport:
                // ...
            case .banner:
                // ...
            case .message:
                let sizeList = self.presenter.getMessages().enumerated().map { index, item -> CGSize in
                    // 投稿セルのサイズを計算
                    switch MessageCellType(message: item) {
                    case .image(let viewData):
                        imageCell.setForLayout(viewData: viewData)
                        return imageCell.messageCellSize(environment: environment)
                    case .product(let viewData):
                        productCell.setForLayout(viewData: viewData)
                        return productCell.messageCellSize(environment: environment)
                    case .description(let viewData):
                        descriptionCell.setForLayout(viewData: viewData)
                        return descriptionCell.messageCellSize(environment: environment)
                    }
                }
                self.sizeCache = sizeList
                // ↓ 注目
                return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList) 
                            return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList)
                return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList) 
                            return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList)
                return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList) 
            default:
                return nil
            }
        }
    }
}

ここで注目していただきたいのが、投稿Sectionに使われている WaterfallLayout です。
参考サイト(外部サイト)
WaterfallLayout は、投稿のレイアウトを作成しているクラスになります。

class WaterfallLayout {
    static let homeBottomMargin: CGFloat = 80

    static func createSection(environment: NSCollectionLayoutEnvironment, sizeCollection: [CGSize]) -> NSCollectionLayoutSection {
        var items: [NSCollectionLayoutGroupCustomItem] = []
        var layouts: [Int: CGFloat] = [:]
        let space: CGFloat = 4
        let numberOfColumn: CGFloat = 2

        (0..<Int(numberOfColumn)).forEach { layouts[$0] = 0 }

        sizeCollection.forEach { size in
            let aspect = CGFloat(size.height) / CGFloat(size.width)

            let width = (environment.container.effectiveContentSize.width - (numberOfColumn + 1) * space) / numberOfColumn
            let height = width * aspect

            // 各列から最小の高さを計算
            let minHeight = layouts.min(by: { a, b in a.value < b.value })?.value ?? 0
            // 最小の高さを持つ列が複数あった場合、左からセルを整列させる
            let nextColumn = layouts
                .filter { layout in layout.value == minHeight }
                .map { $0.key }
                .min() ?? 0

            let y = layouts[nextColumn] ?? 0.0 + space
            let x = width * CGFloat(nextColumn) + space * (CGFloat(nextColumn) + 1)

            let frame = CGRect(x: x, y: y + space, width: width, height: height)
            let item = NSCollectionLayoutGroupCustomItem(frame: frame)
            items.append(item)

            layouts[nextColumn] = frame.maxY
        }
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute((layouts.max(by: { a, b in a.value < b.value })?.value ?? 0) + homeBottomMargin)
        )
        let group = NSCollectionLayoutGroup.custom(layoutSize: groupSize) { _ -> [NSCollectionLayoutGroupCustomItem] in
            return items
        }
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 0, bottom: 0, trailing: 0)

        return section
    }
}

WaterfallLayout.createSection() では、 NSCollectionLayoutGroup.custom を利用して独自のレイアウトを実装しています。

NSCollectionLayoutGroup.customNSCollectionLayoutGroupCustomItem というサイズと位置を決めた Itemを渡すことで、Itemの並びを自由にカスタマイズできるGroupです。 実装では、全ての投稿セルのサイズをforEachにかけ、何番目のItemはどの位置で表示されるかを計算しています。

例えば、Item1 と Item2 が以下のように配置されていた場合、Item3 はどこに配置するべきかを考えます。

WaterfallLayoutの説明図1

まず、左の列と右の列で、高さが低い列を見つけます。この例だと右の列です。低い列が見つかったら、その列の下にItem3が配置されるように表示位置を計算します。

WaterfallLayoutの説明図2

次のItem4も同じように計算していきます。

WaterfallLayoutの説明図3

これを繰り返すことで、現在の投稿画面のレイアウトが出来上がります。

WaterfallLayoutの完成例

カスタムレイアウト高速化のコツ

このようにItemの位置とサイズを自前で計算することで、タイムライン投稿のレイアウトを実現しています。ただしページング処理などで投稿が新しく追加された場合、collectionViewのリロードが走るため、投稿のサイズと位置を再度計算する必要があり、負荷が大きくなってしまう問題があります。そこで、一度表示された投稿セルはサイズをキャッシュしておき、リロードされた際にはキャッシュされた値を使うことで、負荷を軽減しています。

class HomeMessageViewController {
    private var sizeCache: [CGSize] = []

    private func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { [weak self] (section, environment) -> NSCollectionLayoutSection? in
            guard let self = self else { return nil }
            switch HomeMessageSection(rawValue: section) {
                // ...
            case .message:
                let sizeList = self.presenter.getMessages().enumerated().map { index, item -> CGSize in
                                    // ↓ キャッシュがあれば、セルのサイズを計算しない
                if let cache = self.sizeCache[safe: index] {
                    return cache
                }
                switch MessageCellType(message: item) {
                case .image(let viewData):
                    imageCell.setForLayout(viewData: viewData)
                    return imageCell.messageCellSize(environment: environment)
                case .product(let viewData):
                    productCell.setForLayout(viewData: viewData)
                    return productCell.messageCellSize(environment: environment)
                case .description(let viewData):
                    descriptionCell.setForLayout(viewData: viewData)
                    return descriptionCell.messageCellSize(environment: environment)
                }
            }
            self.sizeCache = sizeList
            return WaterfallLayout.createSection(environment: environment, sizeCollection: sizeList)
        default:
            return nil
        }
    }
}

レイアウト実現手法の別解(今回は採用せず)

独自でレイアウトを組むことで投稿のレイアウトを実現できたわけですが、実は開発当初は別の方法も検討されていました。

その方法とは、縦に配置されるGroupを横に2つ並べてItemを配置していく方法です。この方法であれば、左の列か右の列のどちらにItemを入れるのかを判断するだけでよく、 Itemの位置の計算は不要になります。

Groupを2つ並べたレイアウト

ですが、PayPayフリマでは分析の要件があり、これを満たせないため採用しませんでした。頭から何番目のItemがタップされたかというデバッグや分析をしたい時に、このレイアウト方法だとログの落とし方に問題が生じます。

例えば、2番目のItemがタップされたというログを落としたい時、上のやり方だと左の列の2番目のItemが対象になります。

Groupを2つ並べたレイアウトにおける2番目のItem

このあたりは、アプリのログ仕様にもよりますが、PayPayフリマでは左から右への順序になるのが理想であったため、Groupを横に2つ並べる方法は採用されませんでした。 このようにレイアウトの方法によって、Itemの順序は変わってしまうため、ログやItemの順番を含めたロジックには注意しましょう。

WaterfallLayoutにおける2番目のItem

おわりに

いかがでしたか? CompositionalLayoutは非常に強力なレイアウト手法ではありますが、柔軟かつ自由度が高いがゆえに、奥が深いレイアウト手法でもあると思います。
今回の内容が、CompositionalLayoutを使いたいと思っている方の参考になれば幸いです。

WWDC22ではSwiftUIやXcodePreviewのアップデートがされたことで、今後SwiftUIを導入する機会も増えていきそうです。UI周りは、OSバージョンの問題や移行が難しいことから、新しい技術が出てもなかなか導入されにくい領域だと思いますが、新機能のタイミングなどで積極的に採用していきたいですね。

よろしければPayPayフリマの他のiOS関連記事もご覧ください。

また、PayPayフリマでは一緒にサービスを盛り上げてくれるメンバーを募集中です!
iOSエンジニアとして新しい技術を用いた開発に挑戦したい方は、ぜひ採用ページをご覧ください!

参考文献(外部サイト)

こちらの記事はいかがでしたか?

  • 学びがある
  • わかりやすい
  • 新しい視点

ご感想ありがとうございました


新妻 広康
iOSエンジニア
PayPayフリマアプリのiOS開発をしています。クリエイティブなことが大好きです。

関連記事

このページの先頭へ