Shadow loaders are becoming increasingly popular as a UI/UX feature in mobile apps. They replace traditional "spinners" with a more elegant solution. By filling the entire screen with a "skeleton" layout, the app can later inflate views after fetching data.
Implementation
I made an assumption to make the implementation as easy as possible and as configurable as I could. Let's examine this code snippet:
public struct AGShadowLoader: View {
var backgroundColor = Color.black
var gradientColors = [
Color.clear,
Color.white.opacity(0.7),
Color.clear
]
var animationTime: Double = 2
var cornerRadius: CGFloat = 10
var shadowWidth: CGFloat = 80
@State private var animate = false
public var body: some View {
GeometryReader { geometry in
ZStack {
roundedRectangle()
.foregroundColor(backgroundColor)
roundedRectangle()
.fill(
gradient()
)
.frame(width: shadowWidth)
.offset(
x: animationOffset(with: geometry),
y: 0
)
.animation(
animation()
)
}
.clipShape(roundedRectangle())
.onAppear() {
self.animate = true
}
}
}
@ViewBuilder
private func gradient() -> LinearGradient {
LinearGradient(
gradient: Gradient(colors: gradientColors),
startPoint: .leading,
endPoint: .trailing)
}
@ViewBuilder
private func roundedRectangle() -> RoundedRectangle {
RoundedRectangle(cornerRadius: cornerRadius)
}
private func animation() -> Animation {
Animation.easeInOut(duration: animationTime)
.repeatForever(autoreverses: false)
}
private func animationOffset(with geometry: GeometryProxy) -> CGFloat {
animate ?
geometry.size.width / 2.0 + shadowWidth
: -geometry.size.width / 2.0 - shadowWidth
}
}
At the top, we have the configuration part, a basic set of configurable data, with default values.
The view's body
contains three important structures: GeometryReader
for relative size and position, ZStack
for drawing one view on top of another, and RoundedRectangle
to give it an elegant shape.
We start with ZStack
, at the bottom of the ZAxis
(the invisible, "virtual" axis that goes to the "bottom" of the layout), where I draw a simple rounded rectangle with color. This serves as our base view on which the animation will be displayed.
The second rectangle acts as our "progress" indicator, animating itself from left to right indefinitely and auto-repeating.
To tie everything together, we clip the shape to a rounded rectangle so that our animated view doesn't draw outside the main view bounds.
Below everything, I created helper functions to unclutter the main view code, providing a more readable solution.
Configuration
Our view is already configurable, but if we want to change every property, the init will have five lines! I think we can remedy that by providing an additional internal structure:
public struct AGShadowLoader: View {
public struct AGShadowLoaderConfig {
var backgroundColor = Color.black
var gradientColors = [
Color.clear,
Color.white.opacity(0.7),
Color.clear
]
var animationTime: Double = 2
var cornerRadius: CGFloat = 10
var shadowWidth: CGFloat = 80
}
var config = AGShadowLoaderConfig()
@State private var animate = false
public var body: some View {
...
}
..........
}
By adding internal structure, we keep namespaces uncluttered, initialization simpler, and we can also easily share styles across the codebase. How, you ask? By extensions!
extension AGShadowLoader.AGShadowLoaderConfig {
static var myAwesomeConfig: AGShadowLoader.AGShadowLoaderConfig {
AGShadowLoader.AGShadowLoaderConfig(
backgroundColor: .blue,
gradientColors: [.yellow, .green, .yellow],
animationTime: 3,
cornerRadius: 5,
shadowWidth: 120
)
}
}
And usage:
AGShadowLoader(config: .myAwesomeConfig)
Wrap up
I hope you can use this implementation for your own project! It is available as Swift Package here!