mirror of
https://github.com/EmergeTools/Pow.git
synced 2026-03-25 08:55:50 +00:00
Co-authored-by: Robert Böhnke <robb@robb.is> Co-authored-by: Kasper Lahti <kasper@lahti.email>
314 lines
10 KiB
Swift
314 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
public extension AnyChangeEffect {
|
|
/// An effect that emits the provided particles from the origin point and slowly float up while moving side to side.
|
|
///
|
|
/// This effect respects `particleLayer()`.
|
|
///
|
|
/// - Parameters:
|
|
/// - origin: The origin of the particle.
|
|
/// - layer: The `ParticleLayer` on which to render the effect, default is `local`.
|
|
/// - particles: The particles to emit.
|
|
static func rise(origin: UnitPoint = .center, layer: ParticleLayer = .local, @ViewBuilder _ particles: () -> some View) -> AnyChangeEffect {
|
|
let particles = particles()
|
|
return .simulation { change in
|
|
RisingParticleSimulation(origin: origin, particles: particles, impulseCount: change, layer: layer)
|
|
}
|
|
}
|
|
|
|
/// An effect that emits the provided particle from the origin point and slowly float up while moving side to side.
|
|
///
|
|
/// - Parameters:
|
|
/// - origin: The origin of the particle.
|
|
/// - particle: The particle to emit.
|
|
@available(*, deprecated, renamed: "rise(origin:_:)")
|
|
static func risingParticle(origin: UnitPoint = .center, @ViewBuilder _ particle: () -> some View) -> AnyChangeEffect {
|
|
rise(origin: origin, particle)
|
|
}
|
|
}
|
|
|
|
internal struct RisingParticleSimulation<ParticlesView: View>: ViewModifier, Simulative {
|
|
var origin: UnitPoint
|
|
|
|
var particles: ParticlesView
|
|
|
|
var impulseCount: Int = 0
|
|
|
|
var initialVelocity: CGFloat = 0.0
|
|
|
|
private let spring = Spring(zeta: 1, stiffness: 30)
|
|
|
|
private struct Item: Identifiable {
|
|
let id: UUID
|
|
var progress: CGFloat
|
|
var velocity: CGFloat
|
|
var change: Int
|
|
}
|
|
|
|
@State
|
|
private var items: [Item] = []
|
|
|
|
private let target: CGFloat = 1.0
|
|
|
|
private let layer: ParticleLayer
|
|
|
|
private var isSimulationPaused: Bool {
|
|
items.isEmpty
|
|
}
|
|
|
|
internal init(origin: UnitPoint, particles: ParticlesView, impulseCount: Int = 0, layer: ParticleLayer) {
|
|
self.origin = origin
|
|
self.particles = particles
|
|
self.impulseCount = impulseCount
|
|
self.layer = layer
|
|
}
|
|
|
|
private struct _ViewContainer: SwiftUI._VariadicView.MultiViewRoot {
|
|
func body(children: _VariadicView.Children) -> some View {
|
|
ForEach(Array(zip(0..., children)), id: \.1.id) { offset, child in
|
|
child.tag(offset)
|
|
}
|
|
}
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
let overlay = TimelineView(.animation(paused: isSimulationPaused)) { context in
|
|
let insets = EdgeInsets(top: 80, leading: 40, bottom: 20, trailing: 40)
|
|
|
|
Canvas { context, size in
|
|
var symbols: [GraphicsContext.ResolvedSymbol] = []
|
|
|
|
var i = 0
|
|
var nextSymbol: GraphicsContext.ResolvedSymbol? = context.resolveSymbol(id: i)
|
|
while let symbol = nextSymbol {
|
|
symbols.append(symbol)
|
|
i += 1
|
|
nextSymbol = context.resolveSymbol(id: i)
|
|
}
|
|
|
|
if symbols.isEmpty { return }
|
|
|
|
context.translateBy(x: size.width / 2, y: insets.top + (size.height - insets.top - insets.bottom) / 2)
|
|
|
|
for item in items {
|
|
var rng = SeededRandomNumberGenerator(seed: item.id)
|
|
|
|
let symbolIndex = max(0, item.change - 1) % symbols.count
|
|
|
|
let progress = item.progress
|
|
|
|
let angle = Angle.degrees(.random(in: -10 ... 10, using: &rng))
|
|
|
|
let scale = 1 + 0.2 * progress
|
|
|
|
context.opacity = 1.0 - pow(1.0 - 2.0 * progress, 4.0)
|
|
context.drawLayer { context in
|
|
context.rotate(by: .degrees(-angle.degrees * Double(1 - progress)))
|
|
context.translateBy(
|
|
x: progress * sin(progress * 1.4 * .pi) * .random(in: -20 ... 20, using: &rng),
|
|
y: progress * -50 - .random(in: 0 ... 10, using: &rng)
|
|
)
|
|
context.rotate(by: angle)
|
|
context.scaleBy(x: scale, y: scale)
|
|
|
|
let symbol = symbols[symbolIndex]
|
|
|
|
context.draw(symbol, at: .zero)
|
|
}
|
|
}
|
|
} symbols: {
|
|
SwiftUI._VariadicView.Tree(_ViewContainer()) {
|
|
particles
|
|
}
|
|
}
|
|
.padding(insets.inverse)
|
|
.modifier(RelativeOffsetModifier(anchor: origin))
|
|
.allowsHitTesting(false)
|
|
.onChange(of: context.date) { (newValue: Date) in
|
|
let duration = Double(newValue.timeIntervalSince(context.date))
|
|
withAnimation(nil) {
|
|
update(max(0, min(duration, 1 / 30)))
|
|
}
|
|
}
|
|
}
|
|
|
|
content
|
|
.particleLayerOverlay(alignment: .top, layer: layer, isEnabled: !isSimulationPaused) {
|
|
overlay
|
|
}
|
|
.onChange(of: impulseCount) { newValue in
|
|
let item = Item(
|
|
id: UUID(),
|
|
progress: 0,
|
|
velocity: initialVelocity,
|
|
change: newValue
|
|
)
|
|
withAnimation(nil) {
|
|
items.append(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func update(_ step: Double) {
|
|
for index in items.indices.reversed() {
|
|
var item = items[index]
|
|
|
|
if spring.response > 0 {
|
|
let (newValue, newVelocity) = spring.value(
|
|
from: item.progress,
|
|
to: target,
|
|
velocity: item.velocity,
|
|
timestep: step
|
|
)
|
|
item.progress = newValue
|
|
item.velocity = newVelocity
|
|
} else {
|
|
item.progress = target
|
|
item.velocity = .zero
|
|
}
|
|
|
|
items[index] = item
|
|
|
|
if abs(item.progress - target) < 0.04 && item.velocity < 0.04 {
|
|
items.remove(at: index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RelativeOffsetModifier: GeometryEffect {
|
|
var anchor: UnitPoint
|
|
|
|
func effectValue(size: CGSize) -> ProjectionTransform {
|
|
let x = size.width * (-0.5 + anchor.x)
|
|
let y = size.height * (-0.5 + anchor.y)
|
|
|
|
return ProjectionTransform(
|
|
CGAffineTransform(translationX: x, y: y)
|
|
)
|
|
}
|
|
}
|
|
|
|
#if os(iOS) && DEBUG
|
|
struct RisingParticleEffect_Previews: PreviewProvider {
|
|
struct ButtonPreview: View {
|
|
@State
|
|
var claps = 28
|
|
|
|
@State
|
|
var stars = 18
|
|
|
|
@State
|
|
var likes = 61
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Button {
|
|
claps += 1
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "hands.clap.fill")
|
|
Text(claps.formatted())
|
|
}
|
|
}
|
|
.changeEffect(.rise(origin: UnitPoint(x: 0.7, y: 0.5)) {
|
|
Group {
|
|
Text("+1")
|
|
Image(systemName: "hands.clap")
|
|
Image(systemName: "sparkle")
|
|
Image(systemName: "hand.thumbsup")
|
|
}
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.tint)
|
|
.tint(.blue)
|
|
}, value: claps)
|
|
|
|
Button {
|
|
stars += 1
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "star.fill")
|
|
Text("\(stars, format: .number)")
|
|
}
|
|
}
|
|
.changeEffect(.rise(origin: UnitPoint(x: 0.7, y: 0.5)) {
|
|
Text("\(1, format: .number.sign(strategy: .always()))")
|
|
.font(.caption)
|
|
.bold()
|
|
.foregroundStyle(.tint)
|
|
}, value: stars)
|
|
.tint(.yellow)
|
|
.environment(\.layoutDirection, .rightToLeft)
|
|
.environment(\.locale, .init(identifier: "ar_EG"))
|
|
|
|
Button {
|
|
likes += 1
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "heart.fill")
|
|
Text(likes.formatted())
|
|
}
|
|
}
|
|
.changeEffect(.rise(origin: UnitPoint(x: 0.3, y: 0.5)) {
|
|
Image(systemName: "heart.fill")
|
|
.foregroundStyle(.tint)
|
|
}, value: likes)
|
|
.clipped()
|
|
.tint(.red)
|
|
}
|
|
.particleLayer(name: "root")
|
|
.buttonStyle(.bordered)
|
|
.monospacedDigit()
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
struct ListPreview: View {
|
|
@State
|
|
var claps: [Int: Int] = [:]
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
List {
|
|
ForEach(0 ..< 30) { i in
|
|
HStack {
|
|
Text("Cell #\(i)")
|
|
Spacer()
|
|
|
|
Button {
|
|
claps[i, default: 0] += 1
|
|
} label: {
|
|
Label(claps[i, default: 0].formatted(), systemImage: "heart.fill")
|
|
}
|
|
.monospacedDigit()
|
|
.controlSize(.small)
|
|
.buttonBorderShape(.capsule)
|
|
.changeEffect(.rise(layer: .named("root")) {
|
|
Image(systemName: "heart.fill").foregroundStyle(.tint)
|
|
}, value: claps[i, default: 0])
|
|
.tint(.red)
|
|
}
|
|
}
|
|
}
|
|
.labelStyle(.titleOnly)
|
|
.buttonStyle(.borderedProminent)
|
|
.navigationTitle("Cells")
|
|
}
|
|
.particleLayer(name: "root")
|
|
}
|
|
}
|
|
|
|
static var previews: some View {
|
|
NavigationView {
|
|
ButtonPreview()
|
|
}
|
|
.environment(\.colorScheme, .dark)
|
|
.previewDisplayName("Buttons")
|
|
|
|
|
|
ListPreview()
|
|
.environment(\.colorScheme, .dark)
|
|
.previewDisplayName("Escaping List")
|
|
}
|
|
}
|
|
#endif
|