UIProgressViewを使ってインスタのストーリーみたいなUIを作ってみる

こんにちは。
今日は山の日⛰ですが、天気が悪くて予定していた山登りを断念しました。台風〜〜〜😭🌪!!

今回はインスタグラムのストーリーのようなUIを作ってみたので実装方法のご紹介です。
以下のような画面で、画面表示とともにプログレスバーがアニメーションを開始し、アニメーションが完了したら次の画像を表示します。
ユーザーが左右にスワイプした時には、表示されている画像と同じ位置のプログレスバーがアニメーションを開始します。

f:id:muchan611:20210808155312g:plain:w300

実装方針を簡単に説明すると、

  • UIScrollViewの上に画像を表示して左右にスクロールできるようにする
  • UIProgressViewで実装したプログレスバーの進行状態をタイマー(CADisplayLink)を使って更新し、アニメーションしているように見せる
  • ユーザーが画面をスクロールしたり、プログレスバーのアニメーションが完了したら、画像・プログレスバーの位置を切り替える

といった内容になります。以下で詳しく説明します。
いつもの通りコードはこちらにあげています。

画面(ProgressBarViewController.swift)の実装

UIの実装

  • UIScrollViewをviewに追加して画面いっぱいに表示する
    • 表示したい画像の分だけUIImageViewを生成・追加し、画像の数に応じてcontentSizeを変える
    • UIScrollViewのisPagingEnabledをtrueにすることで適切な位置でスクロールビューが止まるようにする
override func viewDidLoad() {
    super.viewDidLoad()
    {..省略..}
    containerScrollView.isPagingEnabled = true
    containerScrollView.showsVerticalScrollIndicator = false
    containerScrollView.showsHorizontalScrollIndicator = false
    containerScrollView.delegate = self
    view.addSubview(containerScrollView)
    containerScrollView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        containerScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        containerScrollView.topAnchor.constraint(equalTo: view.topAnchor),
        containerScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        containerScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ])
    {..省略..}
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    containerScrollView.contentSize = CGSize(width: view.bounds.width * CGFloat(imageIDs.count), height: view.bounds.height)
    for (i, imageView) in imageViews.enumerated() {
        let frame = CGRect(x: CGFloat(i) * view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height)
        imageView.frame = frame
    }
    scrollContentToCurrentIndex()
    runAnimationOfCurrentIndex()
}
imageIDs.enumerated().forEach { index, id in
    let imageView = UIImageView(frame: .init(x: 0, y: 0, width: 300, height: 200))
    containerScrollView.addSubview(imageView)
    imageView.image = UIImage(named: "\(id).jpg")
    imageView.contentMode = .scaleAspectFit
    imageView.clipsToBounds = true
    imageViews.append(imageView)
    let progressBar = ProgressBar(with: index)
    progressBar.delegate = self
    progressBarStackView.addArrangedSubview(progressBar)
}

ユーザーアクションやアニメーション終了時の動作

  • 左右にスクロールした場合
    • スクロールの向きを計算して、表示する画像の位置を更新する
    • また、stackViewに含まれるプログレスバーのアニメーションをリセットして、画像位置に対応したプログレスバーをアクティブにする
extension ProgressBarViewController: UIScrollViewDelegate {
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let currentPageIndex = Int((scrollView.contentOffset.x + (0.5 * scrollView.bounds.width)) / scrollView.bounds.width)
        if currentPageIndex == currentIndex { return }

        let isMovingRight = scrollView.panGestureRecognizer.translation(in: scrollView.superview).x < 0
        if isMovingRight {
            if currentIndex == (imageIDs.count - 1) {
                return
            } else {
                currentIndex = currentPageIndex
            }
        } else {
            if currentIndex == 0 {
                currentIndex = 0
            } else {
                currentIndex = currentPageIndex
            }
        }
        progressBarStackView.arrangedSubviews.enumerated().forEach { index, subview in
            if let bar = subview as? ProgressBar {
                bar.pauseAnimation()
                bar.reset()
                if index < currentIndex {
                    bar.fill()
                }
            }
        }
        runAnimationOfCurrentIndex()
    }
}
  • プログレスバーのアニメーションが終了した場合
    • 表示する画像の位置を更新して、アニメーションをリセットしたりscrollViewを適切な位置に移動する
extension ProgressBarViewController: ProgressBarDelegate {
    func didCompleteAnimation(index: Int) {
        if index < (imageIDs.count - 1) {
            currentIndex = index + 1
        } else {
            progressBarStackView.arrangedSubviews.forEach { subview in
                if let bar = subview as? ProgressBar {
                    bar.reset()
                }
            }
            currentIndex = 0
        }
        runAnimationOfCurrentIndex()
        scrollContentToCurrentIndex()
    }
}

プログレスバー(ProgressBar.swift)の実装

  • UIProgressViewでプログレスバーを実装する
  • CADisplayLinkという実行タイミングが画面のリフレッシュレートと同期するタイマーオブジェクトを使って、プログレスバーの表示を更新する
    • displayLinkから前回リフレッシュした日時が取得できるので、その差分から位置を割り出して決定する
override func willMove(toWindow newWindow: UIWindow?) {
    super.willMove(toWindow: newWindow)
    displayLink?.invalidate()
    displayLink = nil
    if newWindow != nil {
        let displayLink = CADisplayLink(target: self, selector: #selector(update))
        displayLink.add(to: .main, forMode: .common)
        self.displayLink = displayLink
    }
}


@objc private func update(_ displayLink: CADisplayLink) {
    if isPaused { return }
    if let lastTimeStamp = lastTimeStamp {
        currentTime += displayLink.timestamp - lastTimeStamp
    } else {
        currentTime = 0.0
    }
    lastTimeStamp = displayLink.timestamp
    progressView.progress = min(max(0.0, Float(currentTime / duration)), 1.0)

    if progressView.progress >= 1.0 {
        delegate?.didCompleteAnimation(index: index)
        isPaused = true
    }
}

さいごに

UIScrollViewの上に画像を表示し、CADisplayLinkとUIProgressViewを使ってプログレスバーのアニメーションを実現してみました。

ストーリーのように画面タップでアニメーションを止めたり、左右タップで画像を切り替えるような実装は入れていませんが、
今回の実装にはアニメーションの停止や画像の切り替えの処理も含んでいるので、それらの機能を追加することは難しくないかなと思います。

半分自分の備忘録として書いていますが、どなたかの参考になればうれしいです📝(Core Animationを使うなど、もっと良い実装方法もありそうですが💭💭)

ではでは〜〜👋

iOSアプリ 環境によって参照する値を変える

定期的にあれどうやるんだっけ?となる、備忘録メモシリーズ📝

production・staging・devなどの環境毎に参照する値を変えたい時に行うXcodeの設定や対処方法は下記の通り。

1. Build Settings > + > Add User-Defined Setting から新しい項目を追加

f:id:muchan611:20210619171045p:plain:w500

2. 各環境ごとの値を設定

f:id:muchan611:20210619171113p:plain:w500

3. Info.plist に適当な項目を追加し、そのValueに先ほど追加した値を$({value})の形で追加

f:id:muchan611:20210619171130p:plain:w500

4. 実装時にはKeyを指定して値を読み込む

guard let let apiBaseURLString = Bundle.main.object(forInfoDictionaryKey: "kAPIBaseURL") as? String else { return }

iOS14.5 IDFAを扱っていないつもりだったけどApp Tracking Transparencyの文脈でリジェクトされてしまった🙄

iOS14.5から広告トラッキングなどの実装を入れているアプリについては、AppTrackingTransparencyフレームワークを通じたユーザーからの許可が必須なりましたね。
個人開発のアプリで意図的にトラッキングをしていないにも関わらす、リジェクトされてしまったので、この記事はその時の対応備忘録です。

三行まとめ

  • アプリ内で意図的にユーザーをトラッキングしてないのにリジェクトされた
  • 原因はサードパーティSDK内でIDFAを取得していたことと、App Store Connectのプライバシー設定が正しくないことだった
  • 原因と思われるFirebaseやFacebookSDKをアップデート・置き換え・設定の変更を実施したり、App Store Connectのプライバシー設定を変更したりすることで審査を通過した

アプリでユーザーをトラッキングしてないのにリジェクトされた

個人開発のアプリではまだ広告を出しておらずユーザーをトラッキングする実装を入れていませんでした。しかし、2021年5月頭に出した審査でリジェクトされてしまいました。

リジェクト理由は下記の通りで、トラッキング前に許諾をリクエストしていないことと、App Store ConnectのAppのプライバシー設定が適切でないことが指摘されていました。

対応としては、トラッキングの機能を取り除くか、トラッキングが必要ならAppTrackingTransparencyフレームワークを通して許諾を取ること、そしてApp Store ConnectのAppのプライバシー設定を適切な内容に見直すこと等が求められました。

Guideline 5.1.2 - Legal - Privacy - Data Use and Sharing


We noticed you do not use App Tracking Transparency to request the user's permission before tracking their activity across apps and websites. The app privacy information you provided in App Store Connect indicates you collect data in order to track the user, including Product Interaction, Crash Data, User ID, Performance Data, and Other Diagnostic Data.

Starting with iOS 14.5, apps on the App Store need to receive the user’s permission through the AppTrackingTransparency framework before collecting data used to track them. This requirement protects the privacy of App Store users.

Next Steps

Here are two ways to resolve this issue:

- You can remove the tracking functionality from your app and update your app privacy information in App Store Connect.
- If you decide to continue tracking users, you must implement App Tracking Transparency and request permission before collecting data used to track the user or device. When you resubmit, indicate in the Review Notes where the permission request is located.

Resources

- See Frequently Asked Questions about the new requirements for apps that track users.
- Learn more about designing appropriate permission requests.

そもそもトラッキングとは?

なぜユーザーをトラッキングしていないのに、リジェクトされてしまったのでしょう。
ラッキングの定義についてAppleのドキュメントには、
「トラッキングとは、自分のAppで収集したユーザーやデバイスに関するデータを、ターゲット広告や広告効果測定を目的として、他社のApp、Webサイト、またはオフラインのプロパティから収集されたユーザーやデバイスに関するデータに紐付ける行為を指します」
と書かれていました。
ターゲット広告や広告効果測定を目的としていることがポイントですね。

また注目したいのが、 自分のAppで収集したユーザーデータを、他のデベロッパのAppで収集されたユーザーデータと組み合わせてターゲット広告の表示や広告効率測定を行うサードパーティのSDKをAppに組み込むこと(これら以外の目的でそのSDKを使用する場合も含む)。たとえば、アナリティクスのSDKによって自分のAppで収集したデータを、他のデベロッパのAppがターゲット広告を表示するために利用することがこれに該当します。 という説明です。

つまり、自分のアプリではトラッキングをしていなくても、使っているSDKの中で広告ID(IDFA)を取得していると、これもトラッキングとしてみなされるようです。

そのため、今回はSDK側のトラッキングの可能性とトラッキングの可能性がある場合はそれを停止するための調査・対応を行いました。

原因と思われるサードパーティSDKのアップデートや設定の変更

Facebook SDKの設定変更

今回のアプリではFacebookログインを実装しているため、FBSDKLoginKitを使っています。FBSDKLoginKitやそれに付随してインストールされるSDKの中ではIDFAを取得している箇所があったため以下の対応を行ました。

まず、広告IDの収集に関する設定FacebookAdvertiserIDCollectionEnabledをfalseにしました。

Disable Collection of Advertiser IDs To disable collection of advertiser-id, open the application's .plist as code in Xcode and add the following XML to the property dictionary:

<key>FacebookAdvertiserIDCollectionEnabled</key>
<false/>

https://developers.facebook.com/docs/app-events/getting-started-app-events-ios?locale=ja_JP

また、ドキュメントにしたがってDeveloperサイトからIDFAの収集をオフにしました。

IDFAを収集せずにApp Eventsをトラッキングする場合は、アプリダッシュボードの詳細設定セクションでIDFAの収集を無効にします。

https://developers.facebook.com/docs/ios/troubleshooting?locale=ja_JP#faq_1717948608473924

f:id:muchan611:20210513020427p:plain

Firebase/AnalyticsFirebase/AnalyticsWithoutAdIdSupportに置き換える

Facebookの設定だけでは審査に通らなかったので、FirebaseのSDKについても調査したところ、Firebase/AnalyticsSDKを利用していると審査をリジェクトされるといった事例をissueから発見しました。
https://github.com/firebase/quickstart-unity/issues/994#issuecomment-825922406
さらにfirebaseのリリースノートを確認したところ、この問題に対応したFirebase/AnalyticsWithoutAdIdSupportがリリースされていたため、Firebase/AnalyticsFirebase/AnalyticsWithoutAdIdSupportに置き換える作業をしました。

Added Firebase/AnalyticsWithoutAdIdSupport subspec for developers of apps that aren’t using AdSupport or subject to AppTrackingTransparency.

https://firebase.google.com/support/release-notes/ios?hl=ja#version_7110-april_20_2021

App Store ConnectのAppのプライバシー設定を変更

App Store Connect > 一般 > Appのプライバシーから、ユーザートラッキングデータの追加・変更ができます。
「ユーザートラッキングに使用されるデータ」に該当するものがある場合、AppStoreで以下のように表示がされます。(ID、使用状況データなどの各項目はアプリの設定によって異なります)

リジェクトされたアプリでは、これまで、「ユーザートラッキングに使用されるデータ」にID・使用状況データ・診断データなどを含んでいましたが、今回は上記対応によってSDK側でのトラッキングも取りやめたため、「ユーザートラッキングに使用されるデータ」の項目自体をApp Store Connectのプライバシー設定から取り除きました。

以上の対応を行うことで、無事審査に通過することができました。

まとめ

ここまで述べたように、自分のアプリでトラッキングをしていなくても利用しているSDKの中でIDFAを取得していて、許諾を取っていないと、アプリがリジェクトされてしまうようです。
今回はFirebaseの事例を紹介しましたが、マーケティングツールで有名なAdjustのSDKについても、IDFA取得が理由でリジェクトされる事例が報告されており、Adjust側でSDKの修正を行っているようです。
https://www.adjust.com/ja/blog/How-to-be-strategic-about-attribution-after-iOS145/
こういった場合は各SDKリポジトリで既にissueが立っていることも多いかと思いますので、困った際には原因になっていそうなSDKのissueを調べてみると良いかもしれません。

そして意外と雰囲気で設定してしまうことのある(そんなことない?)プライバシー設定は、よくよくAppleのドキュメントと照らし合わせると、正しく設定できていないこともあります。こちらは審査に関係なく修正が可能ですので、改めて見直してみると良いかもしれません。

だいぶ調査に時間を要したので、気持ちがこもって少し長い記事になってしまいましたが、似たような問題を抱えた方々の参考になるといいなぁ💭💭と思っています。

ではでは🤗🤗

コンテンツの潰れにくさの設定

定期的にあれどうやるんだっけ?となる、備忘録メモシリーズ📝

setContentCompressionResistancePriority(_:for:)

https://developer.apple.com/documentation/uikit/uiview/1622526-setcontentcompressionresistancep

コンテンツの圧縮抵抗の優先度を指定する関数。つまりコンテンツの潰れにくさを指定できる。 例えば、「木曜日」と表示したいラベルが以下のように「木...」潰れてしまっている場合、こちらの優先度を変えると正しく表示される。

dateLabel.setContentCompressionResistancePriority(UILayoutPriority(751), for: .horizontal) というように、指定すると正しく表示される。

UILayoutPriorityにはdefaultHeightdefaultLowがあるが、それぞれのrawValueは750と250。「木曜日」の左にあるViewのcontentCompressionResistancePriorityデフォルト値が750だったので、751を指定すると正しく動く。

😋 😋

MKMapSnapshotterを使ってCell内に地図を表示しピンを立てる

こんにちは。
今日はハロウィンということで、美容室に行ったらお菓子をいただきました(ヤッター)🍭🎃

最近MKMapSnapshotterについて調べることがあったので、今回はその備忘録も兼ねて記事を書いてみます。 MKMapSnapshotterはMapKit内に用意されたクラスで、地図のキャプチャ画像を生成できる仕組みです。

作りたいもの

今回は、以下のようにCollectionViewのCellの中に地図を表示し、タップしたら地図画面に遷移させる、という処理を実装してみました。 (サンプルコードにて↓の実装を公開しています。)

f:id:muchan611:20201031143221p:plain:w250 f:id:muchan611:20201031143246p:plain:w250

実は、CollectionViewやTableViewのCellに地図を表示したい際に、MKMapViewをそのまま使ってしまうと、動作がとても重くなってしまいます。ひどいと画面が固まったかのように見えます 💭💭
そこで、地図をキャプチャして画像にしてくれるMKMapSnapshotterを使うと、Viewは画像を表示するだけでよくなりスムーズに描画できるようになるのです。

MKMapSnapshotterの使い方

MKMapSnapshotterを使って画像を取得する実装はとても簡単で、座標や画像のサイズなどのオプションを指定しMKMapSnapshotterから得た画像を対象のUIImageViewにセットするだけです。

let location = CLLocationCoordinate2D(latitude: item.lat, longitude: item.lon)
let span = MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
let options = MKMapSnapshotter.Options()
options.size = CGSize(width: 400, height: 200)
options.region = MKCoordinateRegion(center: location, span: span)
options.scale = UIScreen.main.scale
options.mapType = .standard

let snapshotter = MKMapSnapshotter(options: options)
snapshotter.start(completionHandler: { [weak self] (snapshot, _) in
    self?.mapImageView.image = snapshot?.image
})

今回はその中心にピン画像も表示したかったので、MKPinAnnotationViewを使ってピンを表示する実装も追加しました。 サンプルコードはこのあたり

snapshotter.start(completionHandler: { [weak self] (snapshot, _) in
    guard let snapshot = snapshot else { return }
    
    let image = UIGraphicsImageRenderer(size: options.size).image { _ in
        snapshot.image.draw(at: .zero)

        let pinView = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil)
        let pinImage = UIImage(named: "pin")!
        pinView.image = pinImage

        var point = snapshot.point(for: location)
        point.x -= pinView.bounds.width / 2
        point.y -= pinView.bounds.height / 2
        point.x += pinView.centerOffset.x
        point.y += pinView.centerOffset.y
        pinImage.draw(at: point)
    }

    self?.mapImageView.image = image
})

MKMapViewで使うMKAnnotationViewの画像を変える方法

画面全体に地図を表示する実装もとても簡単でした。サンプルコードはこちら。 ピンをオリジナルの画像にしたい場合は、MKMapViewDelegateの mapView(_:viewFor:) 内で表示したい画像に差し替える必要がありそうでした。

extension MapKitSampleMapViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        let myAnnotation = MKAnnotationView(annotation: annotation, reuseIdentifier: "pin")
        myAnnotation.image = UIImage(named: "pin")!
        myAnnotation.annotation = annotation
        return myAnnotation
    }
}



MapBoxを使った似たような実装を、プライベートで開発しているYamarii(ヤマリー)の一部画面で取り入れています。 良かったら覗いてみてください〜 👀 😊

Xibを使ったScrollViewの実装

こんにちは。
WWDCで発表された新OS BigSurが、どうもピクサーに聞こえてしまう今日この頃です。

今回は、「Xcode11における、Xibを使ってUISrollViewを実装する時の制約のかけ方」についての備忘録です。コードには制約を記述せず、Xibのみで実現しようした際に、思ったより手間取ったので手順をメモ。

いつものごとく、サンプル実装をこちらにのせています

やりたかったこと

縦スクロールできるScrollViewが全画面で表示されるViewControllerを、Xibを使って実装したかった。動きとしては👇です。

f:id:muchan611:20200705112154g:plain

ポイント

Xcode11からは、ScrollViewにContent Layout GuideFrame Layout Guideが追加されました。
Content Layout GuideはScrollViewの中身の領域、Frame Layout GuideはScrollView自体のframeについてのlayout guideです。

そのため、中に表示するものの制約はContent Layout Guideに紐づけて、ScrollView自体のframeに関する制約はFrame Layout Guideに紐づける必要 があります。

ここのポイントを理解すると実装はそれほど難しくありませんでした。
具体的な手順を書いていきます。

手順

まず、Xibとクラスを紐付けるため、File's Ownerを対象のクラス指定します。
その後、File's OwnerとViewを紐づけます。

f:id:muchan611:20200705111454p:plain f:id:muchan611:20200705111514p:plain


その後、ViewにUIScrollView を追加してSafeAreaに対しての制約(上下左右0)を追加します。

f:id:muchan611:20200705111533p:plain


ScrollViewの中にViewを追加(ここではContent Viewという名前に変更)し、Content Layout Guideに対して制約(上下左右0)を追加します。
Content Viewは、ScrollViewの中に表示するものなのでContent Layout Guideに紐付けます。

f:id:muchan611:20200705111612p:plain


そして、Content View内に高さを指定したOrange ViewとBrown Viewを追加します。

最後に、Content ViewのwidthとFrame Layout Guideのwidthに紐づけます。
高さはContent View内に存在するOrange ViewとBrown Viewの高さに依存するため、ここでは指定しません。

f:id:muchan611:20200705111639p:plain


これで完成です🎉

「中に表示するものの制約はContent Layout Guideに紐づけて、ScrollView自体のframeに関する制約はFrame Layout Guideに紐づける」ということを覚えておけば、迷わず実装ができそうです。

Firebaseを利用したプッシュ通知のカスタムデータの解釈(その2)

こちらの記事の続きです。

前回は、Firebaseを利用してプッシュ通知を実装する場合、どのような形式でデータが送られてくるのか、またその内容はどこで受け取れるのか、という内容を書きました。
今回は、受け取ったデータをどういった構造体に変換して保持したのか、について書きたいと思います。 この方針が必ず正しいという訳ではなく、あくまでひとつの考え方として紹介します。

前回の記事にも書いた通り、実現したいことは、
受信したプッシュ通知をタップした時に任意の画面へ遷移させること です。
そのため、遷移先によって必要な情報が異なります。 つまり、プッシュ通知のペイロードには様々な値が含まれ、それらはほぼ全てnullableになるということです。

仮に、それらをひとつのstructで管理しようとした場合、
nullableの各値を利用する際に毎回オプショナルバインディングを行う必要があり、処理が非常に煩雑になってしまいます。

そこで今回は、遷移先ごとに異なるstructを用意し、必要な値は必ず存在するように しました。
存在すべきはずの値が含まれていなければエラーを起こし、エラーが起きた場合は処理をスキップしてどこにも遷移しないようにします。

具体的に以下で説明していきます。

前提

検索ができるECアプリを例に考えてみることにします。
通知をタップすると商品検索結果一覧、商品詳細の画面へ遷移する場合があるとしましょう。それぞれ、遷移に必要な値は以下の通りです。

  • 商品検索結果一覧 への遷移に必要な値
    • 検索キーワード
    • 並び順
  • 商品詳細 への遷移に必要な値
    • 商品ID

そして、プッシュ通知のペイロード形式は以下を想定します。 遷移先を識別するための値 transition_type は必ず存在し、それ以外は遷移先によって含まれたり含まれなかったりします。

{
  "transition_type" : "{遷移先を識別するの値}", // -> search_results_list or product_detail が入る
  "search_keyword" : "{検索キーワード}", // nullable
  "order" : "{並び順}", // nullable
  "product_id" : "{商品ID}" // nullable
}

遷移先を扱う構造体の実装手順

遷移先を扱うための構造体は、こちらのソースコードに載せていますが、以下3つの手順で実現しました。

  • A. プッシュ通知に含まれるペイロードをデコードするためのstructを用意する
  • B. 商品検索結果一覧 商品詳細 用のstructを用意する
  • C. transition_type によって生成するstructを出し分け、enumでそれらを管理する

AはAPIをから得たJSONをパースするときと同じような一般的な処理ですが、B・Cは 遷移先ごとに異なるstructを用意し、必要な値は必ず存在するようにするために必要な処理です。

A. プッシュ通知に含まれるペイロードをデコードするためのstructを用意する

まずはペイロードのデコードです。
データをデコードするための一般的な実装で、ペイロードを扱うモデルであるPayloadDataEntityをDecodableに準拠させます。 また、ペイロードはdictionaryの形式で送られてくるので、そのdictionaryからモデルを生成できるようにします。

対象コード

struct RemoteNotification {
  struct PayloadDataEntity: Decodable {
      enum CodingKeys: String, CodingKey {
          case transitionType = "transition_type"
          case searchKeyword = "post_id"
          case order
          case productID = "product_id"
      }

      let transitionType: String
      let searchKeyword: String?
      let order: String?
      let productID: String?

      init(dictionary: [String: AnyObject]) throws {
          self = try JSONDecoder().decode(PayloadDataEntity.self, from: JSONSerialization.data(withJSONObject: dictionary))
      }
  }  
}

B. 商品検索結果一覧 商品詳細 用のstructを用意する

各遷移時に必ず必要な値がnullableにならないよう、遷移先ごとにstructを用意し、必要な値が必ず存在することを保証できるようにします。

対象コード

struct RemoteNotification {
  struct PayloadDataEntity: Decodable {..省略..}

  struct SearchResultsListEntity {
      let searchKeyword: String
      let order: String
  }

  struct ProductDetailEntity {
      let productID: String
  }
}

C. transition_type によって生成するstructを出し分け、enumでそれらを管理する

transitionTypeによって、SearchResultsListEntity・ProductDetailEntity どちらの構造体を生成するか決めます。また、それぞれに必要な値が不足している場合はエラーを起こすようにします。

そして、生成したentityを詰めるためのenumである NotificationType に変換しておきます。 Swiftのenumは、Associated Valuesを使う事でcaseの値に付加情報を保持できるようになるため、その仕組みを利用します。
利用する際のコードを後述しますが、enumに変換することでその後の処理がシンプルに書きやすくなります。

対象コード

struct RemoteNotification {
  (..上部省略..)

  // ペイロードに含まれる transition_type をこのenumに変換してから扱います
  enum TransitionType: String {
      case searchResultsList = "search_results_list"
      case productDetail = "product_detail"
  }

  // entityを詰めるためのenum
  enum NotificationType {
      case searchResultsList(SearchResultsListEntity)
      case productDetail(ProductDetailEntity)
  }

  enum NotificationError: Error {
      case invalidType
      case invalidData
  }

  // データを各entityを含んだNotificationTypeに変換する
  static func convert(from data: [String: AnyObject]) throws -> NotificationType {
      let data = try PayloadDataEntity(dictionary: data)
      // 先にtransition_typeをTransitionTypeに変換し扱いやすくする
      guard let transitionType = TransitionType(rawValue: data.transitionType) else {
        throw NotificationError.invalidType // 未知のタイプの場合はエラーを起こす
      }
      switch transitionType {
      case .searchResultsList:
          guard let searchKeyword = data.searchKeyword, let order = data.order else {
            throw NotificationError.invalidData // パラメータの場合はエラーを起こす
          }
          let entity = SearchResultsListEntity(searchKeyword: searchKeyword, order: order)
          return .searchResultsList(entity)
      case .productDetail:
          guard let productID = data.productID else {
            throw NotificationError.invalidData // パラメータの場合はエラーを起こす
          }
          let entity = ProductDetailEntity(productID: productID)
          return .productDetail(entity)
      }
  }  
}

利用するときは...

これらを実際に利用する際は、以下のように呼び出す事ができます。
NotificationTypeとしてenumで管理することでswitch文が利用できるため、実装の見通しがよくなります。

class PushNotificationRouter {
  // 画面遷移する関数の例(notificationPayloadにプッシュ通知のペイロードが渡ってくる想定)
  static func openTargetViewIfPossible(with notificationPayload: [AnyHashable: Any], navigationController: UINavigationController) {
      guard let data = notificationPayload as? [String: AnyObject] else { return }

      do {
          let notificationType: RemoteNotification.NotificationType = try RemoteNotification.convert(from: data)
          let viewController: UIViewController
          switch notificationType { // notificationType によって遷移先を変える
          case .searchResultsList(let entity):
              viewController = SearchResultsListViewController(keyword: entity.searchKeyword, order: entity.order)
          case .productDetail(let entity):
              viewController = ProductDetailViewController(id: entity.postID)
          }
          navigationController.pushViewController(viewController, animated: true)
      } catch {
          // エラー時の処理
          print(error)
          return
      }
  }  
}

前の記事で説明したように、例えばアプリが未起動状態でプッシュ通知をタップして開いた場合に遷移させたければ、AppDelegateを以下のように記述すれば遷移できるようになります。(処理がちょっと雑ですが...)

class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
      if let data = launchOptions?[.remoteNotification] as? [AnyHashable: Any], let rootViewController = window?.rootViewController {
        let navigationController = UINavigationController(rootViewController: rootViewController)
        PushNotificationRouter.openTargetViewIfPossible(with: data, navigationController: navigationController)
      }
      return true
  }
}

まとめ

ということで、通知タップ時の遷移先を管理するための構造体について紹介しました。

遷移先ごとに異なるstructを用意し、必要な値は必ず存在するようにすること、そしてそれらをenumとそのAssociated Valuesで管理することで、利用する際にオプショナル・バインディングの記述量も少なくて済みスッキリ見通しが良くなります。

他にも良い方法はありそうですが、ひとまず私なりのやり方をブログに書き起こしてみました。
同じような課題の解決策を探している人の参考になればいいなと思います😊

ではでは〜👋👋