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