承接上一章的内容,本章继续完成如何使用Gestures
手势和Animations
动画实现SwipeCard
卡片滑动的效果。
在动态创建新的卡片视图之前,我们必须首先实现Gestures
手势特性。拖动卡片视图状态最好的方法是使用Enum
枚举,Enum
枚举可以将isPressed
和dragOffset
状态合并到一个属性中。
Gestures手势特性
我们声明一个叫做DragState
的枚举:
//Gestures手势特性 enum DragState { case inactive case pressing case dragging(translation: CGSize) var translation: CGSize { switch self { case .inactive, .pressing: return .zero case .dragging(let translation): return translation } } var isDragging: Bool { switch self { case .dragging: return true case .pressing, .inactive: return false } } var isPressing: Bool { switch self { case .pressing, .dragging: return true case .inactive: return false } } }
Enum
枚举有三种状态:不活动,按下和和拖拽。接下来,让我们定义一个@GestureState
变量来存储拖动状态,它默认被设置为非活动状态:
@GestureState private var dragState = DragState.inactive
然后我们给CardView
加上Gestures
手势特性。
.offset(x: self.dragState.translation.width, y: self.dragState.translation.height) .scaleEffect(self.dragState.isDragging ? 0.95 : 1.0) .rotationEffect(Angle(degrees: Double( self.dragState.translation.width / 10))) .animation(.interpolatingSpring(stiffness: 180, damping: 100),value: offset) .gesture(LongPressGesture(minimumDuration: 0.01) .sequenced(before: DragGesture()) .updating(self.$dragState, body: { (value, state, transaction) in switch value { case .first(true): state = .pressing case .second(true, let drag): state = .dragging(translation: drag?.translation ?? .zero) default: break } }) )
我们顺便给BottomBarMenu
底部导航栏加个交互效果,当我们左右滑动CardView
卡片视图时,隐藏BottomBarMenu
底部导航栏。
BottomBarMenu() .opacity(dragState.isDragging ? 0.0 : 1.0) .animation(.default, value: offset)
我们发现,好像最展示了一张CardView
卡片视图,其实不是的,而是我们创建的两个视图重叠了,执行一样的动作,所以才看起来只有一张图片卡片。
我们只需要在offset
偏移量、 scaleEffect
缩放和rotationEffect
旋转做修订,判断操作的对象是不是album
卡片视图,保证设置的操作只作用在最上面的卡片视图就可以了。
//偏移 .offset(x: self.isTopCard(cardView: album) ? self.dragState.translation.width : 0, y: self.isTopCard(cardView: album) ?self.dragState.translation.height : 0) //缩放 .scaleEffect(self.dragState.isDragging && self.isTopCard(cardView: album) ? 0.95 : 1.0) //旋转 .rotationEffect(Angle(degrees: self.isTopCard(cardView: album) ? Double( self.dragState.translation.width / 10) : 0))
好了,我们完成了卡片的基础的左右滑动了!
展示like/dislike图
下面我们再完善下样式,当我们向右滑动CardVIew
卡片的时候,展示一个点赞
表示喜欢
,当我们左滑动卡片的时候,展示一个点差
表示不喜欢
。
我们在ContentView
定义一个拖拽位置dragPosition
参数,当我们拖拽CardVIew
卡片到一定的位置的时候,就展示喜欢
或者不喜欢
。
private let dragPosition: CGFloat = 80.0
我们可以使用.overlay
覆盖修饰符,将我们想要的样式覆盖在CardVIew
卡片上,我们把代码放在.zIndex
修饰符后面。
我们就实现了向左滑动超过dragPosition
参数时,CardVIew
卡片展示不喜欢Image
图片,向右滑动超过dragPosition
参数时,CardVIew
卡片展示喜欢Image
图片。
//判断喜欢或者不喜欢 .overlay( ZStack { Image(systemName: "hand.thumbsdown.fill") .foregroundColor(.white) .font(.system(size: 100)) .opacity(self.dragState.translation.width < -self.dragPosition && self .isTopCard(cardView: album) ? 1.0 : 0) Image(systemName: "hand.thumbsup.fill") .foregroundColor(.white) .font(.system(size: 100)) .opacity(self.dragState.translation.width > self.dragPosition && self .isTopCard(cardView: album) ? 1.0 : 0.0) } )
下面我们继续完成交互样式。
moveCard移除卡片视图
我们拖拽CardView
卡片再放手时,CardVIew
卡片会回到原来的位置。还记得之前的章节的内容吗?我们在List
列表删除一条数据,系统会自动加回来的问题。
是的,我们用的是同一个方法。我们用@State
标记albums
图片数组,这样我们就可以更新它的值并刷新页面。
//图片数组 @State var albums: [Album] = { var views = [Album]() for index in 0 ..< 2 { views.append(Album(name: album[index].name, image: album[index].image)) } return views }()
然后,我们需要声明一个新的变量,来定位CardVIew
卡片最后一张。当albums
图片数组初始化的时候,我们显示存储在albums
图片数组中的前两个图片,最后一个图片的索引值
设置为1
。
//最后一张图片索引值 @State private var lastIndex = 1
紧接着,我们实现滑动卡片移除
上面一张图片,再显示
下一张图片的moveCard
方法。
//移除卡片显示下一张卡片 func moveCard() { albums.removeFirst() self.lastIndex += 1 let cards = album[lastIndex % album.count] let newCardView = Album(name: cards.name, image: cards.image) albums.append(newCardView) }
moveCard
方法首先从albums
图片数组中移除最上面的卡片。然后实例化
一个新的CardView
卡片视图用来显示后面的Image
图片。由于albums
图片数组被定义为一个状态属性
,因此一旦albums
图片数组的值发生变化,SwiftUI
将再次呈现CardView
卡片视图,这就是滑动移除一张图片显示另一张图片的原理
了。
然后,我们还需要更新.gesture
修饰符并插入.onEnded
函数,实现拖拽到dragPosition
位置时调用用moveCard
移除卡片方法。
//拖拽移除卡片 .onEnded({ (value) in guard case .second(true, let drag?) = value else { return } if drag.translation.width < -self.dragPosition || drag.translation.width > self.dragPosition { self.moveCard() } })
恭喜你!又离成功近了一步!
Transition转场动画
如果我们功能做完了,但是交互效果还是差一点,为了App
实际运行时的动画效果,我们可以尝试附加转场动画
修饰符,并将一个不对称的转场动画应用到CardView
卡片视图。
我们使用非对称转场动画
是为了达到下面的效果:
1、CardVIew卡片视图被移除时动画化有转换动画,有种卡片被扔出去的感觉
2、当一个新的CardVIew卡片视图展示在上面的时候没有动画。
我们可以创建一个叫做AnyTransition
的扩展,并定义两个转场动画效果:
//转场动画效果 extension AnyTransition { static var trailingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition.move(edge: .trailing).combined(with: .move(edge : .bottom)) ) } static var leadingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition.move(edge: .leading).combined(with: .move(edge: .bottom)) ) } }
trailingBottom
转场效果在CardView
卡片视图被扔到到屏幕右侧时使用,而我们在CardView
卡片视图被丢弃到屏幕左侧时应用leadingBotto
m转场效果。
接下来,声明一个保存转换类型的@state
属性,默认为trailingBottom
。
//转场类型动画 @State private var removalTransition = AnyTransition.trailingBottom
然后,将.transition
修饰符附加到CardView
卡片视图中的.animation
修饰符之后。
//转场动画 .transition(self.removalTransition)
最后,用onChanged
函数更新.gesture
修饰符的代码,通过设置removalTransition
让转换类型根据滑动方向更新样式。
//拖动时添加转场效果 .onChanged({ (value) in guard case .second(true, let drag?) = value else { return } if drag.translation.width < -self.dragPosition { self.removalTransition = .leadingBottom } if drag.translation.width > self.dragPosition { self.removalTransition = .trailingBottom } })
恭喜恭喜!
我们终于完成了整个SwipeCard
卡片滑动效果的学习。
由于写的内容太多了,一边写一边改,有些地方能解释说明的尽量解释说明
,但还是有很多地方没有办法一一详细说明清楚。
这两章关于SwipeCard
卡片滑动效果的使用,基本涉及到了前面的所有章节的内容,因此有些方法就粗略带过了。
源代码也会分享给大家,供大家参考学习,算是一个实战小项目
吧。
完整
代码如下:
import SwiftUI // 创建Album定义变量 struct Album: Identifiable { var id = UUID() var name: String var image: String } // 创建演示数据 var album = [ Album(name: "图片01", image: "image01"), Album(name: "图片02", image: "image02"), Album(name: "图片03", image: "image03"), Album(name: "图片04", image: "image04"), Album(name: "图片05", image: "image05"), Album(name: "图片06", image: "image06"), Album(name: "图片07", image: "image07"), Album(name: "图片08", image: "image08"), Album(name: "图片09", image: "image09"), ] // 创建2个卡片视图 var albums: [Album] = { var views = [Album]() for index in 0 ..< 2 { views.append(Album(name: album[index].name, image: album[index].image)) } return views }() //主视图 struct ContentView: View { @GestureState private var dragState = DragState.inactive @State private var offset: CGFloat = .zero private let dragPosition: CGFloat = 80.0 //图片数组 @State var albums: [Album] = { var views = [Album]() for index in 0 ..< 2 { views.append(Album(name: album[index].name, image: album[index].image)) } return views }() //最后一张图片索引值 @State private var lastIndex = 1 //转场类型动画 @State private var removalTransition = AnyTransition.trailingBottom var body: some View { VStack { TopBarMenu() // 卡片视图 ZStack { ForEach(albums) { album in CardView(name: album.name, image: album.image) .zIndex(self.isTopCard(cardView: album) ? 1 : 0) //判断喜欢或者不喜欢 .overlay( ZStack { Image(systemName: "hand.thumbsdown.fill") .foregroundColor(.white) .font(.system(size: 100)) .opacity(self.dragState.translation.width < -self.dragPosition && self .isTopCard(cardView: album) ? 1.0 : 0) Image(systemName: "hand.thumbsup.fill") .foregroundColor(.white) .font(.system(size: 100)) .opacity(self.dragState.translation.width > self.dragPosition && self .isTopCard(cardView: album) ? 1.0 : 0.0) } ) //偏移 .offset(x: self.isTopCard(cardView: album) ? self.dragState.translation.width : 0, y: self.isTopCard(cardView: album) ? self.dragState.translation.height : 0) //缩放 .scaleEffect(self.dragState.isDragging && self.isTopCard(cardView: album) ? 0.95 : 1.0) //旋转 .rotationEffect(Angle(degrees: self.isTopCard(cardView: album) ? Double( self.dragState.translation.width / 10) : 0)) //动画 .animation(.interpolatingSpring(stiffness: 180, damping: 100), value: offset) //转场动画 .transition(self.removalTransition) //手势 .gesture(LongPressGesture(minimumDuration: 0.01) .sequenced(before: DragGesture()) .updating(self.$dragState, body: { value, state, _ in switch value { case .first(true): state = .pressing case .second(true, let drag): state = .dragging(translation: drag?.translation ?? .zero) default: break } }) //拖动时添加转场效果 .onChanged({ (value) in guard case .second(true, let drag?) = value else { return } if drag.translation.width < -self.dragPosition { self.removalTransition = .leadingBottom } if drag.translation.width > self.dragPosition { self.removalTransition = .trailingBottom } }) //拖拽移除卡片 .onEnded({ (value) in guard case .second(true, let drag?) = value else { return } if drag.translation.width < -self.dragPosition || drag.translation.width > self.dragPosition { self.moveCard() } }) ) } } Spacer(minLength: 20) BottomBarMenu() .opacity(dragState.isDragging ? 0.0 : 1.0) .animation(.default, value: offset) } } // 获得图片zIndex值 func isTopCard(cardView: Album) -> Bool { guard let index = albums.firstIndex(where: { $0.id == cardView.id }) else { return false } return index == 0 } //移除卡片显示下一张卡片 func moveCard() { albums.removeFirst() self.lastIndex += 1 let cards = album[lastIndex % album.count] let newCardView = Album(name: cards.name, image: cards.image) albums.append(newCardView) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } // 顶部导航栏 struct TopBarMenu: View { var body: some View { HStack { Image(systemName: "ellipsis.circle") .font(.system(size: 30)) Spacer() Image(systemName: "heart.circle") .font(.system(size: 30)) }.padding() } } // 卡片视图 struct CardView: View { let name: String let image: String var body: some View { Image(image) .resizable() .frame(minWidth: 0, maxWidth: .infinity) .cornerRadius(10) .padding(.horizontal, 15) .overlay( VStack { Text(name) .font(.system(.headline, design: .rounded)).fontWeight(.bold) .padding(.horizontal, 30) .padding(.vertical, 10) .background(Color.white) .cornerRadius(5) } .padding([.bottom], 20), alignment: .bottom ) } } // 底部导航栏 struct BottomBarMenu: View { var body: some View { HStack { Image(systemName: "xmark") .font(.system(size: 30)) .foregroundColor(.black) Button(action: { }) { Text("立即选择") .font(.system(.subheadline, design: .rounded)).bold() .foregroundColor(.white) .padding(.horizontal, 35) .padding(.vertical, 15) .background(Color.black) .cornerRadius(10) }.padding(.horizontal, 20) Image(systemName: "heart") .font(.system(size: 30)) .foregroundColor(.black) } } } // Gestures手势特性 enum DragState { case inactive case pressing case dragging(translation: CGSize) var translation: CGSize { switch self { case .inactive, .pressing: return .zero case let .dragging(translation): return translation } } var isDragging: Bool { switch self { case .dragging: return true case .pressing, .inactive: return false } } var isPressing: Bool { switch self { case .pressing, .dragging: return true case .inactive: return false } } } //转场动画效果 extension AnyTransition { static var trailingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition.move(edge: .trailing).combined(with: .move(edge : .bottom)) ) } static var leadingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition.move(edge: .leading).combined(with: .move(edge: .bottom)) ) } }
快来动手试试吧!