斯坦福大学2020CS193P教程学到了第6课基本完成了记忆卡片游戏,在代码中我们可以学到:
第1课:讲解了课程的基本介绍及SwiftUI的基本介绍与使用。
基本的VHZstack布局,形状
第2课:理解MVVM的编辑模式及Swift的类型系统
第3课:响应式UI +协议+布局
第4课:表格、枚举、可选类型
第5课:形状与视图修改器
第6课:动画详解
import SwiftUI
struct EmojiMemoryGameView: View {
@ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
VStack{
Spacer()//卡片挤上去
//垂直布局,弹性宽度范围在120~130之间
LazyVGrid(columns: [GridItem(.adaptive(minimum: 72, maximum: 80))]) {
ForEach(viewModel.cards){ card in
SingleCardView(card: card).onTapGesture {
withAnimation(Animation.linear(duration: 2.5)){
viewModel.choose(card: card)//点击后调用choose方法
}
}
.frame(height: 135)//设置卡片的统一高度
.foregroundColor(.orange)//卡片统一颜色
.rotationEffect(.zero)
}
.animation(.easeInOut)//淡进淡出
}
.padding()//留LazyVGrid边框
Spacer()//卡片挤上去
}
}
}
struct SingleCardView:View {
var card:MemoryGame<String>.Card //单张卡片
var body: some View{
GeometryReader{ getmetry in
self.body(size: getmetry.size) //通过getmetry传入容器的大小
}
}
@State private var animatiedBonusremaining: Double = 0
private func startBonusTimeAnimation(){
animatiedBonusremaining = card.bonusRemaining //监听剩余时间
//利用线性动画的duration时充当计时器
withAnimation(Animation.linear(duration: 10)){
animatiedBonusremaining = 0
}
}
@ViewBuilder //作为子视图生成闭包参数的参数属性,允许这些闭包提供多个子视图。
private func body(size:CGSize) -> some View{
//只显示卡片朝上或者未匹配的卡片
if card.isFaceUp || !card.isMatched{
ZStack{
Group{
if card.isConsumingBonusTime {
//Pie是自己定义的一个图形
Pie(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(-animatiedBonusremaining*360-90))//从12点钟位置开始画到endAngle
.onAppear{
self.startBonusTimeAnimation()
}
}else{
Pie(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(270))//从12点钟位置开始画到endAngle
}
/*
我们需要随着时间的流逝变化 0-90 ... 360-90
*/
}
.transition(.scale)
.padding(5).opacity(0.4)//填充并减淡颜色
Text(card.content)
.font(Font.system(size: fontSize(size: size)))//根据容器大下设置字号
.rotationEffect(Angle.degrees(card.isMatched ? 360 : 0))
.animation(card.isMatched ? Animation.linear(duration: 1).repeatForever(autoreverses: false) : .default)
//当卡片匹配时播放360度旋转动画不间隙。
}
.cardify(isFaceUp: card.isFaceUp) //调用自定义的modifier
//AnyTransition是一种擦除的过度效果
.transition(AnyTransition.offset(CGSize(width: -1000, height: 2000)))//飞出屏幕
// .transition(AnyTransition.scale)//缩小消失过渡
}
}
private func fontSize(size:CGSize) -> CGFloat {
//min返回两个可比较值中较小的一个。
min(size.width, size.height) * 0.7 //返回字体的大小为容器的75%
}
}
//预览界面
struct ContentView_Previews: PreviewProvider {
static let game: EmojiMemoryGame = EmojiMemoryGame()//初始化静态的game
static var previews: some View {
game.choose(card: game.cards[0])//选中一张卡
return Group {
EmojiMemoryGameView(viewModel: game)
}
}
}
import Foundation
//CardContent 为卡片内容,类型:泛型 条件 卡片内容可以比对
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>//定义只读的卡片数组<卡片数据结构>
//找到一个唯一朝上的卡片索引值(可为空)->这是核心算法
private var indexOfTheOneAndOnlyFaceUpCard: Int? {
get{
//indices按升序对集合下标有效的索引。filter返回一个数组,按顺序包含序列中满足给定谓词的元素。
cards.indices.filter { cards[$0].isFaceUp }.only //only为扩展,只返回1个值的数组
}
set{
for i in cards.indices{
cards[i].isFaceUp = i == newValue //把每个值跑一次,如果索引与新值相等,卡片向上
}
}
}
//选择一张卡片并修改里的bool值->这也是核心算法
mutating func choose(card:Card) {
//先使用firstIndex(自己扩展的功能)找到被点击卡片的索引,然后判断[索引]卡未朝上,且未被匹配过
if let choosenIndex = cards.firstIndex(maching:card),!cards[choosenIndex].isFaceUp,!cards[choosenIndex].isMatched{
//从indexOfTheOneAndOnlyFaceUpCard里找到一张向上的卡片,充分理解这个变量是核心
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard{
//匹配一对卡片(对比被点击的卡与上次被点击的卡)
if cards[choosenIndex].content == cards[potentialMatchIndex].content {
cards[choosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
//被点击的卡片朝上
cards[choosenIndex].isFaceUp = true
// print("已找到卡片:(card)")//打印卡片信息
}else{
indexOfTheOneAndOnlyFaceUpCard = choosenIndex //没有找到卡片,则设置choosenIndex(被点击的)卡片向上
// print("未找到卡片:(card)")//打印卡片信息
}
}
// print("所有卡片信息:(cards)")//打印卡片信息
}
//初始化numberOfPairsOfCards(几对卡片),CardContentFactory(卡片内容初始化)
init(numberOfPairsOfCards: Int, cardContentFactory:(Int) -> CardContent){
cards = Array<Card>() //初始化一个空的卡片数组
//将传入的要实例化的数量(numberOfPairsOfCards)里所有的卡片追加到cards数组里
for i in 0..<numberOfPairsOfCards{
let content = cardContentFactory(i) //为每个卡片返回卡片内容
cards.append(Card(id: i * 2, content: content))//追加第一个卡片
cards.append(Card(id: i * 2 + 1, content: content))//追加第二个卡片
//因为卡片需要成对的出现,所以这里要根据ID的不同需要追加2次
}
cards.shuffle()//随机排序(自己也有扩展的,这个是系统自带的)
}
//定义一个ID识别卡片
struct Card:Identifiable {
var id:Int //识别ID
var isFaceUp:Bool = false //是否翻转
{
didSet{
if isFaceUp {
startUsingBonusTime() //当卡片被翻开后开始计时
}else{
stopUsingBonusTime() //卡片翻转到背面停止计时
}
}
}
var isMatched:Bool = false//是否匹配
{
didSet{
stopUsingBonusTime()//当卡片匹配就停止计时
}
}
var content:CardContent//卡片内容<泛型>
//剩余时间算法,根据每次翻开或者匹配卡片进行时间的跟踪处理动画与数据
var bonusTimeLimit: TimeInterval = 6 //单次匹配最大耗时
var lastFaceUpDate: Date? //卡片上次翻转的时间
var pastFaceUpTime: TimeInterval = 0 //卡片朝上的过去时间初始
//卡片翻转已用时
private var faceUpTime: TimeInterval{
if let lastFaceUpDate = self.lastFaceUpDate{
return pastFaceUpTime + Date().timeIntervalSince(lastFaceUpDate)//将上次的时间转为Int交给已耗时
}else{
return pastFaceUpTime
}
}
//奖金剩余时间
var bonusTimeRemaining: TimeInterval{
max(0, bonusTimeLimit - faceUpTime)
}
//剩余奖金
var bonusRemaining: Double{
(bonusTimeLimit > 0 && bonusTimeRemaining > 0) ? bonusTimeRemaining / bonusTimeLimit : 0
}
//赢得了奖金
var hasEarnedBonus: Bool{
isMatched && bonusRemaining > 0
}
//是否在浪费额外的时间
var isConsumingBonusTime: Bool{
isFaceUp && !isMatched && bonusRemaining > 0
}
//开始使用奖金时间的方法
private mutating func startUsingBonusTime(){
if isConsumingBonusTime,lastFaceUpDate == nil{
lastFaceUpDate = Date()
}
}
//结束使用奖金时间的方法
private mutating func stopUsingBonusTime(){
pastFaceUpTime = faceUpTime
self.lastFaceUpDate = nil
}
}
}
import Foundation
class EmojiMemoryGame: ObservableObject {
@Published private var model: MemoryGame<String> = EmojiMemoryGame.createEmojiMemoryGame()//调用自己的静态方法createEmojiMemoryGame创建一个Emoji游戏数据
/*
通过下面定义的createEmojiMemoryGame静态方法创建一组数据供View使用
Publisthed表示让视图根据model的变化刷新
*/
//定义一个静态的方法,用于存Emoji
private static func createEmojiMemoryGame() -> MemoryGame<String>{
let emojis = ["A","B","C","D","E","F","G"] //可以使用表情
//返回一个MemoryGame初始化后的Card数组,emojis[$0]里为卡片的内容
return MemoryGame<String>(numberOfPairsOfCards:emojis.count){emojis[$0]}
}
var cards:Array<MemoryGame<String>.Card>{
model.cards
}
func choose(card:MemoryGame<String>.Card) {
var i = 0
for card in model.cards{
if !card.isMatched{
i += 1
}
}
print(i)// 12 10 8 6 4 2 0
if i <= 2 {
restartGame() //最后一组时重新开始游戏
}else{
model.choose(card:card)//大于2张卡片未匹配时继续选择卡片
}
}
func restartGame() {
model = EmojiMemoryGame.createEmojiMemoryGame()//重新开始游戏
}
}
import SwiftUI
//定义一个卡片修改器,符合AnimatableModifier协议,该协议继承 Animatable, ViewModifier
fileprivate struct Cardify: AnimatableModifier {
var rotation: Double
init(isFaceUp:Bool) {
rotation = isFaceUp ? 0 : 180//通过isFaceUp确定rotation的值
}
var isFaceUp:Bool{
rotation < 90 //通过rotation确定isFaceUp的值
}
//因为AnimatableModifier继承了Animatable,所以需要使用animatableData
//animatableData是将数据进行动画处理。
var animatableData: Double{
get{
return rotation//将直接获取rotation的值,因为它们一样的
}
set{
rotation = newValue //修改animatableData的同时也修改rotation
}
}
func body(content:Content) -> some View {
ZStack{
Group{
RoundedRectangle(cornerRadius: cornerRadius).fill(Color(UIColor(displayP3Red: 1, green: 2, blue: 3, alpha: 4)))//填充纯白色
RoundedRectangle(cornerRadius: cornerRadius).stroke(lineWidth: edgeLineWidth)//设置边框
content.transition(.scale)//被修改的内容放到这里并使用缩放过度
}
.opacity(isFaceUp ? 1 : 0)//隐藏/显示卡片内容,以保证动画一直存在
//卡片未被匹配才显示纯色
RoundedRectangle(cornerRadius: cornerRadius).fill().transition(.opacity)//加入透明过度效果
.opacity(isFaceUp ? 0 : 1)//隐藏/显示纯色,以保证动画一直存在
}
.rotation3DEffect(Angle.degrees(rotation),axis: (0,1,0))
}
private let cornerRadius:CGFloat = 10.0 //圆角值
private let edgeLineWidth:CGFloat = 3.0//边框大小
}
//扩展View直接使用.Cardify修改视图
extension View{
//扩展操作后在主视图里就可以不使用.modifier了
func cardify(isFaceUp:Bool) -> some View {
self.modifier(Cardify(isFaceUp: isFaceUp))//指定使用的修改器
}
}
import SwiftUI
/*
Shape协议绘制视图时可以使用的二维形状。
没有填充或描边的形状将根据前景色获得默认填充。
您可以定义与隐式参考框架相关的形状,例如包含它的视图的自然大小。
另外,你也可以用绝对坐标来定义形状。
*/
struct Pie: Shape {
//下面参数需要
var startAngle:Angle //开始位置的角度
var endAngle:Angle //结束位置的角度
var clockwise:Bool = true //是否顺时针
/* Angle是一种几何角度,其值可以以弧度或角度来访问。0到360°,用弧度是0到2π,2πr是圆的周长 */
//将动画数据化,AnimatablePair返回两组Double数据
var animatableData: AnimatablePair<Double,Double>{
get{
AnimatablePair(startAngle.radians, endAngle.radians) //动画化的数据返回为开始角度与结束角度
}
set{
// startAngle.radians = newValue.first //将开始角度数据同步
// endAngle.radians = newValue.second//将结束角度的数据同步
startAngle = Angle.radians(newValue.first) //将开始角度数据同步
endAngle = Angle.radians(newValue.second) //将结束角度的数据同步
}
}
//定义一个符合Shape协议的路径,要求格式:path(in rect:CGRect) -> Path
func path(in rect:CGRect) -> Path {
//以CG开头的类型是系统提供的底层图形
let center = CGPoint(x: rect.midX, y: rect.midY) //找到图形的中心点坐标
let radius = min(rect.width,rect.height) / 2 //中心半径
//下面的start主要是找到开始位置的线条怎么画出来(看不懂就照抄)
let start = CGPoint(
x: center.x + radius * cos(CGFloat(startAngle.radians)),//X坐标开始于中心点X + 半径值 * cos余弦(开始点半径)
y: center.y + radius * sin(CGFloat(startAngle.radians))//Y坐标开始于中心点Y + 半径值 * sin正弦(开始点半径)
)
var p = Path() //定义一个路径
p.move(to: center)//路径从图形的中心开始画
p.addLine(to: start)//从圆心画一条线到开始点(边缘坐标)
//addArc在路径上添加一个圆弧,指定半径和角度。
p.addArc(
center: center,//中心线
radius: radius,//中心半径
startAngle: startAngle, //开始的角度
endAngle: endAngle,//结束的角度
clockwise: clockwise
)
return p//返回画好以后的图形
}
}
import Foundation
//扩展Array 条件:Identifiable可识别的
extension Array where Element:Identifiable{
//根据可识别数组里的ID去对比并返回当前的索引值(i)
func firstIndex(maching:Element) -> Int? {
for i in 0..<self.count{
if self[i].id == maching.id{
return i//找到第i个
}
}
return nil //没有找到相等的索引
}
}
//扩展一个only变量,只返回1个值的数组
extension Array{
//only为可选值
var only:Element? {
count == 1 ? first : nil //如果数组里只有1个值则返回当前值,否则返回空
}
}
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/139
《【SwiftUI实战】记忆卡片游戏(斯坦福大学2020CS193P学习笔记)》的网友评论(0)