iosswiftswiftui

Mask multiple shapes in SwiftUI


I'm trying to create a custom progress bar like so (ignore the thumb for now):

enter image description here

At the moment I'm able to gradually reveal the wave using another rectangle applied as a mask. I'm not sure how to clip the left portion of the line as the progress increases. I tried setting the wave view with its mask as a mask to the line but that didn't work. Should I update the width of the line based on the progress or is it possible to use a mask in a way such that, as the wave starts to turn visible from the leading edge, the line shrinks towards the trailing edge? Please advise on an efficient way to do this. Any help is appreciated.

struct Line: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: rect.height/2))
        path.addLine(to: CGPoint(x: rect.width, y: rect.height/2))
        return path
    }
}

struct Wave: Shape {
    var strength: Double
    var frequency: Double
    var phase: Double
    var animatableData: Double {
        get { phase }
        set { self.phase = newValue }
    }
    
    func path(in rect: CGRect) -> Path {
        var path = Path()

        let width = Double(rect.width)
        let height = Double(rect.height)
        let midHeight = height / 2
        let wavelength = width / frequency

        let firstX = 0.0
        let firstRelativeX = firstX / wavelength
        let firstSine = sin(firstRelativeX + phase)
        let firstY = strength * firstSine + midHeight
        path.move(to: CGPoint(x: firstX, y: firstY))

        for x in stride(from: 0.0, through: width, by: 1) {
            let relativeX = x / wavelength
            let sine = sin(relativeX + phase)
            let y = strength * sine + midHeight
            path.addLine(to: CGPoint(x: x, y: y))
        }

        return path
    }
}

struct ContentView: View {
    @State private var phase = 0.0
    @State private var progress = 0.0

    var body: some View {
        VStack {
            GeometryReader(content: { geometry in
                
                ZStack {
                    
                    Line()
                        .stroke(Color.white.opacity(0.5), lineWidth: 5.0)
                        .frame(width: geometry.size.width)
                    
                    Wave(strength: 10, frequency: 30, phase: phase)
                        .stroke(Color.white, lineWidth: 5)
                        .mask(
                            Rectangle()
                                .frame(width: (geometry.size.width * progress), height: geometry.size.height)
                                .padding(.trailing, geometry.size.width * (1 - progress))
                        )
                }
            })
            
            Slider(value: $progress, in: 0...1)
                .padding()
        }
        .background(Color.black.ignoresSafeArea())
        .onAppear {
            withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
                self.phase = .pi * 2
            }
        }
    }
}

Solution

  • A Shape is greedy and consumes as much space as available. So your code can be simplified by applying a fixed width only where it is actually needed. Then, you don't need to fix the width of the line, you just need to add padding.

    Also, if you provide an alignment parameter to the .mask then the padding on the mask can be avoided too.

    GeometryReader { geometry in
        let progressWidth = geometry.size.width * progress
        ZStack {
            Line()
                .stroke(Color.white.opacity(0.5), lineWidth: 5.0)
                .padding(.leading, progressWidth)
    
            Wave(strength: 10, frequency: 30, phase: phase)
                .stroke(Color.white, lineWidth: 5)
                .mask(alignment: .leading) {
                    Rectangle()
                        .frame(width: progressWidth)
                }
        }
    }