iOSDC Japan 2019 に社員2名がLT枠で登壇&ブース企画のご案内

こんにちは!広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。
「今年は梅雨が長いな」なんて思っていたらあっという間に暑くなり、気づけば本当の夏も過ぎ……。夏の終りの気配が見えてきましたね。

さて、iOSと周辺技術を題材としたカンファレンス、iOSDC Japan 2019が今年も9月5日(木)〜9月7日(土)に開催されますね!

クックパッドは、プラチナスポンサーをさせていただいており、ブースを出展いたします。今年は、@giginet@hiragram がLT枠で登壇し、@_sgr_ksmt@natmarkがiOSDCのスタッフとして関わってくれます。カンファレンスには、他にも多くの社員が参加いたしますので、会場でクックパッド社員をお見かけの際には、お声がけいただけますと嬉しいです。

参加予定社員一覧

@kanny, @slightair, @giginet, @hiragram, @_sgr_ksmt@natmark, @ichiko_revjune, @iceman5499, @sagaraya, @to9nariyui

登壇スケジュール

クックパッドの社員2名は、day2 - 9/7(土)に登壇いたします。 以下、スケジュールと登壇内容のご紹介です。

day2 - 9/7(土)

15:45〜 Track A @hiragram俺たちのARKitでめちゃめちゃ表情豊かなVTuber向け表情トラッカーを作るぞ

Animojiにも使われているTrueDepthカメラを使って3Dモデルの表情を動かす表情トラッカーを作りました。webカメラを用いて顔認識する他のシステムよりも精度高く、細かく、感情表現に必要な顔のパラメータを取得できるTrueDepthカメラの本気をお見せします。 表情トラッキングの精度以外にも、ARKitのおかげでバーチャルYouTuberを運用するにあたって地味に嬉しい機能をたくさん獲得しているので、プロデュースの現場の目線から面白おかしく紹介できればと思います。

16:55〜 Track A @giginet令和時代のゲームボーイ開発 👾

1989年に発売したゲームボーイは、今年30周年を迎えました。 そんな今だからこそ、実機で動くゲームボーイ開発をしてみましょう! 30年の時を経て、ゲームボーイが最新の技術で蘇ります。

ブース

今年のクックパッドブースではグッズの配布はもちろんですが、2つの企画を行います! 

f:id:tokunarigyozadaisuki:20190827145810j:plain
昨年のブースの様子

クックパッドのコード全部見せます大質問会

day2 - 9/7(土)の11:00-13:00にアンカンファレンストラックにて「iOS版クックパッドアプリのコード全部見せます大質問会」を開催します!

この会は、実際のプロダクトコードを見せながら、ディスカッション形式で、クックパッドのiOS開発について紹介します。 例えば以下のような質問を歓迎しています。

  • どういう開発体制でやってるの?
  • リリースフローやQAについて見せて
  • アーキテクチャはどうなってるの?
  • Podfile見せてください

なにか見たい部分や、開発上での質問がある方は、day1 - 9/6(金)にクックパッドブースへお越しください!当日はその質問を中心にご紹介します。飛び入りでの質問も歓迎です。

クックパッドエンジニアとの「カジュアルトーク」

エンジニアリングマネージャーや、現場で活躍するエンジニアと直接話せるカジュアルトーク企画を実施します。開発に関する話からオープンな場で聞きにくいキャリアについての話などを1on1のようなスタイルでお話しましょう! あんなことやこんなことまで……どんなことでもお気軽に。 参加メンバーとスケジュールは以下のとおりです。

エンジニアリングマネージャーと話せる枠

クックパッドブースにお越しいただき、希望する時間を選んでいただくと、その時間に VP of Tech 星 北斗 @kani_b、モバイル基盤部 部長 茂呂 智大 @slightairと話せます。

  • day1 - 9/6(金)11:00-18:00:星・茂呂
  • day2 - 9/7(土)11:00-18:00:茂呂

▼ RubyKaigi 2019 実施時の様子

特定の技術に詳しいエンジニアと話せる枠

以下の時間は、特定の技術について豊富な知見を持っている社員がブースにおりますのでぜひ情報交換しましょう!

day1 - 9/6(金)12:50-14:20 fastlane / Carthage:@giginet

fastlaneCarthageのコミッターやってるのでチョットできます。 その他にもSwiftPMやCocoaPods, XcodeGenなどの開発ツール全般についても知見あります。上記の話題にかかわらず、興味のある技術トピックがあればわいわいしましょう 🙌

day1 - 9/6(金)14:20-15:10 モバイルテスト自動化/QA @ichiko_revjune

参考:https://techlife.cookpad.com/entry/2018/12/12/120000

day1 - 9/6(金)15:10-16:00 SwiftUI @iceman5499

参考:https://techlife.cookpad.com/entry/2019/06/25/120000

おわりに

クックパッドはiOSエンジニアを募集しています。クックパッドで働くことに少しでもご興味をお持ちの方は、お気軽にブースまでお越しください! みなさまにお会いできることを楽しみにしております。

Android cookpadLiveで採用してる技術 2019夏

メディアプロダクト開発部の安部(@STAR_ZERO)です。

Android cookpadLiveで採用してる技術について紹介したいと思います。

cookpadLiveとは

cookpadLive は、料理上手な有名人や料理家がクッキングLiveを生配信しています。一緒に、Live配信でリアルタイムに料理が楽しめるアプリです。

ダウンロード: Android アプリ iOS アプリ

ぜひ、ダウンロードしてLive配信を見てください!

基本環境

基本となる環境です

  • Kotlin
  • minSdkVersion 21
  • targetSdkVersion 28
  • AndroidX

特別な箇所はないですが、最新に追随するように努めています。

比較的新しいアプリなので、最初からすべてKotlinで記述されています。

targetSdkVersionについてはそろそろ29に対応する予定です。29にすることでの影響を調査している状況です。

Android Studio 3.5

Android Studio 3.5はbetaの段階から導入しています。理由としてはIncremental annotation processingを使いたかったためです。

cookpadLiveでは全面的にDataBindingを採用しているため、これの恩恵は非常に大きいものになります。 これまでは、レイアウトファイルを変更してビルドし直さなければコードが生成されず効率がよくありませんでした。3.5からはレイアウトファイルを変更すると同時にコード生成も行われるのでビルドによる待ち時間が減り効率よく開発できるようになりました。

Jetpack

現在、cookpadLiveではJetpackを積極的に採用しています。

DataBinding、LiveData、ViewModelについては、最初は使用されていなかったのですが、徐々に導入を進めて今では全面的に使用しています。

意外と便利だったのが、ViewModelをActivityに関連付けることでActivityとFragment間、それぞれのFragment間でのデータやイベントのやりとりが可能になる機能です。この機能のおかげ実装がだいぶ楽になったこともありました。 例えば、以下のようにActivityのイベントをFragmentでも受け取ることが簡単にできます。

class HogeActivity: AppCompatActivity() {
    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }
    fun someEvent() {
        // イベント発行
        viewModel.somaEvent.value = someValue // someEventはLiveData
    }
}

class HogeFragment: Fragment() {
    private val activityViewModel by lazy {
        // thisではなく、Activityを指定することで共通のViewModelを使用できる
       ViewModelProviders.of(requireActivity()).get(HogeViewModel::class.java)
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        activityViewModel.somaEvent.observe(viewLifecycleOwner) {
            // Activityのイベントを受信
        }
    }
}

Pagingはだいぶ癖があるライブラリですが、ちゃんと理解して使う非常に便利です。これも早い段階から導入しています。現状ではネットワークにはライブラリ側で対応はされてないですが、今後される予定らしいので、楽しみにしています。

Navigationについてはそこまで活用できないですが、部分的に使っています。全面的にSingle Activityにすることは考えてないですが、出来る箇所はFragmentに移行していきたいと考えています。また、SafeArgsは画面間の値の受け渡しが便利になるので積極的に使っていこうと思います。

RoomはDBまわりの処理には欠かせないくらい便利です。RoomはLivaData、RxJavaと簡単に連携することができるため、既存の実装に組み合わせることが簡単にできました。SQLも補完とシンタックスハイライトが効くので非常に助かります。

DI

DIについて Dagger を使用しています。こちらも最初は使用されてなかったのですが、徐々に導入をすすめました。

Daggerについては非常に難しい印象がある人が多いと思いますが、一度使うと便利すぎて手放せません。 DaggerでRepositoryクラスなど生成するようにして、あとは使いた箇所でInjectするだけです。ViewModelなどで必要なRepositoryが増えた場合も、生成するコードを意識せずパラメータに追加するだけ済みます。 最初の設定さえうまくやってしまえば、あとは楽になるはずです。

以前、部内でやったDagger勉強会のチュートリアルコードのリンクを貼っておきます。(まだ@Component.Factoryには対応してないです…)

STAR-ZERO/dagger-tutorial

AppSync

cookpadLiveではライブ中のコメントやハートなどのリアルタイム通信にAWSの AppSync を使用しています。

f:id:STAR_ZERO:20190826153800p:plain:w300

この部分が一番特徴的かもしれません。

AppSyncはAWSのマネージド型GraphQLサービスです。 ユーザーが送信したコメントやハートをAppSync(GraphQL)のSubscriptionの機能を使い受信するようにしています。

ライブによって非常に多くのコメントやハートを受信することになりますが、受信のたびに画面に描画するのではなくRxJavaのbufferを使ってある程度まとめて画面に描画するようにしています。このあたりはうまくRxJavaと組み合わせて実装しています。

AppSyncの話は以下の記事の後半部分を見ていただければと思います。

CookpadTVのCTOが語る、料理動画サービス開発の課題と実装 - ログミーTech

その他ライブラリ

このあたりよく使われてるライブラリですね。これらももちろん活用しています。

設計

MVVM + Repositoryパターンを採用しています。Googleが公開してる Guide to app architecture とほぼ同じです。

元々はVIPERだったのですが、DataBindingやLiveDataとViewModelを導入していくと同時にMVVMに移行していきました。今ではすべてMVVMで実装されています。 私個人の経験からもJetpackを導入することで、開発効率と品質に大きく貢献することは明確だったので、これらを導入しました。

VIPERはAndroidではあまりの馴染みがないかもしれませんが、MVPパターンのようにInterfaceを使って各レイヤー間の処理を呼び出します。 すごく簡単な例ですが、以下のような感じです。(例ではViewとPresenterしか登場してないです)

// HogeView.kt
interface HogeView {
    fun show()
    fun hide()
}

// HogeFragment.kt
class HogeFragment: Fragment(), HogeView {

    private val presenter by lazy { HogePresenter(this) }

    override fun onCreateView(/** */): View? {
        // ...
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        presenter.fetch()
    }
    override fun show() {
        // View.VISIBLEにする
    }
    override fun hide() {
        // View.GONEにする
    }
}

// HogePresenter.kt
class HogePresenter(private val view: HogeView) {
    fun fetch() {
        val data == // ...
        if (data != null) {
            view.show()
        } else {
            view.hide()
        }
    }
}

このようにViewへの処理を呼び出すのにInterfaceを使ってPresenterからViewへの処理を呼び出しています。

一見、問題がなさそうですが、この時点で既に問題があります。例えば、Presenterでデータ取得中にActiivty/Fragmentが破棄された場合はどうなるでしょう。破棄されてるオブジェクトにアクセスすることになり、場合によってはクラッシュします。これはPresenterがActivity/FragmentのLifecycleについて何も知らないからです。これを解決するにはPresenter側に破棄されたことを教えてあげる必要があります。

では、これを今の実装で書き換えた場合です。

// HogeFragment.kt
class HogeFragment: Fragment() {

    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }

    private lateinit var binding: FragmentHogeBinding

    override fun onCreateView(/** */): View? {
        // ...
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        viewModel.fetch()
    }
}

// HogeViewModel.kt
class HogeViewModel: ViewModel() {
    val isShow = MutableLiveData<Boolean>()

    fun fetch() {
        val data == // ...
        isShow.value = data != null
    }
}
<!-- fragment_hoge.xml -->
<layout>
    <data>
        <variable name="viewModel" type="...HogeViewModel" />
        <import type="android.view.View" />
    </data>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="..."
        android:visibility="@{viewModel.isShow ? View.VISIBLE : View.GONE}" />
</layout>

ここではDataBindingを使用していて、ViewModelが保持しているLiveDataとバインドしています。このLiveDataの値を変更すると自動的にView側にも反映される仕組みになっています。 ここで重要なのは、LiveDataはLifecycleのことを知っているので、Activity/Fragmentがアクティブな状態のときしかデータを流しません。そのため、さきほど説明したActivity/Fragmentが破棄されたときの対応を特別にせずとも問題が起こることがありません。またViewModelおいては回転でActivityが再生成されたときもViewModelは状態をもっているため、データ取得を中断することなく処理を継続することができます。

この設計にすることでActivity/FragmentはViewModelの状態を反映すれば良いだけになり、責務もしっかり分かれて見通しが良くなりました。また単純にVIPERはファイル数が多くなるため、コードを追う時にコードジャンプであちこち飛ばなければならず、個人的にはコードが追いにくい感じでした。

他にも様々な面で効率・品質を向上させるのに貢献してくれています。その他便利なJetpackライブラリも簡単に導入できるようになっています。

今ではすべての画面が同じような感じになってるので、どういう処理をしてるのかを理解しやすくなっています。

この設計変更ですが、新規画面については新しい設計でやり、既存については少しずつ進めていました。またUIを大きく変更するタイミングもあったので、その時に一気に直した箇所もありました。出来ることからコツコツやってこともあり、大きくコストをかけることなく変更できました。

CI環境

CIに関しては、Jenkinsを使っています。やってることは以下になります。

  • Pull Request
    • テスト、lintを実行
    • 社内テスト環境にAPKをアップロード
  • masterマージ後
    • 社内テスト環境にAPKをアップロード
    • Google Play内部テストへアップロード

可能な限り早い段階でリリース版をビルドして触ることで、問題があったときに早めに気づくことができるようにしています。特にProguardまわりは見落としがちになるを防げます。

リリースするときは内部テスト版を製品版へ昇格するだけになっています。今ここは手作業でやってるのですが、ChatOps等で出来るようにしたいと考えています。

マルチモジュール

現状では、スマホ、AndroidTV、FireTVで共有するようなモジュールと、featureモジュールをいくつか分割しています。

図にすると以下のような感じです。

f:id:STAR_ZERO:20190826154302p:plain:w300

  • core
    • 全モジュールで共通処理
    • APIアクセス、Repository、データモデルなど
  • appcore
    • スマホアプリ共通処理
    • 共通View、ログ、リソースなど
  • feature
    • 各機能を分割したモジュール
  • app
    • スマホアプリメイン
  • smarttv
    • TVアプリ共通処理
    • 共通View、ログ、リソースなど
  • androidtv
    • AndroidTVメイン
  • firetv
    • FireTVメイン

まだfetureモジュールは分割できる箇所があるので、少しずつでも進めていきたいと思います。

課題と今後

テスト

正直、まだそこまでうまく書けてる状況ではないので、なんとかしていきたいと思っています。 せっかくなので、ライブラリの選定から考えようとも思っています。Truth 良さそうですね。

StyleとTheme

StyleとThemeについては結構ちらかってる状態なので、整理したい思っています。画面数もそこそこあるので、だいぶ大変な作業になる気配がしています。まずは、どういうふうに整理するかを検討してから少しずつやっていく感じになりそうです。

Navigation

前に書きましたが、まだまだ活用できる箇所があります。すべてSingleActivityとは考えてないですが、Fragmentでの遷移で良い箇所もあるので、そういった箇所に対応していきたいと考えています。

Coroutines

Coroutinesについては、どうするかを検討している段階です。現状でCoroutinesじゃないと困るような場面は出てきていませんが、JetpackもCoroutinesの対応が進んでいて実装するのに困ることはないと考えています。また、今後Coroutinesによって実装コストが下がるような機能なんかも出てくる可能性ありそうです。 メンバーと会話して、導入する気持ちはありますが、進め方やどこから導入するのかを考えています。

まとめ

cookpadLiveでは積極的にJetpackを使っていき、Googleが推奨しているやり方にどんどん乗っかっていっています。 今後もJetpackも改善されていくと思うので、それにいつでも追随できるような状態を保つようにしています。

これからもcookpadLiveでは新しい技術を積極的に取り入れていきますし、やりたいこともまだまだたくさんあります。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

UICollectionViewでページングスクロールを実装する

 こんにちは。新規サービス開発部の中村です。普段は「たべドリ」アプリの開発をしています。「たべドリ」は料理の学習アプリです。詳細はこちらの記事をご覧ください。本記事では UICollectionView でページングスクロールを実装する方法について解説します。

概要

f:id:nkmrh:20190807175935p:plain f:id:nkmrh:20190807175941p:plain f:id:nkmrh:20190807175952p:plain

 上記画像が今回解説する iOS アプリのUIです。左右のコンテンツが少し見えているカルーセルUIで、以下の要件を満たすものです。

  • 先頭にヘッダーを表示する
  • セルが水平方向にページングスクロールする

色々な実装方法があると思いますが、今回はヘッダーがあるため複数の異なる幅のViewを表示させながら、ページングスクロールを実現する方法を解説します。実装のポイントは以下の2点です。

  • UICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドしてUICollectionViewcontentOffsetを計算する
  • UICollectionViewdecelerationRateプロパティに.fastを指定する

以降、実装方法の詳細を解説していきます。

ベースとなる画面の作成

 まずベースとなる画面を作成します。UICollectionViewController UICollectionViewFlowLayoutを使いヘッダーとセルを表示します。UICollectionViewFlowLayoutscrollDirectionプロパティは.horizontalを指定し横スクロールさせます。ヘッダーとセルのサイズ、セクションとセルのマージンは任意の値を指定します。

ViewController.swift

override func viewDidLoad() {
    ... (省略)
    flowLayout.scrollDirection = .horizontal
    flowLayout.itemSize = cellSize
    flowLayout.minimumInteritemSpacing = collectionView.bounds.height
    flowLayout.minimumLineSpacing = 20
    flowLayout.sectionInset = UIEdgeInsets(top: 0, left: 40, bottom: 0, right: 40)
}
    
... (以下 UICollectionViewDataSource は省略)

f:id:nkmrh:20190807174701g:plain

ここまではUICollectionViewControllerの基本的な実装です。

isPagingEnabled プロパティ

 ページングスクロールさせたい場合、最初に試したくなるのがUIScrollViewisPagingEnabledプロパティをtrueに指定することですが、この方法ではセルが画面の中途半端な位置に表示されてしまいます。これはcollectionViewの横幅の単位でスクロールされるためです。この方法でもセルの幅をcollectionViewの横幅と同じ値に設定し、セルのマージンを0に指定することで画面中央にセルを表示させることが可能です。しかし、今回はセルの幅と異なる幅のヘッダーも表示させる必要があるためこの方法では実現できません。

f:id:nkmrh:20190807174812g:plain

セルを画面中央に表示する

 そこでUICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドします。このメソッドはユーザーが画面をスクロールして指を離した後に呼ばれます。メソッドの戻り値はスクロールアニメーションが終わった後のcollectionViewcontentOffsetの値となります。このメソッドを以下のように実装します。

FlowLayout.swift

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return proposedContentOffset }

    // sectionInset を考慮して表示領域を拡大する
    let expansionMargin = sectionInset.left + sectionInset.right
    let expandedVisibleRect = CGRect(x: collectionView.contentOffset.x - expansionMargin,
                                      y: 0,
                                      width: collectionView.bounds.width + (expansionMargin * 2),
                                      height: collectionView.bounds.height)

    // 表示領域の layoutAttributes を取得し、X座標でソートする
    guard let targetAttributes = layoutAttributesForElements(in: expandedVisibleRect)?
        .sorted(by: { $0.frame.minX < $1.frame.minX }) else { return proposedContentOffset }

    let nextAttributes: UICollectionViewLayoutAttributes?
    if velocity.x == 0 {
        // スワイプせずに指を離した場合は、画面中央から一番近い要素を取得する
        nextAttributes = layoutAttributesForNearbyCenterX(in: targetAttributes, collectionView: collectionView)
    } else if velocity.x > 0 {
        // 左スワイプの場合は、最後の要素を取得する
        nextAttributes = targetAttributes.last
    } else {
        // 右スワイプの場合は、先頭の要素を取得する
        nextAttributes = targetAttributes.first
    }
    guard let attributes = nextAttributes else { return proposedContentOffset }

    if attributes.representedElementKind == UICollectionView.elementKindSectionHeader {
        // ヘッダーの場合は先頭の座標を返す
        return CGPoint(x: 0, y: collectionView.contentOffset.y)
    } else {
        // 画面左端からセルのマージンを引いた座標を返して画面中央に表示されるようにする
        let cellLeftMargin = (collectionView.bounds.width - attributes.bounds.width) * 0.5
        return CGPoint(x: attributes.frame.minX - cellLeftMargin, y: collectionView.contentOffset.y)
    }
}

// 画面中央に一番近いセルの attributes を取得する
private func layoutAttributesForNearbyCenterX(in attributes: [UICollectionViewLayoutAttributes], collectionView: UICollectionView) -> UICollectionViewLayoutAttributes? {
    ... (省略)
}

velocity引数をもとにユーザーが左右どちらにスワイプしたか、またはスワイプせずに指を離したかの判定をしています。スワイプしていない場合は画面中央に近いUICollectionViewLyaoutAttributesをもとに座標を計算します。ユーザーが左にスワイプした場合は取得したUICollectionViewLayoutAttributes配列の最後の要素、右スワイプの場合は最初の要素をもとに座標を計算します。これでセルを画面中央に表示できます。

f:id:nkmrh:20190807174938g:plain

 セルの位置は期待通りになりましたが、スクロールの速度が緩やかなのでスナップが効いた動きにします。UIScrollViewdecelerationRateプロパティを.fastに指定するとスクロールの減速が通常より速くなりスナップの効いた動作となります。

collectionView.decelerationRate = .fast

f:id:nkmrh:20190807175325g:plain

1ページずつのページング

 これで完成したように見えますが、スクロールの仕方によっては1ページずつではなくページを飛ばしてスクロールしてしまうことがあります。これを防ぎたい場合targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドで取得しているUICollectionViewLayoutAttributes配列の取得タイミングを、スクロールする直前に変更し、それをもとに座標を計算することで解決できます。以下のように実装を追加・変更します。

FlowLayout.swift

... (省略)

private var layoutAttributesForPaging: [UICollectionViewLayoutAttributes]?

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return proposedContentOffset }
    guard let targetAttributes = layoutAttributesForPaging else { return proposedContentOffset }

    ... (省略)
}

... (省略)

// UIScrollViewDelegate scrollViewWillBeginDragging から呼ぶ
func prepareForPaging() {
    // 1ページずつページングさせるために、あらかじめ表示されている attributes の配列を取得しておく
    guard let collectionView = collectionView else { return }
    let expansionMargin = sectionInset.left + sectionInset.right
    let expandedVisibleRect = CGRect(x: collectionView.contentOffset.x - expansionMargin,
                                      y: 0,
                                      width: collectionView.bounds.width + (expansionMargin * 2),
                                      height: collectionView.bounds.height)
    layoutAttributesForPaging = layoutAttributesForElements(in: expandedVisibleRect)?.sorted { $0.frame.minX < $1.frame.minX }
}
ViewController.swift

... (省略)

extension ViewController: UICollectionViewDelegateFlowLayout {
    override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        let collectionView = scrollView as! UICollectionView
        (collectionView.collectionViewLayout as! FlowLayout).prepareForPaging()
    }
    ... (省略)
}

UIScrollViewDelegatescrollViewWillBeginDragging(_:)が呼ばれたタイミングでprepareForPaging()メソッドを呼びます。このメソッドでスクロール直前のUICollectionViewLayoutAttributes配列をlayoutAttributesForPagingプロパティに保存しておき、targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドの中で保存した配列をもとに座標を計算するように変更します。これで1ページずつページングできるようになりました。

おわりに

 本記事では UICollectionView でページングスクロールを実装する方法を解説しました。このようなUIを実装することは稀だとは思いますが、何かの参考になれば幸いです。

サンプルプロジェクトはこちらhttps://github.com/nkmrh/PagingCollectionViewです。

料理のやり方を1から学んでみたいという方は、ぜひ「たべドリ」を使ってみて下さい!!

apps.apple.com

クックパッドでは新規サービス開発もやりたい、UI・UXにこだわりたいエンジニア・UXエンジニアを募集しています!!!

info.cookpad.com

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/