你使用过 Apple 电子钱包 App 吗?我相信应该有,在上一章中,我们建立了一个如 Tinder UI 的简单 App,而本章要做的是建立一个动态 UI,类似于你在电子钱包 App 中看到的 UI。当你在电子钱包 App 中长按信用卡时,则可使用拖曳手势来重新排列卡片。如果你没有使用过这个 App,请开启电子钱包快速浏览一下,或者你可以访问这个URL (https://link.appcoda.com/swiftui-wallet)来了解我们将建立的动画。

在电子钱包 App 中,点击其中一张信用卡,就会弹出交易历史纪录。我们还建立一个类似的动画,以让你更了解视图转场与水平滚动视图。
为了让你专注于学习动画与视图转场,你可以从初始项目开始(https://www.appcoda.com/resources/swiftui4/SwiftUIWalletStarter.zip)。这个初始项目已经绑定了所需的信用卡图片,并且带有内建的交易历史纪录视图,如果想要使用自己的图片,请在素材目录中替换它们,如图20.2 所示。

在项目导航器中,你应该会发现一些 .swift 档:
Transaction 结构代表电子钱包 App中的交易。每一笔交易有一个唯一的 ID、交易商、金额、日期与图示。除了 Transaction 结构之外,作为示范之用,我们还声明一个测试交易的数组。Card 的结构。Card 表示信用卡的数据,包含卡号、类型、有效日期、图片与客户姓名,除此之外,你可以在文件中找到一个测试信用卡的数组。需要注意的一点是,卡片图片中不包含任何的个人资讯,而只包含卡片品牌(例如: Visa )。稍后,我们将为信用卡建立一个视图。.horizontal 值。请看一下图 20.3 或 Swift 档来了解详细资讯 。
如上一节所述,所有的卡片图片皆不包含任何个人资讯与卡号。再次开启素材目录, 并看一下图片,每张卡片图片只具有卡片标志。我们将很快建立一个卡片视图,来布局个人资讯与卡号,如图 20.4 所示。

要建立卡片视图,则在项目导航器中,右键点选 View 群组,然后建立一个新文件。选取“SwiftUI View”模板,文件名称命名为 CardView.swift。接下来,修改代码如下:
struct CardView: View {
var card: Card
var body: some View {
Image(card.image)
.resizable()
.scaledToFit()
.overlay(
VStack(alignment: .leading) {
Text(card.number)
.bold()
HStack {
Text(card.name)
.bold()
Text("Valid Thru")
.font(.footnote)
Text(card.expiryDate)
.font(.footnote)
}
}
.foregroundColor(.white)
.padding(.leading, 25)
.padding(.bottom, 20)
, alignment: .bottomLeading)
.shadow(color: .gray, radius: 1.0, x: 0.0, y: 1.0)
}
}
我们声明一个 card 属性来带入卡片数据。为了在卡片图片上显示个人数据与卡号,我们使用 overlay 修饰器,并以垂直堆叠视图与水平堆叠视图来布局文字组件。
要预览卡片,则修改 CardView_Previews结构如下:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
ForEach(testCards) { card in
CardView(card: card).previewDisplayName(card.type.rawValue)
}
}
}
testCards 变量在 Card.swift 中定义,因此我们使用 ForEach 来逐一运行卡片,并调用 previewDisplayName 来设定预览的名称。Xcode 将如图 20.5 所示布局卡片。

我们现在已经实现了卡片视图,让我们开始建立电子钱包视图。如果你忘记电子钱包视图的外观,请看一下图 20.6。在进行手势与动画之前,我们将先布局卡片库。

在项目导航器中,你应该会看到 ContentView.swift 档。删除它,然后右键点选 View 数据夹,以建立一个新文件。在对话方块中,选取“SwiftUI View”作为模板,并将文件命名为 WalletView.swift。
如果你预览 WalletView 或在模拟器中运行 App,Xcode 应该会显示一个错误,因为 ContentView 设定为初始视图,并且其被删除了。要修正这个错误,则开启 SwiftUIWallet App.swift,并将 WindowGroup 中下列这行代码:
ContentView()
修改为:
WalletView()
切换回 WalletView.swift。当你修改后,将可修正编译错误,现在我们继续布局电子钱包视图。首先,我们从标题列开始,在 WalletView.swift 档中,为标题列插入一个新结构:
struct TopNavBar: View {
var body: some View {
HStack {
Text("Wallet")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.heavy)
Spacer()
Image(systemName: "plus.circle.fill")
.font(.system(.title))
}
.padding(.horizontal)
.padding(.top, 20)
}
}
代码非常简单,我们使用水平堆叠来布局标题与加号图片。
接下来,是针对卡片库。首先,在 WalletView 结构中,为信用卡数组声明一个属性:
var cards: [Card] = testCards
为了示范,我们只将默认值设定为 Card.swift 档中定义的 testCards。要布局电子钱包视图,我们同时使用VStack 与 ZStack,修改 body 变量如下:
var body: some View {
VStack {
TopNavBar()
.padding(.bottom)
Spacer()
ZStack {
ForEach(cards) { card in
CardView(card: card)
.padding(.horizontal, 35)
}
}
Spacer()
}
}
如果你在模拟器中运行这个 App 或直接预览 UI,则应该只看到卡片库中的最后一张卡片,如图 20.7 所示。

目前的实现有两个问题:
Card.swift 中的 testCards 数组,第一张卡片是 Visa 卡,最后一张卡片是 Discover 卡。那么,我们要如何修正这个问题呢?对于第一个问题,我们可以使用 offset修饰器来展开一副卡片。而对于第二个问题,我们显然可以修改每个 CardView 的 zIndex,以改变卡片的顺序。图 20.8 说明了这个解决方案是如何工作的。

我们先讨论一下 z-index。每张卡片的 z-index 是其在 cards 数组中索引的负值,如此最后一个项目拥有数组索引的最大值,也将会有最小的 z-index。对于实际的实现,我们将建立一个单独的函数来处理 z-index 的计算。在WalletView 中,插入下列的代码:
private func zIndex(for card: Card) -> Double {
guard let cardIndex = index(for: card) else {
return 0.0
}
return -Double(cardIndex)
}
private func index(for card: Card) -> Int? {
guard let index = cards.firstIndex(where: { $0.id == card.id }) else {
return nil
}
return index
}
这两个函数可以一起找出给定卡片的正确 z-index。要计算正确的 z-index,我们首先需要取得 cards 数组中卡片的索引值,index(for:) 函数是为了找出给定卡片的数组索引值而设计的。当我们有了索引值后,就可以将其变成负值,这就是 zIndex(for:) 函数的作用。
现在,你可以将 zIndex 修饰器加到 CardView,如下所示:
CardView(card: card)
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
当你修改后,Visa 卡片应该移到卡片库的最上方。
接下来,我们修正第一个问题来展开卡片,每张卡片都应偏移一定的垂直距离。而这个距离是使用卡片的索引值来计算的。假设我们将默认的垂直偏移量设定为 50 点,最后一张卡片将会位移 200 点(50×4)。
现在,你应该了解我们将如何展开卡片了,我们来编写代码。在 WalletView 中声明默认的垂直偏移量:
private static let cardOffset: CGFloat = 50.0
接下来,建立一个名为 offset(for:)的新函数,用于计算给定卡片的垂直偏移量:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}
最后,将 offset 修饰器加入到 CardView:
CardView(card: card)
.padding(.horizontal, 35)
.offset(self.offset(for: card))
.zIndex(self.zIndex(for: card))
这就是我们使用 offset 修饰器来展开卡片的方式。若是一切正确,你应该会看到如图 20.9 所示的预览。

我们现在已经完成了电子钱包视图的布局,是时候加入一些动画了,我要加入的第一个动画是滑入动画。当第一次开启 App 时,每张卡片都从屏幕最左侧滑入,你可能认为这个动画是不必要的,但是我想藉此机会教你如何建立动画以及开启 App 时的视图转场。

首先,我们需要一种触发过渡动画的方法, 先在 CardView 的开头声明一个状态变量:
@State private var isCardPresented = false
此变量指示卡片是否应显示在屏幕上。 在默认的情况下,它设置为false。 稍后,我们将此值设置为 true 以启动视图转换。
每张卡片都是一个视图。要实现如图 20.10 所示的动画,我们需要将 transition 与 animation 修饰器加到CardView 上,如下所示:
CardView(card: card)
.offset(self.offset(for: card))
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
.transition(AnyTransition.slide.combined(with: .move(edge: .leading)).combined(with: .opacity))
.animation(self.transitionAnimation(for: card), value: isCardPresented)
对于转场,我们将默认的滑动转场与移动转场结合在一起。如前所述,若是没有 animation 修饰器,则转场将不会动画化,这就是为何我们还要加入 animation 修饰器的缘故。由于每张卡片都有自己的动画,我们建立一个名为 transitionAnimation(for:) 函数来计算动画。插入下列的代码来建立函数:
private func transitionAnimation(for card: Card) -> Animation {
var delay = 0.0
if let index = index(for: card) {
delay = Double(cards.count - index) * 0.1
}
return Animation.spring(response: 0.1, dampingFraction: 0.8, blendDuration: 0.02).delay(delay)
}
事实上,所有的卡片都有相似的动画(即弹簧动画),差别在于“延迟”(delay )。卡片库的最后一张卡片将先出现,因此延迟值应该最小。下面是我们如何计算每张卡片的延迟的公式,索引值越小,延迟越长。
delay = Double(cards.count - index) * 0.1
哪我们要如何在 App 启动的时候触发转场?诀窍就是为每一个卡视图加入id 修饰器。
CardView(card: card)
.
.
.
.id(isCardPresented)
.
.animation(self.transitionAnimation(for: card), value: isCardPresented)
id 的值设定为 isCardPresented 。之后,加入 onAppear 修饰器,并将它加到 ZStack:
.onAppear {
isCardPresented.toggle()
}
当 ZStack 出现时,我们将 isCardPresented 的值从 false 修改为 true,这将触发卡片的视图动画。应用修改后,在预览画布中点墼“Play”按钮,来进行测试。
修改后,点击 Play 按钮在模拟器中测试App,App启动时就会呈现动画。
当使用者点击卡片时,App 会向上移动所选的卡片,并且显示历史交易纪录。对于其他没有选到的卡片,它们会被移出屏幕。
要实现这个功能,我们还需要两个状态变量。在 WalletView 中声明这些变量:
@State var isCardPressed = false
@State var selectedCard: Card?
isCardPressed 变量指示是否选择卡片,而 selectedCard 变量储存使用者选择的卡片。
.gesture(
TapGesture()
.onEnded({ _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
})
)
要处理点击手势,我们可以将上述的 gesture 修饰器加到 CardView,并使用内建的 TapGesture 来捕捉点击事件。在代码区块中,我们只需切换isCardPressed的状态,并将目前的卡片设定为 selectedCard 变量。
要将所选的卡片(及其下方的卡片)向上移动,并让其余的卡片移出屏幕的话,则修改 offset(for:) 函数如下:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
if cardIndex >= selectedCardIndex {
return .zero
}
let offset = CGSize(width: 0, height: 1400)
return offset
}
return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}
我们加入了一个 if 语句来检查卡片是否被选中。如果给定的卡片是使用者选择的卡片, 则我们将偏移量设定为.zero。对于所选卡片正下方的那些卡片,我们也将向上移动, 这就是为什么我们将偏移量设定为 .zero。而其余的卡片,我们将它们移出屏幕,因此垂直偏移量设定为 1400 点。
现在,我们已经准备好编写用于弹出交易历史纪录视图的代码。正如一开始所述, 初始项目已经提供这个交易历史纪录视图。因此,你不需要自己建立它。
藉由 isCardPressed 状态变量,我们可以使用它来确定是否显示交易历史纪录视图。在 Spacer() 前面插入下列代码:
if isCardPressed {
TransactionHistoryView(transactions: testTransactions)
.padding(.top, 10)
.transition(.move(edge: .bottom))
}
在上列的代码中,我们设定转场为 .move,以从屏幕底部带入视图,你可以依照自己的喜好来随意修改它。

现在,来到本章的核心部分,我们来看如何让使用者以拖曳手势重新排列卡片库。首先,我详细描述此功能的工作原理:

现在你应该了解我们将要做什么,我们来继续实现。如果你忘记我们如何使用 SwiftUI 处理手势,则请回头阅读第 17 章,该章已经讨论了我们将使用的大多数技术。
首先,在 WalletView.swift 中插入下列的代码,来建立 DragState 枚举,以使我们可以轻松追踪拖曳状态:
enum DragState {
case inactive
case pressing(index: Int? = nil)
case dragging(index: Int? = nil, translation: CGSize)
var index: Int? {
switch self {
case .pressing(let index), .dragging(let index, _):
return index
case .inactive:
return nil
}
}
var translation: CGSize {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(_, let translation):
return translation
}
}
var isPressing: Bool {
switch self {
case .pressing, .dragging:
return true
case .inactive:
return false
}
}
var isDragging: Bool {
switch self {
case .dragging:
return true
case .inactive, .pressing:
return false
}
}
}
接下来,在 WalletView 中声明一个状态变量,来持续追踪拖曳状态:
@GestureState private var dragState = DragState.inactive
如果你之前阅读过第 17 章,那么你应该已经知道如何侦测长按与拖曳手势。然而,这次有点不同,我们需要同时处理点击手势、拖曳与长按手势。而且,如果侦测到长按手势,则 App 应该忽略点击手势。
现在修改 CardView 的 gesture 修饰器如下:
.gesture(
TapGesture()
.onEnded({ _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
})
.exclusively(before: LongPressGesture(minimumDuration: 0.05)
.sequenced(before: DragGesture())
.updating(self.$dragState, body: { (value, state, transaction) in
switch value {
case .first(true):
state = .pressing(index: self.index(for: card))
case .second(true, let drag):
state = .dragging(index: self.index(for: card), translation: drag?.translation ?? .zero)
default:
break
}
})
.onEnded({ (value) in
guard case .second(true, let drag?) = value else {
return
}
// 重新排列卡片
})
)
)
SwiftUI 让你可专门组合多种手势。在上列的代码中,我们告诉 SwiftUI 捕捉点击手势或长按手势,换句话说,当侦测到点击手势时,SwiftUI 将忽略长按手势。
点击手势的代码与我们之前编写的代码完全相同,而拖曳手势是排列在长按手势之后。在 updating 函数中,我们将拖曳的状态、转场与卡片的索引值设定为之前定义的 dragState 变量。我将不会像第 17 章那样详细解释代码。
在拖曳卡片之前,你必须修改 offset(for:) 函数如下:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
if cardIndex >= selectedCardIndex {
return .zero
}
let offset = CGSize(width: 0, height: 1400)
return offset
}
// Handle dragging
var pressedOffset = CGSize.zero
var dragOffsetY: CGFloat = 0.0
if let draggingIndex = dragState.index,
cardIndex == draggingIndex {
pressedOffset.height = dragState.isPressing ? -20 : 0
switch dragState.translation.width {
case let width where width < -10: pressedOffset.width = -20
case let width where width > 10: pressedOffset.width = 20
default: break
}
dragOffsetY = dragState.translation.height
}
return CGSize(width: 0 + pressedOffset.width, height: -50 * CGFloat(cardIndex) + pressedOffset.height + dragOffsetY)
}
我们加入一段代码区块来处理拖曳。请谨记,只有选定的卡片是可拖曳的。因此, 在修改偏移量之前,我们需要检查给定的卡片是否为使用者拖曳的卡片。
之前,我们将卡片索引值储存在 dragState 变量中,因此我们可轻松比较给定的卡片索引值与储存在 dragState 中的卡片索引值,以找出拖曳的卡片。
对于拖曳的卡片,我们在水平与垂直方向上都加入了额外的偏移量。
现在,你可以运行App 来进行测试,长按卡片并任意拖曳,如图20.13 所示。

现在,你应该可以拖曳卡片,不过卡片的 z-index 并没有相应做修改,例如:如果你拖曳Visa 卡,它总是停留在卡片库的最上层,我们通过修改 zIndex(for:) 函数来修正它:
private func zIndex(for card: Card) -> Double {
guard let cardIndex = index(for: card) else {
return 0.0
}
// 卡片的默认 z-index 设定为卡片索引值的负值,
// 因此第一张卡片具有最大的 z-index
let defaultZIndex = -Double(cardIndex)
// 如果它是拖曳的卡片
if let draggingIndex = dragState.index,
cardIndex == draggingIndex {
// 我们根据位移的高度来计算新的 z-index
return defaultZIndex + Double(dragState.translation.height/Self.cardOffset)
}
// 否则我们回传默认的 z-index
return defaultZIndex
}
默认的 z-index 仍设定为卡片索引值的负值。对于拖曳的卡片,当使用者在卡片库上拖曳时,我们需要计算一个新的 z-index。修改后的 z-index 是根据位移的高度与卡片的默认偏移量(即 50 点)来计算。
运行 App 并尝试再次拖曳 Visa 卡片。现在,当你拖曳卡片时,z-index 会不断修改。

当你放开卡片时,它现在会回到原来的位置,那么我们如何在拖曳之后重新排序卡片的位置呢?
这里的技巧是修改 cards数组的项目,以触发 UI 修改。首先,我们需要将 cards 变量标记为状态变量,如下所示:
@State var cards: [Card] = testCards
接下来,我们建立另一个新函数来重新排列卡片:
private func rearrangeCards(with card: Card, dragOffset: CGSize) {
guard let draggingCardIndex = index(for: card) else {
return
}
var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)
newIndex = newIndex >= cards.count ? cards.count - 1 : newIndex
newIndex = newIndex < 0 ? 0 : newIndex
let removedCard = cards.remove(at: draggingCardIndex)
cards.insert(removedCard, at: newIndex)
}
当你将卡片拖曳到相邻的卡片上时,一旦拖曳的位移量大于默认的偏移量,我们便需要修改 z-index。图 20.15 显示了拖曳的预期行为。

这是我们计算修改后的 z-index 的公式:
var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)
一旦我们有了修改后的索引值,最后一步就是通过移除拖曳的卡片,并将其插入新位置,以修改在 cards 数组中的项目。由于 cards 数组现在是一个状态变量,因此 SwiftUI 修改卡片库且自动渲染动画。
最后,在“// 重新排列卡片”的下面插入下列这行代码来调用函数:
withAnimation(.spring()) {
self.rearrangeCards(with: card, dragOffset: drag.translation)
}
之后,你可以运行 App 来测试它。你已经建立了如电子钱包般的动画。
阅读完本章后,我希望你对于 SwiftUI 动画与视图转场有更深入的了解。如果你将 SwiftUI 与原来的UIKit 框架进行比较,你会发现 SwiftUI 让“使用动画”变得非常容易。你还记得如何为使用者放开拖曳的卡片来渲染卡片动画吗?你需要做的是修改状态变量, 而 SwiftUI 就会处理这些繁重的工作,这就是 SwiftUI 的力量。
为了方便进一步参考,您可以至下列网址下载完整项目: