SwiftUI Custom Progress Bar with masking


Greetings, traveler!

SwiftUI ProgressView is a valuable tool for displaying progress. But sometimes, we must create something custom to gain complete control over the UI. We can create a custom progress bar with masking.

Note

By the way, we have already discussed the SwiftUI progress bar with masking. There, we discussed using an image to change a progress bar shape. You can read more about it here.

Let’s create some mock views and two @State variables:

  1. Progress value
  2. Progress bar value

We will use the second one to store progress value, calculated with a view frame.

struct ContentView: View {
    
    @State private var progress: CGFloat = .zero
    @State private var progressBarValue: CGFloat = .zero
    
    var body: some View {
        VStack {
            Text(progress, format: .percent.precision(.fractionLength(.zero)))
                .frame(maxWidth: .infinity, minHeight: 80)
            
            Slider(value: $progress, in: 0...1)
                .padding()
        }
        .padding()
    }
    
}

After that, create a new View. Here, we will use GeometryReader and two rectangles stored inside the ZStack.

var progressBar: some View {
        GeometryReader { geometry in
            ZStack {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.green)
                
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.gray)
            }
        }
    }

Now, we need to calculate the progress bar value. We will use GeometryReader to do this.

var progressBar: some View {
        GeometryReader { geometry in
            ZStack {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.green)
                
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.gray)
                    .mask {
                        Rectangle()
                            .offset(x: -progressBarValue)
                    }
            }
        }
    }

This value can be used to create a mask. Since we are using ZStack here, these two rectangles overlay one another. We will use a mask to partially or fully reveal one of them.

var progressBar: some View {
        GeometryReader { geometry in
            ZStack {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.green)
                
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.gray)
                    .mask {
                        Rectangle()
                            .offset(x: -progressBarValue)
                    }
            }
            .onChange(of: progress) { _, newValue in
                progressBarValue = -geometry.size.width * newValue
            }
        }
    }

Now, we can use it. Check out the full code:

struct ContentView: View {
    
    @State private var progress: CGFloat = .zero
    @State private var progressBarValue: CGFloat = .zero
    
    var body: some View {
        VStack {
            Text(progress, format: .percent.precision(.fractionLength(.zero)))
                .frame(maxWidth: .infinity, minHeight: 80)
                .background {
                    progressBar
                }
            
            Slider(value: $progress, in: 0...1)
                .padding()
        }
        .padding()
    }
    
    var progressBar: some View {
        GeometryReader { geometry in
            ZStack {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.green)
                
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.gray)
                    .mask {
                        Rectangle()
                            .offset(x: -progressBarValue)
                    }
            }
            .onChange(of: progress) { _, newValue in
                progressBarValue = -geometry.size.width * newValue
            }
        }
    }
    
}

Cool!

Text masking

We can also change the text color with a mask to ensure it is always visible. We will use the same approach: creating two views and revealing one with a mask.

import SwiftUI

struct ContentView: View {
    
    @State private var progress: CGFloat = .zero
    @State private var progressBarValue: CGFloat = .zero
    
    var body: some View {
        VStack {
            progressBar
                .frame(maxWidth: .infinity, maxHeight: 80)
            
            Slider(value: $progress, in: 0...1)
                .padding()
        }
        
        .padding()
    }
    
    var progressBar: some View {
        GeometryReader { geometry in
            ZStack {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.green)
                
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(.gray)
                    .mask {
                        Rectangle()
                            .offset(x: -progressBarValue)
                    }
                
                Text("Some text here...")
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity)
                
                Text("Some text here...")
                    .foregroundStyle(.black)
                    .frame(maxWidth: .infinity)
                    .mask {
                        Rectangle()
                            .offset(x: -progressBarValue)
                    }
            }
            .onChange(of: progress) { _, newValue in
                progressBarValue = -geometry.size.width * newValue
            }
        }
    }
    
}

Conclusion

This simple example opens up opportunities for more flexible customization. You can find the source code here.