iosswiftswiftuiswiftui-listswiftui-layout

Creating Evenly Distributed Dotted Outline for a Rounded Rectangle in SwiftUI


EDIT: The dash size of 10 in the code sample is arbitrary. The dash size is whatever looks good.

Let's say in SwiftUI, you want to have a custom list item that has a dotted outline -- to serve as a "add item hint."

So you use a strokeBorder like this:

struct ContentView: View {
    
    let items:[String] = ["a","b","c"]
    @State var itemsEmpty = false
    
    var body: some View {
        Text("toggle").onTapGesture {
            itemsEmpty.toggle()
        }
        VStack {
            Spacer()
            if !itemsEmpty {
                List{
                    ForEach(items, id: \.self) {item in
                        Text(item)
                    }
                }
            } else {
                // The "list item hint" view
                List{
                    Text("ADD")
                        .listRowBackground(
                            RoundedRectangle(cornerSize: CGSize(width: 15, height: 15))
                                .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10]))
                            
                        )
                }
            }
            Spacer()
        }
        .padding()
    }
}

Which results in this:

enter image description here

This same technique is used in this Apple sample, but it is unsatisfactory to me because the lines (understandably) are not evenly distributed and we get a nasty truncation at the wrap-around point.

The only (also unsatisfactory) ways I can think of to solve this are:

A) To create the view using two mirrored shapes instead... (but this will still result in truncation... only it will be symmetrical trunctation this time).

B) Making some kind of "sun rays" radial gradient and stack two rounded rectangles in a way that gives the illusion that we have a dotted outline... (but the "heads and tails" of these lines will not be perpendicular to the outline path because the "sun rays" all originate at a single point)

C) Using the parametric equation for a squircle (squircle because a superellipse looks like crap IMO if it is rectangular) (which would need to be cut in half and stretched like limo) and creating a path from it, then drawing the dashes as intermittent lines along that path using t = 2 * (pi) / some divisor to establish the resolution. The trouble with this is that the modified (limo-fied) shape would be a piecewise function and therefore cannot be driven by t. Also even if it could be, the dashes would not be regular in size for somewhat obvious reasons I can't put into words.

How can this be done??


Solution

  • The dash will not be truncated if the length of the stroke (perimeter of the shape) is a integer multiple of the dash size.

    Therefore, you need to dynamically adjust the dash size according to the perimeter. See this and this for finding the arc length of parametric and non-parametric curves respectively. If this is too difficult to integrate, also consider calculating the lengths of each CGPathElements in the underlying CGPath for the shape. See this gist (though it's written in Swift 2), or this repo for how to do this.

    In the case of the rounded rectangle with a corner radius, you can simply add up the lengths of the straight parts and the circular parts.

    .listRowBackground(
        GeometryReader { geo in
            let strokeWidth: CGFloat = 4
            let radius: CGFloat = 15
            let insettedDiameter = radius * 2 - strokeWidth
            let desiredDash: CGFloat = 10
            // note that we need to take the inset of strokeBorder into account
            // or just use stroke instead of strokeBorder to make this simpler
            let perimeter = (geo.size.width - strokeWidth / 2 - insettedDiameter) * 2 + // horizontal straight edges
                            (geo.size.height - strokeWidth / 2 - insettedDiameter) * 2 + // vertical straight edges
                            (insettedDiameter * .pi) // the circular parts
    
            // this finds the smallest adjustedDash such that
            // - perimeter is an integer multiple of adjustedDash
            // - adjustedDash > desiredDash
            let floored = floor(perimeter / desiredDash)
            let adjustedDash = (perimeter - desiredDash * floored) / floored + desiredDash
            
            RoundedRectangle(cornerRadius: radius)
                .strokeBorder(style: StrokeStyle(lineWidth: strokeWidth, dash: [adjustedDash]))
        }
    )
    

    Example output:

    enter image description here