|
| 1 | +// |
| 2 | +// DeckShuffleAnimation.swift |
| 3 | +// DeckKit |
| 4 | +// |
| 5 | +// Created by Daniel Saidi on 2023-06-13. |
| 6 | +// Copyright © 2020-2023 Daniel Saidi. All rights reserved. |
| 7 | +// |
| 8 | + |
| 9 | +import SwiftUI |
| 10 | + |
| 11 | +/** |
| 12 | + This animation can be used to animate deck shuffling. |
| 13 | + |
| 14 | + To use this animation, first create a `@StateObject` in the |
| 15 | + view that should use the animation, then bind the animation |
| 16 | + to your deck item views using the `withDeckShuffleAnimation` |
| 17 | + view modifier, then call `shuffle` shuffle the deck with an |
| 18 | + animation. |
| 19 | + */ |
| 20 | +public class DeckShuffleAnimation: ObservableObject { |
| 21 | + |
| 22 | + /** |
| 23 | + Create a deck shuffle animation. |
| 24 | + |
| 25 | + - Parameters: |
| 26 | + - maxDegrees: The max rotation to apply to the cards, by default `6`. |
| 27 | + - maxOffsetX: The max x offset to apply to the cards, by default `6`. |
| 28 | + - maxOffsetY: The max y offset to apply to the cards, by default `6`. |
| 29 | + */ |
| 30 | + public init( |
| 31 | + maxDegrees: Double = 6, |
| 32 | + maxOffsetX: Double = 6, |
| 33 | + maxOffsetY: Double = 6 |
| 34 | + ) { |
| 35 | + self.maxDegrees = maxDegrees |
| 36 | + self.maxOffsetX = maxOffsetX |
| 37 | + self.maxOffsetY = maxOffsetY |
| 38 | + } |
| 39 | + |
| 40 | + /// The max rotation to apply to the cards. |
| 41 | + public let maxDegrees: Double |
| 42 | + |
| 43 | + /// The max x offset to apply to the cards. |
| 44 | + public let maxOffsetX: Double |
| 45 | + |
| 46 | + /// The max y offset to apply to the cards. |
| 47 | + public let maxOffsetY: Double |
| 48 | + |
| 49 | + |
| 50 | + /// Whether or not the animation is currently shuffling. |
| 51 | + @Published |
| 52 | + public private(set) var isShuffling = false |
| 53 | + |
| 54 | + @Published |
| 55 | + fileprivate var animationTrigger = false |
| 56 | + |
| 57 | + |
| 58 | + /// This data type defines shuffle rotation and offsets. |
| 59 | + public typealias ShuffleData = (Angle, x: Double, y: Double) |
| 60 | + |
| 61 | + private var shuffleData: [ShuffleData] = [] |
| 62 | +} |
| 63 | + |
| 64 | +public extension View { |
| 65 | + |
| 66 | + /** |
| 67 | + Apply a shuffle animation to a deck item view. |
| 68 | + */ |
| 69 | + func withShuffleAnimation<Item>( |
| 70 | + _ animation: DeckShuffleAnimation, |
| 71 | + for item: Item, |
| 72 | + in deck: Deck<Item> |
| 73 | + ) -> some View { |
| 74 | + let data = animation.shuffleData(for: item, in: deck) |
| 75 | + return self.rotationEffect(data?.0 ?? .zero) |
| 76 | + .offset(x: data?.1 ?? 0, y: data?.2 ?? 0) |
| 77 | + .animation(.default, value: animation.animationTrigger) |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +public extension DeckShuffleAnimation { |
| 82 | + |
| 83 | + /** |
| 84 | + Shuffle the provided deck with a shuffle animation. |
| 85 | + |
| 86 | + - Parameters: |
| 87 | + - deck: The deck to shuffle. |
| 88 | + - times: The number of times to shuffle the deck, by default `5`. |
| 89 | + */ |
| 90 | + func shuffle<Item>( |
| 91 | + _ deck: Binding<Deck<Item>>, |
| 92 | + times: Int = 5 |
| 93 | + ) { |
| 94 | + if animationTrigger { return } |
| 95 | + randomizeShuffleData(for: deck) |
| 96 | + shuffle(deck, times: times, time: 1) |
| 97 | + } |
| 98 | + |
| 99 | + /** |
| 100 | + Get the current shuffle data for a certain deck item. |
| 101 | + */ |
| 102 | + func shuffleData<Item>( |
| 103 | + for item: Item, |
| 104 | + in deck: Deck<Item> |
| 105 | + ) -> ShuffleData? { |
| 106 | + guard |
| 107 | + shuffleData.count == deck.items.count, |
| 108 | + let index = deck.items.firstIndex(of: item) |
| 109 | + else { return nil } |
| 110 | + return shuffleData[index] |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +private extension DeckShuffleAnimation { |
| 115 | + |
| 116 | + func performAfterDelay(_ action: @escaping () -> Void) { |
| 117 | + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: action) |
| 118 | + } |
| 119 | + |
| 120 | + func randomizeShuffleData<Item>(for deck: Binding<Deck<Item>>) { |
| 121 | + shuffleData = (0..<deck.wrappedValue.items.count).map { _ in |
| 122 | + ( |
| 123 | + Angle.degrees(Double.random(in: -maxDegrees...maxDegrees)), |
| 124 | + Double.random(in: -maxOffsetX...maxOffsetX), |
| 125 | + Double.random(in: -maxOffsetY...maxOffsetY) |
| 126 | + ) |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + func shuffle<Item>( |
| 131 | + _ deck: Binding<Deck<Item>>, |
| 132 | + times: Int, |
| 133 | + time: Int |
| 134 | + ) { |
| 135 | + animationTrigger.toggle() |
| 136 | + performAfterDelay { |
| 137 | + if time < times { |
| 138 | + self.randomizeShuffleData(for: deck) |
| 139 | + self.shuffle(deck, times: times, time: time + 1) |
| 140 | + } else { |
| 141 | + self.easeOutShuffleState(for: deck) |
| 142 | + } |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + func easeOutShuffleState<Item>(for deck: Binding<Deck<Item>>) { |
| 147 | + shuffleData = shuffleData.map { ($0.0/2, $0.1/2, $0.2/2) } |
| 148 | + animationTrigger.toggle() |
| 149 | + performAfterDelay { |
| 150 | + self.resetShuffleState(for: deck) |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + func resetShuffleState<Item>(for deck: Binding<Deck<Item>>) { |
| 155 | + animationTrigger.toggle() |
| 156 | + shuffleData = [] |
| 157 | + performAfterDelay { |
| 158 | + deck.wrappedValue.shuffle() |
| 159 | + self.animationTrigger = false |
| 160 | + } |
| 161 | + } |
| 162 | +} |
0 commit comments