iosswiftui

Masking issue in SwiftUI


I'm working on a custom slider using a RoundedRectangle for the track with another Rectangle for the progress with a mask applied. This largely works fine as long as the thumb is not close to the end. As the thumb is dragged close to the right side edge, the RoundedRectangle looks rounded at both ends. How can I fix it such that only the right end of the rounded rectangle shows as rounded ?

struct Slider: View {
    @Binding var progress: CGFloat
    private let stroke: CGFloat = 5.0
    private var thumbWidth: CGFloat { stroke * 2 }
    private var thumbHeight: CGFloat { thumbWidth * 3.5 }
    
    var body: some View {
        GeometryReader { geometry in
            
            ZStack {
                Color.black.ignoresSafeArea()
                
                VStack {
                    let progressWidth = geometry.size.width * progress
                    let sliderWidth = geometry.size.width - thumbWidth
                    
                    ZStack(alignment: .leading) {
                        RoundedRectangle(cornerRadius: stroke)
                            .frame(height: stroke)
                            .foregroundStyle(Color.white.opacity(0.5))
                            .padding(.leading, progressWidth)
                        
                        Rectangle()
                            .fill(Color.white.opacity(0.5))
                            .mask(alignment: .leading) {
                                Rectangle()
                                    .frame(width: progressWidth)
                            }
                        
                        Capsule()
                            .fill(.red)
                            .frame(width: thumbWidth, height: thumbHeight)
                            .offset(x: progress * (geometry.size.width - thumbWidth))
                            .gesture(
                                DragGesture()
                                    .onChanged { gesture in
                                        let newProgress = min(max(gesture.location.x / sliderWidth, 0), 1)
                                        progress = newProgress
                                    }
                            )
                    }
                    .frame(height: thumbHeight)
                }
            }
        }
    }
}

struct SliderView: View {
    @State private var progress: CGFloat = 0.0
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            Slider(progress: $progress)
        }
    }
}

With thumb in the middle: enter image description here

With thumb close to the end: enter image description here


Solution

  • Your calculations are a bit off. Try adding .opacity(0.3) to the Capsule and see that the horizontal center of the thumb is not always where the two other rectangles "meet".

    I assume you want the horizontal center of the thumb to be where the two other rectangles meet. So you need to add an extra thumbWidth / 2 to their width/padding.

    progressWidth should be calculated from sliderWidth not geometry.size.width, because sliderWidth is the total length across which the thumb can move.

    let sliderWidth = geometry.size.width - thumbWidth
    let progressWidth = sliderWidth * progress
    

    Then add/subtract thumbWidth / 2 where appropriate:

    ZStack(alignment: .leading) {
        RoundedRectangle(cornerRadius: stroke)
            .frame(height: stroke)
            .foregroundStyle(Color.white.opacity(0.5))
            // this rectangle will at least have a width of half the thumb
            .padding(.leading, progressWidth + thumbWidth / 2)
        
        Rectangle()
            .fill(Color.white.opacity(0.5))
            // this does not need to be a mask at all - you can just apply .frame to the rectangle directly
            // I'll assume in your real code this is not a simple Rectangle
            .mask(alignment: .leading) {
                Rectangle()
                    // this rectangle will at least be half the thumb width away from the leading side
                    .frame(width: progressWidth + thumbWidth / 2)
            }
        
        Capsule()
            .fill(.red)
            .frame(width: thumbWidth, height: thumbHeight)
            .offset(x: progressWidth)
            .gesture(
                DragGesture()
                    .onChanged { gesture in
                        // the actual slider starts at x = thumbWidth / 2, so the x coordinate needs to be shifted here
                        let newProgress = min(max((gesture.location.x - thumbWidth / 2) / sliderWidth, 0), 1)
                        progress = newProgress
                    }
            )
    }
    .frame(height: thumbHeight)