ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftUI向けのStoreKitについて
Apple StoreのプロダクトメタデータとXcode Previewsを使って、わずか数行のコードだけでアプリ内課金をアプリに追加する方法について紹介します。また、StoreKitの新しいUI要素について確認し、いかに簡単にマーチャンダイジングを実装したり、ユーザーが情報に基づいて判断しやすいように、サブスクリプションを提示したりできるかについて解説します。
リソース
関連ビデオ
WWDC23
WWDC22
WWDC21
WWDC20
-
ダウンロード
「SwiftUI向けのStoreKitについて」 にようこそ 私はStoreKitチームのエンジニア Gregです アプリ内課金のマーチャンダイジング についてお話しします アプリ内課金のマーチャンダイジングとは 要するに商品のオファーを提示して ユーザーが支払いを完了できる 方法を提供することです
マーチャンダイジングでは最初に 販売する商品のデータと ユーザーのステータスを獲得します 例えば ユーザーは既に私の 非消耗型の商品を持っているかどうか すでにサブスクリプション登録しているか
といったデータをあわせて インターフェイスを作成し ユーザーに向けて商品を提示し インタラクションを行なって 商品の購入につなげます 一見小さなこの赤い長方形ですが 実はインターフェイスを作るための 膨大な作業がすべて入っています
ユーザーインターフェイスの作成には 実に多くの側面があり 様々な分野のスキルが必要です 次に ユーザーが商品の購入を決めます みなさんのアプリは購入のAPIを使って対応し 購入の結果としてインターフェイスを 更新する必要があります アプリにアプリ内課金を 追加したことがある方は 適切なマーチャンダイジングが 必要不可欠であることをご存知でしょう これらのステップすべてを抽象化して 単純かつパワフルなビューにできたら 素晴らしいと思いませんか? このビューは共通の機能をすべて処理し パラメータを取るので みなさんはアプリを完成させる 細かな点が設定できます ではマーチャンダイジングUI作成のために StoreKitが提供する パワフルなAPIの新セットを紹介します Xcode 15では StoreKitは 様々なSwiftUIビューを提供しており これによって宣言型アプリ内課金UIの 作成が容易になりました どのようなマーチャンダイジング体験を 望むのかを宣言するだけで システムが水面下で宣言内容を実行します
StoreViewとProductView及び SubscriptionStoreViewは マーチャンダイジングをこれまで以上に 加速してくれる新たなビューです これらのビューがApp Sotreからの データフローを抽象化して システム提供のUIを提示し アプリ内課金を表示します 既にお馴染みのSwiftUI APIを使って これらのビューがどのように みなさんのアプリと統合するかの カスタマイズも可能です
これらのビューはSwiftUIと同様に 全プラットフォームでサポートされ アプリ内課金のマーチャンダイジングは これまでになく簡単になり iPhone iPad Mac Apple Watch Apple TVのすべてが対象です
Backyard Birdsという新ゲームに アプリ内課金を加えて欲しいと 羽の生えたお友達が頼んできました
StoreKitの新しいビューがあるので もちろんこれを引き受けました
それではこれから Backyard Birdsでの 素晴らしいアプリ内課金体験を お見せします サンプルプロジェクトをダウンロードして 一緒に作業してみてください ここではXcode Previewsを使って SwiftUIビューを素早くイテレートします
今日はカバーする内容が非常に多いので StoreKitの構成ファイルは作成済みです ここに含まれるのは 私たちのアプリ内課金のメタデータで StoreKitでXcode Previewsを 使う際に必要です
みなさんが自身のアプリで始める時に役立つ 素晴らしいセッションがあります 「What's new in StoreKit Testing」 そして「Introducing StoreKit Testing in Xcode」です ではXcodeを始めましょう Backyard Birdsで売りたいのは 高級な鳥の餌で 例えば栄養ペレットですね 餌の購入後はそれを裏庭に置いておき 腹ぺこの鳥をもっと呼ぼうとします ではコードに移ってStoreKitが これらの商品のマーチャンダイジングに どう役立つか見ていきます
最初にBirdFoodShopというビューを作成し 鳥の餌をマーチャンダイズします
このビューを実行するファイルは もう作成済みです StoreKitを使ってビューを作成するには StoreKitとSwiftUIの両方を ファイルのトップにインポートします
次にここで鳥の餌のデータモデルを 得るためのクエリを宣言し これでストアの作成が容易になります
アプリにStoreViewを追加しているのは マーチャンダイジングのビューを 稼働させるのに最も迅速な方法だからです そこにStoreKit構成ファイルから 一群のプロダクトIDを提供する 必要がありますが それはbirdFoodモデルから得られます
この宣言が済むと マーチャンダイジングの ビューが機能しています StoreKitがApp Storeから すべてのプロダクトIDをロードし UIに表示してくれます 表示名や説明や価格 すべてApp Storeから直接得たもので App Store Connectや あるいはStoreKitの構成ファイルで みなさんが設定したものを使います StoreKitは下記のような細かくて かつ重要な考慮事項も扱えます 有効期限まで あるいはシステムメモリの 負荷が高い間はデータをキャッシュしたり スクリーンタイムではアプリ内課金が 無効であるかチェックしたりできます 以前 鳥のデザイナーが 個々の鳥の餌の商品に対して 装飾アイコンを送ってきました そのアイコンをストアビューに追加するのも 単にtrailing view builderを追加して アイコンを表示するSwiftUIビューに 渡すだけです
view builderはProduct値を パラメータとして捉え それを使って使用するアイコンを 決められます プロダクトIDを使って 私たちのアセットカタログから 正しいアイコンを見つけてくれる ヘルパービューを作成しました
それが済むと 更新されたプレビューが出て それぞれのプロダクトの アイコンが表示されます ストアビューがプロダクトIDやアイコンを 機能的でよくデザインされた ストアに変えてくれるので 稼働がとても楽になります ストアビューのパワフルな機能で 異なるプラットフォーム上でも プロダクトは自動で調整され iPadやMac そしてApple Watchでも 素晴らしいショップができています XcodeのターゲットをApple Watchに変え ショップのプレビューを見てみましょう
いいですね! Apple Watchでも鳥の餌が 売り始められそうです
独自の販売方法でプロダクトを 整理したいという要望は 多くのビジネスに共通しています
私たちの鳥デザイナーチームは 日々奮闘して 鳥の餌を陳列紹介するための 工夫を凝らしています ここではお買い得商品が 目立つように表示し 他の商品は棚に整理して並べます
これはStoreViewで得られる リスト様式のレイアウトとは異なりますが StoreKitはここでも役立ちます
さらに詳細なレイアウトには 新しいProductViewが使えます 実際 今見てきたStoreViewは その行を作るのに 同じProductViewを使います では新しいストアのための コンテナの宣言から始めましょう
栄養ペレットの箱が一番のお買い得品なので 他のプロダクトより目立つように 陳列したいと思います そのために 栄養ペレットの 箱のIDを提供して ProductViewを宣言します
StoreViewの時と同様に trailing closureを追加して 装飾アイコンを加えます 再び 先ほどのヘルパービューを使います
次に 他のフードアイテム用に 下にセクションを追加しましょう まずはお買い得品の後ろに 背景をつけてから…
それからヘッダーと 鳥の餌を棚に並べるために 私が作成したもう一つの ヘルパービューを加えます
この棚のヘルパービュー内で 個々の鳥の餌のプロダクトに 絵のアイコンをあわせて ProductViewを宣言できます
ショップの完成には もう一つだけ作業が必要です この栄養ペレットの箱が ユーザーの目を引くように 表示したいのですが 鳥のデザイナーは今のままの方が 見た目が良いと思っています 問題解決のためには 新しいproductViewStyle APIを使って 主力プロダクトのスタイルを 設定することができます Largeスタイルを選んで 非常に目立つようにします
StoreKitの新しいProductViewを使って 鳥の餌だけに特化したショップを わずか数分で作成しました
ProductViewのLargeスタイルでは 1つのview modifierを加えるだけで お買い得品を目立たせることができました
標準スタイルには3つあり 需要に合わせて選べます Compactは狭いスペースで より多くのプロダクトを表示できます 鳥の餌の商品棚では自動的に Regularスタイルが使われ Largeスタイルは目立たせたい場合の 表示に最適です
StoreViewはProductViewの インスタンスでできているので 同じproductViewStyle modifierを使って StoreViewのスタイルを変更できます
またカスタムスタイルを作成して ProductViewやStoreViewで 使うこともできます セッションでのちほどお見せします
ProductViewを使って 消耗型の鳥の餌をアプリ内課金で 販売できるようになりました 鳥の営業係はまだ不十分だと考え オファーを提供するように 要求してきたサブスクリプションが 熱烈なバードウォッチャー用の Backyard Birds Passです サブスクリプションUIはProductViewか StoreViewを使って作成できますが 新しいSubscriptionStoreViewは サブスクリプションに特化しています Xcodeに戻って一緒に作成しましょう まず初めに StoreKit設定で作成した 「Backyard Birds Pass」という サブスクリプショングループは 3レベルのサービスを提供します
このグループIDをメモしてください このあと必要になります
先ほど 私たちのパスショップ用に 新しいファイルを作ったので 早速SubscriptionStoreViewに 行ってみましょう
SubscriptionStoreViewで 最も迅速に稼働するには StoreKit構成ファイルか App Store Connectからの グループIDを提示します 私たちの環境には既に グループIDを追加しておいたので 環境プロパティを宣言するだけで アクセスでき その後 グループIDを提示して SubscriptionStoreViewを宣言します
StoreViewやProductViewと同様に SubscriptionStoreViewも 私たちの代わりにデータフローを管理し 異なるプランのオプションで ビューをレイアウトします 既存のサブスクリプション登録者か どうかのステータスや ユーザーが お試しオファーの 対象になるかどうかもチェックします 自動で作成されるビューも良いですが もっとBackyard Birdsに ふさわしいビューにするために パワフルな新APIが使えます 例えば ヘッダーの マーケティングコンテンツも 好きなSwiftUIビューに変えられます 先ほど作ったマーケティングコンテンツの ビューをここにドロップします
また サブスクリプションストアに コンテナ背景を追加して 視覚的にも面白くできます 新しいSwiftUI containerBackground APIを使うだけです
ご覧のようにサブスクリプションストアの 一番上にこれをつけてから 空のグラデーションと雲を使って 先ほど作っておいた ビューを宣言しています 全体をまとめるには サブスクリプションストアのスタイル用の 別のAPIが使えます デフォルトでは サブスクリプションストアは サブスクリプションコントロールと 全面背景との間に マテリアルレイヤーを追加します
背景のstyle modifierを使って サブスクリプションコントロールの 後ろ側は無背景にします
今度はsubscriptionStore ButtonLabelを使って サブスクリプション登録ボタンの マルチラインレイアウトを選びます
これで登録ボタンに 価格と「無料でお試し」の両方が付きました
次に追加するのはsubscriptionStore PickerItemBackgroundで サブスクリプションのオプションに マテリアル効果を宣言します
サブスクリプションプランオプションにも 空のグラデーションがかかっています
最後に 私たちのサブスクリプションには オファーコードがあるので 新しいstoreButton modifierを使って 「コードを使う」ボタンが 見えるように宣言します
このたった1つのview modifierで ボタンが表示され ユーザーはオファーコード 引き換えシートが開けます
これでサブスクリプションビューも Backyard Birdsらしくなりました これらの新しいビューのおかげで アプリにアプリ内課金を 追加するのは楽になりましたが まだ少し足りないものがあります まずは 購入後にコンテンツを アクセスしてもらうための ロジックが必要です
2つ目には ユーザーがすでに サブスクリプション登録済みかどうかと その際SubscriptionStoreViewを提示する コントロールを非表示にしているかの確認です
StoreKitのビューはサブスクリプション 登録済みのユーザーに自動的に対処しますが 多くの場合 最善なのは どのマーチャンダイジングUIも 既存の登録者に表示しないことです
StoreKitの最新のAPIは このような重要な機能の実行を コンテンツ販売と同じように 楽しくしてくれます そのようなAPIを始める前に ビジネスロジックを既に実装しておくか 何らかの足場かけを 実装しておく必要があります 確認すべきことは 処理しているのが 最新のトランザクションであること サーバーと協力していること 消耗型のエンタイトルメントを 追跡していること 自分のUIコードに適した データモデルを作成していること などなどです ビジネスロジックの実装については 「Meet StoreKit 2」や 「What's new in App Store server APIs」で さらに詳しく確認することを おすすめします
私は鳥のビジネスロジックを BirdBrainという名のアクターに 先に実装しておきました もうすぐ これについてお話しします
まずバードウォッチャーが 購入済みの消耗型の鳥の餌に アクセスできるようにすることから 始めましょう どのStoreKitビューからの購入でも その処理はシンプルです onInAppPurchaseCompletionで ビューを変更して 購入が完了した時に呼び出す 関数を付与します この方法ならどんなビューも変更可能で 最後のStoreKitビューが 購入を終了した時に コールが行われます このmodifierを私たちの BirdFoodShopに追加しましょう
このmodifierが教えてくれるのは 購入されたプロダクトと 購入の結果が 成功したか否かについてです これを実装して成功した結果を BirdBrainアクターに送り 処理してもらいましょう
このmodifierを追加することで 購入された消耗型の鳥の餌が アンロックされアクセス可能になります シミュレータでやってみましょう
バックヤードを選んでから サプライをタップします
それから栄養ペレットを購入します
シートが消えると サプライの在庫には栄養ペレットが 5個になっているのが分かります
そして 栄養ペレットを置いて 庭に お腹を空かせた鳥たちが 集まってくるのを待ちます
onInAppPurchaseCompletionに加えて 他にも幾つか関連する view modifierがあり StoreKitのビューからそれを使って イベントに対処できます
ユーザーが購入ボタンを選択した場合 onInAppPurchaseStartを使って 対処できますが 購入が始まる前に限ります 購入が行われている間に 調光操作などでUI要素を 更新したい場合には便利です ここで提供する関数は これから購入されるプロダクトを パラメータとして受け取ります
これらのmodifierを使う時に 知っておくのが重要なのは 対処するイベントは 派生的なProductViewや StoreViewやSubscriptionStoreViewの インスタンスだということです 複数のmodifierを追加した場合 すべてのアクションが 各イベントに対して実行されます こういったmodifierを使うかどうかは 完全に任意の判断です デフォルトではStoreKitのビューからの 成功したトランザクションは Transaction.updatesのシーケンスから 消去されますが onInAppPurchaseCompletionを追加して 結果を直接取り扱うという オプションがあります
これらのどのmodifierにでも nilを渡せば デフォルト動作に戻せます
Backyard Birds Passサブスクリプションの 扱いについてお話ししましょう 新規ビューのAPIに加えて StoreKitは SwiftUIにおける データの独立性を宣言するための 新しいview modifierがあります 最初にお話しするのは subscriptionStatusTaskで これを使えばパスのアンロックが とても簡単になります 私たちのサブスクリプションに依存する どんなビューにも subscriptionStatusTask modifierを 追加できます Backyard Gridでやってみましょう サブスクリプションのオファーシートを 開くボタンが出る場所です
subscriptionStatusTask modifierは 依存するサブスクリプションの グループIDを使います
先ほどSubscriptionStoreViewを 宣言した時に 使用したのと同じグループIDです これでBackyard Gridが現れるたびに バックグラウンドタスクが サブスクリプションのステータスを読み込み タスクが完了すると 提供される関数を呼び出します
このAPIを使用するのに最適なのは 単にステータスを ビジネスロジックに渡す時で この場合だと BirdBrainアクターですが そのアクターにデータを処理させて 私たちのUIコード内で扱いやすい モデルタイプを戻すことです
ここでは このパスステータスを列挙型にして これを割り当てる状態プロパティを 作ることにします
そしてサブスクリプションオファーを 見せる相手は 現在サブスクリプションに 登録していない人だけを選びます
この追加だけで オファーカード の提示対象をサブスクリプション― 登録していないバードウォッチャー に限定できます StoreKitはステータスが変わると 関数を呼び出すので 私たちのビューは常に 最新情報を反映しています
アプリ全体で同じパターンを使って Backyard Birds Passの コンテンツをアンロックし onInAppPurchaseCompletion modifierを使って サブスクリプションが成功したあとは 自動的にPass Shopシートを 消去することができます この部分は既に完了してあるので iPhoneシミュレータでアプリを実行し 全体をテストしましょう
「おすすめをチェック」をタップし 「無料でお試し」を押します
支払いシートが現れて 「サブスクリプション登録」をタップし 警告を消します
これでオファーシートは自動的に消え オファーカードも隠れています その理由はsubscription status taskが ステータスが変わるごとに 関数を呼び出すからで 私たちのアプリのUIは常に 最新の状態になるわけです
このトピックに関連して アプリのオファーが非消耗型であったり 非更新型のサブスクリプションである場合 subscriptionStatusTaskのように 簡単にエンタイトルメントの― 確認をしてくれる 新しいAPIがあります currentEntitlementTask modifierを使って プロダクトIDに対する現在の エンタイトルメントに依存するものとして ビューを宣言すれば システムが現在のエンタイトルメントを 非同期的に読み込み 変化が起こるたびに現在の エンタイトルメントを用いて 関数を呼び出します
subscriptionStatusTaskと currentEntitlementTaskの両方で みなさんが提供した関数は entitlement task stateを パラメータとしてとらえます それにより ケースの扱いを 細かく選ぶことができて エンタイトルメントが読み込み中の場合 読み込みが失敗した場合 読み込みが成功した場合で選べます 以上 新しいStoreKitのビューにより Backyard Birdsでの アプリ内課金の統合を 容易に効率化できることを見てきました では もう少し掘り下げて SwiftUIのための 新しいStoreKit APIを使って これらのビューをさらに 駆使していこうと思います
まず ProductViewや StoreViewのアイコン設定での さらなるオプションについてです 次にプロダクトビューの スタイリングの詳細です そのあとは SotreViewや SubscriptionStoreViewに 共通の機能を持つボタンを 追加する方法です 最後に みなさんのブランドに適した サブスクリプションストアビューを 作るために使える 数々の新しいAPIについてです では装飾アイコンについてです アイコンを付与すると 標準のProduct View stylesでは プロダクトの読み込み中に プレースホルダアイコンが現れます 左側にある感じですね 時には自動アイコンが 自分の予想していたものとは 違うことがあります 例えば iPhoneでは自動生成の プレースホルダは四角ですが 私たちはBird Foodのプロダクトに 丸いアイコンを使います
これは自分がプレイスホルダーに 使用したいアイコンで ProductViewに第二の trailing closureを追加すれば 簡単に改善できます ここでは単にプレイスホルダに 丸を付与しただけです
App Store Connectに App Storeの プロモーション画像をつけたいなら SwiftUIビューの代わりに同じ画像を ProductViewに使えます prefersPromotionalIconのパラメータを trueに設定するだけです
SwiftUIビューは代替として 提供できますが プロダクトにプロモーションアイコンが ある限り これは無視されます
「What's new in StoreKit 2 and StoreKit Testing in Xcode」と 「What's new in App Store Connect」で プロモーションアイコン設定を確認できます
App Storeのプロモーションアイコンを 使いたくない場合でも SwiftUIで宣言されたアイコンのために アプリ内課金アイコンの クールなトリートメントが使えます
アイコンに付与したビューに このmodifierを加えるだけで ビューにこのボーダーが追加されます プロダクトビューのアイコンについては 以上です ストアビューのアイコンにも 同様のことがすべてできる 同等のAPIがありますのでお忘れなく
では プロダクトビューの スタイリングについてです
このセッションの最初の方で話した プロダクトビュースタイルのカスタマイズ― について ようやくお話しできます
プロダクトビューの外見やレイアウト動作 そしてインタラクションは 使用されるスタイルによって 完全に左右されます 狙いに適した標準スタイルが 見つからない場合は いつでも独自のプロダクトビュー スタイルが作成できます
ここでの最初のケースは 標準スタイルで構成される カスタムスタイルの作成で ゼロからすべてを 作るわけではありません 例えば 読み込み中に出るのが 標準のプレースホルダではなく プロダクトビューに 進捗スピナーを表示したい場合は?
カスタムスタイル作成の最初のステップは ProductViewStyleプロトコルに準拠する タイプの作成です
プロトコル実装における唯一の要件は このmakeBodyメソッドです makeBodyメソッドに渡される構成値は 素晴らしいプロダクトビューを宣言するのに 必要なプロパティをすべて持っています 例えば 状態の列挙があり プロダクトの読み込み過程における 異なる状態をカバーします 読み込み中の外見をカスタマイズするには loading stateのProgressViewを 宣言すればいいだけです
そして他の状態に対しては 標準のProductView動作に 戻すことができますが 単にProductViewインスタンスに 設定を渡せばいいだけです
カスタムスタイルの適用も 標準スタイル適用の場合と同じで productViewStyle modifierに 渡すだけです
もちろん標準スタイルを使って カスタムスタイルを作る必要はありません
makeBodyメソッドのほかのビューを使って いつでも独自のスタイルを 定義することができます タスク状態が成功になれば ビューが表示している Product値にアクセスできます みなさんのアプリがStoreKit 2を 使っている場合は 使い慣れたProduct値です Productのすべてのプロパティを使って ビューが作成できます
configurationからも 装飾アイコンにアクセスできます
購入ボタンを追加する時は 必ずconfiguration値での購入方法を 使うようにしましょう product値ではありません configurationでのメソッドを 使用することで デフォルトの購入オプションを 追加することになり これによって支払い確認シートが 必ずプロダクトビューに 近接して表示されるようになり onInAppPurchaseCompletionのような リアクティブなmodifierをトリガーします
大事なのは ゼロから カスタムスタイルを作成すると そのスタイルを使った プロダクトビューの外見と動作は スタイル作成のために作ったビューと 一致するということです
カスタムスタイルの作成では App Storeデータフローなどの プロダクトビューのための 全インフラストラクチャを 上手く利用できる一方で 自分好みの外見や動作を 自由に宣言できます
私たちがBird Food Shopのために 作成したUIは 読み込み中に 各プロダクトのための プレースホルダの形状を表示します ですが 右側のように 読み込み中スピナーを表示したい場合は?
この問題の解決方法は stateのリフトアップです どういうことか説明します
この図式は先ほど作成した BirdFoodShopの階層を表します BirdFoodShopはいくつかの 下位レベルのProductViewがあります ProductViewをプロダクトIDで初期化すると 各ビューはプロダクトの状態 つまりstateを内部で維持しますが これは読み込みオペレーションが 非同期だからです プロダクトの読み込み中に 親元のBirdFoodShopの外見が 異なるエフェクトを作成したいなら 状態を親元のBirdFoodShopに リフトアップする必要があります 親元のBirdFoodShopが プロダクトの状態を管理するようにしたら データの読み込み中に その外見を自由に変更でき IDの代わりに予め読み込まれた product値を使って ProductViewインスタンスが 作成できます ここまではまだプロダクトIDで プロダクトビューを作成するだけですが 読み込み済みのProduct値を ProductViewに渡せることを知るのは 重要なことです これによってプロダクトビューは 読み込みをスキップし マーチャンダイジングビューを 直接レイアウトします
ここでみなさんは 「それはすごいけど それをするには 独自のプロダクトリクエストと キャッシングロジックを作らないと」
と思われるでしょう でも安心してください 私たちはStoreKitビューの内側を view modifierとして 公開しているので どのビューでもプロダクトIDのメタデータに 依存していると宣言できます StoreKitが代わりに プロダクトの読み込みや キャッシング そして 最新状態への更新を行います そのために使うのが 新しい storeProductsTask modifierです 先ほどのsubscription StatusTaskと似ていて ビューが依存するプロダクトIDの 収集を渡します
そして非同期タスクの状態を扱うのに使う state値を獲得します 先ほど見たカスタムの ProductViewStyle実装からも 見慣れた感じですね
ここから読み込み中の 読み込みビューが出て…
プロダクトが取得できない場合 新しいContentUnavailableViewを使うか
予め読み込み済みのProduct値で 直接BirdFoodShopを表示します とても簡単ですね
簡単と言えば アプリ内課金の マーチャンダイジングUIでは 含められる便利な共通のアクションが いくつかあります StoreViewとSubscriptionStoreViewで そのような共通アクションのための 補助ボタンの追加が 非常に簡単になります
補助ボタンというのは ビューの主要目的を補助する アクションを実行するためのボタンです 例えば この「キャンセル」ボタンや 「コードを使う」ボタンは どちらもパスのサブスクリプション登録に 対して補助的なものです
storeButton modifierを使った 「コードを使う」ボタンの追加は 最初にこのシートを作成した時に 既に見ておきましたね このview modifierを もう少し詳しく見てみましょう
この2つの各パラメータのために パスできる値がいくつかあります 最初のパラメータでは 可視性の選択ができるようになります すべてのボタンのデフォルトは 「automatic」で その場合StoreKitが コンテクストから判断して ボタンを可視にするかどうか選びます ボタンを明確に「visible」か 「hidden」にすることもできます
次のパラメータは可視性の内容を 設定するボタンの 種類を選択するものです
「キャンセル」ボタンは ビューを閉じるもので プラットフォームに適したボタンです このボタンはStoreViewと SubscriptionStoreViewで有効です
キャンセルボタンの自動動作として ビューが表示されるたびに表示されます
右側では サブスクリプションストアビューが シートとして表示されていて キャンセルボタンが自動的に 右上に出ています 左側では ビューはシートとして 表示されてないので キャンセルボタンはありません もちろんこの動作を 上書きするという選択もあり キャンセルボタンを隠すこともできます 気をつけたい点ですが この選択は このキャンセルボタンの代わりに 独自のキャンセルボタンを使う場合のみです 自分のマーチャンダイジングUIには 表示を消すための 明確なボタンを必ず設けるよう 心がけましょう
キャンセルボタンと同様に ストアビューと サブスクリプションストアビューは 購入を復元するボタンが表示できます
デフォルトで 購入を復元するボタンは 常に隠されていますが storeButton modifierを使って マーチャンダイジングUIに 表示することもできます
次の3種のボタンが使えるのは SubscriptionStoreViewだけです
「コードを使う」ボタンについては 既にお話ししました 次は サインインボタンです App Store外からのサブスクリプションを 許可している場合には サインインボタンを表示するのが 良いでしょう 既存の登録者がアクセスを 必要としている場合に対応できます サインインボタンについて 知っておくべき重要なことは サインインのアクションを宣言するには 新しいsubscriptionStoreSignInAction modifierを使う必要があることです サインインのアクションを設定したら サインインボタンは自動的に可視になります
サインインボタンは subscriptionStoreSignInActionで 宣言した関数を単純に呼び出すので サインインのフローを実行する シグナルとして使えます
最後のボタンの種類はポリシーです サブスクリプションオファーと一緒に 利用規約とプライバシーポリシーの リンクを表示しておくべきですが SubscriptionStoreViewでこれが 非常に簡単になります
典型的には ポリシーボタンは デフォルトで隠れています storeButton modifierでそれを 可視にすれば iOSやMacでは サブスクリプション登録の コントロール上に 表示されるようになります これらのボタンは コンテナ背景の上に表示されるので デフォルトのスタイルでは背景に対して 読みづらいかもしれません subscriptionStorePolicy ForegroundStyleで 背景に対しても読みやすい ポリシーボタンを使えるような 形状スタイルを設定できます
storeButton modifierで 補助ボタンを設定すると シンプルな宣言をいくつかするだけで マーチャンダイジングUIに パワフルな機能を追加しやすくなります 先ほどサブスクリプションストアビューの スタイルを設定して Backyard Birdsにふさわしい ビューを作成しましたね では このようなスタイルAPIを 詳しく見てみましょう
まずは コントロールスタイルの選択から 見ていきます SubscriptionStoreViewは マーチャンダイジングの対象である サブスクリプションの種類に基づいて 自動でコントロールスタイルを選びます
新しいsubscriptionStore ControlStyle modifierを使えば サブスクリプションのプランに使う コントロールスタイルが自分で選べます 例えば 自動ピッカーの代わりに プランごとにでも ボタンを選べます
コントロールの異なるスタイルについて お話ししましょう
独自のスタイルを特定しなければ サブスクリプションストアビューが自動で コントロールを選びます iPhoneでは これが 複数プランのオプションを持つ サブスクリプションの ピッカーコントロールです
自分で明確にピッカーコントロールを 選ぶこともできます
iOSとMacでは 主要の ピッカーコントロールがあり 影や選択リングを使って サブスクリプションプランのオプションを もっと目立つように表示します
最後に ピッカーコントロールの代わりに 各サブスクリプションプランに ボタンを表示する選択もできます
サブスクリプション登録ボタンと言えば ボタンのラベルをカスタマイズするのに 使える新しいAPIがあります
SubscriptionStoreViewがデフォルトで 表示するサブスクリプション登録ボタンは アクションフレーズを含み ボタンの上のキャプションとして 価格情報が出ています
subscriptionStoreButtonLabel modifierを追加して ボタンのラベルを複数行に変更することで 価格のテキストはボタンの ラベル内に含まれるようになり 切り離されたキャプションではなくなります
ボタンラベルのレイアウトを カスタマイズすることに加え コンテンツもカスタマイズできます
例えば アクションフレーズではなく 選択したサブスクリプションの 表示名を表示することもできます
レイアウトとコンテンツの両方を使い ボタンラベル値を作成することも可能で コンポーネンツをこのように つなぎ合わせて行います
ボタンコントロールは ピッカーコントロールと同じ サブスクリプション登録ボタンで 構成されているので これらのボタンのカスタマイズにも 同じmodifierが使えます
例えば ラベルに価格だけを 表示することもできます すべてのプランが同じサービスを提供し 価格オプションだけが違う場合に便利です
異なるサブスクリプションプランは コントロールを作成するために App Store Connectで設定した 表示名と説明を使います こういったコントロールを もっと面白くするために 異なるプランごとに 装飾ビューを加えることもできます
そのためにはsubscriptionStore ControlIcon modifierを サブスクリプションストアに加えるだけです
このmodifierはview builderを使います
view builderにProduct値と SubscriptionInfo値の 両方を提供します これらのパラメータを使い プランごとに異なるビューができます
これらのアイコンは サブスクリプションプランに ボタンコントロールスタイルを 使っているときも有効です それではもう少し詳しく サブスクリプションストアビューへの 背景コンテンツ追加を見てみましょう 先ほどのおさらいをすると containerBackground modifierで 販売コンテンツを変更することで サブスクリプションストアに コンテナ背景が追加できます
この例では 背景にアクセントカラーの グラデーションを使い サブスクリプションストアに 表示することにしました
新しいcontainerBackground APIに ついての詳細は 「What's new in SwiftUI」の セッションでどうぞ
サブスクリプションストアに使える 背景プレイスメントには 異なるものがいくつかあります
Subscription Store placementを使えば コンテクストに基づいて 自動でプレイスメントを選びます
iOSやMacでは サブスクリプションストアの ヘッダーに置きたい背景を 明確に特定できます このプレイスメントは 販売コンテンツの後ろ側です
Full Height placementもあり サブスクリプションストアビューの 全面に背景を設置します
セッションの最初の方で subscription status taskのような APIを使って Get Backyard Birds Passシートの表示を 避ける方法を見ましたね ですが 既存の登録者に サブスクリプションストアビューを 表示したい場合もあります 例えば登録者にプレミアムプランへの アップグレードをすすめる時です
登録者が現在 プレミアムより低レベルのプランを サブスクリプション登録していると わかったら アップグレードをvisibleRelationshipsの パラメータとしてパスすることで アップグレードシートが表示できます サブスクリプションの関係は どの組み合わせでもよく 現在サブスクリプション登録している ユーザーにのみ有効となります
オファーの効果を高めるために 販売コンテンツの異なるビューを提示し プレミアムプランの利点を説明します subscriptionStatusTaskを使って 登録者のサービスレベルを把握し その情報を元に どのオファーを ユーザーに見せるか決めます
これで本日の内容はすべてカバーしました アプリ内課金をアプリに追加する時は すぐに実稼働できるよう StoreViewを宣言しましょう
カスタマイズされたレイアウトには ProductViewを試してみてください サブスクリプションには SubscriptionStoreViewを宣言して 説得力のあるオファーを作りましょう さらに上のレベルを目指すなら 新しいview modifierやほかのAPIを使って 独自のものを準備しましょう
StoreKitやSwiftUIについて さらに知りたい場合は こちらのセッションをどうぞ 「What's new in StoreKit 2 and StoreKit Testing in Xcode」と 「What's new in SwiftUI」です
本日は SwiftUIのための 新しいStoreKit APIについてでした コーディングを楽しんでください ご視聴ありがとうございます
-
-
3:35 - Setting up the bird food shop view
import SwiftUI struct BirdFoodShop: View { var body: some View { Text("Hello, world!") } }
-
3:42 - Import StoreKit to use the new merchandising views with SwiftUI
import SwiftUI import StoreKit struct BirdFoodShop: View { var body: some View { Text("Hello, world!") } }
-
3:51 - Declaring a query to access the bird food data model
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { Text("Hello, world!") } }
-
4:18 - Meet store view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { StoreView(ids: birdFood.productIDs) } }
-
4:51 - Adding decorative icons to the store view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { StoreView(ids: birdFood.productIDs) { product in BirdFoodProductIcon(productID: product.id) } } }
-
6:38 - Creating a container for a custom store layout
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
6:47 - Meet product view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:03 - Adding a decorative icon to the product view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:17 - Adding more containers to layout product views
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:36 - Declaring product views for the remaining products
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { ForEach(birdFood.orderedProducts) { product in ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:50 - Choosing a product view style
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) .padding() .productViewStyle(.large) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { ForEach(birdFood.orderedProducts) { product in ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
8:25 - Styling the store view
StoreView(ids: birdFood.productIDs) { product in BirdFoodShopIcon(productID: product.id) } .productViewStyle(.compact)
-
9:53 - Setting up the Backyard Birds pass shop
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { var body: some View { Text("Hello, world!") } }
-
9:57 - Meet subscription store view
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) } }
-
10:38 - Customizing the subscription store view's marketing content
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() } } }
-
10:57 - Declaring a full height container background
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } } }
-
11:21 - Configuring the control background style
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) } }
-
11:44 - Choosing a subscribe button label layout
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) } }
-
12:01 - Choosing a subscription store picker item background
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) .subscriptionStorePicketItemBackground(.thinMaterial) } }
-
12:20 - Declaring a redeem code button
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) .subscriptionStorePicketItemBackground(.thinMaterial) .storeButton(.visible, for: .redeemCode) } }
-
14:10 - Reacting to completed purchases from descendant views
BirdFoodShop() .onInAppPurchaseCompletion { (product: Product, result: Result<Product.PurchaseResult, Error>) in if case .success(.success(let transaction)) = result { await BirdBrain.shared.process(transaction: transaction) dismiss() } }
-
15:43 - Reacting to in-app purchases starting
BirdFoodShop() .onInAppPurchaseStart { (product: Product) in self.isPurchasing = true }
-
16:57 - Declaring a subscription status dependency
subscriptionStatusTask(for: passGroupID) { taskState in if let statuses = taskState.value { passStatus = await BirdBrain.shared.status(for: statuses) } }
-
19:37 - Unlocking non-consumables
currentEntitlementTask(for: "com.example.id") { state in self.isPurchased = BirdBrain.shared.isPurchased( for: state.transaction ) }
-
20:52 - Declaring placeholder icons
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() } placeholderIcon: { Circle() }
-
21:25 - Using the promotional icon
ProductView( id: ids.nutritionPelletBox, prefersPromotionalIcon: true ) { BoxOfNutritionPelletsIcon() }
-
21:56 - Using the promotional icon border
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() .productIconBorder() }
-
23:02 - Composing standard styles to create custom styles
struct SpinnerWhenLoadingStyle: ProductViewStyle { func makeBody(configuration: Configuration) -> some View { switch configuration.state { case .loading: ProgressView() .progressViewStyle(.circular) default: ProductView(configuration) } } }
-
23:44 - Applying custom styles to the product view
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() } .productViewStyle(SpinnerWhenLoadingStyle())
-
23:58 - Declaring custom styles
struct BackyardBirdsStyle: ProductViewStyle { func makeBody(configuration: Configuration) -> some View { switch configuration.state { case .loading: // Handle loading state here case .failure(let error): // Handle failure state here case .unavailable: // Handle unavailabiltity here case .success(let product): HStack(spacing: 12) { configuration.icon VStack(alignment: .leading, spacing: 10) { Text(product.displayName) Button(product.displayPrice) { configuration.purchase() } .bold() } } .backyardBirdsProductBackground() } } }
-
26:44 - Declaring a dependency on products
@State var productsState: Product.CollectionTaskState = .loading var body: some View { ZStack { switch productsState { case .loading: BirdFoodShopLoadingView() case .failed(let error): ContentUnavailableView(/* ... */) case .success(let products, let unavailableIDs): if products.isEmpty { ContentUnavailableView(/* ... */) } else { BirdFoodShop(products: products) } } } .storeProductsTask(for: productIDs) { state in self.productsState = state } }
-
27:54 - Configuring the visibility of auxiliary buttons
SubscriptionStoreView(groupID: passGroupID) { // ... } .storeButton(.visible, for: .redeemCode)
-
29:56 - Adding a sign in action
@State var presentingSignInSheet = false var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreSignInAction { presentingSignInSheet = true } .sheet(isPresented: $presentingSignInSheet) { SignInToBirdAccountView() } }
-
30:32 - Displaying policies from the App Store metadata
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStorePolicyForegroundStyle(.white) .storeButton(.visible, for: .policies)
-
31:22 - Choosing a control style
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlStyle(.buttons)
-
32:28 - Declaring the layout of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.multiline)
-
32:51 - Declaring the content of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.displayName)
-
33:04 - Declaring the layout and content of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.multiline.displayName)
-
33:44 - Decorating subscription plans
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlIcon { subscription, info in Group { let status = PassStatus( levelOfService: info.groupLevel ) switch status { case .premium: Image(systemName: "bird") case .family: Image(systemName: "person.3.sequence") default: Image(systemName: "wallet.pass") } } .foregroundStyle(.tint) .symbolVariant(.fill) }
-
34:07 - Decorating subscription plans with the button control style
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlIcon { subscription, info in Group { let status = PassStatus( levelOfService: info.groupLevel ) switch status { case .premium: Image(systemName: "bird") case .family: Image(systemName: "person.3.sequence") default: Image(systemName: "wallet.pass") } } .symbolVariant(.fill) } .foregroundStyle(.white) .subscriptionStoreControlStyle(.buttons)
-
34:14 - Adding a container background
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground( .accent.gradient, for: .subscriptionStore ) }
-
35:30 - Presenting upgrade offers
SubscriptionStoreView( groupID: passGroupID, visibleRelationships: .upgrade ) { PremiumMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。