gh-EmergeTools-Pow/Sources/Pow/Transitions/Anvil.swift
Joe Fabisevich 1b4b1dda28
Some checks failed
Build / build (push) Has been cancelled
Fix ambiguous use of .pi (#83)
2026-02-20 10:10:51 -05:00

301 lines
11 KiB
Swift

import SwiftUI
#if os(iOS) && EMG_PREVIEWS
import SnapshotPreferences
#endif
public extension AnyTransition.MovingParts {
/// A transition that drops the view down from the top.
///
/// The transition is only performed on insertion and takes 1.4 seconds.
static var anvil: AnyTransition {
.asymmetric(
insertion: .modifier(
active: Anvil(animatableData: 0),
identity: Anvil(animatableData: 1)
),
removal: .identity
)
.animation(.linear(duration: 1.4))
}
}
internal struct Anvil: ViewModifier, ProgressableAnimation, AnimatableModifier {
var animatableData: CGFloat = 0
#if os(iOS)
@State
var feedbackGenerator: UIImpactFeedbackGenerator?
#endif
internal init(animatableData: CGFloat = 0) {
self.animatableData = animatableData
}
func body(content: Content) -> some View {
/// Fraction of the animation spent on the view falling down.
let fall: CGFloat = 0.1
/// Progress of the fall.
let fallT = map(value: min(progress, fall), inMin: 0, inMax: fall, outMin: 0, outMax: 1)
/// Progress of the shake.
let shakeT = map(value: clamp(fall, progress - 0.01, 2 * fall) - fall, inMin: 0, inMax: fall, outMin: 0, outMax: 1)
let padding = EdgeInsets(top: 150, leading: 130, bottom: 100, trailing: 130)
let grayImage: Image = Image("anvil_smoke_gray", bundle: .module)
let whiteImage: Image = Image("anvil_smoke_white", bundle: .module)
content
#if os(iOS)
.onChange(of: fallT) { newFallT in
if fallT < 1 && newFallT >= 1 {
feedbackGenerator?.impactOccurred()
feedbackGenerator = nil
} else if newFallT > 0 && feedbackGenerator == nil {
feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
feedbackGenerator?.prepare()
}
}
#endif
.offset(x: 0, y: -400 * (1 - fallT))
.animation(nil, value: progress)
.offset(
x: 2 * sin(shakeT * 3 * .pi) * (1 - shakeT),
y: 4 * sin(shakeT * 4 * .pi) * (1 - shakeT)
)
.overlay {
Canvas { ctx, size in
if progress == 1 { return }
var rng = SeededRandomNumberGenerator(seed: size.width)
let bounds = CGRect(origin: .zero, size: size).insetBy(dx: 130, dy: 100)
do {
let resolvedGrayImage = ctx.resolve(grayImage)
let resolvedWhiteImage = ctx.resolve(whiteImage)
/// Progress of the dust animation.
let dustT = map(value: max(0, progress - fall), inMin: 0, inMax: 1 - fall, outMin: 0, outMax: 1)
// How far are the particles apart.
let particleDistance: CGFloat = 10
let particleSize = CGSize(width: 88, height: 88)
let rows = Int((bounds.width / particleDistance).rounded(.up))
let cols = 2
guard rows > 0 else {
return
}
for x in 0 ..< rows {
for _ in 0 ..< cols {
let x = CGFloat(x)
let relativeX = (x / CGFloat(rows - 1))
let center = CGPoint(
x: bounds.minX + x * particleDistance + .random(in: -15 ... 15, using: &rng),
y: bounds.maxY + .random(in: -5 ... 5, using: &rng)
)
let maxOffsetX: CGFloat = particleDistance * 4
let maxOffsetY: CGFloat = particleDistance * 2
let t = easeOut(dustT)
let offsetX = maxOffsetX * (relativeX - 0.5) * 2 * .random(in: 0.8 ... 1.2, using: &rng)
let offsetY = CGFloat.random(in: -maxOffsetY / 2 ... maxOffsetY / 2, using: &rng) + (t * t) * -50
var scale = 1 + 0.6 * (1 - pow(sin(relativeX * CGFloat.pi), 0.4)) + .random(in: 0 ... 0.2, using: &rng)
scale *= 0.8 + (dustT * 0.2)
scale /= 3
scale *= 1 - pow(2, -50 * dustT)
var rotation = Angle.degrees(180) * .random(in: -1 ... 1, using: &rng)
rotation += .degrees(125) * -(relativeX - 0.5) * CGFloat.random(in: 0.5 ... 1, using: &rng) * t * 1.5
ctx.drawLayer { ctx in
ctx.translateBy(x: 0, y: -(particleSize.height * scale * 0.9) / 2)
ctx.translateBy(
x: offsetX * t,
y: offsetY * t
)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: rotation)
ctx.scaleBy(x: scale, y: scale)
ctx.opacity = 0.8 * (1 - 0.5 * abs(relativeX - 0.5)) * (1 - dustT)
if progress >= fall {
if Double(x).truncatingRemainder(dividingBy: 2.0).isZero {
ctx.draw(resolvedWhiteImage, in: CGRect(center: .zero, size: resolvedWhiteImage.size))
} else {
ctx.draw(resolvedGrayImage, in: CGRect(center: .zero, size: resolvedGrayImage.size))
}
}
}
}
}
}
do {
// Progress of the specks animating.
let speckT = clamp(map(value: progress, inMin: fall + 0.02, inMax: 1 - 0.2, outMin: 0, outMax: 1))
let specks = 20
let speckSize = CGSize(width: 1, height: 1)
let arc = 1 - pow(2 * speckT - 1, 2)
let maxOffsetY = bounds.height * 0.9
let maxOffsetX = bounds.width * 0.6
for s in 0 ..< specks {
let s = CGFloat(s)
let xFrac = (s / CGFloat(specks))
var dX = CGFloat.random(in: -maxOffsetX ... maxOffsetX, using: &rng)
dX += 60 * (xFrac - 0.5) * 2
let dY = CGFloat.random(in: -maxOffsetY ... 0, using: &rng)
ctx.drawLayer { ctx in
var center = CGPoint(
x: .random(in: bounds.minX ... bounds.maxX, using: &rng),
y: bounds.maxY
)
center.x += dX * speckT
center.y += arc * dY
let speckBounds = CGRect(
origin: .zero,
size: speckSize
)
let speck = Circle().path(in: speckBounds)
let scale = CGFloat.random(in: 2 ... 3, using: &rng) * (0.5 + (1 - speckT) / 2)
ctx.translateBy(x: center.x, y: center.y)
ctx.scaleBy(x: scale, y: scale)
ctx.opacity = Double(pow(sin(speckT * CGFloat.pi), 0.2))
ctx.fill(speck, with: .color(Color(white: .random(in: 0.75 ... 0.9, using: &rng))))
}
}
}
}
.padding(padding.inverse)
.allowsHitTesting(false)
}
}
}
extension EdgeInsets {
var inverse: Self {
EdgeInsets(top: -top, leading: -leading, bottom: -bottom, trailing: -trailing)
}
}
#if os(iOS) && DEBUG
struct Anvil_Preview: PreviewableAnimation, PreviewProvider {
static var animation: Anvil {
Anvil(animatableData: 0)
}
static var content: any View {
RoundedRectangle(
cornerRadius: 8,
style: .continuous)
.fill(Color.blue)
.frame(width: 80, height: 80)
.preferredColorScheme(.dark)
}
}
@available(iOS 15.0, *)
struct Anvil_Previews: PreviewProvider {
struct Item: Identifiable {
var color: Color
let id: UUID = UUID()
init() {
color = [Color.red, .orange, .yellow, .green, .purple, .mint].randomElement()!
}
}
struct Preview: View {
@State
var items: [Item] = [Item()]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Text("Anvil")
.bold()
Text("myView.transition(**.movingParts.anvil**)")
}
.font(.footnote.monospaced())
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading)
.padding()
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.thickMaterial)
)
Stepper("Count") {
withAnimation {
items.append(Item())
}
} onDecrement: {
if !items.isEmpty {
items.removeLast()
}
}
let columns: [GridItem] = [
.init(.flexible()),
.init(.flexible())
]
LazyVGrid(columns: columns) {
ForEach(items) { item in
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(item.color)
.transition(.movingParts.anvil)
.aspectRatio(1, contentMode: .fit)
.id(item.id)
}
}
Spacer()
}
.padding()
}
}
}
static var previews: some View {
NavigationView {
Preview()
.navigationBarHidden(true)
}
.environment(\.colorScheme, .dark)
#if os(iOS) && EMG_PREVIEWS
.emergeSnapshotPrecision(0)
#endif
}
}
#endif