skip to Main Content

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??

2

Answers


  1. Chosen as BEST ANSWER

    For anyone interested...

    This is an alternate re-usable version where instead of defining a desired dash length, we set a desired number of dashes.

    It also prevents a problematic odd number of dashes.

    struct DottedRoundedRectangle: View {
        
        enum BorderPosition {
            case inset, centered
        }
        
        let evenNumberOfDashes: Int
        let color: Color
        let strokeWidth: Double
        let radius: Double
        let borderPosition: BorderPosition
        
        private var insetDistance: Double {
                switch borderPosition {
                case .centered:
                    return 0
                case .inset:
                    return strokeWidth / 2
                }
            }
        
        var body: some View {
            GeometryReader { geo in
                let w = geo.size.width
                let h = geo.size.height
                
                // an odd number of dashes is bad -- results in back-to-back dashes at wrap around point
                let _evenNumDashes = evenNumberOfDashes + (evenNumberOfDashes % 2)
                
                let horizontalSideLength = (w - (2 * insetDistance)) - (2 * (radius - insetDistance))
                let verticalSideLength = (h - (2 * insetDistance)) - (2 * (radius - insetDistance))
                let cornerLength = Double.pi/2 * (radius - insetDistance)
               
                let perimeter = (horizontalSideLength * 2) + (verticalSideLength * 2) + (cornerLength * 4)
               
                let dashLength:Double = perimeter/Double(_evenNumDashes)
                        
                switch borderPosition {
                case .inset:
                    RoundedRectangle(cornerRadius: radius)
                        .fill(Material.ultraThin)
                        .strokeBorder(style: StrokeStyle(lineWidth: strokeWidth, dash: [dashLength]))
                        .foregroundColor(color)
                case .centered:
                    RoundedRectangle(cornerRadius: radius)
                        .fill(Material.ultraThin)
                        .stroke(color, style: StrokeStyle(lineWidth: strokeWidth, dash: [dashLength]))
                }
               
            }
        }
        
    }
    

  2. 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 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 - radius) * 2 + // horizontal straight edges
                            (geo.size.height - strokeWidth / 2 - radius) * 2 + // vertical straight edges
                            (2 * .pi * (radius - strokeWidth / 2)) // 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

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