mirror of
https://github.com/EmergeTools/Pow.git
synced 2026-03-25 08:55:50 +00:00
317 lines
9.9 KiB
Swift
317 lines
9.9 KiB
Swift
import SwiftUI
|
||
|
||
public extension AnyChangeEffect {
|
||
/// An effect that highlights the view with a glow around it.
|
||
///
|
||
/// The glow appears for a second.
|
||
static var glow: AnyChangeEffect {
|
||
glow(color: .accentColor)
|
||
}
|
||
|
||
/// An effect that highlights the view with a glow around it.
|
||
///
|
||
/// The glow appears for a second.
|
||
///
|
||
/// - Parameters:
|
||
/// - color: The color of the glow.
|
||
/// - radius: The radius of the glow.
|
||
static func glow(color: Color, radius: CGFloat = 16) -> AnyChangeEffect {
|
||
.simulation { change in
|
||
PulseGlowModifier(impulseCount: change, color: color, radius: min(100, radius))
|
||
}
|
||
}
|
||
}
|
||
|
||
public extension AnyConditionalEffect {
|
||
/// An effect that highlights the view with a glow around it.
|
||
static var glow: AnyConditionalEffect {
|
||
glow(color: .accentColor)
|
||
}
|
||
|
||
/// An effect that highlights the view with a glow around it.
|
||
///
|
||
/// - Parameters:
|
||
/// - color: The color of the glow.
|
||
/// - radius: The radius of the glow.
|
||
static func glow(color: Color, radius: CGFloat = 16) -> AnyConditionalEffect {
|
||
.continuous(
|
||
.modifier { isActive in
|
||
ContinuousGlowModifier(color: color, radius: radius, isActive: isActive)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
internal struct GlowModifier: ViewModifier, Animatable {
|
||
var animatableData: CGFloat
|
||
|
||
var color: Color
|
||
|
||
var radius: CGFloat
|
||
|
||
let ramp = cubicBezier(x1: 0.3, y1: 0.0, x2: 0.7, y2: 1)
|
||
|
||
init(glow: CGFloat, color: Color, radius: CGFloat) {
|
||
self.animatableData = glow
|
||
self.color = color
|
||
self.radius = radius
|
||
}
|
||
|
||
var glow: CGFloat {
|
||
get { animatableData }
|
||
set { animatableData = newValue }
|
||
}
|
||
|
||
func body(content: Content) -> some View {
|
||
let amount = min(glow, 1.5)
|
||
|
||
let shadowOpacity = sqrt(amount)
|
||
|
||
content
|
||
.transformEnvironment(\.backgroundMaterial) { material in
|
||
material = nil
|
||
}
|
||
.overlay {
|
||
color
|
||
.opacity(ramp(amount))
|
||
.blendMode(.sourceAtop)
|
||
.brightness(ramp(abs(amount)) * 0.1)
|
||
.allowsHitTesting(false)
|
||
}
|
||
.compositingGroup()
|
||
.shadow(color: color.opacity(shadowOpacity / 1.2), radius: amount * radius / 4.0, x: 0, y: 0)
|
||
.shadow(color: color.opacity(shadowOpacity / 4.0), radius: amount * radius / 2.0, x: 0, y: 0)
|
||
.shadow(color: color.opacity(shadowOpacity / 8.0), radius: amount * radius, x: 0, y: 0)
|
||
.shadow(color: color.opacity(shadowOpacity / 16.0), radius: amount * radius * 2.0, x: 0, y: 0)
|
||
.brightness(ramp(abs(amount)) * 0.25)
|
||
.animation(nil, value: amount)
|
||
}
|
||
}
|
||
|
||
internal struct ContinuousGlowModifier: ViewModifier, Continuous {
|
||
var color: Color
|
||
|
||
var radius: CGFloat
|
||
|
||
var isActive: Bool
|
||
|
||
init(color: Color, radius: CGFloat, isActive: Bool) {
|
||
self.color = color
|
||
self.radius = radius
|
||
self.isActive = isActive
|
||
}
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.modifier(
|
||
GlowModifier(glow: isActive ? 0.7 : 0, color: color, radius: radius)
|
||
.animation(.easeInOut(duration: 0.25))
|
||
)
|
||
}
|
||
}
|
||
|
||
internal struct PulseGlowModifier: ViewModifier, Simulative {
|
||
var impulseCount: Int
|
||
|
||
var initialVelocity: CGFloat = 0
|
||
|
||
let spring = Spring(zeta: 0.75, stiffness: 15, mass: 1)
|
||
|
||
var color: Color
|
||
|
||
var radius: CGFloat
|
||
|
||
@State
|
||
private var targetGlow: CGFloat = 0.0
|
||
|
||
@State
|
||
private var glow: CGFloat = 0.0
|
||
|
||
@State
|
||
private var glowVelocity: CGFloat = 0.0
|
||
|
||
private var isSimulationPaused: Bool {
|
||
targetGlow == glow && abs(glowVelocity) <= 0.02
|
||
}
|
||
|
||
internal func body(content: Content) -> some View {
|
||
TimelineView(.animation(paused: isSimulationPaused)) { context in
|
||
content
|
||
.modifier(GlowModifier(glow: glow, color: color, radius: radius))
|
||
.onChange(of: context.date) { (newValue: Date) in
|
||
let duration = Double(newValue.timeIntervalSince(context.date))
|
||
withAnimation(nil) {
|
||
update(clamp(0, duration, 1 / 30))
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: impulseCount) { newValue in
|
||
withAnimation(nil) {
|
||
if glowVelocity <= 0.05 {
|
||
glowVelocity = 5
|
||
} else {
|
||
glowVelocity += 1.5
|
||
}
|
||
|
||
glowVelocity = min(glowVelocity, 5)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func update(_ step: Double) {
|
||
let newValue: Double
|
||
let newVelocity: Double
|
||
|
||
if spring.response > 0 {
|
||
(newValue, newVelocity) = spring.value(
|
||
from: glow,
|
||
to: targetGlow,
|
||
velocity: glowVelocity,
|
||
timestep: step
|
||
)
|
||
} else {
|
||
newValue = targetGlow
|
||
newVelocity = 0.0
|
||
}
|
||
|
||
glow = newValue
|
||
glowVelocity = newVelocity
|
||
|
||
if abs(newValue - targetGlow) < 0.01, newVelocity < 0.01 {
|
||
glow = targetGlow
|
||
glowVelocity = 0.0
|
||
}
|
||
}
|
||
}
|
||
|
||
#if os(iOS) && DEBUG
|
||
struct GlowChangeEffect_Previews: PreviewProvider {
|
||
struct Cart: View {
|
||
@State
|
||
var itemCount: Int = 1
|
||
|
||
var total: Double {
|
||
9.99 * Double(itemCount)
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
HStack(alignment: .center, spacing: 16) {
|
||
AsyncImage(url: URL(string: "https://movingparts.io/frontpage/checkout-smooth-blend@3x.png")) { phase in
|
||
if let image = phase.image {
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
}
|
||
}
|
||
.background(Color(white: 0.9))
|
||
.frame(width: 72, height: 72)
|
||
.changeEffect(.shine(angle: .degrees(180), duration: 0.5), value: itemCount, isEnabled: itemCount > 0)
|
||
|
||
HStack(alignment: .firstTextBaseline) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Seasonal Blend, Spring Here")
|
||
.font(.body.weight(.medium))
|
||
.lineSpacing(-10)
|
||
|
||
Text("500g")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
Spacer()
|
||
|
||
VStack(alignment: .trailing) {
|
||
Text("\(itemCount.formatted())× ").foregroundColor(.secondary) +
|
||
Text(9.99.formatted(.currency(code: "EUR")))
|
||
Stepper(value: $itemCount, in: 0...1000) {
|
||
Text("Quantity ") + Text(itemCount.formatted()).foregroundColor(.secondary)
|
||
}
|
||
.labelsHidden()
|
||
.font(.callout)
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.navigationTitle("Cart")
|
||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
VStack {
|
||
if #available(iOS 16.0, *) {
|
||
LabeledContent("Subtotal", value: total, format: .currency(code: "USD"))
|
||
LabeledContent("Shipping", value: 0, format: .currency(code: "USD"))
|
||
LabeledContent("Total") {
|
||
Text(total, format: .currency(code: "USD"))
|
||
.foregroundStyle(.primary)
|
||
.changeEffect(.glow(color: .accentColor, radius: 32), value: itemCount)
|
||
}
|
||
.tint(.red)
|
||
.bold()
|
||
}
|
||
|
||
Divider().hidden()
|
||
|
||
Button {
|
||
} label: {
|
||
Label("Checkout", systemImage: "cart")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.disabled(itemCount == 0)
|
||
.animation(.default, value: itemCount == 0)
|
||
}
|
||
.monospacedDigit()
|
||
.padding()
|
||
.background(.regularMaterial)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct Preview: View {
|
||
@State
|
||
var isOn = false
|
||
|
||
var body: some View {
|
||
VStack {
|
||
Spacer()
|
||
|
||
Button("Continue") {
|
||
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.buttonBorderShape(.capsule)
|
||
.controlSize(.large)
|
||
.conditionalEffect(.repeat(.glow(color: .blue, radius: 50), every: 1.5), condition: isOn)
|
||
|
||
Button("Continue") {
|
||
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.buttonBorderShape(.capsule)
|
||
.controlSize(.large)
|
||
.conditionalEffect(.glow(color: .blue, radius: 50), condition: isOn)
|
||
|
||
Spacer()
|
||
|
||
Toggle("Enabled", isOn: $isOn)
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
static var previews: some View {
|
||
NavigationView {
|
||
Cart()
|
||
}
|
||
.preferredColorScheme(.dark)
|
||
.environment(\.dynamicTypeSize, .xxLarge)
|
||
.previewDisplayName("Change Effect")
|
||
|
||
Preview()
|
||
.preferredColorScheme(.dark)
|
||
.environment(\.dynamicTypeSize, .xxLarge)
|
||
.previewDisplayName("Conditional Effect")
|
||
}
|
||
}
|
||
#endif
|