feat(view): 增加动画效果

增加发牌动效,增加倒计时动效

Signed-off-by: Ching <loooching@gmail.com>
This commit is contained in:
Ching 2023-02-16 23:21:20 +08:00
parent a296e999c0
commit 21ed5d55cb
6 changed files with 215 additions and 17 deletions

View File

@ -7,24 +7,35 @@
import SwiftUI
struct Cardify: ViewModifier {
var isFaceUp: Bool
struct Cardify: AnimatableModifier {
init(isFaceUp: Bool) {
self.rotation = isFaceUp ? 0 : 180
}
var animatableData: Double {
get { rotation }
set { rotation = newValue }
}
var rotation: Double
func body(content: Content) -> some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: const.DrawingConstants.cornerRadius)
if isFaceUp {
if rotation < 90 {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: const.DrawingConstants.lineWidth)
content
} else {
shape.fill()
}
content
.opacity(rotation < 90 ? 1 : 0)
}
.rotation3DEffect(Angle.degrees(rotation), axis: (x: 0, y: 1, z: 0))
}
}
extension View {
func cadify(isFaceUp: Bool) -> some View {
self.modifier(Cardify(isFaceUp: isFaceUp))
modifier(Cardify(isFaceUp: isFaceUp))
}
}

View File

@ -10,14 +10,21 @@ import SwiftUI
struct const {
enum DrawingConstants {
static let cardColor = Color.red
static let cornerRadius: CGFloat = 10
static let lineWidth: CGFloat = 3
static let fontScale: CGFloat = 0.6
static let fontSize: CGFloat = 32
static let gridWidth: CGFloat = 80
static let gridAspectRatio: CGFloat = 2 / 3
static let matchedCardOpacity: Double = 0
static let gridPadding: CGFloat = 4
static let piePadding: CGFloat = 4
static let pieOpacity: CGFloat = 0.5
static let undealtHeight: CGFloat = 90
static let undealtWidth: CGFloat = undealtHeight * gridAspectRatio
static let dealDuration: Double = 1
static let totalDealDuration: Double = 1.5
static let bonusTimeLimit: TimeInterval = 6
}
}

View File

@ -27,4 +27,12 @@ class EmojiMemoryGame: ObservableObject {
func choose(_ card: Card) {
model.choose(card)
}
func shuffle() {
model.shuffle()
}
func restart() {
model = EmojiMemoryGame.createMemoryGame()
}
}

View File

@ -9,40 +9,144 @@ import SwiftUI
struct EmojiMemoryGameView: View {
@ObservedObject var game: EmojiMemoryGame
@Namespace private var dealingNamespace
var body: some View {
ZStack(alignment: .bottom) {
VStack {
gameBody
HStack {
restart
Spacer()
shuffle
}
.padding(.horizontal)
}
.padding()
deckBody
}
}
@State private var dealt = Set<Int>()
private func deal(_ card: EmojiMemoryGame.Card) {
dealt.insert(card.id)
}
private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool {
!dealt.contains(card.id)
}
private func dealAnimation(for card: EmojiMemoryGame.Card) -> Animation {
var delay = 0.0
if let index = game.cards.firstIndex(where: { $0.id == card.id }) {
delay = Double(index) * (const.DrawingConstants.totalDealDuration / Double(game.cards.count))
}
return Animation.easeInOut(duration: const.DrawingConstants.dealDuration).delay(delay)
}
private func zIndex(of card: EmojiMemoryGame.Card) -> Double {
-Double(game.cards.firstIndex(where: { $0.id == card.id }) ?? 0)
}
var gameBody: some View {
AspectVGrid(items: game.cards, aspectRatio: const.DrawingConstants.gridAspectRatio) {
card in
if card.isMatched && !card.isFaceUp {
Rectangle().opacity(0)
if isUndealt(card) || (card.isMatched && !card.isFaceUp) {
Color.clear
} else {
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.padding(const.DrawingConstants.gridPadding)
.zIndex(zIndex(of: card))
.transition(AnyTransition.asymmetric(insertion: .identity, removal: .scale))
.onTapGesture {
game.choose(card)
withAnimation {
game.choose(card)
}
}
}
}
.foregroundColor(.red)
.padding(.horizontal)
.foregroundColor(const.DrawingConstants.cardColor)
}
var deckBody: some View {
ZStack {
ForEach(game.cards.filter(isUndealt)) {
card in CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
.zIndex(zIndex(of: card))
}
}
.frame(width: const.DrawingConstants.undealtWidth, height: const.DrawingConstants.undealtHeight)
.foregroundColor(const.DrawingConstants.cardColor)
.onTapGesture {
for card in game.cards {
withAnimation(dealAnimation(for: card)) {
deal(card)
}
}
}
}
var shuffle: some View {
Button("Shuffle") {
withAnimation {
game.shuffle()
}
}
}
var restart: some View {
Button("Restart") {
withAnimation {
dealt = []
game.restart()
}
}
}
}
struct CardView: View {
let card: EmojiMemoryGame.Card
@State private var animatedBonusRemaining: Double = 0
var body: some View {
GeometryReader { geometry in
ZStack {
Pie(startAngle: Angle(degrees: 270),
endAngle: Angle(degrees: 380))
.padding(const.DrawingConstants.piePadding)
.opacity(const.DrawingConstants.pieOpacity)
Text(card.content).font(font(in: geometry.size))
Group {
if card.isConsumingBonusTime {
Pie(startAngle: Angle(degrees: 270),
endAngle: Angle(degrees: (1 - animatedBonusRemaining) * 360 - 90))
.onAppear {
animatedBonusRemaining = card.bonusRemaining
withAnimation(.linear(duration: card.bonusTimeRemaining)) {
animatedBonusRemaining = 0
}
}
} else {
Pie(startAngle: Angle(degrees: 0 - 90),
endAngle: Angle(degrees: (1 - card.bonusRemaining) * 360 - 90))
}
}
.padding(const.DrawingConstants.piePadding)
.opacity(const.DrawingConstants.pieOpacity)
Text(card.content)
.rotationEffect(Angle.degrees(card.isMatched ? 360 : 0))
.animation(Animation.easeInOut)
.font(Font.system(size: const.DrawingConstants.fontSize))
.scaleEffect(scale(thatFits: geometry.size))
}.cadify(isFaceUp: card.isFaceUp)
}
}
private func scale(thatFits size: CGSize) -> CGFloat {
min(size.width, size.height) / const.DrawingConstants.fontSize * const.DrawingConstants.fontScale
}
private func font(in size: CGSize) -> Font {
Font.system(size: min(size.width, size.height) * const.DrawingConstants.fontScale)
}

View File

@ -29,6 +29,10 @@ struct MemoryGame<CardContent> where CardContent: Equatable {
}
}
mutating func shuffle() {
cards.shuffle()
}
func index(of card: Card) -> Int? {
for index in 0 ..< cards.count {
if cards[index].id == card.id {
@ -46,13 +50,67 @@ struct MemoryGame<CardContent> where CardContent: Equatable {
cards.append(Card(content: content, id: pairIndex * 2))
cards.append(Card(content: content, id: pairIndex * 2 + 1))
}
cards.shuffle()
}
struct Card: Identifiable {
var isFaceUp = false
var isMatched = false
var isFaceUp = false {
didSet {
if isFaceUp {
startUsingBonusTime()
} else {
stopUsingBonusTime()
}
}
}
var isMatched = false {
didSet {
stopUsingBonusTime()
}
}
let content: CardContent
let id: Int
var bonusTimeLimit = const.DrawingConstants.bonusTimeLimit
private var faceUpTime: TimeInterval {
if let lastFaceUpDate = 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
lastFaceUpDate = nil
}
}
}

View File

@ -12,6 +12,16 @@ struct Pie: Shape {
var endAngle: Angle
var clockwise = false
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(startAngle.radians, endAngle.radians)
}
set {
startAngle = Angle.radians(newValue.first)
endAngle = Angle.radians(newValue.second)
}
}
func path(in rect: CGRect) -> Path {
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.height, rect.width) / 2