Mastering SwiftUI Shadow Loaders: Making Implementation a Breeze

Photo by Marra / Unsplash

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!

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland