第20章:令人愉悦的用户体验--Layout¶
随着功能的完成和你的应用程序运行得如此之好,现在是时候让用户界面看起来和感觉令人愉快了。遵循帕累托80/20
原则,这最后20%
的代码往往会花费80%
的时间。但这是值得的,因为虽然确保应用程序的运行很重要,但除非你的应用程序看起来和感觉很好,否则没有人会愿意使用它。
启动程序¶
自上一章的挑战项目以来,该项目有了一些变化。这些是主要的变化:
- 为了防止巨大的、单一的视图,经常进行重构是个好主意。
CardDetailView
越来越难读了,所以启动程序已经把模态视图移到它们自己的视图修改器CardModalViews
中。 - 资产目录中有更多令人愉悦的随机颜色可用于背景,以及你在最后这些章节中会用到的其他颜色。
-
ResizableView
使用了一个视图比例因子,这样以后你就可以很容易地缩放卡片。默认的比例是1,所以开始时你不会注意到它。 -
CardsApp
用提供的默认预览数据初始化应用程序数据,这样你就有了和章节一样的数据。当你想再次开始保存自己的卡片时,记得在CardsApp.swift
中改成@StateObject var store = CardStore()
。 - 在
CardStore
中修正了卡片的删除,这样删除的卡片会从Documents
以及cards
中删除所有图片文件。 CardDrop
有size
和frame
属性,你将在挑战赛中使用。
这是你到目前为止创建的应用程序的视图层次结构。
正如你所看到的,它是非常模块化的。例如,你可以改变卡片缩略图的样子,并把它直接插回去。你可以很容易地在工具栏上添加按钮,并添加一个相应的模态。
你实例化了一个单一的真理源--CardStore
--并通过绑定将其传递给所有这些视图。
设计卡片列表¶
这个应用程序的设计者为光明和黑暗模式提出了这样的设计:
顶部的分段控制器将在网格列表视图和旋转木马之间交换显示。底部的按钮很宽。这是你要尝试复制的设计。
添加列表的背景颜色¶
➤ 在向项目添加任何东西之前,在模拟器中构建并运行应用程序,并选择Device ▸ Erase All
所有内容和设置...
这将删除你到目前为止为该应用创建的所有数据。目前,你将使用该应用程序提供的默认数据。
➤ 打开CardsView.swift
。
➤ 给ZStack
添加一个修改器:
.background(
Color("background")
.edgesIgnoringSafeArea(.all))
这将使用资产目录中一个名为background
的颜色作为背景色。这被定义为浅灰色代表浅色外观,深灰色代表深色外观。通过使用edgesIgnoringSafeArea(_:)
,可以确保背景覆盖所有的屏幕。
➤ 预览视图。在此图像中,为了清晰起见,背景颜色是粉红色;你的背景颜色将是浅灰色。
即使你忽略了所有的安全区域,背景色也不是显示在整个视图中,而是只显示在滚动视图的区域。这是因为ZStack
只占用其子视图所需的空间。
Layout
¶
本节中你将学习的技能:控制视图布局
现在是时候深入了解一下SwiftUI
是如何处理视图布局的。大多数时候,SwiftUI
的视图都是自己布局的,而且看起来很好,你根本不需要考虑布局问题。但是,当你想要精确的定位,或者一个视图的行为方式与你想象的不一样时,你可能会开始与系统对抗。一旦你理解了布局,并从逻辑上对待它,那么一切都会变得容易得多。
布局是从视图层次结构的顶端开始的。父视图告诉它的子视图,"我建议这个尺寸"。然后每个子视图在父视图的可用空间内占用尽可能多的空间,并告诉父视图"我只需要这个尺寸"。这个过程一直延续到视图层次结构中。然后,父视图将自己的大小调整为其子视图的大小。
➤ 创建一个名为LayoutView.swift
的新SwiftUI
视图文件,以试验各种布局。如果你的文件中仍有ContentView.swift
,你可以用它来代替。
➤ 在 LayoutView_Previews
中,为LayoutView
添加一个新的修改器:
.previewLayout(.fixed(width: 500, height: 300))
这给预览提供了一个固定的尺寸,即500 x 300
。
➤ 在LayoutView
中,为Text
添加一个新的修改器:
.background(Color.red)
➤ 预览该视图。红色显示了Text
视图在屏幕上占用的空间。
在这里的视图树层次中,有三个视图:
LayoutView ➤ Text (modified) ➤ Red
LayoutView
有一个500x300
点的固定尺寸。Text
占用指定字体大小的字母所需的空间。Color
有点不同。它是一个迟到的绑定标记,这意味着尺寸是在最后一刻分配的。
一个Color
视图会填满其父辈的整个空间。
➤ 将LayoutView
改为:
struct LayoutView: View {
var body: some View {
HStack {
Text("Hello, World!")
.background(Color.red)
Text("Hello, World!")
.padding()
.background(Color.red)
}
.background(Color.gray)
}
}
➤ 在这里,你创建了一个有两个Text
视图的水平堆栈。第二个Text
有填充。
现在的视图树是:
LayoutView ➤ HStack ➤ Text (modified) ➤ Red
➤ Text (modified) ➤ Padding (modified) ➤ Red
➤ Gray
LayoutView
仍然有500x300
点的固定尺寸。HStack
将500x300
点呈现给它的孩子。第一个Text
返回它所需要的空间,但是第二个文本有一个padding
修改器,所以返回它的空间加上padding
。然后HStack
只占用它的两个子视图所需的空间,加上HStack
在两个子视图之间的默认填充。HStack
的灰色背景颜色填补了HStack
在两个Text
视图下面所占的空间。
每次你添加一个修改器,你都会在视图层次中创建一个新层。但是不要担心这样做的效率--SwiftUI
的视图是轻量级的,添加新的视图是非常快的。
框架修改器¶
在以前的代码中,你使用frame(width:height:alignment:)
改变了视图的默认尺寸,给width
和height
以绝对值。
当你想相对于父视图的尺寸来布局视图时,你可以使用frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
指定最小和最大的宽度和高度。
➤ 在.background(Color.gray)
之前,添加这个:
.frame(maxWidth: .infinity)
HStack
现在告诉它的父辈,它想要最大的可用宽度,所以HStack
,用灰色的颜色,扩展到整个视图的宽度。
还记得你之前提出的背景颜色只占ScrollView
的宽度的问题吗?指定一个maxWidth
和maxHeight
为infinity
的框架,是填满整个可用背景的一种方法。
GeometryReader
¶
本节将学习的技能: GeometryReader
; 使用给定的视图尺寸来布局子视图
然而,当你需要知道父视图的尺寸,以便你能更精确地布局子视图时,还有一种灵活的视图可以占用整个可用空间,并以点为单位给出尺寸。GeometryReader
是一个容器视图,可以返回它的首选尺寸。稍后,你将使用GeometryReader
根据可用空间的宽度来确定卡片缩略图的大小。
➤ 在LayoutView
中,将HStack
嵌入到GeometryReader
中,并给它一个黄色背景:
GeometryReader { proxy in
HStack {
...
}
.frame(maxWidth: .infinity)
.background(Color.gray)
}
.background(Color.yellow)
GeometryReader
占用了父体的大小,在这里是整个500 x 300
点的视图。它返回一个GeometryProxy
类型的值,其中包括一个size
属性,这样你就可以准确地找到视图的尺寸。然后你可以用这个尺寸来布置子视图。
注意GeometryReader
改变了对齐行为。HStack
不再在其父视图中居中,现在它被对齐到其父视图的左上方。在本章后面,你会发现更多关于对齐的内容。
➤ 将HStack
的修改器改为:
.frame(width: proxy.size.width 0.8)
.background(Color.gray)
.padding(
.leading, (proxy.size.width - proxy.size.width 0.8) / 2)
frame(width:height:alignment)
现在使用一个相对值,即可用区域宽度的五分之四。如果父视图变大,例如在设备旋转时,proxy.size
将更新并刷新视图。视图将调整到新的父级尺寸的五分之四。
为了使HStack
居中,你要使用几何代理宽度来计算前导填充。
注意这些修饰语的顺序。如果你改变其中任何一个的顺序,你会得到一个不同的结果。在填充颜色之前,你必须设置视图的大小。如果你在填充灰色之前计算了padding
,那么你会把文本视图放在中心位置,但不会把背景灰色放在中心位置。
设置卡片缩略图的大小¶
当在iPad
上显示卡片缩略图列表时,你有比小型设备更多的空间,所以缩略图的尺寸应该更大。如果宽度大于500
点的阈值,你就会显示一个更大的缩略图。测试设备尺寸的一种方法是使用紧凑或常规布局。另外,你可以使用GeometryReader
获得视图的确切尺寸,这就是你在这里要使用的方法。
➤ 打开CardsListView.swift
。
➤ 在GeometryReader
中嵌入ScrollView
。
GeometryReader { proxy in
ScrollView(showsIndicators: false) {
...
}
}
➤ 打开CardsView.swift
并预览。
注意使用GeometryReader
的副作用。在CardsView.swift
中,CardsListView
的父级是ZStack
。GeometryReader
占用了所有的可用空间并将其传回给ZStack
。这意味着ZStack
的浅灰色背景颜色现在充满了整个屏幕。
第二个副作用是ScrollView
的中心对齐方式消失了。你将在不久后添加网格视图时解决这个问题。
➤ 再次打开CardsListView.swift
。
在ScrollView
中,你现在可以访问proxy.size
,它给你提供了CardsListView
的父节点的全部可用空间。
➤ 将CardThumbnailView(card: card)
改为:
CardThumbnailView(card: card, size: proxy.size)
你现在把尺寸传给缩略图,它可以采取适当的行动。在修复CardThumbnailView
之前,你会得到一个编译错误。
➤ 打开CardThumbnailView.swift
。
➤ 在卡片属性后面添加大小:
var size: CGSize = .zero
➤ 打开Settings.swift
,用thumbnailSize
代替:
static func thumbnailSize(size: CGSize) -> CGSize {
let threshold: CGFloat = 500
var scale: CGFloat = 0.12
if size.width > threshold && size.height > threshold {
scale = 0.2
}
return CGSize(
width: Settings.cardSize.width scale,
height: Settings.cardSize.height scale)
}
缩略图的尺寸将缩减到卡片最终尺寸的12%
。当提供的尺寸的宽度或高度大于500
点时,就会缩放到最终尺寸的20%
。
➤ 在CardThumbnailView.swift
中,将 frame(width:height:)
替换为:
.frame(
width: Settings.thumbnailSize(size: size).width,
height: Settings.thumbnailSize(size: size).height)
现在你知道了卡片列表的可用屏幕空间,就可以相应地计算出缩略图的框架。
➤ 在iPad
和iPhone
模拟器上建立和运行,并比较两个缩略图的大小。
iPad
上的缩略图尺寸要比iPhone
上的大。
添加一个懒惰的网格视图¶
本节中你将学习的技能:GeometryProxy
的尺寸计算
你将添加一个LazyVGrid
来显示多列的卡片,而不是显示一列滚动的卡片。这应该是自适应的,取决于设备的当前显示宽度。
➤ 打开CardsListView.swift
,为CardsListView
添加一个新方法:
func columns(size: CGSize) -> [GridItem] {
[
GridItem(.adaptive(
minimum: Settings.thumbnailSize(size: size).width))
]
}
这将返回一个GridItem
数组--在本例中,有一个元素--你可以用它来告诉LazyVGrid
每一行的大小和位置。这个GridItem
是自适应的,这意味着网格将在提供的最小尺寸下尽可能多地容纳项目。
➤ 在body
中,将ForEach
嵌入到LazyVGrid
中:
GeometryReader { proxy in
ScrollView(showsIndicators: false) {
LazyVGrid(columns: columns(size: proxy.size), spacing: 30) {
ForEach(store.cards) { card in
...
}
}
}
}
现在你有一个灵活的网格,垂直间距为30
点。
➤ 在各种模拟器上构建并运行该应用程序,并从纵向切换到横向,以观察列的变化。
Note
如果你想直观地了解视图所占用的空间,可以尝试在各种视图中添加.background(Color.red)
作为修改器。
创建新卡的按钮¶
现在你要在屏幕的脚下放置一个按钮来创建一个新的卡片。
➤ 打开CardsView.swift
。
➤ 在CardsView
中,删除VStack
及其内容,这样ZStack
只包含SingleCardView
及其条件:
ZStack {
if !viewState.showAllCards {
SingleCardView()
}
}
.background...
➤ 在CardsView
中,创建一个新的按钮属性:
var createButton: some View {
// 1
Button(action: {
viewState.selectedCard = store.addCard()
viewState.showAllCards = false
}) {
Label("Create New", systemImage: "plus")
}
.font(.system(size: 16, weight: .bold))
// 2
.frame(maxWidth: .infinity)
.padding([.top, .bottom], 10)
// 3
.background(Color("barColor"))
}
你并不总是要为视图创建新的结构。有时,如果它是一个简单的视图,而且你只使用一次,那么将视图作为属性或方法进行跟踪会更容易。
浏览一下这段代码:
- 使用
Label
格式创建一个简单的按钮,这样你可以指定一个系统图像。当点击时,你创建一个新的卡片并将其分配给viewState.selectedCard
。你把viewState.showAllCards
设置为false
,这样SingleCardView
就会显示。 - 按钮一直延伸到整个屏幕上,减去填充物。
- 背景颜色在资产目录中。你很快就会自定义按钮的文本颜色。
你将把按钮作为另一个层添加到CardsListView
的上面。
➤ 在ZStack
的顶部,添加这个代码:
CardsListView()
VStack {
Spacer()
createButton
}
VStack
和Spacer
将把按钮放在屏幕的底部。
现在CardsView
看起来像这样:
ZStack {
CardsListView()
VStack {
Spacer()
createButton
}
if !viewState.showAllCards ...
}
➤ 构建和运行(或使用实时预览)并测试您的新按钮。
这个按钮的代码有一个"疑点"。虽然按钮的框架一直延伸到整个屏幕,但只有文本是可以触摸的。
➤ 在createButton
中,将frame(maxWidth: .infinity)
从Button
的修改器移到Label
的修改器:
Button(action: {
...
}) {
Label("Create New", systemImage: "plus")
.frame(maxWidth: .infinity)
}
...
➤ 建立并再次运行。这个按钮看起来是一样的,但是可以一路敲击。
勾勒卡片的轮廓¶
打开CardThumbnailView.swift
。
使用RoundedRectangle
的一个替代方法是使用卡片的背景颜色作为视图。
➤ 将RoundedRectangle(cornerRadius:)
和foregroundColor(_:)
改为:
card.backgroundColor
.cornerRadius(10)
这将改变角的半径,以配合设计,但除此之外,产生的结果与以前相同。
➤ 在frame(width:height:alignment:)
之后,向card.background
添加一个修改器:
.shadow(
color: Color("shadow-color"),
radius: 3,
x: 0.0,
y: 0.0)
在这里,你用你指定的颜色和3
的半径添加一个阴影。由于x
和y
的位置都是零,阴影将是三个点,围绕着视图。
这是一个非常微妙的轮廓颜色,但如果你的设计师告诉你要添加它,请相信设计师。:]
➤ 暂时将card.backgroundColor
改为:
Color(UIColor.systemBackground)
由于卡的颜色现在与屏幕的背景颜色相同,你就可以看到阴影了。
➤ 预览视图,在检查预览中的深色和浅色方案之间切换。
➤ 将Color(UIColor.systemBackground)
改回:
card.backgroundColor
这将恢复你的卡片的背景颜色。
设计卡片详情界面¶
本节中你将学习的技能:重点颜色;缩放固定尺寸视图
自定义重点颜色¶
应用程序的重点颜色决定了应用程序控件上文本的默认颜色。你可以通过改变资产目录中的AccentColor
颜色来为整个应用程序设置这个颜色,也可以用accentColor(_:)
修改器来改变每个视图的强调色。默认的是蓝色,这对文本按钮来说,效果一点也不好:
➤ 打开Assets.xcassets
并选择AccentColor
。
使用应用程序模板创建新项目时,会自动创建AccentColor
。
➤ 将任何外观的颜色改为黑色,暗色外观的颜色改为白色。
这将改变整个应用程序中所有控件的默认重点颜色。
➤ 打开CardsView.swift
并预览它。
现在,创建按钮的文本是黑色的,不会显示在黑条上。
➤ 在createButton
中,在background(Color("barColor")
后添加一个新的修改器:
.accentColor(.white)
由于按钮在浅色和深色的外观下都是深色的,所以将按钮的重点颜色设置为始终是白色。
➤ 实时预览浅色和深色两种颜色方案中的视图:
在整个应用程序中,除了在特定的视图上指定accentColor(_:)
外,文本在Assets.xcassets
中采用了AccentColor
。
缩放卡片以适应设备¶
目前,无论你使用什么设备或方向,卡片都会占据屏幕的全部尺寸。当你创建了一个纵向的卡片,然后把设备转为横向时,这显然是不行的。
你要创建的卡片的固定尺寸是1300
乘2000
。无论方向如何,整个卡片都将一次可见,你将使用几何读卡器代理尺寸来计算卡片视图的适当尺寸。
➤ 打开CardDetailView.swift
。
➤ 向CardDetailView
添加这些新方法:
func calculateSize(_ size: CGSize) -> CGSize {
var newSize = size
let ratio =
Settings.cardSize.width / Settings.cardSize.height
if size.width < size.height {
newSize.height = min(size.height, newSize.width / ratio)
newSize.width = min(size.width, newSize.height ratio)
} else {
newSize.width = min(size.width, newSize.height ratio)
newSize.height = min(size.height, newSize.width / ratio)
}
return newSize
}
func calculateScale(_ size: CGSize) -> CGFloat {
let newSize = calculateSize(size)
return newSize.width / Settings.cardSize.width
}
这些方法使用一个给定的尺寸,以正确的长宽比计算卡片视图的大小和比例。这个尺寸将来自于GeometryReader
的GeometryProxy
。
➤ 在body
中,将content
嵌入到GeometryReader
中:
var body: some View {
GeometryReader { proxy in
content
.onChange(of: scenePhase) ...
现在可以使用几何读取器的代理尺寸来计算content
的框架。
➤ 在cardModals(card:currentModal:)
之后向content
添加这些修改器:
// 1
.frame(
width: calculateSize(proxy.size).width ,
height: calculateSize(proxy.size).height)
// 2
.clipped()
// 3
.frame(maxWidth: .infinity, maxHeight: .infinity)
在这几个修饰语中,有很多的布局:
- 鉴于可用空间,计算卡片视图的大小。
- 背景颜色会溢出框架,所以要把它剪掉。
- 确保
content
占据所有可用的空间。这将使卡片视图在几何阅读器中居中。
➤ 在视图组中,打开ResizableView.swift
。
请注意,这个文件中的新变化调整了所有的偏移和尺寸,使其按viewScale
的比例进行缩放。这个默认值是1
,所以如果你不想的话,就不必指定一个视图比例。
➤ 再次打开CardDetailView.swift
。
➤ 在var content
中,将resizableView(transform: bindingTransform(for: element))
改为:
.resizableView(
transform: bindingTransform(for: element),
viewScale: calculateScale(size))
当ResizableView
转换每个元素的大小时,它现在使用用代理大小计算的比例。
不幸的是,你的应用程序无法编译,因为proxy
的size
对content
不可用。
➤ 将var content: some View {
改为:
func content(size: CGSize) -> some View {
这就把content
改成了一个方法,而不是一个属性,这样就可以传递几何代理的尺寸。
➤ 在body
中,将content
改为:
content(size: proxy.size)
➤ 在各种设备和方向上构建和运行,并检查您新缩放的卡片视图。卡片保持纵向,并固定为按比例的1300×2000
的大小。
不幸的是,现在你有了一个新的问题! 当你添加照片时,由于卡片被缩放,它太小了,无法管理。
➤ 打开Settings.swift
,将defaultElementSize
改为:
static let defaultElementSize =
CGSize(width: 800, height: 800)
➤ 构建并运行,现在可以添加适合设备尺寸的元素。
Alignment
¶
本节将学习的技能:堆栈对齐
布局中的最后一个主题,你将涉及的是对齐。再看一下前面的图片。目前,你的工具栏按钮中的图像大小不一,这使得按钮的文字错位。你的关注细节的基因应该因为这个而在心里哭泣。
VStack(alignment:spacing:)
和HStack(alignment:spacing:)
有可选的对齐参数。
用HStack
来描述子视图在垂直方向的对齐方式,用VStack
来描述视图的水平对齐方式。
➤ 打开CardBottomToolbar.swift
并预览该视图。
Xcode
不要忘记你的键盘快捷键Shift-Command-O
来快速打开一个文件的名字。要在项目导航器中查看当前文件,请按Shift-Command-J
。
目前,在CardBottomToolbar
中,你的工具条按钮是在一个居中对齐的HStack
中。这意味着ToolbarButtonView
由VStack
组成,上面是Image
,下面是Text
,都是中心对齐的。
➤ 在CardBottomToolbar
中,将HStack {
改为:
HStack(alignment: .top) {
这将使按钮在HStack
的顶部对齐。
➤ 现在试试底部对齐。将对齐方式改为:
HStack(alignment: .bottom) {
这是最好的结果,因为现在所有的文本都是对齐的。
当你在iPhone
上建立并运行该应用程序,并旋转到横向时,你会看到图像逃过了栏的顶部,主栏覆盖了文本。
在第16章"为你的应用程序添加资产"中,你用紧凑和普通的尺寸类来决定使用哪种启动图像。在这里,你将在代码中检查设备的尺寸类别,并为每个尺寸类别使用不同的视图。紧凑尺寸类将只显示图像,而普通尺寸类将同时显示图像和文本。
➤ 还是在CardBottomToolbar.swift
中,在ToolbarButtonView
中,添加这个:
func regularView(
_ imageName: String,
_ text: String
) -> some View {
VStack(spacing: 2) {
Image(systemName: imageName)
Text(text)
}
.frame(minWidth: 60)
.padding(.top, 5)
}
这是常规尺寸的类视图,同时显示图像和文本。
➤ 添加此方法:
func compactView(_ imageName: String) -> some View {
VStack(spacing: 2) {
Image(systemName: imageName)
}
.frame(minWidth: 60)
.padding(.top, 5)
}
这是紧凑尺寸的类视图,只显示图像。
➤ 给ToolbarButtonView
添加一个新的环境属性:
@Environment(\.verticalSizeClass) var verticalSizeClass
无论垂直尺寸类目前是紧凑的还是规则的,这个系统环境属性都成立。
➤ 将body
替换为:
var body: some View {
if let text = modalButton[modal]?.text,
let imageName = modalButton[modal]?.imageName {
if verticalSizeClass == .compact {
compactView(imageName)
} else {
regularView(imageName, text)
}
}
}
这将为正确的尺寸类显示正确的视图。
➤ 在iPhone
模拟器上构建并运行该应用程序,并打开一张卡片。当您旋转模拟器时,文本和图像都显示为纵向,但只有图像显示为横向。
挑战¶
在第17章"与UIKit的交互"中,你实现了拖放功能。然而,当你放下一个项目时,它被添加到卡片的中心位置,偏移量为0
。
使用GeometryReader
,你现在可以将拖放的位置转换成卡片上的正确偏移量。
CardDrop
, 丢弃委托,现在可以接受一个size
和一个frame
。在CardDetailView
的body
中,改变.onDrop(of:delegate:)
,这样CardDrop
就会收到计算好的卡片尺寸和全局坐标的框架。这就是proxy.frame(in: .global)
。- 在
CardDrop
中,检查Dispatch.main.async
闭包。offset
是为你计算的,使用info.location
。丢弃委托提供了这个丢弃位置。calculateOffset
在坐标空间之间进行各种计算。
为了说明什么是坐标空间,所有卡片的偏移量都以卡片的中心为原点来保存。原点是位置(0, 0)。然而,info.location
是屏幕坐标,原点是在屏幕的左上方。所以你必须从"屏幕空间"转换到"卡片空间"。
如果你需要提醒你如何进行拖放,请再看看第17章"与UIKit的接口"。
在iPad
上试试拖放,你就有无限多的谷歌图片来装饰你的卡片了。
关键点¶
- 尽管你的应用程序可以使用,但在你的应用程序使用起来很有趣之前,你还没有完成。如果你没有一个专业的设计师,请尝试很多不同的设计和布局,直到有一个被选中。
SwiftUI
的布局需要仔细考虑,因为有时它可能是不可预测的。黄金法则是,视图的尺寸来自其子代。GeometryReader
是一个视图,它在GeometryProxy
中返回它的首选尺寸和框架。这意味着在GeometryReader
视图层次中的任何视图都可以访问大小和框架来确定自己的大小。- 堆栈有对齐功能。如果这些还不够,你也可以创建你自己的自定义对齐方式。有一个很好的苹果
WWDC
视频,深入介绍了SwiftUI的布局系统:https://apple.co/39uamSx。