大多数浏览器和
Developer App 均支持流媒体播放。
-
认识 StoreKit 与 SwiftUI
了解如何使用 App Store 产品元数据和 Xcode Previews,只需几行代码即可用它们为你的 App 添加 App 内购买项目。在 StoreKit 中探索全新的 UI 组件集合,了解如何轻松推销你的产品,以及如何用帮助用户做出明智决策的方式展示订阅等内容。
资源
相关视频
WWDC23
WWDC22
WWDC21
WWDC20
-
下载
欢迎来到 “认识 StoreKit 与 SwiftUI” 我是 StoreKit 团队的 工程师 Greg 让我们来聊聊 App 内购买项目的营销 App 内购买项目的营销 主要就是将你的 各种产品展示出来 并为顾客提供购买产品的方式
营销的第一步 就是获取你要销售的商品的数据 以及顾客的状态 例如 顾客是否 已经拥有了我的非消耗型产品? 顾客是否已经购买了订阅?
你可以结合这些数据 建立一个界面 来向顾客推广自己的产品 并提供交互方式 让顾客产品购买 这个红色的小方框 无法完全体现出你 构建界面需要付出的辛苦工作
实际上 构建界面涉及到很多方面 需要各种不同的专业技能 接下来 当用户选择购买你的产品 你的 App 需要 使用购买 API 进行响应 并根据购买结果更新其界面 如果你曾经为 App 添加过 App 内购买项目 就会知道 恰当的商品推销至关重要 如果我们能将所有这些步骤抽象成 一个简单而强大的视图 那岂不是更好? 这个视图可以处理所有常见的功能 并接受参数 以便让你配置各个部分 使自己的 App 与众不同 那么 我很高兴能介绍一组 来自 StoreKit 的 新的强大 API 用于构建营销 UI 在 Xcode 15 中 StoreKit 现在 提供一系列 SwiftUI 视图 可帮助你构建 声明式的 App 内购买项目 UI 你只需声明你想要的营销体验 系统就会在幕后实现你的声明
StoreView、ProductView 和 SubscriptionStoreView 是全新的视图 可以帮助你 更快地开始商品营销 这些视图抽象了来自 App Store 的数据流 并用系统提供的 UI 来展示 App 内购买项目的界面和内容 你甚至可以使用 已经熟悉的 SwiftUI API 来自定义这些视图与 App 的整合
就像 SwiftUI 一样 这些新视图可以在所有平台上使用 因此在 iPhone、iPad、Mac、 Apple Watch 和 Apple TV 上 App 内购买项目的营销 将比以往更加简单
一群小鸟找到我 问我能不能为它们的新游戏 Backyard Birds 添加 App 内购买项目
有了这些来自 StoreKit 的新视图 我当然会说:“没问题”
请与我一起 为 Backyard Birds 打造出色的 App 内购买项目体验 欢迎大家下载演示项目 和我一起完成这项任务 我们将使用 Xcode Previews 来快速迭代我们的 SwiftUI 视图
因为有很多要内容要介绍 我已经设置好了 一个 StoreKit 配置文件 该文件包含了关于我们 App 内购买项目的元数据 这是使用 Xcode Previews 与 StoreKit 所必需的
我们有一些很棒的讲座 可以帮助 你在自己的 App 中入门 如“StoreKit 测试中的新功能” 以及“介绍 Xcode 中的 StoreKit 测试” 让我们直接进入 Xcode 吧 在 Backyard Birds 中 我们想销售优质鸟食 比如营养颗粒 购买了这种食物后 我们可以把它放在后院 以吸引更多饥饿的小鸟来访 让我们在代码中 看看如何利用 StoreKit 来营销这些产品
首先 我们将创建一个名为 BirdFoodShop 的视图 来推销我们的鸟食
我已经创建了一个文件 来实现这个视图 为了使用 StoreKit 构建我们的视图 我们需要在文件的顶部同时导入 StoreKit 和 SwiftUI
接下来 我将在这里声明一个查询 来获取我们的鸟食数据模型 这可以帮我们构建我们的商店
下面我为 App 添加一个 StoreView 因为这是建好营销视图并 让其运行的最快方法 我们需要从 StoreKit 配置文件中 向它提供一组产品标识符 我们可以从 birdFood 模型中 获取这些标识符
在进行过声明后 我们就有了 一个正常运行的营销视图 StoreKit 从 App Store 加载所有产品标识符 并将它们呈现在 UI 中 供我们查看 显示名称、描述和价格 都直接来自 App Store 它使用了你在 App Store Connect 或 StoreKit 配置文件中 设置的内容 StoreKit 甚至可以处理 更细节的重要问题 比如在数据过期前 或系统内存压力下缓存数据 或者检查屏幕使用时间中 是否禁用 App 内购买项目 之前 小鸟设计师为每种鸟食 设计了装饰性图标 我们只需添加一个尾随视图构建器 并传入一个代表 我们图标的 SwiftUI 视图 就可以将这些图标 添加到 StoreView 中
视图构建器 将 Product 值作为参数 我们可以用它来确定要使用的图标 我创建了一个辅助视图 它可以接受产品 ID 并从我们的资源目录中 查找正确的图标
我把它输入进去 你可以看到预览会更新 并显示每个产品的图标 StoreView 能 将我们的产品标识符和图标 转化为一个功能齐全 且设计精良的商店 让一切轻松步入正轨 StoreView 的 一个强大功能就是 它可以帮助我们的产品 自动适应不同的平台 因此店铺能完美适配 iPad、Mac 和 Apple Watch 让我们将 Xcode 中的目标改为 Apple Watch 预览我们的商店
看起来真不错! 看来我们在 Apple Watch 上 也可以销售鸟食了
很多人都希望 以个性化的方式来排列产品 以体现自己产品的独特性
我们的小鸟设计团队努力设计了 一种用来展示鸟食的构图 这种构图突出显示了最划算的商品 并将其他产品以货架形式陈列
这与我们用 StoreView 支持的 列表样式布局不同 但 StoreKit 在这里 也为我们提供了解决方案
对于更详细的布局 我们 可以使用新的 ProductView 事实上 我们刚才 看到的 StoreView 也同样使用 ProductView 来创建行 让我们首先为我们的新商店 声明一个包装器
我希望将一盒营养颗粒 突出显示在其他产品之上 因为它是我们最划算的商品 为此 我将通过提供 一盒营养颗粒的 ID 来声明一个 ProductView
就像在 StoreView 中一样 我们可以通过添加一个尾随闭包 来添加一个装饰性图标 我将继续使用之前的辅助视图
接下来 我们在下方 为其他鸟食产品添加一个区域 我将首先在最划算 产品的后面放置一个背景
然后是标题 以及 我制作的另一个辅助视图 用于将鸟食进行货架形式陈列
在这个货架辅助视图中 我们可以为每种鸟食产品 声明一个 ProductView 随附装饰性图标的闭包
为了完善整个商店 我们还有最后一件事要做 我们非常希望能将 一盒营养颗粒 突出地展示给顾客 但小鸟设计师认为 我们现在的视图还可以更好看 为了取悦小鸟 我们可以使用新的 productViewStyle API 来为我们的主打产品设置风格 我会使用 large 样式 使其脱颖而出
在短短几分钟内 我们利用 StoreKit 新的 ProductView 打造了一个专门的鸟食商店
只需添加一个视图修饰符 ProductView 的 large 样式 就可以帮我们突出显示最划算商品
你可以从三种标准样式中 选择需要的样式 Compact 样式能够 在较小的空间中展示更多产品 我们的鸟食货架 则自动使用 Regular 样式 当然 还有适合用来突出显示的 Large 样式
由于 StoreView 由 ProductView 实例组成 你可以使用相同的 productViewStyle 修饰符 来更改 StoreView 中的样式
你甚至可以创建自定义样式 并将其与 ProductView 和 StoreView 一起使用 请不要走开 我将稍后 在本讲座中为你介绍如何操作
我们开发了出很棒的方法 利用 ProductView 提供 消耗型鸟食商品的 App 内购买项目 但商务团队的小鸟 认为我们做的还不够 它们让我推出一项名为 Backyard Birds Pass 的 订阅服务 专为 最狂热的观鸟爱好者而设 虽然我们可以使用 ProductView 或 StoreView 构建订阅 UI 但新的 SubscriptionStoreView 是专门为订阅而设计的 让我们回到 Xcode 来一起构建新的订阅 首先 在我们的 StoreKit 配置中 我创建了这个名为 Backyard Birds Pass 的订阅组 它提供三个级别的服务
请注意这个组的 ID 我们稍后会用到它
早些时候 我为我们的发布与订阅服务 商店制作了一个新文件 所以让我们直接进入 SubscriptionStoreView
运行 SubscriptionStoreView 的 最快方法 就是直接提供组 ID 该 ID 可以在 StoreKit 配置文件 或 App Store Connect 中获得 我已经将组 ID 添加到我们的环境中 所以我们只需 声明一个环境属性来访问它 然后通过提供组 ID 来声明一个 SubscriptionStoreView
就像 StoreView 和 ProductView 一样 SubscriptionStoreView 为我们管理数据流 并展示了一个 带有不同订阅选项的视图 它还会检查现有的订阅者状态 以及顾客是否 有资格获得推介促销优惠 虽然这个自动生成的外观很不错 但我们可以利用一些 新的强大 API 让这个界面 更符合 Backyard Birds 的 外观和风格 例如 我们可以用 任何 SwiftUI 视图替换 标题中的营销内容 我之前已经创建了营销内容视图 所以我现在把它放在这里
我们还可以在 SubscriptionStore 中 添加一个包装器背景 以增加视觉上的趣味性 我们可以使用 SwiftUI 中新的 containerBackground API
注意我在这里选择使用 subscriptionStorefullHeight 并声明一个我之前创建的 带有渐变天空和云朵的视图 为了让整体更完善 我们可以使用其他一些 API 来设计SubscriptionStore 的样式 默认情况下 SubscriptionStore 会在订阅控件 和全高背景之间添加一个材质层
我们可以使用背景样式修饰符 让订阅控件后面的背景变得透明
现在 我将使用 subscriptionStoreButtonLabel 为我们的订阅按钮 选择一个多行布局
我们可以看到订阅按钮 现在同时包含了价格 和 “Try it Free”(免费试用)
接下来 我将添加 subscriptionStorePickerItemBackground 为我们的订阅选项 声明一个材质效果
我们可以看到天空渐变 从订阅计划选项后面透了过来
最后 由于我们的订阅 可以使用优惠码 我将使用新的 storeButton 修饰符 将 Redeem Code (兑换优惠码) 按钮声明为可见
仅仅通过这一个视图修饰符 我们就有了 一个供顾客打开优惠码 兑换界面的按钮
现在 订阅视图和 Backyard Birds 其他部分的风格一致了 虽然这些新视图 显著减少了为 App 添加 App 内购买项目的工作量 但有几个重要的部分 我们还没有涉及到 首先 我们必须添加逻辑 以在顾客完成购买后 实际解锁内容
其次 我们需要检查 顾客是否已经订阅 并隐藏显示 SubscriptionStoreView 的任何控件
StoreKit 视图虽然会自动处理 已经订阅的顾客 但在许多情况下 我们的最佳选择是不向 已经订阅的顾客 展示任何营销 UI
StoreKit 拥有一些全新的 API 可以让实现这些重要功能 像销售你的内容一样简单有趣 在开始使用这些 API 之前 你需要已经实现了业务逻辑 或者至少已经有了一些基架 请确保你能够处理更新的交易、 与服务器进行协作、 跟踪可使用的权限 并创建适合你 UI 代码的 数据模型 等其他事项 我建议你观看 “认识 StoreKit 2” 和 “App Store 服务器 API 的更新“ 以深入了解如何实现业务逻辑
我已经将小鸟的业务逻辑 实现到一个名为 BirdBrain 的主角中 我很快还会再提到它
让我们开始帮助观鸟爱好者们 访问他们要购买的消耗型鸟食 处理来自任何 StoreKit 视图的购买非常简单 你只需使用 onInAppPurchaseCompletion 修改视图 并提供一个在购买完成时 调用的函数 你可以使用此方法修改任何视图 并且每当后代的 StoreKit 视图完成购买时 都会调用该函数 让我们将此修饰符添加到 我们的 BirdFoodShop 中
修饰符将为我们提供 顾客所购买的产品 以及购买的结果 无论购买是否成功 让我们将其实现为 将任何成功的结果 发送给 BirdBrain 主角进行处理
通过添加这个修饰符 我们现解锁了顾客 购买的消耗型鸟食 让我们在模拟器中试一试吧
我现在选择一个后院 并点选补给
接下来 我要买一些营养颗粒
弹窗消失后 可以看到现在我们的补给清单中 有五颗营养颗粒
现在 我们可以放置一颗营养颗粒 然后慢慢等待我们的颗粒 把所有饥饿的小鸟引到院子里
除了 onInAppPurchaseCompletion 之外 你还可以使用一些 其他相关的视图修饰符 处理来自 StoreKit 视图的事件
你可以使用 onInAppPurchaseStart 来处理有人触发购买按钮 但还未购买的情况 如果你想在购买过程中 更新一些 UI 组件 例如调暗控件 这将非常有用 你在此处提供的函数 将接收将要购买的产品 作为参数
使用这些修饰符时 必须要意识到 它们将处理来自 任何来自后代 ProductView、 StoreView 或 SubscriptionStoreView 实例的事件 如果你添加了多个修饰符 那么你的所有操作 都将应用于每个事件 请记住 使用这些修饰符 完全是可选的 默认情况下 来自 StoreKit 视图的成功交易 将从 Transaction.updates 序列发出 但你可以选择添加 onInAppPurchaseCompletion 来直接处理结果
你可以向任何修饰符 传递 nil 以恢复至默认行为
现在 让我们来谈谈如何处理 Backyard Birds Pass 的订阅 除了新的视图 API StoreKit 还具有 新的视图修饰符 用于声明 SwiftUI 中的 数据依赖项 首先 我将介绍 subscriptionStatusTask 我们可以使用它来 轻松解锁发布和订阅服务 在任何依赖于我们的订阅的视图中 我们可以添加 subscriptionStatusTask 修饰符 让我们从 Backyard Grid 开始 因为这里有用来 打开订阅选项弹窗的按钮
subscriptionStatusTask 修饰符 采用我们依赖的订阅的组 ID
这与我们之前在声明 SubscriptionStoreView 时使用的组 ID 相同 现在 每当 Backyard Grid 出现时 一项后台任务将加载订阅状态 然后在任务完成后 调用我们提供的函数
使用此 API 的推荐操作 是将状态传递给我们的业务逻辑 在我们的案例中 就是 BirdBrain 主角 然后让主角处理数据 并返回一个在我们的 UI 代码中更容易处理的模型类型
我创建了这个 Pass 状态枚举 因此我将创建一个 状态属性来进行赋值
然后 我们可以选择 只有在用户没有订阅时 对其显示订阅优惠卡片
通过这些快速的添加 我们现在只会向尚未订阅的观鸟者 显示订阅优惠卡片 StoreKit 会在状态更改时 调用我们的函数 因此我们的视图 将始终反映最新的信息
我们可以在整个 App 中 使用相同的模式 来解锁 Backyard Birds Pass 的内容 我们可以使用 onInAppPurchaseCompletion 修饰符 在订阅成功后 自动关闭发布与订阅服务商店弹窗 我之前已经完成了这个部分 接下来让我们在 iPhone 模拟器中运行 App 对整个订阅流程进行测试
我现在先点击 Check It Out (查看) 再点击 Try it Free (免费试用)
付款弹窗出现了 然后我再点 Subscribe (订阅) 接着关闭提醒弹窗
可以看到订阅优惠弹窗自动消失了 优惠卡片也被隐藏了 这是因为订阅状态任务 会在每次状态变化时 调用我们的函数 这样我们就可以确保 App 的 UI 始终是最新的
顺便提一下 如果你的 App 提供非消耗型产品 或非续期订阅 现在有一个新的 API 可以使检查权限 像 subscriptionStatusTask 一样轻松 你可以使用 currentEntitlementTask 修饰符 将视图声明为依赖于产品 ID 的 当前权限 系统将异步加载当前权限 并在权限更改时以当前权限 调用你的函数
你提供给 subscriptionStatusTask 和 currentEntitlementTask 的 函数 都需要一个权限任务状态作为参数 这样你可以针对以下情况 进行精细处理: 权限仍在加载、 权限加载失败、 以及权限加载成功 我已经介绍了 新的 StoreKit 视图 如何在 Backyard Birds 中 简化 App 内购买项目的整合 现在 我想深入介绍并展示 如何通过用于 SwiftUI 的 所有新的 StoreKit API 进一步发挥这些视图的作用
首先 我们将了解 为 ProductView 和 StoreView 设置图标的更多选项 然后 我将详细介绍如何 在 ProductView 中设计样式 接下来 我将介绍如何向 StoreView 和 SubscriptionStoreView 添加具有常见功能的按钮 最后 我将详细介绍 各种新的 API 你可以利用它们 让 SubscriptionStoreView 更符合自己品牌的风格 让我们先谈谈装饰性图标 如果你提供一个图标 标准的 ProductView 样式 会在产品加载时显示一个占位图标 就像左侧显示的那样 有时 自动占位图标并不完全符合 你所期望的实际图标 例如 在 iPhone 上 自动占位图标是一个正方形 但我们为鸟食商品 使用的是圆形图标
你可以通过 在 ProductView 中 添加第二个尾随闭包 表明你想要用作占位符的图标 轻松改进这个外观 在本例中 我简单指定了一个圆形作为占位符
如果你在 App Store Connect 中 设置了一个 App Store 推广图像 你可以让 ProductView 使用同样的图像 而不是一个 SwiftUI 视图 你只需将 prefersPromotionalIcon 参数设置为 true
你仍然可以提供一个 SwiftUI 视图作为备用 但只要产品有一个推广图标 这个视图就会被忽略
请查看“StoreKit 2 和 Xcode 中的 StoreKit 测试的新功能” 以及“App Store Connect 的 新功能”来了解如何设置推广图标
即使你不想使用 App Store 中的推广图标 也仍然可以为你在 SwiftUI 中 声明的图标使用酷炫的 App 内购买项目的图标处理
只需将此修饰符 添加到你为图标提供的视图中 即可为视图添加边框 这就是 ProductView 中 关于图标的全部内容 请记住 对于 StoreView 图标 也可以使用相应的 API 执行同样的操作
现在 让我们谈谈 ProductView 中的设计样式
在本讲座之前的内容中 我提到过你 可以自定义 ProductView 样式 现在终于可以展示该如何操作了
ProductView 的 外观、布局行为和交互 完全由其使用的样式决定 因此 如果你找不到 满足你需求的标准样式 你可以随时创建自己的 自定义 ProductView 样式
我们的第一个示例 将在标准样式的基础上 创建自定义样式 这样我们就不用从头开始创建 比方说 如果我们想让 ProductView 在加载时显示一个旋转的加载图标 而不是标准的占位符外观 该如何做呢?
创建自定义样式的第一步 是创建一个符合 ProductViewStyle 协议的样式
实现该协议需要的唯一条件 就是这个 makeBody 方法 传递给 makeBody 方法的配置值 包含了声明一个出色的 ProductView 所需的所有属性 比如 它拥有一个状态枚举 涵盖了加载产品时的不同状态 要自定义加载外观 我们只需为加载状态声明一个 ProgressView
然后 只需将配置传递给 ProductView 实例 我们就可以针对其他任何状态 回退到标准的 ProductView 行为
你可以像应用标准样式一样 应用自定义样式 只需将其传递给 productViewStyle 修饰符即可
当然 你不需要在标准样式的基础上 创建自定义样式
你始终可以在 makeBody 方法中 使用其他视图来自定义样式 当任务状态为成功时 你可以访问 视图所代表的 Product 值 如果你的 App 使用 StoreKit 2 那这与你已经习惯使用的 Product 值是相同的 你可以使用 Product 的所有属性 来创建你的视图
配置还允许你访问装饰性图标
在添加购买按钮时 请确保对配置值使用 purchase 方法 而不是对 Product 值 对配置值使用此方法 将添加默认的购买选项 以确保付款确认弹窗 显示在 与 ProductView 接近的位置 并触发 onInAppPurchaseCompletion 等响应式修饰符
要记住 当你从头开始 构建自定义样式时 使用该样式的 ProductView 的外观和行为 将与你创建的用于 构建样式的视图的相匹配
创建自定义样式可以很好地利用 ProductView 的 所有基础架构 如 App Store 数据流 同时你还可以自由声明 想要的任何外观和行为
在加载时 我们为 Bird Food Shop 创建的 UI 会显示出每个产品的占位轮廓 若我想让它像右边一样 显示旋转加载图标该怎么做呢?
解决这个问题的 方法是状态提升 让我来解释一下什么意思
这个图表展示了我们之前构建的 BirdFoodShop 的 层级结构 BirdFoodShop 有多个 后代 ProductView 当你用产品 ID 初始化一个 ProductView 时 由于加载操作是异步进行的 每个视图都会在 内部保留产品的状态 如果你想创建一种效果 使父级 BirdFoodShop 在产品加载时显示不同的外观 则需要将状态提升到 父级 BirdFoodShop 中 一旦父级 BirdFoodShop 管理了产品的状态 它就可以在数据加载时 自由地改变外观 然后使用预加载的产品值 而不是它们的 ID 来创建 ProductView 实例 我们到目前只介绍了如何 通过产品 ID 创建 ProductView 但是要知道你也可以将 已加载的 Product 值 传递给 ProductView 这样一来 ProductView 将跳过加载 并直接布局营销视图
你可能会想:这很好 但要这样做的话 我现在要编写自己的 产品请求和缓存逻辑
好消息是 我们将 StoreKit 视图的内部 作为视图修饰符 进行了公开 因此你可以将任何视图 声明为依赖于产品 ID 的元数据 StoreKit 将会为你 处理产品的加载、 缓存和保持更新的工作 要做到这一点 你只需使用新的 storeProductsTask 修饰符 与我们之前介绍的 subscriptionStatusTask 类似 你应传递一个产品 ID 的集合 让视图依赖于它
然后 你会得到一个状态值 可以用来处理异步任务的状态 在我们刚刚介绍了如何 实现自定义 ProductViewStyle 后 这一切都应该感觉很熟悉
现在开始 我们可以在加载时 显示我们的加载视图
如果产品不可用 可以使用新的 ContentUnavailableView
或者直接显示带有预加载的 Product 值的 BirdFoodShop 就是这么简单
说到简单 还有几个有用的常见操作 可以包含在 App 内购买项目的 营销 UI 中 StoreView 和 SubscriptionStoreView 让你为这些常用操作 添加辅助按钮 变得十分容易
所谓辅助按钮 是指用于执行操作 以支持视图主要目的的按钮 比如这个取消按钮 和 Redeem Code (兑换优惠码) 按钮 都是在发布与订阅服务中 用于订阅的辅助按钮
首次构建这个弹窗时 我们已经介绍了 如何用 storeButton 修饰符 添加兑换优惠码按钮 现在让我们更详细地 了解一下这个视图修饰符
这两个参数各自有 几个可以选择的值 你可以用第一个参数选择可见性 默认情况下 所有按钮 都是 automatic 让 StoreKit 根据上下文来选择 是否让按钮可见 你还可以将按钮 明确设置为 visible 或 hidden 状态
第二个参数可以让你选择 将可见性应用在哪个按钮上
取消按钮 会显示一个适用于平台的按钮 用来关闭视图 该按钮适用于 StoreView 和 SubscriptionStoreView
取消按钮的 自动默认行为 是在视图呈现时出现
在右侧 SubscriptionStoreView 以弹窗的形式呈现 因此取消按钮 会自动显示在右上角 在左侧 视图没有 以弹窗的形式呈现 因此取消按钮 也不会出现 当然 你可以覆盖此行为 并在呈现时 隐藏取消按钮 但记住 在执行此操作时 你最好用自己的 取消按钮 替换之前的 取消按钮 我们推荐你 始终在你的营销 UI 上 放置一个清晰的取消 按钮 用于关闭视图
就像取消按钮一样 StoreView 和 SubscriptionStoreView 都可以显示恢复购买按钮
默认情况下 恢复购买按钮 始终处于隐藏状态 但你可以选择使用 storeButton 修饰符 让该按钮 在自己的营销 UI 中显示
接下来的三种按钮仅适用于 SubscriptionStoreView
我们之前已经介绍了 兑换优惠码按钮 下一个是登录按钮 如果你的订阅服务允许用户 在 App Store 之外进行订阅 那么最好显示一个登录按钮 用于让已经订阅的顾客 访问其订阅内容 关于登录按钮有一件很重要的事情 那就是你必须使用新的 subscriptionStoreSignInAction 修饰符声明一个登录操作 如果你设置了登录操作 那么登录按钮将自动显示
登录按钮会调用你使用 subscriptionStoreSignInAction 声明的函数 因此你可以将其作为 运行登录流程的信号
最后要介绍的 按钮类型是政策 你可能希望在订阅优惠中 显示服务条款 和隐私政策的链接 而 SubscriptionStoreView 可以轻松实现这一点
通常 政策按钮默认是隐藏的 如果你使用 storeButton 修饰符使其可见 在 iOS 和 Mac 上 该按钮 将显示在订阅控件上方 由于这类按钮 显示在你的包装器背景之上 默认样式可能 在你的背景上不易辨认 请使用 subscriptionStorePolicyForegroundStyle 为政策按钮设置一个 易于在你的背景上阅读的形状样式
使用 storeButton 修饰符配置辅助按钮 你只需要进行几个简单的声明 就可以为你的营销 UI 添加强大的功能 之前在本讲座中 我们在 SubscriptionStoreView 中配置了样式 使其与 Backyard Birds 的 外观和风格相配 现在 我想更仔细地 介绍一下这些样式 API
首先 让我们来为控件选择样式 SubscriptionStoreView 会根据你销售的 订阅类型自动选择控件的样式
你可以使用新的 subscriptionStoreControlStyle 修饰符 来选择用于订阅计划的控件样式 例如 你可以选择用按钮 来表示每种订阅计划 而不是自动的勾选控件
让我们看看都有哪些控件样式
如果你不指定一种样式 SubscriptionStoreView 会自动选择一种控件 对于 iPhone 在有多种订阅计划选项时 自动样式就是勾选控件
当然你也可以指定使用勾选控件
对于 iOS 和 Mac 你还可以 选择突出勾选控件 该样式可以用阴影和选择环更突出地 显示订阅计划
最后 你还可以选择用按钮 显示每个订阅计划 而不是勾选控件
关于订阅按钮 你可以使用一个新的 API 来自定义按钮标签
默认情况下 SubscriptionStoreView 会显示一个 包含了操作短语的订阅按钮 并将价格信息 显示为按钮上方的说明文字
你可以使用 subscriptionStoreButtonLabel 修饰符 将按钮标签改为多行 这样价格信息 就会作为按钮标签的一部分 而不是作为独立的说明文字显示
除了自定义按钮标签的布局之外 你还可以自定义其内容
例如 你可以让按钮显示 所选订阅的显示名称 而不是操作短语
你甚至还可以通过 将组件串联在一起 来组成一个同时包含布局 和内容的按钮标签值 就像这样
由于按钮控件和勾选控件由相同的 订阅按钮组成 你可以使用相同的修饰符 来自定义这些按钮
例如 你可以选择 在标签中只显示价格 当你的订阅计划都是相同的服务 但价格不同时 这么做会很有用
不同的订阅计划会使用你在 App Store Connect 中设置的 显示名称和描述 来构建控件 为了让这些控件更有趣 你可以选择为每个不同的计划 添加装饰性视图
要添加装饰性视图 只需将 subscriptionStoreControlIcon 修饰符添加到 SubscriptionStore 中
该修饰符采用视图构建器
它为视图构建器 提供 Product 值 和 SubscriptionInfo 值 使用这些参数 你可以 为每个计划提供不同的视图
当你为订阅计划 使用按钮控件样式时 这些图标同样适用 现在让我们看看 如何为 SubscriptionStoreView 添加背景内容 先回顾一下之前的内容 你可以通过使用 containerBackground 修饰符 修改营销内容 来为 SubscriptionStore 添加包装器背景
在本例中 我们为背景选择一个渐变的强调色 并将其放置在 SubscriptionStore 中
想了解更多有关新 containerBackground API 的内容 请观看“SwiftUI中的新功能”
在 SubscriptionStore 中 你可以选择几种不同的 背景放置方式
如果你选择 SubscriptionStore 放置方式 它将根据上下文自动选择放置位置
在 iOS 和 Mac 上 你可以明确指定将背景 放置在 SubscriptionStore 的标头上 这种方式会将背景 放在你的营销内容后面
还有一种 Full Height 放置方式 这种方式可以将背景 放在全高度的 SubscriptionStoreView 的后面
之前在本讲座中 我们介绍了如何使用 subscriptionstatustask API 来隐藏 Get Backyard Birds Pass 弹窗 然而 我们可能会需要 对已经订阅的用户 显示 SubscriptionStoreView 比如我们想鼓励订阅者 升级到高级订阅计划时
当我们检测到用户当前的订阅计划 没有达到高级订阅计划时 我们可以将 visibleRelationships 参数 设置为 upgrade 来呈现一个升级弹窗 它可以显示我们 想要的任意订阅组合 而且它只有在用户 当前已订阅时才会生效
然后 为了使优惠更具吸引力 我们还可以 为营销内容提供不同的视图 为用户说明高级计划的优势 你可以使用 subscriptionStatusTask 来跟踪用户的订阅级别 并利用此信息来确定 向用户展示哪种计划
这就是今天的全部内容 当你准备为 App 添加 App 内购买项目时 声明一个 StoreView 可以帮助你快速上手
如果你想要更加自定义的布局 可以试试 ProductView 对于订阅服务 你可以声明 SubscriptionStoreView 来打造吸引人的优惠计划 如果你准备好更进一步的话 可以使用新的视图修饰符 和其他 API 让你的 App 真正与众不同
如果你对 StoreKit 和 SwiftUI 还意犹未尽 请继续查看这些讲座: “StoreKit 2 和 Xcode 中的 StoreKit 测试的新功能” 以及“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() } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。