skip to Main Content

I have a rectangle and I want to give it a stroke, but I that gives the whole rectangle a stroke. I want the stroke to represent a percentage, so like 50% only half the rectangle would be stroked

    VStack {
        Text(dayAbbreviation)
            .foregroundStyle(Color("dark_grey"))
            .font(.title)
        
        Text(String(day))
            .padding(.horizontal)
            .font(.largeTitle)
        
        Text(percentageCompleted + "%")
            .font(.footnote)
            .foregroundStyle(Color("dark_grey"))
            .bold()
            .padding(.bottom, 2)
        
    }.background {
        Rectangle()
            .fill(.white)
            .cornerRadius(3.0)
            .aspectRatio(contentMode: .fill)
    }
    .padding(8)
    .background {
        Rectangle()
            .fill(.blue)
            .cornerRadius(3.0)
            .aspectRatio(contentMode: .fill)
            
    }

2

Answers


  1. Note: From your question it is not completely clear how you want to make your stroke as a progress. I will assume that you wish to stroke it as if you were drawing. You start at one point and you "circle" around the center to create a rectangle.

    There is a general solution where you can clip your whole view using clipShape on any of your views. This could be an image or a stroked rectangle or anything. And you can make it as a modifier. See the following:

    /// Clips the current view with circular shape for given progress
    /// - Parameter progress: A progress in range [0, 1]
    func useAsProgress(progress: CGFloat) -> some View {
        GeometryReader { proxy in
            let path = Path { path in
                let center = CGPoint(x: proxy.size.width/2,
                                     y: proxy.size.height/2)
                let longPoint = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2)
                let radius = (longPoint.x*longPoint.x + longPoint.y*longPoint.y).squareRoot()
                path.move(to: center)
                path.addArc(center: center,
                            radius: radius,
                            startAngle: .degrees(0),
                            endAngle: .degrees(360*progress),
                            clockwise: false)
            }
            self.clipShape(path)
        }
        
    }
    

    And it becomes as simple as

    var body: some View {
        Rectangle()
            .inset(by: 10) // Insetting half the line width
            .stroke(Color.blue, lineWidth: 20.0)
            .useAsProgress(progress: 0.55)
    }
    

    But the solution is not very pretty on a shape such as rectangle. If you animated the progress you would see the line is "moving" faster near the corners. Also there is not much you can do with edges being cut very sharply:

    enter image description here

    So to make it very nice and smooth you will need to construct the path out of lines so that you can define progress correctly plus you can control how the stroke is being drawn. It is a bit more work but it can be done like so:

    var progress: CGFloat
    
    var body: some View {
        GeometryReader { proxy in
            Path { path in
                guard progress < 1.0 else {
                    path.addRect(CGRect(x: 0,
                                        y: 0,
                                        width: proxy.size.width,
                                        height: proxy.size.height))
                    return
                }
                
                path.move(to: CGPoint(x: proxy.size.width,
                                      y: proxy.size.height/2))
                
                var progressLeft = progress
                // Downwards from start represents [0, 1/8] portion
                if progressLeft > 0 {
                    let portionToUse = min(progressLeft, 1.0/8.0)
                    let relativeSizeToUse = portionToUse / (1.0/8.0)
                    path.addLine(to: CGPoint(x: proxy.size.width,
                                             y: proxy.size.height/2 + relativeSizeToUse*proxy.size.height/2))
                    progressLeft -= portionToUse
                }
                // Leftwards from bottom right represents [0, 1/4] portion
                if progressLeft > 0 {
                    let portionToUse = min(progressLeft, 1.0/4.0)
                    let relativeSizeToUse = portionToUse / (1.0/4.0)
                    path.addLine(to: CGPoint(x: proxy.size.width - relativeSizeToUse*proxy.size.width,
                                             y: proxy.size.height))
                    progressLeft -= portionToUse
                }
                // Upwards from bottom left represents [0, 1/4] portion
                if progressLeft > 0 {
                    let portionToUse = min(progressLeft, 1.0/4.0)
                    let relativeSizeToUse = portionToUse / (1.0/4.0)
                    path.addLine(to: CGPoint(x: 0.0,
                                             y: proxy.size.height - relativeSizeToUse*proxy.size.height))
                    progressLeft -= portionToUse
                }
                // Rightwards from top left represents [0, 1/4] portion
                if progressLeft > 0 {
                    let portionToUse = min(progressLeft, 1.0/4.0)
                    let relativeSizeToUse = portionToUse / (1.0/4.0)
                    path.addLine(to: CGPoint(x: 0.0 + relativeSizeToUse*proxy.size.width,
                                             y: 0.0))
                    progressLeft -= portionToUse
                }
                // Downwards from top right represents [0, 1/8] portion
                if progressLeft > 0 {
                    let portionToUse = min(progressLeft, 1.0/8.0)
                    let relativeSizeToUse = portionToUse / (1.0/8.0)
                    path.addLine(to: CGPoint(x: proxy.size.width,
                                             y: 0.0 + relativeSizeToUse*proxy.size.height/2))
                    progressLeft -= portionToUse
                }
            }
            .stroke(Color.blue,
                    style: .init(lineWidth: 20,
                                 lineCap: .round,
                                 lineJoin: .round))
        }
    }
    

    And the result looks way nicer:

    enter image description here

    Login or Signup to reply.
  2. You didn’t describe, how exactly the partial rectangle should look. However, the text in the foreground of your example is a date, so I am guessing you want the border around the text to reflect the amount of the day that has elapsed. And since this relates to actual time-of-day, it probably makes sense if it starts drawing at 12 o’clock and goes clockwise, with the position for the end-of-stroke reflecting the current time of day on a 24-hour clock.

    One way to do this is to trim the rectangle and then use .stroke instead of .fill – see Why does the behavior of .trim() and .stroke() modifiers change on a Circle, depending on their order? for an explanation of why it doesn’t work with .fill. However, a stroke normally starts in the top-left corner, so to make it start at 12 o’clock it means adding 12.5% to the percent completed. This works fine, until the percent completed exceeds 87.5, because it won’t stroke past 100%. So for the last part of the border between 87.5% and 100% it is necessary to use a second stroke.

    Like this:

    let percent = 50.0
    
    var body: some View {
        VStack {
            // content as before
        }
        .padding(4)
        .background {
            ZStack {
                Rectangle()
                    .trim(from: 0.125, to: min(1.0, 0.125 + (percent / 100)))
                    .stroke(style: .init(lineWidth: 8, lineCap: .round, lineJoin: .round))
                    .foregroundColor(.blue)
                if percent > 87.5 {
                    Rectangle()
                        .trim(from: 0, to: (percent - 87.5) / 100)
                        .stroke(style: .init(lineWidth: 8, lineCap: .round, lineJoin: .round))
                        .foregroundColor(.blue)
                }
            }
            .aspectRatio(contentMode: .fill)
            .background(.white)
        }
        .padding(4)
    }
    
    

    PercentCompleted

    What you might notice, is that the start and end points go over the exact position. This is because the stroke style uses a round line cap and this cap is drawn with its center-point at the calculated position. This causes the end of the stroke to go over the exact point by half the line width. This can be corrected by using a GeometryReader to obtain the size of the frame and applying a small correction to the trim value:

    .background {
        GeometryReader { proxy in
            ZStack {
                Rectangle()
                    .trim(
                        from: 0.125 + (4.0 / (proxy.size.width * 4)),
                        to: min(1.0, 0.125 + (percent / 100) - (4.0 / (proxy.size.width * 4)))
                    )
                    .stroke(style: .init(lineWidth: 8, lineCap: .round, lineJoin: .round))
                    .foregroundColor(.blue)
                if percent > 87.5 {
                    Rectangle()
                        .trim(
                            from: 0,
                            to: max(0, ((percent - 87.5) / 100) - (4.0 / (proxy.size.width * 4)))
                        )
                        .stroke(style: .init(lineWidth: 8, lineCap: .round, lineJoin: .round))
                        .foregroundColor(.blue)
                }
            }
        }
        .aspectRatio(contentMode: .fill)
        .background(.white)
    }
    

    CorrectedLineEnd

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search