上一章节中, 我们使用SwiftUI的手势与动画,实现了一个类似Tinder的卡片滑动UI。
在本篇教程中,我们将继续探索SwiftUI中的动画与手势,
并使用 LongPressGesture
和 DragGesture
来实现一个类似Apple Wallet的卡片堆叠、拖拽重排以及视图过渡效果。
通过学习本示例,你将掌握以下内容:
- 如何结合 State、GestureState、TapGesture、LongPressGesture 和 DragGesture 实现交互控制;
- 如何利用 transition 与 animation 修饰器,实现卡片的进场动画以及点击卡片后显示交易历史的动画过渡;
- 如何拖拽并重排卡片,同时更新数据源并触发SwiftUI的自动动画刷新。
若你还未使用过苹果自带的Wallet App,不妨先打开Wallet查看其交互效果,了解本章节要实现的最终动画效果。
目录
项目准备
卡片数据结构如下:
struct Card: Identifiable {
let id: UUID
let number: String
let type: CardType
let expiryDate: String
let name: String
let image: String
}
// 示例卡片数据
let testCards: [Card] = [
Card(id: UUID(), number: "1234 5678 9012 3456", type: .visa, expiryDate: "12/25", name: "John Doe", image: "visa"),
Card(id: UUID(), number: "6789 0123 4567 8901", type: .mastercard, expiryDate: "06/26", name: "Jane Smith", image: "mastercard"),
Card(id: UUID(), number: "3456 7890 1234 5678", type: .discover, expiryDate: "09/27", name: "Alice Johnson", image: "discover"),
]
交易数据结构如下:
struct Transaction: Identifiable {
let id: UUID
let merchant: String
let amount: Double
let date: Date
let icon: String
}
// 示例交易数据
let testTransactions: [Transaction] = [
Transaction(id: UUID(), merchant: "Apple", amount: 100.0, date: Date(), icon: "apple"),
Transaction(id: UUID(), merchant: "Amazon", amount: 200.0, date: Date(), icon: "amazon"),
Transaction(id: UUID(), merchant: "Starbucks", amount: 300.0, date: Date(), icon: "starbucks"),
]
创建卡片视图(CardView
)
示例中的卡片图片只包含卡片品牌(例如Visa、Master等),并不带个人信息或卡号。
为了更真实,我们将使用 overlay
在图片上叠加文字显示卡号和姓名。
创建名为 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)
}
}
预览CardView
在 #Preview
中传入示例 Card
即可进行预览:
#Preview(testCards[0].type.rawValue) {
CardView(card: testCards[0])
}
如果一切正常,在Xcode预览中就能看到带有文字信息的卡片效果。
构建WalletView和卡片堆叠
接下来,我们将借助 ZStack
叠加数张 CardView
,形成立体堆叠效果,并在其上实现各种动画与手势功能。
- 删除原本的
ContentView.swift
; - 在
View
文件夹下创建新的WalletView.swift
; - 修改
SwiftUIWalletApp.swift
里的WindowGroup
初始视图为WalletView()
,以避免编译错误:
struct SwiftUIWalletApp: App {
var body: some Scene {
WindowGroup {
WalletView() // 替换为WalletView
}
}
}
顶部导航栏
在 WalletView.swift
中,先创建一个简单的顶部导航栏 TopNavBar
:
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
的主体布局中,使用 VStack
竖向排列顶部导航与卡片堆叠:
struct WalletView: View {
var cards: [Card] = testCards // 默认测试数据
var body: some View {
VStack {
TopNavBar()
.padding(.bottom)
Spacer()
ZStack {
ForEach(cards) { card in
CardView(card: card)
.padding(.horizontal, 35)
}
}
Spacer()
}
}
}
如果此时预览或运行,会发现最后一张卡片(如Discover)遮住了其它卡片,并且叠放顺序与期望相反。以下两点需要解决:
- 卡片未展开:它们都叠在同一位置;
- 顺序反了:
testCards
第一张应该在最顶层。
调整叠放顺序(zIndex
)并让卡片错落展开
1. 设置zIndex
SwiftUI 中,zIndex
值越大,该视图越在顶层。
我们可以将索引较小的卡片视为顶层,因此令其拥有更大的zIndex。
在 WalletView
中添加辅助函数:
private func zIndex(for card: Card) -> Double {
guard let cardIndex = index(for: card) else {
return 0.0
}
return -Double(cardIndex) // 索引小 -> zIndex大
}
private func index(for card: Card) -> Int? {
guard let index = cards.firstIndex(where: { $0.id == card.id }) else {
return nil
}
return index
}
并在 CardView(card: card)
处加上 .zIndex(self.zIndex(for: card))
:
CardView(card: card)
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
现在,数组首位(如Visa卡)的 zIndex
值最大(负数最小),会置顶显示。
2. 通过offset让卡片分层
接着为每张卡片设定不同的垂直偏移量(offset
)。
我们先定义一个卡片偏移常量,在 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()
}
// 每张卡片在垂直方向错开50的倍数
return CGSize(width: 0, height: -Self.cardOffset * CGFloat(cardIndex))
}
然后在 CardView
中添加 .offset(self.offset(for: card))
:
CardView(card: card)
.padding(.horizontal, 35)
.offset(self.offset(for: card))
.zIndex(self.zIndex(for: card))
这样,最上层卡片(索引0)偏移最小,下面的卡片偏移会更大,从而在视觉上出现堆叠的效果。
添加进场动画(Slide-in Animation
)
我们想让卡片在应用启动时,从左侧滑入并依次出现。
为此,可在 WalletView
中使用 transition 与 animation 修饰器,
为每张卡片实现渐入效果。
1. 触发动画的 State
在 WalletView
中声明一个 @State private var isCardPresented = false
表示卡片是否已呈现:
@State private var isCardPresented = false
2. 为卡片配置视图转换与动画
在 ForEach(cards)
内部,为 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)
.id(isCardPresented) // 以State变量为id,触发视图刷新
这里的 transition
指定了 slide + move(edge: .leading) + opacity 的组合效果,而 .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
) 实现先展示索引较大的卡片(底层卡)再出现索引较小的卡片(顶部卡)。
3. 在 onAppear
中触发动画
在 ZStack
或 VStack
上添加 onAppear
,
当视图加载后立即将 isCardPresented
切为 true
,从而触发动画:
ZStack {
ForEach(cards) { card in
...
}
}
.onAppear {
isCardPresented.toggle()
}
这样,一启动应用,卡片会从屏幕左侧依次滑入并呈现层叠效果。
点击卡片并显示交易历史
Wallet应用中,轻点某张卡片可以查看交易历史(自下方弹出)。
我们用 isCardPressed
和 selectedCard
这两个State变量来控制这部分逻辑。
@State var isCardPressed = false
@State var selectedCard: Card?
1. 绑定TapGesture
在对 CardView
的 .gesture
处理中,添加一个 TapGesture
来切换 isCardPressed
以及记录当前卡片:
.gesture(
TapGesture()
.onEnded { _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
}
)
2. 调整卡片偏移
当某张卡片被选中时,我们希望选中卡片及其上方卡片都上移(偏移量0),而其他 卡片移到屏幕下方,形成离场效果。
更新 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: -Self.cardOffset * CGFloat(cardIndex))
}
3. 显示交易历史视图
在 VStack
的 Spacer()
之上插入一个条件视图,根据 isCardPressed
来显示 TransactionHistoryView
:
if isCardPressed {
TransactionHistoryView(transactions: testTransactions)
.padding(.top, 10)
.transition(.move(edge: .bottom))
}
一旦 isCardPressed
为true,交易历史视图就会自底部过渡出现,营造抽屉弹出的效果。
使用拖拽手势重排卡片
Wallet 还有一项功能:长按并拖拽卡片进行重新排序。整体流程如下:
- 仅当用户长按卡片成功后,才允许进行拖拽;