gh-EmergeTools-Pow/Sources/Pow/Effects/GlowEffect.swift
2023-12-15 23:55:55 -05:00

317 lines
9.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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