What is Masking?

Masking is a graphical technique that controls the visibility of elements by using a secondary view to define which parts of the primary view are visible. In SwiftUI, it works much like a stencil, where the opacity of the mask determines what is shown or hidden. Areas of the mask that are black or transparent will conceal the underlying view, while white or opaque areas will reveal it.

We can apply mask to a view using mask(alignment:_:) modifier. Lets go through step by step on how we can use this modifier to create a shine effect.

Implementation

Let’s create a view with a ZStack and add black color to fill the screen so we can see the effect in detail and we’ll add our image on top of it and make it fit the screen.

struct ShineView: View {
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            Image(.badge)
                .resizable()
                .scaledToFit()
        }
    }
}

Next we’re going to create a gradient, that is white in the middle but it fades out in both directions and add it on top of the image in the ZStack.

var gradient = LinearGradient(gradient: Gradient(colors: [.clear, .clear, .white.opacity(0.3), .white, .white.opacity(0.3), .clear, .clear]), startPoint: .leading, endPoint: .trailing)

var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            
            Image(.badge)
                .resizable()
                .scaledToFit()
                
            gradient
                .frame(width: 100, height: 700) // height has to be bigger than the image.
        }  
    }

To enhance the shine effect and make it appear more natural, we’ll introduce a slight rotation to the gradient. This subtle adjustment ensures the effect feels visually appealing when applied. Additionally, we’ll offset the gradient along the x-axis, positioning it off-screen initially.

gradient
  .rotationEffect(.degrees(20))
  .offset(x: -350) // You can use geometry reader to get accurate value for screen's width.

Notice the gradient is slightly visible on screen, that is fine since it won’t matter when we apply a mask over it.

Now let’s animate it going left to right on screen, we would need to create a state variable for that. and we’re going to start the animation on onAppear of our view.

@State var animate: Bool = false

 .onAppear {        
    withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
      animate = true
    }     
}

We use the .offset(x:) modifier twice: the initial offset places the gradient off-screen to the left, and the animated offset moves it across the view. By toggling the animate state, the gradient transitions from left side to the right.

gradient
  .rotationEffect(.degrees(20))
  .offset(x: -350)
  .offset(x: animate ? 700 : -350) // You can experiment with these values. 

The final step is to apply the mask to the gradient so it aligns with the underlying image, giving it the shine effect. Using the .mask modifier, we apply the same shape or view as the mask that matches the element we want to highlight which in our case is the Newcastle logo image. Here’s how the complete implementation looks with the mask applied, bringing the entire shine effect together.

struct ShineView: View {
    @State var animate: Bool = false

    var gradient = LinearGradient(gradient: Gradient(colors: [.clear, .clear, .white.opacity(0.3), .white, .white.opacity(0.3), .clear, .clear]), startPoint: .leading, endPoint: .trailing)
    
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            
            Image(.badge)
                .resizable()
                .scaledToFit()
            
            gradient
                .rotationEffect(.degrees(20))
                .offset(x: -350)
                .offset(x: animate ? 700 : -350)
                .mask {
                    Image(.badge)
                        .resizable()
                        .scaledToFit()
                }
        }
        .onAppear {
            
            withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
                animate = true
            }
            
        }
       
    }
}