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>
156 lines
5.9 KiB
Swift
156 lines
5.9 KiB
Swift
import SwiftUI
|
||
|
||
public extension AnyChangeEffect {
|
||
/// An effect that highlights the view with a shine moving over the view.
|
||
///
|
||
/// The shine moves from the top leading edge to bottom trailing edge.
|
||
static var shine: AnyChangeEffect {
|
||
shine(duration: 1)
|
||
}
|
||
|
||
/// An effect that highlights the view with a shine moving over the view.
|
||
///
|
||
/// The shine moves from the top leading edge to bottom trailing edge.
|
||
static func shine(duration: Double) -> AnyChangeEffect {
|
||
.animation({ change in
|
||
ShineModifier(angle: nil, animatableData: CGFloat(change))
|
||
}, animation: .easeInOut(duration: duration), cooldown: duration * 0.5)
|
||
}
|
||
|
||
/// An effect that highlights the view with a shine moving over the view.
|
||
///
|
||
/// The angle is relative to the current `layoutDirection`, such that 0° represents sweeping towards the trailing edge and 90° represents sweeping towards the bottom edge.
|
||
///
|
||
/// - Parameters:
|
||
/// - angle: The angle of the animation.
|
||
/// - duration: The duration of the animation.
|
||
static func shine(angle: Angle, duration: Double = 1.0) -> AnyChangeEffect {
|
||
.animation({ change in
|
||
ShineModifier(angle: angle, animatableData: CGFloat(change))
|
||
}, animation: .easeInOut(duration: duration), cooldown: duration * 0.5)
|
||
}
|
||
}
|
||
|
||
internal struct ShineModifier: ViewModifier, Animatable {
|
||
var angle: Angle?
|
||
|
||
public var animatableData: CGFloat = 0
|
||
public func body(content: Content) -> some View {
|
||
let fraction = CGFloat(fmodf(Float(animatableData), 1))
|
||
|
||
content
|
||
.overlay(
|
||
GeometryReader { proxy in
|
||
let base = sin(Double(fraction))
|
||
|
||
let frame = CGRect(origin: .zero, size: proxy.size)
|
||
|
||
let resolvedAngle = angle ?? frame.topLeft.angle(to: frame.bottomRight)
|
||
|
||
let bounds = frame.boundingBox(at: resolvedAngle)
|
||
|
||
LinearGradient(
|
||
colors: stride(from: 0.0, through: .pi, by: 0.2).map {
|
||
.white.opacity(pow(sin($0), 2) * 0.8 * base)
|
||
},
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
.frame(width: bounds.width * 2, height: bounds.height)
|
||
.position(
|
||
x: (bounds.minX - bounds.width / 2) + (fraction * bounds.width * 2),
|
||
y: bounds.midY
|
||
)
|
||
.rotationEffect(resolvedAngle)
|
||
.blendMode(.sourceAtop)
|
||
.opacity(1.0 - pow(fraction, 8.0))
|
||
}
|
||
.allowsHitTesting(false)
|
||
)
|
||
.compositingGroup()
|
||
.animation(nil, value: fraction)
|
||
}
|
||
}
|
||
|
||
#if os(iOS) && DEBUG
|
||
struct ShineChangeEffect_Previews: PreviewProvider {
|
||
struct Cart: View {
|
||
@State
|
||
var itemCount: Int = 0
|
||
|
||
@State private var degrees: Double = 45
|
||
|
||
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...10) {
|
||
Text("Quantity ") + Text(itemCount.formatted()).foregroundColor(.secondary)
|
||
}
|
||
.labelsHidden()
|
||
.font(.callout)
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|
||
|
||
Text(degrees, format: .number.precision(.fractionLength(2)))
|
||
Slider(value: $degrees, in: -360.0...360.0)
|
||
|
||
}
|
||
.listStyle(.plain)
|
||
.navigationTitle("Cart")
|
||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
VStack(spacing: 32) {
|
||
Button {
|
||
} label: {
|
||
Label("Checkout", systemImage: "cart")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.disabled(itemCount == 0)
|
||
.animation(.default, value: itemCount == 0)
|
||
.changeEffect(
|
||
.shine(angle: .degrees(degrees)).delay(0.5),
|
||
value: itemCount,
|
||
isEnabled: itemCount > 0
|
||
)
|
||
.padding()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
static var previews: some View {
|
||
NavigationView {
|
||
Cart()
|
||
}
|
||
}
|
||
}
|
||
#endif
|