第88天 项目 17 第三部分
2007年1月,史蒂夫·乔布斯发布首款iPhone时,谈到了用户将如何与这款新设备互动。来看看他当时是怎么说的:
“我们将采用世界上最棒的指向设备。这款指向设备我们每个人生来就有——足足十个。我们将用我们的手指。我们将用手指触摸屏幕。而且我们发明了一项名为多点触控的新技术,这项这项技术非常出色,用起来就像魔法一样。”
如今再听这些话,会觉得是显而易见的道理——我们当然是用手指滑动操作,不然还能用什么呢?这恰恰证明了iPhone对整个行业的影响之深远。我至今还保留着一部与首款iPhone同年推出的Windows Mobile手机,它带有实体键盘(需要按压的物理按键),还有一支用来点击屏幕的细小触控笔。即便是滚动屏幕这样的操作,都需要用触控笔抓住滚动条拖动才行,而这款手机还是在iPhone发布之后推出的。
贝瑟尼·邦焦诺(曾与托比·帕特森共同领导首款iPad的软件工程项目)最近回忆道,他们“会在办公室里花上好几个小时,用开发用的iPad测试机全屏玩谷歌街景……我还记得当时我们脱口而出——哇,这东西一定会让所有人眼前一亮。”
今天我们开始开发这款应用,首先要制作可拖动的卡片。希望你能用心感受一下,用手指操控屏幕上的用户界面是多么顺畅的体验。iPhone的屏幕几乎占据了整个机身,而出色的手势操作能让应用带来“真实可触”的感觉——一定要好好运用这种交互方式!
今天你需要完成三个任务,分别是制作卡片堆、添加手势,然后通过这些手势控制用户界面的其他部分。
- 设计单个卡片视图
- 制作卡片堆
- 用DragGesture和offset()实现视图移动
设计单个卡片视图
作者:Paul Hudson 2024年2月21日
在这个项目中,我们希望用户看到一张卡片,上面显示需要学习的提示文本,比如“苏格兰的首都是哪里?”,点击卡片后就能看到答案(当然是爱丁堡)。
大多数项目的合理起点是定义所需的数据模型:一张信息卡片应该包含哪些内容?如果想进一步完善这款应用,你可以添加一些实用的统计数据,比如卡片显示次数、回答正确次数等,但目前我们只需要存储提示文本和答案文本两个字符串。为了方便开发,我们还会添加一个静态的示例卡片属性,用于预览和原型开发。
首先,创建一个名为Card.swift的新Swift文件,添加以下代码:
struct Card {
var prompt: String
var answer: String
static let example = Card(prompt: "《神秘博士》中第13任博士的扮演者是谁?", answer: "朱迪·惠特克")
}要在SwiftUI视图中显示卡片,需要稍微复杂一点的布局:虽然只是两个上下排列的文本标签,但我们还需要在文本后面添加一张白色卡片来让界面更生动,同时给文本添加少量内边距,避免文本紧贴卡片边缘。用SwiftUI的话来说,就是用ZStack包裹一个白色的RoundedRectangle(圆角矩形),里面再放一个包含两个标签的VStack。
不知道你有没有用过闪卡学习,这类卡片都有一个典型的形状——宽大于高。想想就知道为什么:通常只需要写两三行文字,横向排列比纵向更自然。
我们之前开发的应用大多不关注设备方向,但这次我们要让应用只在横屏模式下运行。这样能为卡片提供更充足的显示空间,后续添加手势时效果也会更好。
要强制横屏模式,需进入项目目标的“信息”(Info)标签页,展开“支持的界面方向(iPhone)”(Supported interface orientations (iPhone))选项,删除“竖屏”(portrait)选项,只保留两个横屏选项。
完成上述设置后,我们就可以初步开发卡片视图了。创建一个名为“CardView”的新SwiftUI视图,添加以下代码:
struct CardView: View {
let card: Card
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(.white)
VStack {
Text(card.prompt)
.font(.largeTitle)
.foregroundStyle(.black)
Text(card.answer)
.font(.title)
.foregroundStyle(.secondary)
}
.padding(20)
.multilineTextAlignment(.center)
}
.frame(width: 450, height: 250)
}
}提示: 宽度设为450并非随意决定——最小尺寸的iPhone横屏宽度为480点,这样设置能确保卡片在所有设备上都能完整显示。
此时预览代码会报错,因为它需要传入一个卡片参数。不过我们已经在Card结构体中添加了静态的示例卡片,正好可以用来解决这个问题。将预览代码更新为:
#Preview {
CardView(card: .example)
}查看预览效果,应该能看到示例卡片,但可能看不出它是一张“卡片”——因为卡片背景是白色,与视图的默认背景融为一体。当我们后续添加多张卡片组成卡片堆时,这个问题会更明显,所有白色卡片会混在一起难以区分。
解决方法很简单:给RoundedRectangle添加阴影效果,营造出轻微的层次感。这样一来,白色卡片就能与白色背景区分开;后续添加更多卡片时,阴影叠加会让效果更突出。
在fill(.white)下方添加以下修饰符:
.shadow(radius: 10)现在,提示文本和答案文本会同时显示,这显然不符合闪卡学习的需求。所以最后一步,我们要设置默认隐藏答案文本,点击卡片时再切换显示状态。
首先,在CardView中添加一个新的@State属性:
@State private var isShowingAnswer = false然后用这个布尔值控制答案视图的显示,修改代码如下:
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundStyle(.secondary)
}这样一来,只有当isShowingAnswer为true时,答案才会显示。
最后,在ZStack的frame()修饰符后面添加onTapGesture()修饰符:
.onTapGesture {
isShowingAnswer.toggle()
}提示: 用点击手势比用按钮更好,因为我们后续会添加拖动功能。放心,我们之后也会完善无障碍访问!
至此,卡片视图的初步开发就完成了。如果想查看实际效果,回到ContentView.swift,将body属性替换为:
var body: some View {
CardView(card: .example)
}运行项目后,应用会自动切换到横屏模式,并显示默认卡片——这是一个不错的开始!
制作卡片堆
作者:Paul Hudson 2024年2月21日
我们已经设计好了单个卡片和对应的卡片视图,接下来要制作一个卡片堆,用来展示用户需要学习的所有内容。由于用户会删除卡片,卡片堆的内容会动态变化,所以需要用@State标记。
目前我们还没有添加卡片的功能,所以先创建一个包含10张示例卡片的卡片堆。Swift的数组有一个实用的初始化方法init(repeating:count:),可以将一个值重复指定次数来创建数组。这里我们就用示例Card来创建一个测试数组。
首先,在ContentView中添加以下属性:
@State private var cards = Array<Card>(repeating: .example, count: 10)ContentView的主体部分会包含多个嵌套的栈视图,但目前我们先搭建一个大致框架:
- 卡片堆放在ZStack中,这样可以实现卡片部分重叠的3D效果。
- ZStack外面包裹一个VStack,目前这个VStack作用不大,但后续我们会在卡片上方添加计时器,到时它就派上用场了。
- VStack外面再包裹一个ZStack,以便在背景上放置卡片和计时器。
现在看来,这些嵌套的栈视图可能有些多余,但随着开发推进,你就会明白这样设计的道理。
接下来代码中唯一复杂的部分,是如何调整卡片堆中卡片的位置,让它们实现轻微重叠的效果。我之前说过,编写SwiftUI代码的最佳方式是将复杂的计算逻辑抽离出来,用方法或修饰符来处理。
这里我们要创建一个新的stacked()修饰符,它接收两个参数:卡片在数组中的位置,以及数组的总长度,然后根据这两个值来调整视图的偏移量。这样就能打造出一个美观的卡片堆,每张卡片都比前一张略微靠下。
在ContentView.swift中,在ContentView结构体外部添加以下扩展:
extension View {
func stacked(at position: Int, in total: Int) -> some View {
let offset = Double(total - position)
return self.offset(y: offset * 10)
}
}可以看到,数组中每个位置的卡片都会向下偏移10点:第0个位置偏移0点,第1个位置偏移10点,第2个位置偏移20点,以此类推。
有了这个简单的修饰符,我们就能按照刚才描述的布局,制作出漂亮的卡片堆效果。将ContentView中当前的body属性替换为:
var body: some View {
ZStack {
VStack {
ZStack {
ForEach(0..<cards.count, id: \.self) { index in
CardView(card: cards[index])
.stacked(at: index, in: cards.count)
}
}
}
}
}运行代码后,你会发现卡片堆的阴影会随着卡片层数增加而叠加。在白色背景下,这种效果会显得比较突兀,但添加背景图片后,整体效果会好很多。
在这个项目的GitHub文件中,有background@2x.jpg和background@3x.jpg两张图片——请将这两张图片拖到你的资源目录(asset catalog)中,以便后续使用。
现在,在ContentView的初始ZStack中添加以下Image视图:
Image(.background)
.resizable()
.ignoresSafeArea()添加背景图片只是一个小改动,但能让整个应用的视觉效果提升不少!
用DragGesture和offset()实现视图移动
作者:Paul Hudson 2024年2月21日
SwiftUI允许我们给任何视图添加自定义手势,然后用手势产生的值来操控其他视图。为了演示这一点,我们要给CardView添加DragGesture(拖动手势),让卡片可以移动,同时利用手势产生的值来控制视图的透明度和旋转角度——卡片拖动时会逐渐倾斜并淡出。实现这个效果需要的代码少得惊人,因为SwiftUI已经帮我们做了大部分工作,相信你一定会印象深刻!
首先,在CardView中添加一个新的@State属性,用于跟踪用户拖动的距离:
@State private var offset = CGSize.zero接下来,我们要给CardView添加三个修饰符,直接放在frame()修饰符下方。记住:修饰符的应用顺序非常重要,在处理偏移和旋转时尤其如此。
如果先旋转再偏移,偏移会基于视图旋转后的坐标轴计算。例如,将某个视图向左偏移100像素后旋转90度,最终效果是视图向左偏移100像素并旋转90度;但如果先旋转90度再向左偏移100像素,最终效果会是视图旋转90度并向下偏移100像素——因为视图的“左”方向已经随着旋转发生了变化。
更复杂的情况是,SwiftUI会通过包装修饰符来创建新视图。在处理移动和旋转时,如果我们希望视图在旋转的同时,还能沿着真实的水平方向(不受旋转影响)滑动,就需要先旋转再偏移。
offset.width会记录用户拖动卡片的水平距离,但我们不能直接用这个值来控制旋转,否则卡片旋转速度会太快。所以,在frame()下方添加以下修饰符,用拖动距离的1/5来计算旋转角度:
.rotationEffect(.degrees(offset.width / 5.0))接下来,我们要实现卡片的移动效果,让卡片根据水平拖动距离滑动。同样,我们不直接使用offset.width的原始值——因为用户需要拖动很长距离才能看到明显效果,所以我们将其乘以5,这样轻轻一滑就能让卡片移开。
在之前的修饰符下方添加:
.offset(x: offset.width * 5)趁现在,我们再添加一个基于拖动手势的修饰符:让卡片拖动距离越远,透明度越低(逐渐淡出)。
这个透明度的计算需要稍微思考一下,如果你不想把代码直接写在一行里,也可以抽成一个方法,这完全没问题。计算逻辑如下:
- 取拖动距离的1/50,避免卡片淡出过快。
- 不管用户向左(负值)还是向右(正值)拖动,我们只关心距离大小,所以用abs()函数处理——如果传入正数,返回原值;如果传入负数,去掉负号后返回正数。
- 用2减去这个结果。
这里用2是有意为之,这样卡片在轻微拖动时能保持不透明。比如,用户没有拖动时,透明度是2.0,和1.0的效果一样(完全不透明);向左或向右拖动50点时,50除以50等于1,2减1等于1,透明度仍然是1.0(完全不透明);但拖动距离超过50点后,卡片开始淡出,直到拖动100点时,透明度变为0(完全透明)。
在之前的两个修饰符下方添加:
.opacity(2 - Double(abs(offset.width / 50)))到这里,我们已经创建了存储拖动距离的属性,也添加了三个利用拖动距离改变视图显示效果的修饰符。接下来是最重要的一步:给卡片添加DragGesture,让它在用户拖动时更新offset的值。拖动手势有两个实用的修饰符,分别用于手势变化时(用户移动手指的每一刻都会触发)和手势结束时(用户抬起手指时触发)执行指定代码。
这两个函数都会接收当前的手势状态。在这里,我们会读取translation属性来获取用户的拖动位置,并以此设置offset属性;你也可以读取起始位置、预测结束位置等信息。至于手势结束时的函数,我们会判断用户是否将卡片拖动了超过100点(无论左右),如果是,就准备删除这张卡片;如果不是,就将offset重置为0(卡片回到原位)。
在之前的三个修饰符下方添加gesture()修饰符:
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
if abs(offset.width) > 100 {
// 删除卡片
} else {
offset = .zero
}
}
)现在运行应用,你会发现卡片拖动时会移动、旋转并逐渐淡出;如果拖动距离超过一定值,卡片会保持在拖动后的位置,而不是跳回原位。
效果已经不错了,但要完成这一步,还需要把// 删除卡片的注释替换成实际的删除逻辑,让父视图(ContentView)能真正删除卡片。不过,我们不希望CardView直接调用ContentView的方法来操作数据,这样会导致代码混乱。更好的做法是在CardView中存储一个闭包参数,后续可以根据需要给这个闭包赋值——这样既能在ContentView中获取回调,又不会让两个视图产生强耦合。
所以,在CardView现有的card属性下方添加一个新属性:
var removal: (() -> Void)? = nil可以看到,这是一个无参数、无返回值的闭包,默认值为nil,因此不需要强制提供。
现在,将// 删除卡片替换为调用这个闭包:
removal?()提示: 这里的问号表示只有当闭包被赋值时,才会执行调用。
回到ContentView,我们需要编写一个删除卡片的方法,并将其与CardView的闭包关联起来。
首先,添加一个接收数组索引、并删除对应卡片的方法:
func removeCard(at index: Int) {
cards.remove(at: index)
}最后,更新创建CardView的代码,用尾随闭包语法实现:当卡片被拖动超过100点时,删除该卡片。只需调用我们刚才编写的removeCard(at:)方法即可,不过如果将其包裹在withAnimation()中,其他卡片会自动向上滑动(产生动画效果)。
修改后的代码如下:
ForEach(0..<cards.count, id: \.self) { index in
CardView(card: cards[index]) {
withAnimation {
removeCard(at: index)
}
}
.stacked(at: index, in: cards.count)
}现在运行应用——效果一定会让你惊艳!你可以滑动卡片堆中的所有卡片,直到全部滑完为止。