HuggingChat-Mac/Animations/Shimmer.swift (126 lines of code) (raw):
//
// Shimmer.swift
// HuggingChat-Mac
//
// Created by Cyril Zakka on 11/30/24.
//
import SwiftUI
/// A view modifier that applies an animated "shimmer" to any view, typically to show that an operation is in progress.
public struct Shimmer: ViewModifier {
public enum Mode {
/// Masks the content with the gradient (this is the usual, default mode).
case mask
/// Overlays the gradient with a given `BlendMode` (`.sourceAtop` by default).
case overlay(blendMode: BlendMode = .sourceAtop)
/// Places the gradient behind the content.
case background
}
private let animation: Animation
private let gradient: Gradient
private let min, max: CGFloat
private let mode: Mode
@State private var isInitialState = true
@Environment(\.layoutDirection) private var layoutDirection
/// Initializes his modifier with a custom animation,
/// - Parameters:
/// - animation: A custom animation. Defaults to ``Shimmer/defaultAnimation``.
/// - gradient: A custom gradient. Defaults to ``Shimmer/defaultGradient``.
/// - bandSize: The size of the animated mask's "band". Defaults to 0.3 unit points, which corresponds to
/// 30% of the extent of the gradient.
public init(
animation: Animation = Self.defaultAnimation,
gradient: Gradient = Self.defaultGradient,
bandSize: CGFloat = 0.3,
mode: Mode = .mask
) {
self.animation = animation
self.gradient = gradient
// Calculate unit point dimensions beyond the gradient's edges by the band size
self.min = 0 - bandSize
self.max = 1 + bandSize
self.mode = mode
}
/// The default animation effect.
public static let defaultAnimation = Animation.linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false)
// A default gradient for the animated mask.
public static let defaultGradient = Gradient(colors: [
.black.opacity(0.3), // translucent
.black, // opaque
.black.opacity(0.3) // translucent
])
/*
Calculating the gradient's animated start and end unit points:
min,min
\
┌───────┐ ┌───────┐
│0,0 │ Animate │ │ "forward" gradient
LTR │ │ ───────►│ 1,1│ / // /
└───────┘ └───────┘
\
max,max
max,min
/
┌───────┐ ┌───────┐
│ 1,0│ Animate │ │ "backward" gradient
RTL │ │ ───────►│0,1 │ \ \\ \
└───────┘ └───────┘
/
min,max
*/
/// The start unit point of our gradient, adjusting for layout direction.
var startPoint: UnitPoint {
if layoutDirection == .rightToLeft {
isInitialState ? UnitPoint(x: max, y: min) : UnitPoint(x: 0, y: 1)
} else {
isInitialState ? UnitPoint(x: min, y: min) : UnitPoint(x: 1, y: 1)
}
}
/// The end unit point of our gradient, adjusting for layout direction.
var endPoint: UnitPoint {
if layoutDirection == .rightToLeft {
isInitialState ? UnitPoint(x: 1, y: 0) : UnitPoint(x: min, y: max)
} else {
isInitialState ? UnitPoint(x: 0, y: 0) : UnitPoint(x: max, y: max)
}
}
public func body(content: Content) -> some View {
applyingGradient(to: content)
.animation(animation, value: isInitialState)
.onAppear {
// Delay the animation until the initial layout is established
// to prevent animating the appearance of the view
DispatchQueue.main.asyncAfter(deadline: .now()) {
isInitialState = false
}
}
}
@ViewBuilder public func applyingGradient(to content: Content) -> some View {
let gradient = LinearGradient(gradient: gradient, startPoint: startPoint, endPoint: endPoint)
switch mode {
case .mask:
content.mask(gradient)
case let .overlay(blendMode: blendMode):
content.overlay(gradient.blendMode(blendMode))
case .background:
content.background(gradient)
}
}
}
public extension View {
/// Adds an animated shimmering effect to any view, typically to show that an operation is in progress.
/// - Parameters:
/// - active: Convenience parameter to conditionally enable the effect. Defaults to `true`.
/// - animation: A custom animation. Defaults to ``Shimmer/defaultAnimation``.
/// - gradient: A custom gradient. Defaults to ``Shimmer/defaultGradient``.
/// - bandSize: The size of the animated mask's "band". Defaults to 0.3 unit points, which corresponds to
/// 20% of the extent of the gradient.
@ViewBuilder func shimmering(
active: Bool = true,
animation: Animation = Shimmer.defaultAnimation,
gradient: Gradient = Shimmer.defaultGradient,
bandSize: CGFloat = 0.3,
mode: Shimmer.Mode = .mask
) -> some View {
if active {
modifier(Shimmer(animation: animation, gradient: gradient, bandSize: bandSize, mode: mode))
} else {
self
}
}
/// Adds an animated shimmering effect to any view, typically to show that an operation is in progress.
/// - Parameters:
/// - active: Convenience parameter to conditionally enable the effect. Defaults to `true`.
/// - duration: The duration of a shimmer cycle in seconds.
/// - bounce: Whether to bounce (reverse) the animation back and forth. Defaults to `false`.
/// - delay:A delay in seconds. Defaults to `0.25`.
@available(*, deprecated, message: "Use shimmering(active:animation:gradient:bandSize:) instead.")
@ViewBuilder func shimmering(
active: Bool = true, duration: Double, bounce: Bool = false, delay: Double = 0.25
) -> some View {
shimmering(
active: active,
animation: .linear(duration: duration).delay(delay).repeatForever(autoreverses: bounce)
)
}
}
#if DEBUG
struct Shimmer_Previews: PreviewProvider {
static var previews: some View {
Group {
Text("SwiftUI Shimmer")
if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
Text("SwiftUI Shimmer").preferredColorScheme(.light)
Text("SwiftUI Shimmer").preferredColorScheme(.dark)
VStack(alignment: .leading) {
Text("Loading...").font(.title)
Text(String(repeating: "Shimmer", count: 12))
.redacted(reason: .placeholder)
}.frame(maxWidth: 200)
}
}
.padding()
.shimmering()
.previewLayout(.sizeThatFits)
VStack(alignment: .leading) {
Text("مرحبًا")
Text("← Right-to-left layout direction").font(.body)
Text("שלום")
}
.font(.largeTitle)
.shimmering()
.environment(\.layoutDirection, .rightToLeft)
Text("Custom Gradient Mode").bold()
.font(.largeTitle)
.shimmering(
gradient: Gradient(colors: [.clear, .orange, .white, .green, .clear]),
bandSize: 0.5,
mode: .overlay()
)
}
}
#endif