简介
在这个项目中,我们将构建一个应用程序,帮助用户使用抽认卡学习东西——卡片上写着一个东西,比如“to buy”,另一个东西写在另一边,比如“Compar”。当然,这是一个数字应用程序,所以我们不需要担心“另一面”,而是可以让卡片的细节在点击时显示出来。
这个项目的名字实际上是我第一个iOS版应用程序的名字——我很久以前发布的一个应用程序是为iPhoneOS编写的,因为iPad还没有发布。实际上,苹果在 app review 期间拒绝了这款应用,因为它的产品名中有“Flash”,而当时苹果真的很想在AppStore安装Flash!时过境迁…
总之,在这个项目中我们有很多有趣的东西要学习,包括手势、触觉、计时器等等,所以请使用单视图应用程序模板创建一个新的iOS项目,命名为Flashzilla。像往常一样,在我们开始构建真正的东西之前,我们需要学习一些技巧,所以让我们开始…
设计单张卡片视图
在这个项目中,我们希望用户看到一张带有提示文本的卡片,以显示他们想要学习的内容,例如“苏格兰的首都是什么?”,当他们点击它时,我们将给出答案,在这种情况下,答案是当然是爱丁堡。
对于大多数项目而言,一个明智的起点是定义我们要使用的数据模型:一张信息卡看起来像什么?如果您想进一步开发该应用,则可以存储一些有趣的统计信息,例如显示的次数和正确的次数,但是在这里,我们仅存储提示字符串和答案字符串。为了更简单,我们还将添加示例卡作为静态属性,因此我们提供了一些用于预览和原型制作的测试数据。
因此,创建一个名为Card.swift的新Swift文件,并为其提供以下代码:
struct Card {
let prompt: String
let answer: String
static var example: Card {
Card(prompt: "Who played the 13th Doctor in Doctor Who?", answer: "Jodie Whittaker")
}
}
要在SwiftUI视图中显示,我们需要稍微复杂一些:是的,会有两个文本标签一个显示在另一个之上,但是我们还需要在它们后面显示一张白卡以使我们的UI栩栩如生,然后添加只需对文本进行一点填充,这样就不会完全掉到它后面的卡片的边缘。用SwiftUI术语来说,这意味着在白色RoundedRectangle
的ZStack
中带有两个被VStack
包含的标签。
到目前为止,我们所有的应用程序都还没有真正关心设备的方向,但是我们将使此应用程序仅在横向模式下可用。这为我们提供了更多抽奖卡的空间,并且一旦稍后引入手势,效果也将更好。
要强制使用横向模式,请查看Xcode项目导航器的顶部。您会看到“Flashzilla”在那里出现两次:一个带有蓝色图标,一个带有黄色图标。黄色图标是保存我们的代码的文件夹,但是蓝色图标是我们的项目,并且包含许多应用程序设置。
在这里面,您将遇到Xcode用户界面中最糟糕的部分之一,因为Xcode实际上将我们的项目划分为另外两件事,也称为“ Flashzilla”:一个是项目,一个是项目内部的目标。目标是将不同类型的代码放入同一个项目的方法:例如,我们可以有一个iOS目标,一个watchOS目标和一个tvOS目标,它们都位于同一项目内。
问题在于,Xcode的用于选择目标的UI对于新手来说非常令人困惑。如何执行取决于您的Xcode配置:
- 如果您看到“Flashzilla”旁边有一个向上/向下的小箭头,则需要显示项目和目标列表。请单击该按钮,然后执行下一步。
- 如果看到 PROJECT 和 Flashzilla,然后看到 TARGET 和 Flashzilla,则表明您的项目和目标列表可见。您应该在TARGET 下选择 Flashzilla。
如果操作正确,您会在Xcode的顶部附近看到很多标签:General,Signing & Capabilities,Resource Tags等。我希望您选择“常规”,然后向下浏览 Device Orientation (设备方向)设置。其默认值是选中 Portrait, Landscape Left, 和 Landscape Right,但是我希望您取消选中 Portrait,因此我们仅支持两种横向方向。
完成此操作后,我们可以采取第一遍方法来代表我们应用中的一张卡。创建一个名为“ CardView”的新SwiftUI视图,并提供以下代码:
struct CardView: View {
let card: Card
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(Color.white)
VStack {
Text(card.prompt)
.font(.largeTitle)
.foregroundColor(.black)
Text(card.answer)
.font(.title)
.foregroundColor(.gray)
}
.padding(20)
.multilineTextAlignment(.center)
}
.frame(width: 450, height: 250)
}
}
提示:宽度450并非偶然:最小的iPhone的横向宽度为480点,因此这意味着我们的卡在所有设备上都是完全可见的。
这将破坏CardView_Previews
结构体,因为它需要传入card
参数,但是为此,我们已经直接向Card
结构体中添加了一个静态示例。因此,将CardView_Previews
结构更新为如下所示:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
CardView(card: Card.example)
}
}
如果您查看预览,应该会看到我们的示例卡片,但您实际上看不到它是卡片——背景为白色,因此在我们视图的默认背景下也不脱颖而出。当我们需要处理一堆卡片时,这将成为双重问题,因为它们都具有白色背景并且彼此融合在一起。
有一个简单的解决方法:我们可以在RoundedRectangle
中添加阴影,以便获得柔和的深度效果。这将使我们的白卡从白色背景中脱颖而出,现在对我们有帮助,但是当我们开始添加更多的卡时,它看起来会更好,因为阴影会累加。
因此,在fill(Color.white)
下面添加此修饰符:
.shadow(radius: 10)
现在,您现在可以同时看到提示和答案,但显然这不会帮助任何人学习。因此,要完成此步骤,我们默认情况下将隐藏答案标签,并在每次轻按卡片时切换其可见性。
因此,首先将这个新的@State
属性添加到CardView
中:
@State private var isShowingAnswer = false
现在,将答案视图包装在该布尔值的条件中,如下所示:
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundColor(.gray)
}
这个简单的更改意味着它只会在isShowingAnswer
为 true 时显示答案。
最后一步是通过将以下代码放在frame()
修饰符之后,将onTapGesture()
修饰符添加到ZStack
:
.onTapGesture {
self.isShowingAnswer.toggle()
}
这是我们暂时完成的卡片视图,因此,如果您想查看它的实际效果,请返回 ContentView.swift并将其body
属性替换为:
var body: some View {
CardView(card: Card.example)
}
当您运行项目时,您会看到该应用程序自动跳入横向模式,并且出现了我们的默认卡——一个好的开始!
建立一叠卡片
现在,我们已经设计了一张卡及其相关的卡视图,下一步是构建一叠这些卡,以表示用户尝试学习的内容。使用该应用程序时,此堆栈将发生变化,因为用户将能够删除卡片,因此我们需要使用@State
对其进行标记。
目前,我们没有任何添加卡片的方法,因此我们将使用示例卡片添加一叠10张卡片。Swift的数组有一个有用的初始化器init(repeating:count:)
,该初始化器采用一个值并将其重复多次以创建数组。在我们的例子中,我们可以将其与示例Card
一起使用以创建一个简单的测试数组。
因此,首先将此属性添加到ContentView
:
@State private var cards = [Card](repeating: Card.example, count: 10)
我们的主ContentView
将在堆栈内包含许多重叠的元素,但现在我们将放入一个粗略的骨架:
- 我们的纸牌叠将放置在
ZStack
内,因此我们可以使它们部分重叠,并具有清晰的3D效果。
2.ZStack
周围将是一个VStack
。目前,VStack
不会做很多事情,但是稍后它可以让我们在卡片上方放置一个计时器。 - 在该
VStack
周围将是另一个ZStack
,因此我们可以将卡和计时器放在背景上。
现在,这些堆栈可能感觉像是有些多余,但是随着我们的进步,它会变得更加有意义。
下一个代码中唯一复杂的部分是如何将卡放置在卡堆中,以便它们略有重叠。我之前已经说过,但是编写SwiftUI代码的最好方法是消除任何混乱的计算,以便将它们作为方法或修饰符处理。
在这种情况下,我们将创建一个新的stacked()
修饰符,该修饰符在数组中占据一个位置以及数组的总大小,并根据这些值使视图偏移一定量。这将使我们能够创建一个吸引人的纸牌叠,其中每张纸牌都比屏幕上的纸牌略远。
将此扩展添加到 ContentView.swift 的 ContentView
结构体之外:
extension View {
func stacked(at position: Int, in total: Int) -> some View {
let offset = CGFloat(total - position)
return self.offset(CGSize(width: 0, height: offset * 10))
}
}
如您所见,这会将视图在数组中的每个位置下移10个点:0,然后是10、20、30,依此类推。
有了这个简单的修饰符,我们现在可以使用我之前描述的布局构建一个非常不错的纸牌堆叠效果。用以下内容替换您当前在ContentView
中的body
属性:
var body: some View {
ZStack {
VStack {
ZStack {
ForEach(0..
当你运行时,您会发现我对随着卡深度增加而逐渐形成阴影的意思。在白色背景下看起来很鲜明,但是如果我们添加背景图片,您会发现它看起来更好。
现在,将此图像视图添加到ContentView
中的最外部ZStack
内部:
Image("background")
.resizable()
.scaledToFill()
.edgesIgnoringSafeArea(.all)
添加背景图片只是很小的变化,但我认为它会使整个应用看起来更好!
使用 DragGesture 和 offset() 移动视图
SwiftUI允许我们将自定义手势附加到任何视图,然后使用这些手势创建的值来操纵视图的其余部分。为了证明这一点,我们将在CardView
上附加一个DragGesture
,这样它就可以移动了,我们还将使用这个手势生成的值来控制视图的不透明度和旋转度–当拖动视图时,它将弯曲并淡出。这只需要很少的代码,因为SwiftUI为我们做了很多事情;我想您一定会印象深刻的!
首先,将这个新的@State
属性添加到CardView
中,以跟踪用户拖动的距离:
@State private var offset = CGSize.zero
接下来,我们将向CardView
添加三个修饰符,它们直接放置在frame()
修饰符的下方。请记住:修饰符的顺序很重要,在处理偏移和旋转时,更能证明。
如果我们先旋转然后偏移,那么偏移将基于视图的旋转后的坐标轴。例如,如果我们向左移动100个像素,然后旋转90度,我们将得到向左100个像素并旋转90度的结果。但是如果我们旋转90度,然后向左移动100个像素,我们会得到旋转90度的物体,然后直接向下移动100个像素,因为它的“左”概念被旋转了。
当您考虑到SwiftUI如何通过包装修饰符来创建新视图时,事情会变得更加棘手。当涉及到移动和旋转时,这意味着如果我们希望一个视图直接滑动到正西(不管它的旋转如何),同时也要旋转它,我们需要先旋转,然后偏移。
现在,offset.width
将包含用户拖动我们的卡片的距离,但我们不想在旋转时使用该值,因为它将使卡片旋转太快,因此请在frame()
下添加此修改器,因此我们使用拖动量的1/5:
.rotationEffect(.degrees(Double(offset.width / 5)))
下一步我们将应用我们的移动,这样卡片相对于水平拖动量滑动。同样,我们不会使用偏移宽度因为这需要用户拖很长一段时间才能得到有意义的结果,所以我们将它乘以5,这样卡片就可以用小手势刷走了。
将此修饰符添加到前一个修饰符的下方:
.offset(x: offset.width * 5, y: 0)
当我们在这里的时候,我想在拖动手势的基础上再添加一个修饰符:我们将使卡片随着拖得更远而淡出。
现在,这个视图的计算需要一点思考,如果你想把它拆分成一个方法而不是把它内联,我不会责怪你。其工作原理如下:
- 我们将取1/50的拖动量,这样卡片不会太快淡出。
- 我们不关心它们是向左移动(负数)还是向右移动(正数),所以我们将通过
abs()
函数来输入值。如果给它一个正数,它会返回相同的数字,但是如果给它一个负数,它会删除负号并返回与正数相同的值。 - 然后用2减去这个结果。
在那里使用2是有意的,因为它允许卡保持不透明,而被拖动一点点。因此,如果用户根本没有拖动,则不透明度为2.0,这与不透明度为1相同。如果他们向左或向右拖动50个点,我们将其除以50得到1,2再减去1得到1,所以不透明度仍然是1——卡片仍然是完全不透明的。但超过50点,我们开始淡出卡,直到100点左右不透明度为0。
将此修饰符添加到前两个下面:
.opacity(2 - Double(abs(offset.width / 50)))
因此,我们创建了一个属性来存储拖动量,并添加了三个使用拖动量来更改视图渲染方式的修饰符。剩下的是最重要的部分:我们需要在我们的卡上实际附加一个DragGesture
,这样当用户拖动卡片时,它会更新偏移量。拖动手势有两个自己有用的修饰符,让我们附加在手势改变时触发的函数(每次移动手指时调用),以及手势结束时触发的函数(抬起手指时调用)。
这两个函数都被赋予当前手势状态以进行评估。在我们的例子中,我们将读取translation
属性以查看用户拖动到了哪里,我们将使用该属性来设置偏移属性,但是您也可以读取开始位置、预测的结束位置等等。当涉及到结束函数时,我们将检查用户是否将其向任一方向移动超过100个点,以便我们可以准备移除卡,但是如果没有,我们将把偏移量 offset
设置回0。
将此gesture()
修饰符添加到前三个下面:
.gesture(
DragGesture()
.onChanged { gesture in
self.offset = gesture.translation
}
.onEnded { _ in
if abs(self.offset.width) > 100 {
// remove the card
} else {
self.offset = .zero
}
}
)
现在就开始运行应用程序:你应该会发现这些卡片在被拖动时会移动、旋转和淡出,如果你拖动超过一定距离,它们就会远离而不是跳回到原来的位置。
这很好,但要真正完成这一步,我们需要填写 //remove the card
注释,以便在父视图中实际删除卡片。现在,我们不希望CardView
调用ContentView
并直接操作它的数据,因为这会产生意大利面代码。相反,一个更好的主意是在CardView
中存储一个闭包参数,它可以在以后用我们想要的任何代码填充它——这意味着我们可以灵活地在ContentView
中获得回调,而不必显式地将两个视图绑定在一起。
因此,请将此新属性添加到CardView
的现有card
属性下:
var removal: (() -> Void)? = nil
如您所见,这是一个不接受任何参数并且不发送任何返回的闭包,默认值为nil
,因此除非明确需要,否则我们不需要提供它。
我们现在可以调用一个闭包来替换 //remove the card
:
self.removal?()
提示:可选值调用:这里的问号意味着只有设置了闭包才会被调用。
回到ContentView
中,我们现在可以编写一个方法来处理移除卡,然后将其连接到该闭包。
首先,添加此方法,该方法在cards
数组中获取索引并删除该项:
func removeCard(at index: Int) {
cards.remove(at: index)
}
最后,我们可以更新创建CardView
的方式,以便在拖动超过100个点时使用尾部闭包语法删除卡片。这只是调用我们刚刚编写的removeCard(at:)
方法的问题,但是如果我们将该方法包装在withAnimation()
调用中,则其他卡将自动向上滑动。
下面是代码的外观:
ForEach(0..
现在就开始运行这个应用程序吧——我觉得结果看起来真的很棒,你现在可以在所有的牌堆中刷你的卡片,直到你到达最后!
译自
Flashzilla: Introduction
Designing a single card view
Building a stack of cards
Moving views with DragGesture and offset()