大多数浏览器和
Developer App 均支持流媒体播放。
-
为 Apple Watch 构建体能训练 app
在“编程临摹课程”活动期间,使用 SwiftUI 和 HealthKit 从头开始构建体能训练 app。了解如何使用时间线来为始终打开状态提供支持,从而更新体能训练的指标。遵循体能训练 app 的最佳设计实践。
资源
相关视频
WWDC23
WWDC21
-
下载
♪低音音乐播放♪ ♪ 布雷迪怀特:我是布雷迪 我是健身团队的工程师 谢谢大家收看这段视频 目前在App Store中 已经有很多 体能训练app可以使用 Apple Watch是一个很厉害的装置 能够追踪各种健身活动 它可以追踪难度很高的 自行车行程的距离和爬升的高度 你可以在激烈的体能训练中 监测你的心率和燃烧的热量 像是游泳 它甚至可以侦测泳姿 和游了几趟 你可以用这些 和其他更多功能 来建立一个很棒的体能训练app 让我们来看看今天会讲到哪些内容 这段内容会带大家一起编码 我们会带各位看看那是什么 以及你们可以如何参与 我们会在SwiftUI中 建立体能训练显示方式 我们会将HealthKit整合到 我们的显示方式中 我们也会告诉大家如何支持 “总是开启”功能 我很期待 让我们开始吧 这次的说明会是跟大家一起编码 我们会一起从头建立一个 体能训练app 在你准备好Xcode的同时 我们先来讲一些概念 什么是体能训练app? 体能训练app会在体能训练的过程中 追踪你的体能状态 体能训练只要点一下就可以开始 进行体能训练时 会显示实时测量指标 例如经过的时间、燃烧的热量、心率 和距离 当体能训练结束时 上面会出现体能训练的 测量指标的记录摘要 这是我们今天要架设的内容 让我们开始建立体能训练显示方式 打开Xcode 并且新增一个项目 点下“建立一个新的Xcode项目” watchOS、Watch app 点“下一步” 帮你的体能训练app取一个名字 例如“我的体能训练” 确认界面是SwiftUI、语言是Swift 然后点“下一步” 决定你的项目要存在什么位置 接着按“建立”
让我们先隐藏检阅器 和重新设定画面的大小
点“重新启动” 从Xcode预览中 看看我们的app是什么样子 很棒 我们的SwiftUI app 已经准备好了 我们要提供一个方式 让大家可以用StartView 开始他们的体能训练 体能训练只要点一下就能开始 以轮播类型呈现的清单画面 提供一连串上下卷动的体能训练 以及很棒的深度效果 我们的体能运动清单包含自行车 跑步和步行 让我们来建立StartView 我们先将“ContentView” 重新命名为“StartView” 同时按命令键和ContentView 选“重新命名” 输入“StartView”做为新的名字 注意在MyWorkoutsApp.swift中 现在StartView已经是 NavigationView的根画面
点一下“重新命名”
让我们来定义 要显示在StartView列表中的 体能训练类型的数组 首先 输入HealthKit 来存取HKWorkoutActivityType
接着 新增体能训练类型的数组
我们的体能训练清单会包含 自行车、跑步和步行的 HKWorkoutActivityTypes 通过延伸 HKWorkoutActivityType枚举 来遵守可识别协议和加上名称变量 让我们的清单 可以存取HKWorkoutActivityType
ID计算属性变量会回传 枚举的rawValue
名称变量会在案例中切换 来回传名称 像“跑步”、“自行车” 或是“步行” 让我们在StartView的本体上 新增清单画面 来显示体能训练的列表
这个列表会将workoutTypes变量 当成它的模型
每个workoutType都会显示 NavigationLink NavigationLink会替基于导航的界面 定义出目的地 目前 目的地是文字显示画面 我们待会会设定这些导航连结 确保它们追踪到正确的体能训练
边距让导航连结比较高 给它们比较大的点按区域 可以轻易地开始体能训练
这个清单使用轮播listStyle 在卷动时提供深度效果
navigationBarTitle 会显示“体能训练” 我们点一下”重新启动“ 来看看StartView的预览
按下”实时预览“就可以滑动 往上滑来看看 轮播ListStyle的深度效果 看起来很好 体能训练的过程 会使用弹出式窗口体验 因为在体能训练时 大家通常 只需要专门在训练中使用的功能 我们不需要去检视体能训练的清单 或去存取app的其他内容 在弹出式窗口提供最重要的项目 可以帮助大家控制他们的训练 并且减少让人分心的事物 在Apple Watch上 使用体能训练app的人 预期画面是按这个顺序排列 在左侧 我们的控制画面有一个按钮 能够控制进行中的体能训练 例如:结束、暂停、重新启动 中间的画面 测量指标 出现在专用屏幕上 让人可以一眼就读取 在右侧的是媒体播放控制 可以在体能训练时控制媒体 当有人往左或往右滑的时候 watchOS上的TabView就会在 多个次级画面间切换 TabView也会在画面的下方 提供页面指示器 TabView能够很优异地 显示我们正在进行的画面 让我们用TabView 替那三个体能训练的画面 建立SessionPagingView
按下档案>新增>档案… SwiftUI画面 点“下一步” 把它命名为“SessionPagingView” 然后按“建立”
让我们建立一个标签枚举 来模拟能在TabView中 被选取的每个画面
标签枚举有三个case条件判断式 控制、测量指标和现在播放 我们还加上一个 叫做“选择”的@State变量 对TabView的选择提供绑定 选择的预设值会是测量指标 所以开始体能训练时 会显示测量指标画面 让我们加上TabView
TabView的选择参数使用绑定 到我们的选择状态变量 文字显示画面是每个画面 被建立前的占位符 每个画面都有一个标签 所以能够被选择 让我们单击“重新启动” 看看SessionPagingView长什么样子
单击“实时预览” 这样你就可以在画面之间滑动
注意到测量指标的文字显示画面 最先显示出来 因为SessionPagingView的选择变量 是以测量指标当预设值 滑到左边会出现控制的文字显示画面 一路滑到最右侧 则会显示现在播放文字显示画面 太好了! 在进行体能训练时 会显示出实时测量指标 当这个训练需要移动 像是跑步 你的app会使用比较大的字体 并且将文字 排列成能够轻易读取重要信息的方式 我们的MetricsView会显示 经过的时间、动态卡路里 目前心率和距离 HealthKit还有很多 HKQuantityTypes 可以让大家使用 让我们来建立MetricsView 按下档案>新增>档案 SwiftUI画面 点“下一步” 把它命名为“MetricsView” 然后按建立
VStack会包含我们四个 测量指标的文字显示画面
在我们将这些文字显示画面 链接到我们的模型前 这些画面会暂时使用预设值
我们把“经过的时间”变成画面焦点 将它的foregroundColor设为黄色 fontWeight设为半粗体
动态卡路里的文字显示画面 以能量单位千卡为预设值来建立测量 测量使用新的格式化功能 它用缩写表示单位 用途是体能训练 代表体能训练燃烧的热量 而numberFormat的 fractionLength为0 用来去除小数的部分
心率的文字显示画面使用 以fractionLength为0的预设值 它在格式化的字符串后面加上“bpm” 意思是每分钟的心跳数
距离的文字显示画面 是以UnitLength.meters为预设值 测量的格式是以单位的缩写表示 用途是马路 它会根据所在位置 以英制或公制来显示自然的前进
我们会使用名称中有圆体的系统字体 monospacedDigits 和lowercaseSmallCaps
我们希望测量指标的前沿能对齐 所以我们给VStack 一个框架检视修改器 将maxWidth设定为全宽度和前沿对齐
我们希望让这个VStack的内容 一路延伸到屏幕的底部 要做到这件事 我们要忽略底部安全区域
我们希望测量指标能对齐 导航栏的名称 所以我们用scenePadding() 来做这件事
我们希望经过的时间的文字显示画面 能适当地格式化经过的时间 并且根据“总是开启” 来显示或隐藏亚秒 要设定这个功能 我们会建立一个 ElapsedTimeView 以及建立一个自定义 ElapsedTimeFormatter 按下档案>新增>档案 SwiftUI画面 点“下一步” 把它命名为“ElapsedTimeView” 然后按建立
我们的ElapsedTimeView 包含elapsedTime 那是一个TimeInterval 预设值为0 showSubseconds是一个 预设为真的布尔自变量 timeFormatter是一个 ElapsedTimeFormatter的状态变量 我们会在下面来定义它
画面的本体包含文字显示画面 它会将elapsedTime 加总为NSNumber 这样一来timeFormatter 就可以使用这个值 文字显示画面的fontWeight为半粗体 当showSubseconds改变时 timeFormatter的 showSubseconds变量也会改变
ElapsedTimeFormatter 是一个自定义格式化程序 它会使用DateComponentsFormatter 我们希望经过的时间能显示 分钟和秒数 并且补上零 showSubseconds变量 会规定要不要显示亚秒
我们覆写在字符串上来取得值功能 这会回传选择性字符串 第一个保卫判断能确保 值为TimeInterval
第二个保卫判断则确保 componentsFormatter 会回传字符串
如果showSubseconds为真 会通过设定dividingBy为1 再乘以100得到的 truncatedRemainder值 来计算亚秒 使用本机decimalSeparator 然后回传加上亚秒的 formattedString
如果showSubseconds为假 那就会回传 没有亚秒的formattedString 单击“重新启动”来看看 ElapsedTimeView的预览
看起来很好 分钟数会在冒号左边补上零 秒数会在冒号右边补上零 亚秒则显示在小数点后面 让我们把ElapsedTimeView 加到MetricsView上 点一下MetricsView
以ElapsedTimeView取代 经过时间的文字显示画面
让我们预览MetricsView 看起来很棒
ControlsView有一个按钮 能控制进行中的内容 例如:结束、暂停、重新启动 当用户按下结束按钮 就会显示体能训练的摘要 当用户按下“暂停”按钮 体能训练会暂停下来 并且显示MetricsView 让我们来建立ControlsView 按下档案>新增>档案 SwiftUI画面
把它命名为“ControlsView” 然后按“建立”
让我们加上结束和暂停按钮
一个HStack包含两个VStacks 而每个VStack包含一个按钮 和一个文字显示画面
结束按钮的标签是一个 systemName为“xmark”的影像 按钮的颜色设定为红色 并且用title2字体 来增加符号的大小 下面的文字显示画面 有一个“结束”字符串
暂停按钮使用一个 systemName为“暂停”的影像 它的颜色为黄色 下面的文字显示画面 有一个“暂停”字符串 按下“重新启动” 来预览ControlsView
看起来很棒
NowPlayingView提供媒体播放控制 让人可以在体能训练时控制媒体 这包含控制目前在播放媒体的 第三方app 让我们加入NowPlayingView 选择SessionPagingView
NowPlayingView是以WatchKit制作 让我们输入WatchKit
用ControlsView、MetricsView 和NowPlayingView 来取代我们的文字显示画面
NowPlayingView是以WatchKit 制作的SwiftUI画面 就是这么简单 单击“重新启动”来看看 SessionPagingView的预览
在预览中 我们可以看到 我们的MetricsView 滑到左边来看ControlsView
一路滑到最右边…
来看NowPlayingView
让我们回到StartView 并且将NavigationLink的目的地 改成SessionPagingView 选择StartView
将目的地更新成SessionPagingView
摘要屏幕会确认体能训练已经完成 并且显示记录到的信息 我们纳入活动圆圈来增强摘要 让大家可以轻松地查看目前的进度 让我们来建立摘要画面 按下档案>新增>档案 SwiftUI画面 点“下一步” 把它命名为“SummaryView” 然后按建立
让我们建立一个自定义 SummaryMetricView 来描述测量指标和它的值
SummaryMetricView 会使用描述测量指标的标题 和测量指标的值字符串
本体包含两个文字显示画面 和一个分隔器 显示测量指标数值的文字显示画面 会使用名称中有圆体的系统字体 和lowercaseSmallCaps 它会以accentColor 做为它的foregroundColor 让我们在SummaryView建立 体能训练时间长短的格式化程序
durationFormatter 是一个DateComponentsFormatter 它会显示小时、分钟、秒数 中间以冒号隔开 并且补上零 让我们在SummaryView 加上SummaryMetricViews 和完成按钮
ScrollView和VStack包含 我们的四个SummaryMetricViews 和完成按钮
全部时间的文字显示画面 使用durationFormatter 来显示小时、分钟、秒数 并且以以冒号隔开
全部距离的SummaryMetricView 使用预设值是以单位缩写 来表示的测量 用途是马路 它会根据所在位置 以英制或公制来显示自然的前进
所有能量的SummaryMetricView 使用预设值和能量单位 为千卡的测量来表示 它的格式是缩写的单位 用途是体能训练 代表体能训练燃烧的热量 而numberFormat的 有效位数fractionLength为零
平均心率SummaryMetricView 使用有效位数 fractionLength为零的预设值 并且在后面加上“bpm” 代表每分钟的心跳数 我们待会会将实际的体能训练值 提供给SummaryMetricViews 我们希望文字显示画面和分隔器 能对齐导航栏的名称 所以我们在VStack上 使用.scenePadding()
navigationTitle会是“摘要” 而且会和导航栏内显示的内容一致 接着我们要在体能训练的摘要中 加上活动圆圈 按下档案>新增>档案 Swift档案 点“下一步” 把它命名为“ActivityRingsView” 然后按“建立”
输入HealthKit来存取HKHealthStore 输入SwiftUI来存取 WKInterfaceObjectRepresentable
ActivityRingsView结构会遵守 WKInterfaceObjectRepresentable healthStore常数被指派到 初始化程序上
要遵守协议需要两个函数 makeWKInterfaceObject 和updateWKInterfaceObject
我们在makeWKInterfaceObject中 宣告activityRingsObject 它是一个WKInterfaceActivityRing
接着我们替HKActivitySummaryQuery 建立谓词 以及设定使用日期组件为今天 接着我们建立查询和处理结果 这会把活动摘要设在主要队列上的 activityRingsObject
接着在HKHealthStore上执行查询 最后 回传activityRingsObject 让我们把ActivityRingsView 加到SummaryView上 单击SummaryView
输入HealthKit来存取HKHealthStore
接着 让我们在完成按钮上 加入一个文字显示画面 和ActivityRingsView
我们加上了文字显示画面 和框架宽度和高度为50的 ActivityRingsViews 我们会先建立一个HKHealthStore 待会我们会重新使用一个 让我们来预览SummaryView 按下“重新启动”
点一下“实时预览”来滑动
看看我们每个SummaryMetricViews
活动圆圈 和“完成”按钮 让我们来介绍HealthKit整合 HealthKit提供内建功能 来追踪在体能训练时的体能状态 并且把那个体能训练 存到HealthKit中 这会帮身为开发者的你省下时间 而你的使用者能把所有的体能训练 存到同一个位置 HKWorkoutSession让装置的传感器 做好收集数据的准备 这样你就可以 精准地收集和体能训练相关的资料 像是卡路里和心率 在进行体能训练时 它也能让你的应用程序在背景中执行 HKLiveWorkoutBuilder会建立 和储存HKWorkout对象 它会自动帮你收集 样本和事件 想了解更多 可以去看 《操作体能训练的新方式》的说明 让我们来看看我们的app中 会有什么样的数据流 WorkoutManager会负责 和HealthKit连接 它和HKWorkoutSession相连接 来开始、暂停和结束体能训练 它和HKLiveWorkoutBuilder相连接 来监听体能训练样本并且将数据 提供给我们的画面 WorkoutManager会是一个环境对象 只要出现可观察的对象变化 环境对象就会让目前的画面无效 我们将MyWorkoutsApp的 NavigationView 指定为WorkoutManager environmentObject 这样会在NavigationView的 画面分层结构中 将WorkoutManager扩大到画面上 接着画面会宣告一个 @EnvironmentObject 来支配在环境中WorkoutManager 让我们来建立WorkoutManager 按下档案>新增>档案 Swift档案 按“下一步” 把它命名为“WorkoutManager” 然后按“建立”
输入HealthKit 让WorkoutManager可以存取 HealthKit的API
接着定义WorkoutManager类别 这会是一个遵守ObservableObject 协议的NSObject 我们希望我们所有的画面都能 存取WorkoutManager 方法是在MyWorkoutsApp的 NavigationView上 将WorkoutManager 指定为环境对象 选择MyWorkoutsApp
将workoutManager 新增为StateObject
将environmentObject画面修改器 新增到NavigationView
当NavigationView被指定为 environmentObject时 它会自动将environmentObject 传送到画面分层结构的画面上 让我们来建立导航模型 选择WorkoutManager
WorkoutManager会管理所选的 体能训练 那属于选择性的 HKWorkoutActivityType
我们加入一个selectedWorkout变量 来追踪所选的体能训练 现在我们StartView的 NavigationLink 必须将它的选择结合到 WorkoutManager的 selectedWorkout上 选择StartView
将workoutManager EnvironmentObject加到StartView
以标签和选择更新NavigationLink
标签是workoutType 选择是在workoutManager上 对selectedWorkout的绑定 现在只要按下体能训练 workoutManager上的 selectedWorkout就会更新 现在 让我们在一个体能训练 被选择时 开始HKWorkoutSession 和HKLiveWorkoutBuilder 选择WorkoutManager
加上HKHealthStore HKWorkoutSession 和HKLiveWorkoutBuilder 让我们建立一个startWorkout函数 来开始体能训练
startWorkout函数 会使用workoutType参数 HKWorkoutConfiguration 是以workoutType建立的 在我们的app中 所有的体能训练都是户外活动 注意到地点类型会决定 HKWorkoutSession和 HKLiveWorkoutBuilder如何表现 举例来说 户外自行车活动 会产出精准的地点数据 而室内自行车活动则不会 以healthStore和组态 来建立HKWorkoutSession
替对话的associatedWorkoutBuilder 指定建立者 这是在do-catch捕获错误区块中进行 来处理任何出现的错误
使用healthStore 和workoutConfiguration 将建立者的dataSource指定到 HKLiveWorkoutDataSource HKLiveWorkoutDataSource 会从进行中的体能训练中 自动提供实时的数据
建立一个startDate 调用对话中的startActivity 和建立者上的beginCollection 只要selectedWorkout改变 我们就调用startWorkout
selectedWorkout可以是空值 在selectedWorkout 不是空值的时候 利用保卫判断陈述叫出startWorkout 在我们的app可以建立体能训练前 我们必须设定HealthKit 并且请求授权来读取 和分享我们app想要使用的 任何健康资料 让我们加入一个请求授权的功能
在体能训练中 我们必须请求权限 来分享体能训练的类型
我们也希望读取在体能训练中 由Apple Watch自动记录下来的 任何数据类型 我们也想要读取活动圆圈摘要的权限
接着叫出在healthStore上的 requestAuthorization 当画面出现时 我们让StartView 从HealthKit上请求授权 单击StartView
在出现的位置 会叫出 workoutManager的 requestAuthorization函数 让我们启用HealthKit 来使用扩展功能 选择我的体能训练的项目档案…
我的体能训练WatchKit扩展功能 签名和功能 选择新增“功能” 往下滑 选择HealthKit
正在执行体能训练的app 可以在背景中执行 所以你需要 在你的WatchKit扩展功能 加上背景模式功能 进行体能训练时 处理体能训练的背景模式 选择新增“功能” 背景模式 选择“处理体能训练” 我们需要在WatchKit扩展功能的 Info.plist档案上 加上用途的描述 选择Info.plist
选择最后一行 然后点下“返回”
使用NSHealth ShareUsageDescription键
描述为什么你的app需要读取 请求的数据 按下“返回”
使用NSHealth UpdateUsageDescription键
描述你的data想要写入的数据
让我们来建立和执行我们的app 从HealthKit来看看 我们app的权限请求 单击“执行”
我们的app已经提出 HealthKit的授权请求 往下滑 按下“检查”
选择“下方所有请求的数据”
大家会看到我们的app提出 要分享体能训练的请求 看到我们提出的解释 按“下一步” 我们的app提出读取的请求 选择“下方所有请求的数据” 这里会看到我们app请求 要读取的数据类型
可以看到我们提出的解释 按下“完成”
现在可以开始使用体能训练了 我们需要控制HKWorkoutSession 选择WorkoutManager
让我们加上对话状态控制逻辑
一个叫做“执行”的@Published变量 会追踪对话是否在执行
暂定和重新启动功能 会暂停和重新启动这个对话 togglePause函数会根据 对话是否正在执行 而暂停或重新启动对话
endWorkout函数会结束这个对话 让我们将WorkoutManager延伸成为 HKWorkoutSession委托 来监听对话状态的改变
只要对话状态改变 就会叫出有日期功能的 workoutSession didChangeTo toState fromState
我们的执行变量 会根据toState是否在执行 以及是否被分派到用户界面的 主要队列来更新
当对话过渡到结束时 叫出在建立者上有终止日期 来停止收集体能训练样本的 endCollection 一旦endCollection结束了 叫出finishWorkout 把HKWorkout储存到健康数据库 一定要把WorkoutManager指定为 HKWorkoutSession委托
现在 我们可以让ControlsView 来暂停、重新启动和结束对话 选择ControlsView
将workoutManager 新增为EnvironmentObject 这样我们的画面就可以控制对话
让结束按钮的动作 调用workoutManager上的 endWorkout
暂停/重新启动按钮 需要暂停或重新启动对话 必且根据对话状态 来更新它的影像和图片
这个按钮的动作 会调用workoutManager的 togglePause函数 来暂停或重新启动对话
会根据workoutManager的执行变量 按钮影像的systemName 会呈现“暂停”或是“播放” 按钮下方的文字 同样会根据 workoutManager的执行变量 出现“暂停”或“重新启动” 让我们更新SessionPagingView 来显示导航栏中的体能训练名称 选择SessionPagingView
SessionPagingView需要存取 WorkoutManager的环境变量 所以我们把它加上去 现在让我们来配置导航栏 导航栏标题 是WorkoutManager的 selectedWorkout的名字 导航栏的返回键被隐藏了 因为我们不希望有人 在体能训练的过程中 回到StartView 显示NowPlayingView的时候 我们希望隐藏导航栏 当有人暂停或重新启动 他们的体能训练时 他们应该不需要滑到MetricsView 通过加入onChange画面修改器 我们可以帮他们做到这件事
当WorkoutManager的 执行宣告变量改变时 displayMetricsView函数 会被叫出来 displayMetricsView将 选择状态变量设定为 测量指标withAnimation 现在体能训练可以结束了 让我们加上显示和离开 SummaryView的函数 调用WorkoutManager
加入一个叫 “showingSummaryView”的宣告变量 它是一个预设为假的布尔
这个变量会提供一个页面选择的绑定 到在我们app的导航栏视图上 在endWorkout中 将showingSummaryView设定为真
让我们把SummaryView新增为页面到 MyWorkoutsApp的NavigationView上 单击MyWorkoutsApp
在NavigationView 加上页面视图修改器
isPresented参数 是针对workoutManager的 showingSummaryView的绑定 页面的内容是SummaryView 在SummaryView 让我们加入离开页面的函数 单击SummaryView
加入离开Dnvironment变量
在完成按钮的动作上调用dismiss()
让我们执行我们的应用程序 来开始和结束对话 并且看看SummaryView的显示 按下“停止”来停止前一次执行
按下“执行”
按下“跑步”
预设的测量指标值会在执行期间 和摘要中显示出来 我们等一下会来设定它 滑到左边 按下“暂停” 注意到现在是显示MetricsView 滑到左边
注意到现在按钮是显示“重新启动” 按下“结束”
我们的体能训练摘要会以页面呈现 往下滑 按下“完成” 会离开页面并且显示StartView 我们现在要让MetricsView 和SummaryView 显示实际的体能训练测量指标 WorkoutManager会公开 MetricsView和SummaryView 可以观察的宣告体能训练测量指标 选择WorkoutManager
让我们把宣告体能训练变量 加到WorkoutManager上
averageHeartRate 会被SummaryView使用 MetricsView会观察heartRate activeEnergy和distance WorkoutManager需要通过 成为HKLiveWorkoutBuilderDelegate 观察加到建立者的体能训练样本 让我们现在来做这件事 首先 我们先把建立者的委托 指定为WorkoutManager
现在我们让WorkoutManager 遵守HKLiveWorkout BuilderDelegate协议
我们已经延伸WorkoutManager 来遵守HKLiveWorkout BuilderDelegate协议
不论什么时候建立者收集一个事件 workoutBuilderDidCollectEvent 都会被叫出来 在我们的app中 我们会让这个功能空白
不论什么时候建立者收集新的样本 workoutBuilder didCollectDataOf collectedTypes就会被叫出来
我们会在collectedTypes中 重复每一个类别 保卫判断会确保收集的类别 是一个HKQuantityType 建立者会读取 那个数量类别的统计数据 我们待会要建立的功能 updateForStatistics会被叫出来 它能更新宣告测量指标值 让我们来建立 updateForStatistics函数
updateForStatistics会用 选择性的HKStatistics对象 如果统计数据为空值 保卫判断会提早回传
异步地分派测量指标更新 到主要队列上 在每个数列类型中切换 就heartRate而言 我们希望是每分钟的心跳数 所以我们使用HKUnit的计数 除以分钟的HKUnit 将heartRate指定为 mostRrecentQuantity的 doubleValue来表示每分钟的心跳 将averageHeartRate指定为 statistics.averageQuantity的 doubleValue来表示每分钟的心跳
在activeEnergyBurned quantityType中 使用千卡energyUnit 将activeEnergy指定为 sumQuantity的doubleValue 来表示energyUnit
就步行、跑步和自行车距离而言 使用的sumQuantity的 doubleValue来表示meterUnit 现在我们让MetricsView 使用来自WorkoutManager的 测量指标值 选择MetricsView
将workoutManager新增为环境变量
让我们用来自WorkoutManager的 测量指标值来更新我们的视图
ElapsedTimeView 使用workoutManager的 建立者的elapsedTime
activeEnergy文字显示画面的测量 使用workoutManager的 activeEnergy
heartRate文字显示画面 使用workoutManager的heartRate
距离的文字显示画面测量 则使用workoutManager的距离
建立者的经过的时间变量还没发布 所以目前建立者elapsedTime更新时 我们的视图还不会更新 我们可以做的事是将VStack 包在TimelineView中
TimelineView是今年的新内容 TimelineView会随着时间更新 并且和它的进度一致 watchOS app现在支持 “总是开启” TimelineViews能让我们的视图 察觉“总是开启”环境的改变 想了解更多 可以去看 “watchOS 8的新功能” 和“SwiftUI的新功能”这两段内容 App可以在激活状态 或是总是开启状态 在总是开启状态下 正在进行体能训练的App 最多每秒更新一次 这代表说在总是开启状态中 MetricsView必须隐藏亚秒 其他的设计也需要考虑到 总是开启状态 例如隐藏页面指示器控制 来简化画面 我们的TimelineView需要 一个会根据总是开启环境所规定的 TimelineScheduleMode来改变间隔的 自定义TimelineSchedule 让我们来建立我们的 自定义TimelineSchedule
MetricsTimelineSchedule 有一个startDate 表示进度什么时候应该开始 它的初始化程序会使用startDate
MetricsTimelineSchedule 实作入口功能来产生 PeriodicTimelineSchedule 入口 这个函数会以startDate 建立一个PeriodicTimelineSchedule 间格是通过TimelineScheduleMode 来决定的 当TimelineScheduleMode 为lowFrequency时 TimelineSchedule的间隔为一秒 当TimelineScheduleMode为正常时 间格为每秒30次 让我们把我们的VStack 包到TimelineView中
TimelineView通过 建立者的startDate 来使用我们的 MetricsTimelineSchedule ElapsedTimeView的showSubseconds 是由TimelineView的 context.cadence来决定的 当节奏是实时的 会显示亚秒 否则在总是开启状态下会隐藏亚秒 让我们回到我们的应用程序来看看 在体能训练期间测量指标的更新状态 按下“停止”来停止目前的执行 按下“执行”
按下“跑步” 注意看经过的时间正在增加 watchOS模拟器会自动模拟 帮你收集实时体能训练的样本 卡路里正在增加 心率正在更新 距离正在累积 让我们按下模拟器上的“锁定”按钮 来试试看总是开启状态
注意到亚秒被隐藏了 而且测量指标 只会每秒更新一次 按下“解锁”按钮返回激活状态
往左滑并且结束体能训练 SummaryView依然需要 真正的HKWorkout值 让我们现在来做这件事 首先 让我们把HKWorkout 加到WorkoutManager上 这样才能在SummaryView中使用 选择WorkoutManager 加上一个HKWorkout宣告变量
当建立者完成储存体能训练 在建立者的 finishWorkout函数完成时 将体能训练指定到WorkoutManager上
我们在主要队列上进行这项指定 来更新用户界面 当SummaryView离开时 我们需要重新设定我们的模型 让我们建立一个会处理这件事的 resetWorkout函数
resetWorkout函数会将我们 所有模型的变量 重新设定回初始状态 当摘要离开时 我们叫出resetWorkout 这是在showingSummaryView的 didSet完成的
让我们在体能训练结束时 显示一个进度画面 这时候刚好在储存体能训练 而且还没显示SummaryView 让我们进入SummaryView 按下SummaryView
首先 在SummaryView加上 workoutManager EnvironmentObject
我们希望ProgressView显示到 建立者完成储存体能训练 使得workoutManager 让HKWorkout被指定为止
如果workoutManager的 体能训练是空值 就会显示ProgressView 上面会有文字显示“储存体能训练” 并且隐藏导航栏
我们也更新了ActivityRingsView 来使用workoutManager的 HKHealthStore 每一个app上 你只需要 单一HKHealthStore 让我们更新我们的 SummaryMetricViews 来使用HKWorkout值
全部时间测量指标视图 使用体能训练的时间长度
totalDistance测量指标视图 使用体能训练的全部距离
全部测量指标视图 使用体能训练的totalEnergyBurned
平均心率测量指标视图 使用workoutManager的 averageHeartRate 如果你想先将平均心率存下来 在储存体能训练前 你可以把它以元数据的形式 新增到建立者上 让我们更新SessionPagingView 来对总是开启状态作出反应 选择SessionPagingView
新增isLuminanceReduced Environment变量
在总是开启状态期间 我们希望隐藏TabView的页面指示器 以及确保MetricsView被显示出来
根据isLuminanceReduced 我们已经把tabViewStyle的 indexDisplayMode设定为永不或自动 当isLuminanceReduced改变时 调用displayMetricsView函数 来显示MetricsView 让我们在模拟器中执行并且试试看 我们的应用程序 按下“停止”来停止前一次执行 按下“执行”
选择“跑步” 注意到测量指标现在正从建立者 实时更新 滑到左边 按下“暂停” 注意到测量指标停止更新 因为体能训练被暂停了 滑到左边 按下重新启动 测量指标重新开始更新 滑到右边 可以看到NowPlayingView 滑到左边 按下“锁定”来启动总是开启状态 注意到亚秒被隐藏起来 而且页面控制指示器也被隐藏起来了 按下“解锁”来恢复激活状态
滑到左边 按下“结束”
体能训练储存下来 摘要显示出来 往下滑来检视每一个测量指标 活动圆圈会根据消耗多少能量 做了多少分钟的运动 和站立时间长短来填充 按下“完成” 现在我们会到开始画面 可以进行下一个体能训练了
大家看到用SwiftUI来实作 功能完全的体能训练app 而且结合支持总是开启状态的 HealthKit有多简单 我们很期待看到大家会接下来 会建立出多棒的体能训练app! ♪
-
-
3:17 - StartView - import HealthKit
import HealthKit
-
3:25 - StartView - workoutTypes
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]
-
3:26 - StartView - HKWorkoutActivityType identifiable and name
extension HKWorkoutActivityType: Identifiable { public var id: UInt { rawValue } var name: String { switch self { case .running: return "Run" case .cycling: return "Bike" case .walking: return "Walk" default: return "" } } }
-
4:22 - StartView - body
List(workoutTypes) { workoutType in NavigationLink( workoutType.name, destination: Text(workoutType.name) ).padding( EdgeInsets(top: 15, leading: 5, bottom: 15, trailing: 5) ) } .listStyle(.carousel) .navigationBarTitle("Workouts")
-
6:55 - SessionPagingView - Tab enum and selection
@State private var selection: Tab = .metrics enum Tab { case controls, metrics, nowPlaying }
-
7:20 - SessionPagingView - TabView
TabView(selection: $selection) { Text("Controls").tag(Tab.controls) Text("Metrics").tag(Tab.metrics) Text("Now Playing").tag(Tab.nowPlaying) }
-
9:02 - MetricsView - VStack and TextViews
VStack(alignment: .leading) { Text("03:15.23") .foregroundColor(Color.yellow) .fontWeight(.semibold) Text( Measurement( value: 47, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( 153.formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: 515, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) } .font(.system(.title, design: .rounded) .monospacedDigit() .lowercaseSmallCaps() ) .frame(maxWidth: .infinity, alignment: .leading) .ignoresSafeArea(edges: .bottom) .scenePadding()
-
11:42 - ElapsedTimeView - ElapsedTimeView and ElapsedTimeFormatter
struct ElapsedTimeView: View { var elapsedTime: TimeInterval = 0 var showSubseconds: Bool = true @State private var timeFormatter = ElapsedTimeFormatter() var body: some View { Text(NSNumber(value: elapsedTime), formatter: timeFormatter) .fontWeight(.semibold) .onChange(of: showSubseconds) { timeFormatter.showSubseconds = $0 } } } class ElapsedTimeFormatter: Formatter { let componentsFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.minute, .second] formatter.zeroFormattingBehavior = .pad return formatter }() var showSubseconds = true override func string(for value: Any?) -> String? { guard let time = value as? TimeInterval else { return nil } guard let formattedString = componentsFormatter.string(from: time) else { return nil } if showSubseconds { let hundredths = Int((time.truncatingRemainder(dividingBy: 1)) * 100) let decimalSeparator = Locale.current.decimalSeparator ?? "." return String(format: "%@%@%0.2d", formattedString, decimalSeparator, hundredths) } return formattedString } }
-
13:56 - MetricsView - replace TextView with ElapsedTimeView
ElapsedTimeView( elapsedTime: 3 * 60 + 15.24, showSubseconds: true ).foregroundColor(Color.yellow)
-
14:47 - ControlsView - Stacks, Buttons and TextViews
HStack { VStack { Button { } label: { Image(systemName: "xmark") } .tint(Color.red) .font(.title2) Text("End") } VStack { Button { } label: { Image(systemName: "pause") } .tint(Color.yellow) .font(.title2) Text("Pause") } }
-
16:05 - SessionPagingView - import WatchKit
import WatchKit
-
16:09 - SessionPagingView - TabView using actual views
ControlsView().tag(Tab.controls) MetricsView().tag(Tab.metrics) NowPlayingView().tag(Tab.nowPlaying)
-
17:08 - StartView - NavigationLink to use SessionPagingView
destination: SessionPagingView()
-
17:50 - SummaryView - SummaryMetricView
struct SummaryMetricView: View { var title: String var value: String var body: some View { Text(title) Text(value) .font(.system(.title2, design: .rounded) .lowercaseSmallCaps() ) .foregroundColor(.accentColor) Divider() } }
-
18:27 - SummaryView - durationFormatter
@State private var durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] formatter.zeroFormattingBehavior = .pad return formatter }()
-
18:45 - SummaryView - body
ScrollView(.vertical) { VStack(alignment: .leading) { SummaryMetricView( title: "Total Time", value: durationFormatter.string(from: 30 * 60 + 15) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: 1625, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Energy", value: Measurement( value: 96, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: 143 .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ).accentColor(Color.red) Button("Done") { } } .scenePadding() } .navigationTitle("Summary") .navigationBarTitleDisplayMode(.inline)
-
21:00 - ActivityRingsView
import HealthKit import SwiftUI struct ActivityRingsView: WKInterfaceObjectRepresentable { let healthStore: HKHealthStore func makeWKInterfaceObject(context: Context) -> some WKInterfaceObject { let activityRingsObject = WKInterfaceActivityRing() let calendar = Calendar.current var components = calendar.dateComponents([.era, .year, .month, .day], from: Date()) components.calendar = calendar let predicate = HKQuery.predicateForActivitySummary(with: components) let query = HKActivitySummaryQuery(predicate: predicate) { query, summaries, error in DispatchQueue.main.async { activityRingsObject.setActivitySummary(summaries?.first, animated: true) } } healthStore.execute(query) return activityRingsObject } func updateWKInterfaceObject(_ wkInterfaceObject: WKInterfaceObjectType, context: Context) { } }
-
22:15 - SummaryView - add ActivityRingsView
Text("Activity Rings") ActivityRingsView( healthStore: HKHealthStore() ).frame(width: 50, height: 50)
-
22:28 - SummaryView - import HealthKit
import HealthKit
-
25:22 - WorkoutManager
import HealthKit class WorkoutManager: NSObject, ObservableObject { }
-
25:53 - MyWorkoutsApp - add workoutManager @StateObject
@StateObject var workoutManager = WorkoutManager()
-
26:00 - MyWorkoutsApp - .environmentObject to NavigationView
.environmentObject(workoutManager)
-
26:25 - WorkoutManager - selectedWorkout
var selectedWorkout: HKWorkoutActivityType?
-
26:49 - StartView - add workoutManager
@EnvironmentObject var workoutManager: WorkoutManager
-
26:56 - StartView - Add tag and selection to NavigationLink
, tag: workoutType, selection: $workoutManager.selectedWorkout
-
27:32 - WorkoutManager - Add healthStore, session, builder
let healthStore = HKHealthStore() var session: HKWorkoutSession? var builder: HKLiveWorkoutBuilder?
-
27:42 - WorkoutManager - startWorkout(workoutType:)
func startWorkout(workoutType: HKWorkoutActivityType) { let configuration = HKWorkoutConfiguration() configuration.activityType = workoutType configuration.locationType = .outdoor do { session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) builder = session?.associatedWorkoutBuilder() } catch { // Handle any exceptions. return } builder?.dataSource = HKLiveWorkoutDataSource( healthStore: healthStore, workoutConfiguration: configuration ) // Start the workout session and begin data collection. let startDate = Date() session?.startActivity(with: startDate) builder?.beginCollection(withStart: startDate) { (success, error) in // The workout has started. } }
-
29:06 - WorkoutManager - selectedWorkout didSet
{ didSet { guard let selectedWorkout = selectedWorkout else { return } startWorkout(workoutType: selectedWorkout) } }
-
29:35 - WorkoutManager - requestAuthorization from HealthKit
// Request authorization to access HealthKit. func requestAuthorization() { // The quantity type to write to the health store. let typesToShare: Set = [ HKQuantityType.workoutType() ] // The quantity types to read from the health store. let typesToRead: Set = [ HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!, HKQuantityType.quantityType(forIdentifier: .distanceCycling)!, HKObjectType.activitySummaryType() ] // Request authorization for those quantity types. healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in // Handle error. } }
-
30:20 - StartView - requestAuthorization onAppear
.onAppear { workoutManager.requestAuthorization() }
-
31:30 - Privacy - Health Share Usage Description - Key
NSHealthShareUsageDescription
-
31:38 - Privacy - Health Share Usage Description - Value
Your workout related data will be used to display your saved workouts in MyWorkouts.
-
31:47 - Privacy - Health Update Usage Description - Key
NSHealthUpdateUsageDescription
-
31:54 - Privacy - Health Update Usage Description - Value
Workouts tracked by MyWorkouts on Apple Watch will be saved to HealthKit.
-
33:29 - WorkoutManager - session state control
// MARK: - State Control // The workout session state. @Published var running = false func pause() { session?.pause() } func resume() { session?.resume() } func togglePause() { if running == true { pause() } else { resume() } } func endWorkout() { session?.end() }
-
34:11 - WorkoutManager - HKWorkoutSessionDelegate
// MARK: - HKWorkoutSessionDelegate extension WorkoutManager: HKWorkoutSessionDelegate { func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { DispatchQueue.main.async { self.running = toState == .running } // Wait for the session to transition states before ending the builder. if toState == .ended { builder?.endCollection(withEnd: date) { (success, error) in self.builder?.finishWorkout { (workout, error) in } } } } func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { } }
-
34:58 - WorkoutManager - assign HKWorkoutSessionDelegate in startWorkout()
session?.delegate = self
-
35:22 - ControlsView - workoutManager environmentObject
@EnvironmentObject var workoutManager: WorkoutManager
-
35:33 - ControlsView - End Button action
workoutManager.endWorkout()
-
35:43 - ControlsView - Pause / Resume Button and Text
Button { workoutManager.togglePause() } label: { Image(systemName: workoutManager.running ? "pause" : "play") } .tint(Color.yellow) .font(.title2) Text(workoutManager.running ? "Pause" : "Resume")
-
36:30 - SessionPagingView - add workoutManager environment variable
@EnvironmentObject var workoutManager: WorkoutManager
-
36:42 - SessionPagingView - navigationBar
.navigationTitle(workoutManager.selectedWorkout?.name ?? "") .navigationBarBackButtonHidden(true) .navigationBarHidden(selection == .nowPlaying)
-
37:10 - SessionPagingView - onChange of workoutManager.running
.onChange(of: workoutManager.running) { _ in displayMetricsView() } } private func displayMetricsView() { withAnimation { selection = .metrics } }
-
37:45 - WorkoutManager - showingSummaryView
@Published var showingSummaryView: Bool = false { didSet { // Sheet dismissed if showingSummaryView == false { selectedWorkout = nil } } }
-
37:59 - WorkoutManager - showingSummaryView true in endWorkout
showingSummaryView = true
-
38:22 - MyWorkoutApp - add summaryView sheet to NavigationView
.sheet(isPresented: $workoutManager.showingSummaryView) { SummaryView() }
-
38:49 - SummaryView - add dismiss environment variable
@Environment(\.dismiss) var dismiss
-
38:58 - SummaryView - add dismiss() to done button
dismiss()
-
40:25 - WorkoutManager - Metric publishers
// MARK: - Workout Metrics @Published var averageHeartRate: Double = 0 @Published var heartRate: Double = 0 @Published var activeEnergy: Double = 0 @Published var distance: Double = 0
-
40:48 - WorkoutManager - assigned as HKLiveWorkoutBuilderDelegate in startWorkout()
builder?.delegate = self
-
41:05 - WorkoutManager - add HKLiveWorkoutBuilderDelegate extension
// MARK: - HKLiveWorkoutBuilderDelegate extension WorkoutManager: HKLiveWorkoutBuilderDelegate { func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { } func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) { for type in collectedTypes { guard let quantityType = type as? HKQuantityType else { return } let statistics = workoutBuilder.statistics(for: quantityType) // Update the published values. updateForStatistics(statistics) } } }
-
42:01 - WorkoutManager - add updateForStatistics()
func updateForStatistics(_ statistics: HKStatistics?) { guard let statistics = statistics else { return } DispatchQueue.main.async { switch statistics.quantityType { case HKQuantityType.quantityType(forIdentifier: .heartRate): let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute()) self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0 self.averageHeartRate = statistics.averageQuantity()?.doubleValue(for: heartRateUnit) ?? 0 case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): let energyUnit = HKUnit.kilocalorie() self.activeEnergy = statistics.sumQuantity()?.doubleValue(for: energyUnit) ?? 0 case HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), HKQuantityType.quantityType(forIdentifier: .distanceCycling): let meterUnit = HKUnit.meter() self.distance = statistics.sumQuantity()?.doubleValue(for: meterUnit) ?? 0 default: return } } }
-
43:25 - MetricsView - add workoutManager as environment variable to MetricsView
@EnvironmentObject var workoutManager: WorkoutManager
-
43:35 - MetricsView - VStack with Text bound to workoutManager variables
VStack(alignment: .leading) { ElapsedTimeView( elapsedTime: workoutManager.builder?.elapsedTime ?? 0, showSubseconds: true ).foregroundColor(Color.yellow) Text( Measurement( value: workoutManager.activeEnergy, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( workoutManager.heartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: workoutManager.distance, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) }
-
45:51 - MetricsView - MetricsTimelineSchedule
private struct MetricsTimelineSchedule: TimelineSchedule { var startDate: Date init(from startDate: Date) { self.startDate = startDate } func entries(from startDate: Date, mode: TimelineScheduleMode) -> PeriodicTimelineSchedule.Entries { PeriodicTimelineSchedule( from: self.startDate, by: (mode == .lowFrequency ? 1.0 : 1.0 / 30.0) ).entries( from: startDate, mode: mode ) } }
-
46:38 - MetricsView - TimelineView wrapping VStack
TimelineView( MetricsTimelineSchedule( from: workoutManager.builder?.startDate ?? Date() ) ) { context in VStack(alignment: .leading) { ElapsedTimeView( elapsedTime: workoutManager.builder?.elapsedTime ?? 0, showSubseconds: context.cadence == .live ).foregroundColor(Color.yellow) Text( Measurement( value: workoutManager.activeEnergy, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( workoutManager.heartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: workoutManager.distance, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) } .font(.system(.title, design: .rounded) .monospacedDigit() .lowercaseSmallCaps() ) .frame(maxWidth: .infinity, alignment: .leading) .ignoresSafeArea(edges: .bottom) .scenePadding() }
-
48:23 - WorkoutManager - workout: HKWorkout added
@Published var workout: HKWorkout?
-
48:38 - WorkoutManager - assign HKWorkout in finishWorkout
DispatchQueue.main.async { self.workout = workout }
-
48:57 - WorkoutManager - resetWorkout()
func resetWorkout() { selectedWorkout = nil builder = nil session = nil workout = nil activeEnergy = 0 averageHeartRate = 0 heartRate = 0 distance = 0 }
-
49:21 - WorkoutManager - add resetWorkout to showingSummaryView didSet
resetWorkout()
-
49:48 - SummaryView - add workoutManager
@EnvironmentObject var workoutManager: WorkoutManager
-
50:06 - SummaryView - add ProgressView
if workoutManager.workout == nil { ProgressView("Saving workout") .navigationBarHidden(true) } else { ScrollView(.vertical) { VStack(alignment: .leading) { SummaryMetricView( title: "Total Time", value: durationFormatter.string(from: 30 * 60 + 15) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: 1625, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Calories", value: Measurement( value: 96, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: 143.formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text("Activity Rings") ActivityRingsView(healthStore: workoutManager.healthStore) .frame(width: 50, height: 50) Button("Done") { dismiss() } } .scenePadding() } .navigationTitle("Summary") .navigationBarTitleDisplayMode(.inline) }
-
50:43 - SummaryView - SummaryMetricViews using HKWorkout values
SummaryMetricView( title: "Total Time", value: durationFormatter .string(from: workoutManager.workout?.duration ?? 0.0) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: workoutManager.workout?.totalDistance? .doubleValue(for: .meter()) ?? 0, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Energy", value: Measurement( value: workoutManager.workout?.totalEnergyBurned? .doubleValue(for: .kilocalorie()) ?? 0, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: workoutManager.averageHeartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ).accentColor(Color.red)
-
51:45 - SessionPagingView - add isLuminanceReduced
@Environment(\.isLuminanceReduced) var isLuminanceReduced
-
51:57 - SessionPagingView - add tabViewStyle and onChangeOf based on isLuminanceReduced
.tabViewStyle( PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic) ) .onChange(of: isLuminanceReduced) { _ in displayMetricsView() }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。