本节是连续上一课的内容,上一节课我们讲理了动画的基本理论及原理等内容,本课将会对上一课中学习的各项理论知识加以运用,接上一课的隐式动画下面将使用以下的内容演示:
要实现牌功能只需要调用数组的shuffle()即可,它可以让数组里的项随机排例。
1、在Model里增加一个shuffle()函数:
mutating func shuffle() {
cards.shuffle()//洗牌,让卡片数组里的内容随机排列
}
2、自动洗牌:游戏一开始就自动洗牌,需要在init里增加一个cards.shuffle()即可。
3、通过VM与View连接起来:
在ViewModel里的// MARK: - Intent(s) 用户意图 下面增加一个函数shuffle(),调用Model里的shuffle()函数。
func shuffle(){
model.shuffle()//调用Model里的洗牌功能
}
4、视频里增加一个调用VM洗牌的按钮:
//一个洗牌的按钮
var shuffle:some View{
Button("重新洗牌"){
withAnimation{//上一课动画理论里说到过,通常配合 用户操作 应用动画
game.shuffle()//通过ViewModel调用Model里的洗牌功能
}
}
}
对View里的代码整理一下,把原来的body里的内容放入到gameBody里,然后我们的body内容改为下面的:
var body: some View{
VStack{
gameBody//原来body里的内容
shuffle//洗牌按钮
}
}
通过VStack垂直排列内容即可。
5、替换更好的Color替换掉Rectangle()
Color.clear//将原来的Rectangle()使用Color替换
视频的第3分钟处左右有详细说明原因,因为Color.clear是透明的颜色,所以比原来使用.opacity(0)更好。还有讲到Path的做为中间视图的运用。
我们将使用系统内置的.rotation3DEffect来实现3D翻转效果,首先将动画效果应用于当用户选择卡片时。上面我们将withAnimation写在View里,这里我与课程不一样,写在了ViewModel里。
func choose(_ card: Card) {
//设置4秒,可以看到动画缓慢播放的过程
withAnimation(.easeInOut(duration: 4)){
model.choose(card)//通过withAnimation应用动画效果
}
}
1、增加3D效果的参数
在struct Cardify:ViewModifier里增加一个3D的参数:
.rotation3DEffect(
Angle.degrees(isFaceUp ? 0 : 180),//当翻转时触发180度
axis: (0,1,0)//分为是X轴、Y轴、Z轴
)
现在可以看到已应用了3D翻转并配合了动画,但是出现了一点问题,翻转的内容卡片出现了透明状况。之所以这样,是因为动画切换过程中默认会应用过渡效果导致。
2、解决翻转过程中的淡入淡出过渡效果
解决思路需要让卡片翻转到90度的时候再去执行content.opacity(isFaceUp ? 1 : 0)效果。这样就能错开透明的问题。这是本节课学习的要点之一(视频第18分钟左右开始)。
//AnimatableModifier包含了Animatable与ViewModifier
struct Cardify:AnimatableModifier {
init(isFaceUp:Bool) {
//动画系统会监听此值,并根据动画播放时长切成n份更新animatableData的值
rotation = isFaceUp ? 0 : 180
}
var rotation: Double //旋转值(我们需要通过animatableData让此值动画化)
//使用animatableData为了符合Animatable协议
//animatableData的值由系统调度根据动画化进程而自动被更新
var animatableData: Double{ //Double符合VectorArithmetic协议
get{ rotation }//需要动画化的值
set{ rotation = newValue }//animatableData 会记录动画运行时数据不断变化的值
}
func body(content: Content) -> some View {
ZStack{
let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
if rotation < 90{ //通过旋转值判断翻转
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: DrawingConstants.lineWidth)
}else{
shape.fill()
}
content.opacity(rotation < 90 ? 1 : 0)//当旋转值小于90时才显示
Text(String(Int(rotation))).font(.largeTitle)//通过UI直接查看animatableData动画后的值
}
.rotation3DEffect(
//rotation的值会一直更新,所以旋转的角度也会产生变化
Angle.degrees(rotation),//使用rotation替换要旋转的值
axis: (0,1,0)//分别为x轴、y轴、z轴
)
}
//参数控制器也剪切过来
private struct DrawingConstants{
static let cornerRadius: CGFloat = 10//圆角值
static let lineWidth: CGFloat = 3//strokeBorder的线宽
}
}
上面代码的核心是理解rotation是怎样被animatableData动画化,并不断的更新其值的。随着动画的播放 rotation 的值会被 set{ rotation = newValue } 不断的更新,导致触发 rotation < 90 和下面一系列的动作。代码不难,难在了理解 rotation 值为什么会变化的原理上面。解释Animatable
当然多了一层rotation不好理解,我们可以删除rotation,把所有rotation代码换成animatableData,这样或许更容易一点。我们来缕一缕animatableData数据变化的过程。
a.首先 定义了 animatableData 为 Double 类型,这个类型是符合VectorArithmetic协议的,说明是可以被动画化的
b. animatableData 随着init里的isFaceUp状态改变而更新为180或者0。
c. animatableData 的值不会立即更新为180,因为我们应用了动画其值被动画化了,180会根据动画需要播放的时候切成了若干份(默认动画为2秒,则每秒显示90份,1秒为1000毫秒,则每100毫秒显示9份)。
d.被切片后的值使得animatableData假设为每100毫秒值发生变化 9、18、27、36...90...172、180(实际上打印出来的是浮点数111.76、97.25、82.49、67.99),当animatableData的值随着动画播放小于90的时候,则视频修改效果被触发。
(rotation = isFaceUp ? -30 : 180的效果)
e.这样我们就得到了,随着动画的播放 animatableData 的作用就是更新动画播放过程中应用的符合VectorArithmetic协议的的值。
f.如果还没有明白就自己百度、google或者看视频吧。这个需要脑回路转一下。我也卡了很久。
g.我能想到的最后一招,在set{ print(rotation) }将rotation打印出来(rotation只是animatableData的代理值,一般情况下我们不直接使用animatableData,因为其意思不能正确表达变量的含义),我们就可以看到其值的被动画化而变化过程。当点击时动画会先从180开始减少到0。
在后面的记时Pie()演示中我们还会学到AnimatablePair<First, Second>由2个VectorArithmetic值影响动画的参数。到时可再一次理解数据被动画化导致数据被切片变化的情况。
我们只需要在gameBody下面的 CardView(card: card)增加一行下面的代码:
.transition(AnyTransition.scale.animation(Animation.easeInOut(duration: 2)))
//应用transition里的scale效果,并使用动画时间2秒的渐进渐出
现在尝试匹配后卡片离开屏幕,会发现卡片慢慢缩放并消失了。这是一种非对称的过渡,我们可以使用下面的代码对其替换为对象过渡效果:
.transition(AnyTransition.asymmetric(insertion: .scale, removal: .opacity))
//asymmetric可以同时定义视图进入时(insertion)缩放、离开时(removal)为透明过渡
经过测试发现,当卡片离开屏幕时确定可以看到透明的过渡效果,但是看不到进入时的缩放效果,现在我们就针对这问题解决,这也将是本节课的重点之一,也是比上面动画化参数更为难理解的部分。
1、不缩放进入的原因分析:
这个问题和前面单个内容旋转一样,过渡效果需要从已经在屏幕上的容器中出现或者消失才会有效果,当父容器AspectVGrid出现在屏幕上时那些卡片已经在那儿了,所以不会应用进入的过渡效果。
换句话说,这些卡片是带着它们的容器一起出现的。所以是这个AspectVGrid的过渡将控制出现或消失,因为它是出现的东西。这也显示了ViewModifiers与过渡和动画之间的巨大差异。
2、解决问题思路:
我们要在启动程序时显示缩放效果,首先要保证AspectVGrid必须首先出现在屏幕上才行。然后再让AspectVGrid里的CardView出现。意思是容器先出现后,容器里的内容才能使用过渡(transition)效果。
思路:我们可以让 AspectVGrid 调用自己的.onAppear { }当其显示在屏幕上执行闭包代码。这里的闭包代码作用是将卡片分发到我们的UI中。这样就可以确定容器先出现,其次才是 卡片 的顺序。
要做这样效果,我们必须得跟踪每一张卡片,所以我们需要定义一个卡片ID的集合来检测当前卡片是否已经放入到了AspectVGrid容器:
@State private var dealt = Set{}//定义一个Int类型的空集合
Set和数组类似,但里面不能存重复的内容,所以上面的Set存放了唯一的ID不会产生重复的卡片,关于Set集合的介绍请查看:http://www.55mx.com/ios/102.html
所以当我们需要发牌的时候 就将这个卡片ID丢进上面定义的dealt里,这样我们就可以知道是否已经发牌了。这是一种简单的跟踪方法(可我怎么想不到呢...)。
3、使用助手处理集合
最开始这个集合是空的,这也意味着我还没有发牌,当AspectVGrid出现时我们通过.onAppear{ 在这里发牌 }。为了调用方便,我们将建立2个小函数来帮助我们对Set进行操作。
private func deal(_ card: EmojiMemoryGame.Card) {
dealt.insert(card.id)//将卡片id插入到集合里
}
//返回卡片在集合里存在的状态,在 = false,不在 = true
private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool{
!dealt.contains(card.id)//contains检查当前卡片是否存在于集合中
}
现在我们有了2个小助手对集合进行处理工作,之所以要使用助手,可以看到他们传入的参数都是EmojiMemoryGame.Card,而我们只需要card.id即可,所以这里使用函数来处理更方便。
4、开始发牌
我们只需要在AspectVGrid下面使用onAppear即可:
AspectVGrid(items:game.cards,aspctRatio:2/3){card in
......
}.onAppear{
withAnimation{ //配合动画处理发牌事项
//遍历game.cards所有的内容准备发牌
for card in game.cards{
deal(card)//将要显示的卡片插入到集合里
}
}
}
现在我们将要显示的内容都放入到了集合里面,但是这个工作只完成了一半,还有一个最重要的事物要处理,怎么样让我们的牌放入容器里。
5、通过判断将牌放入容器
在放入容器之前我们先看一下原来是怎么放入的:
//我们通过判断isMatched与!card.isFaceUp的状态放入的卡片
if card.isMatched && !card.isFaceUp{
Color.clear//这不是卡片
}else{
CardView(card: card)//这里卡片
}
可以看到,是否放入卡片的决定权主要在于这个if判断,我们这里就需要判断,这个卡片是否在集合里,如果没有在集合里就不显示Color.clear,当存在于集合里时,就让卡片显示。所以我们在判断的地方改为下面的代码:
//通过isUndealt检测卡片是否已发牌成功
if isUndealt(card) || (card.isMatched && !card.isFaceUp){
//卡片条件不满足
}else{
//显示卡片
}
到了这一步,我们的目的基本已达到,我们实现了先显示AspectVGrid以后。调用其onAppear来控制卡片后显示的情况。这样过渡效果的条件也就满足了。
尝试在模拟器里启动游戏会发现,已应用了 insertion: .scale ,所以if判断完成了我们发牌的关键一步,把不满足条件的卡片档在了门外,也是if的暂时阻挡,才让我们的容器与卡片有了微小的前后时间差以触发transition 的 insertion: .scale 方法。
通过上面的代码,我们也学习到了@State修饰,这并不陌生,在前几的MVVM之前早就使用过了。这里再次巩固一下。虽然用得不多,但用起来是真的很香。
最后,为了我们看到transition生效的慢动作,我们可以把代码简单的修改为:
.transition(AnyTransition.asymmetric(insertion: .scale, removal: .opacity)
.animation(.easeInOut(duration: 4))//过渡里再次插入了动画,它们可以相互配全使用哦
)
现在我们有了onAppear处理发牌的机制,我们利用这个机制做到让牌从底部飞出去到每个位置上的效果。下面为高能代码,神仙保佑,我能看懂!!!
1、先建立一个存放洗好牌的视图(44:20)
//建立一个洗好牌的待发的视图
var deckBody:some View{
ZStack{
//通过filter过滤掉哪些还未处理的卡片,原型:filter{isUndealt($0)}
ForEach(game.cards.filter(isUndealt)){ card in
CardView(card: card)//通过ZStack让牌叠起来
//设置一个与已发牌相关的过渡,插入时渐隐,删除时缩小
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .scale))
}
}
//限制视图显示大小为固定值
.frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
.foregroundColor(CardConstants.color)
}
//本视图里需要控制的一些参数
private struct CardConstants{
static let color = Color.red //卡片颜色
static let aspectRatio:CGFloat = 2/3
static let dealDuration: Double = 0.5
static let totalDealDuration:Double = 2
static let undealtHeight:CGFloat = 90//未发牌的高度
static let undealtWidth = undealtHeight * aspectRatio//未发牌的宽度
}
除了发牌的视频,我们还设置了一些常数用于控制本视频里的参数信息。有一些替换的工作。
然后将deckBody放入主视图里。这里插一段课外话,为什么叫deck(甲板),这和中文理解的甲板是不一样的,在外企里,PPT也称为deck,因为deck在英文里表示为一层一层的,扑克牌堆一起也有表示一层一层的意思,所以这里变量命名为deckBody。
2、点击后才发牌:
我们不需要在程序启动时自动的让牌发出去,而是需要点击deckBody以后才触发发牌的动画。所以这里我们先把onAppear移除。然后在deckBody上增加:
.onTapGesture {
withAnimation(.easeInOut(duration: 5)){ //5秒内把牌发完
for card in game.cards{
deal(card)//将要显示的卡片插入到集合里达到发牌目的
}
}
}
3、使用matchedGeometryEffect让牌飞出去的动画(视频第50分钟处)
我们的目的是让牌从deckBody飞向它们应该存在的位置上面。这里就需要使用到swiftUI内置的matchedGeometryEffect(上一课提了一下)。
我们要做的就是将牌组中的卡片的几何形状与此处卡片相匹配,达到从A容器飞向B容器的动画效果。随着A容器里消失,B容器出现,它们会通过matchedGeometryEffect匹配几何形状,然后飞向新的位置。视觉上的感觉就是发牌效果。
在gameBody的CardView(card: card)下应用matchedGeometryEffect:
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
//传入的ID用于识别,in是一个名称空间需要在顶部定义
下一步,将deckBody里的CardView(card: card)和上面一样也使用matchedGeometryEffect,这样CardView与deckBody连接到了一起。我们在顶部定义一下命名空间:
@Namespace private var dealingNamespace//定义一个命名空间
运行模拟器测试发现,这2个动画是连接在了一起,但不是我们想到的效果,现在是随着动画时间的播放,由deckBody里的块慢慢放大撑开后变成了gameBody里的内容。
4、修复效果,让牌真正的飞出去
deckBody撑大的原因是.transition导致,因为运行了对称过渡,所以我们需要将gameBody里的CardView对称transition插入端取消掉,使用参数.identity,而删除时应用.scale缩放。
.transition(AnyTransition.asymmetric(insertion: .identity, removal: .scale))
而deckBody里的CardView如果出现(insertion)我们不关心,但消失时我们与上面设置相关的参数:
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
现在看到过渡效果已经很平滑了,但还是没有做到发牌的效果,这是因为,我们的牌是同时出去的,并没有一张一张的发送。要做到一张一张发送,我们就要使用延时动画,让卡片飞出去产生一个时间差:
//延时发牌动画
private func dealAnimation(for card:EmojiMemoryGame.Card) -> Animation{
var delay = 0.0 //初始化延时Double
if let index = game.cards.firstIndex(where: {$0.id == card.id}){
//算出当前卡片需要延时多少
delay = Double(index) * (CardConstants.totalDealDuration / Double(game.cards.count))
}
//返回动画执行时间0.5,并延时delay秒
return Animation.easeInOut(duration: CardConstants.dealDuration).delay(delay)
}
然后将这个通过计算后的延时动画应用到deckBody上。
.onTapGesture {
for card in game.cards{
withAnimation(dealAnimation(for: card)){ //通过当前card计算出需要延时多久
deal(card)//通过延时做为计时器,让卡片慢慢进入集合
}
}
}
上面这一系列操作完美的实现了发牌动画效果,原理说得很清楚,重要的是理解一下发牌那个延时的算法,每张牌是怎样计算出所花费的时间的。这是本课中最最重要的核心内容和学习要点。
5、收尾工作,让卡片从顶部开始发(视频1小时处)
现在我们看到的效果牌是从ZStack的底部开始发出的,我们要想从顶部发片,就需要使用.zIndex控制层级:
//计算出当前卡片的合适zIndex值
private func zIndex(for card:EmojiMemoryGame.Card) -> Double{
-Double(game.cards.firstIndex(where: {$0.id == card.id}) ?? 0)
}
然后分析应用在deckBody与gameBody里的CardView上。
CardView(card: card)
.zIndex(zIndex(for:card))//控制重叠视图的显示顺序。
上面我们已完成了成功发牌的效果,在完成每张卡片计时效果之前我们增加一个重新开始游戏的功能;
1、重新开始游戏
在View里创建一个重新开始的按钮,并调用ViewModel里的restart()功能。
var restart:some View{
Button("重新开始"){
withAnimation{
dealt = [] //初始化dealt集体
game.restart()//通过ViewModel调用Model里的重新开始功能
}
}
}
我们ViewModel里的重新开始功能如下:
func restart(){
model = EmojiMemoryGame.createMemoryGame()//重新初始化游戏
}
然后在主视图上将 重新开始 与 重新洗牌 放到一行展示,在横屏里明显可以看到SwiftUI为deckBody预留了空间,我们要修改一下布局方式,使其布局更合理:
var body: some View{
//使用ZStack 让deckBody底部对齐
ZStack(alignment: .bottom){
VStack{
gameBody
HStack{
restart
Spacer()
shuffle
}
.padding(.horizontal)
}
deckBody//发牌位于 重新开始 和 重新洗牌 之间
}.padding()
}
2、理解计时器算法
课程里的计时器早已写好了,我们需要理解计时器的算法原理,这也是本课的难点之一,在今后的开发中,最烧脑的就是这样大大小小算法,理解了这些算法对我们日常开发工作有很大的帮助。
在Model里将原来的Card替换成下面的代码:
//Card被算法重写了
struct Card: Identifiable {
//isFaceUp增加了属性观察者,当翻开后开始计时,背对暂停计时
var isFaceUp = false {
didSet {
if isFaceUp {
startUsingBonusTime()
} else {
stopUsingBonusTime()
}
}
}
//增加属性观察者,一但匹配停止计时
var isMatched = false {
didSet {
stopUsingBonusTime()
}
}
let content: CardContent
let id: Int
// MARK: - 计时器算法
//额外的时间限制,超时后奖励为0
var bonusTimeLimit: TimeInterval = 6
// 这张牌朝上多久了
private var faceUpTime: TimeInterval {
if let lastFaceUpDate = self.lastFaceUpDate {
return pastFaceUpTime + Date().timeIntervalSince(lastFaceUpDate)
} else {
return pastFaceUpTime
}
}
// 上次这张牌是面朝上的(现在仍然是面朝上)
var lastFaceUpDate: Date?
//这张卡过去的累计时间 (例如,如果当前是正面,则不包含当前时间)
var pastFaceUpTime: TimeInterval = 0
// 还有多少时间奖金就到期了
var bonusTimeRemaining: TimeInterval {
max(0, bonusTimeLimit - faceUpTime)
}
// 剩余奖金时间的百分比
var bonusRemaining: Double {
(bonusTimeLimit > 0 && bonusTimeRemaining > 0) ? bonusTimeRemaining/bonusTimeLimit : 0
}
// 是否在奖金期间匹配该卡
var hasEarnedBonus: Bool {
isMatched && bonusTimeRemaining > 0
}
// 无论我们目前是否正面向上,还是无可匹敌的,奖金窗口还没有用完
var isConsumingBonusTime: Bool {
isFaceUp && !isMatched && bonusTimeRemaining > 0
}
// 当卡片转换为正面状态时调用开始计时
private mutating func startUsingBonusTime() {
if isConsumingBonusTime, lastFaceUpDate == nil {
lastFaceUpDate = Date()
}
}
// 当牌面朝下(或匹配)时调用 暂时/停止计时
private mutating func stopUsingBonusTime() {
pastFaceUpTime = faceUpTime
self.lastFaceUpDate = nil
}
}
我们只需要在正确的时间调用这些开始和停止就能实现计时效果,而再配合动画就能完美实现逻辑与UI同步。
3、让Pie()产生动画效果
查看文档发现Shape符合protocol Shape : Animatable, View,我们直接可以使用animatableData,因为们我需要对2个数据进行检测,所以我们需要定义为:
//设置被动画化数据为2组Double
var animatableData: AnimatablePair<Double,Double>{
get{
//通过AnimatablePair返回开始角度的弧度与结束角度的弧度值
AnimatablePair(startAngle.radians,endAngle.radians)
}
set{
//通过newValue里被动画化的值不断为startAngle与endAngle赋值
startAngle = Angle.radians(newValue.first)
endAngle = Angle.radians(newValue.second)
}
}
然后将Pie里的角度值改为我们算法结果:
Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-card.bonusRemaining)*360-90))
测试发现,计时在后台开始了,并没有体现到UI动画上,原因是(1:14:30):在倒计时期间,我们的Model中没有任何的变化。我们的Model不会不断的修改自己(没有计时机制),而现在Model所做的工作只有跟踪。只有在查询的时间Model才返回被计算的剩余时间,我们现在要做的就是为Model增加一个计时器,我们知道动画会将数据切成n份,我们可以利用动画执行播放过程做为计时器使用:
a.定义一个临时计时器:
@State private var animatedBonusRemaining:Double = 0//初始化为0
b.然后将Pie修改为下面的代码:
Group{
if card.isConsumingBonusTime{
Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-animatedBonusRemaining)*360-90))
.onAppear{
animatedBonusRemaining = card.bonusRemaining//剩余奖金时间的百分比
//利用duration时间切片的属性,实现倒计时机制(还有多少时间奖金就到期了)
withAnimation(.linear(duration: card.bonusTimeRemaining)){
animatedBonusRemaining = 0
}
}
}else{
Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-card.bonusRemaining)*360-90))
}
}
启动模拟器测试,一切运行正常且完美,到这里我们的Memorize课程告一段落,特别是本节课,深入的对动画制作进行了详细介绍,也是出现了多个学习难点的地方。特别是算法和动画运行机制。
延续上一课的动画理论知道,我们知道了隐藏动画与显式动画,也知道了过渡与动画的区别,我们还学习了怎么解决动画播放中出现的BUG问题,利用延时动画让卡片一张一张飞出去。我们遇到了卡片一张旋转另一张不转的问题,这是因为我们使用if-then导致Text离开了屏幕。我们使用透明度将其隐藏的方法让其一直显示在屏幕上并触发动画效果。我们还学习了3D翻转效果、zIndex层叠等。解决了卡片与容器同时出现的过渡效果问题并延伸到发牌效果。
通过这8课的学习,我们基本掌握了SwiftUI的入门工作,swift基础的语法部分并理解了MVVM开发模式,动画效果与过渡、通过算法解决问题等。还学习了使用ViewModifier与@ViewBuilder,以及自己定义一个形状并实现动画效果。我已把Memorize所有的代码打包上传到服务器上,可以下载源代码回顾开发过程。
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/153
《CS193p2021学习笔记第八课:Animation Demonstration动画效果(Memorize完成并结束)》的网友评论(0)