Search code examples
swiftswiftuibuttonuibutton

SwiftUI: How to break button border for a label overlay?


I’m trying to create a button in SwiftUI that has a rounded border and includes an additional label (e.g., “Save 50%”) positioned at the top-left. However, I need the border to “break” where this label appears, similar to the design in the attached image. enter image description here

So far, I’ve tried using overlay with GeometryReader to position the label over the border, but the stroke remains continuous behind it. Here’s my current implementation:

ZStack(alignment: .topLeading) {
    RoundedRectangle(cornerRadius: 20)
        .stroke(Color.black, lineWidth: 1)
        .background(Color.white)
        .cornerRadius(20)
    
    HStack(spacing: 4) {
        Image(systemName: "exclamationmark.circle")
            .resizable()
            .frame(width: 12, height: 12)
        Text("Save 50%")
            .font(.caption)
            .foregroundColor(.black)
            .background(Color.white)
    }
    .padding(.leading, 8)
}
.frame(height: 56)

However, this doesn’t actually break the stroke, and the black border still appears behind the label. How can I achieve a true border gap effect where the stroke is interrupted?


Solution

  • As a complement to @Sweeper's answer, here are some suggestions and an alternate version that doesn't use a ZStack.

    When you apply .cornerRadius (which is deprecated by the way) to a RoundedRectangle, you're effectively clipping the white background to a rounded rectangle, which makes the actual rectangle's border appear thinner than the actual lineWidth value (of 1, in this case).

    Avoid unnecessary styling and deprecated modifiers

    Instead, remove .cornerRadius and simply assign a proper .lineWidth value if you actually want it to be thinner than 1. Otherwise, .cornerRadius is not even needed:

    RoundedRectangle(cornerRadius: 20)
        .stroke(Color.black, lineWidth: 0.5) // <- use 0.5 instead of 1 to make it thinner if needed
    

    Use scheme-appropriate colors

    By using a .black stroke and .white background, you're effectively breaking the compatibility with dark mode, since you're forcing those values.

    Instead, allow the view to adapt as needed to the color scheme:

    RoundedRectangle(cornerRadius: 20)
        .stroke(Color.primary, lineWidth: 0.5) // <- use Color.primary instead of Color.black so it can adapt to the color scheme
        // .background(Color.white) //Remove this so that the background can adapt to the color scheme
    

    The same applies to the offer text background. In order to cover up the stroked outline, the offer text needs a solid background, but if you force it to .white, it will not adapt to dark mode.

    Instead, simply use .background() (which will be black by default in dark mode and white by default in light mode):

    Text("Save 50%")
        .font(.caption)
        //.foregroundColor(.black) // <- remove (will be black by default in light mode and white by default in dark mode)
        .background() // <- remove color so it adapts to color scheme
    

    Avoid making symbols .resizable()

    When you use .resizable() on a symbol, you're effectively converting it to an image and it stops acting like a symbol (see this article). Instead, use a font style or custom font size:

    Image(systemName: "exclamationmark.circle")
        // .resizable() // <- remove
        // .frame(width: 12, height: 12) // <- remove
        .font(.caption) // <- or .font(.system(size: 12))
    

    Better yet, just use a Label, which can simplify things in various cases:

    Label("Save 50%", systemImage: "exclamationmark.circle")
        .font(.caption)
    

    Let it flow

    Unless absolutely needed, let the layout flow by avoiding to use hard coded frame height values. Just add necessary vertical padding to the content and let the rest fill the width:

    // .frame(height: 56) // <- let it flow and fill the width instead
    .frame(maxWidth: .infinity, alignment: .center)
    

    Now, in your case, you needed to set the frame height so that the rectangle doesn't fill the entire screen, but that's because you used a ZStack, which leads me to the next point.

    Don't abuse ZStack

    For the purposes of this view, a ZStack is not required. All you have is a background (for the stroked rounded rectangle), and some text that appears on top of the background (an overlay, basically). So use a .background modifier to allow the stroked outline to adapt to the size of your content (instead of filling the screen as it would in a ZStack).

    Text("$19.99/month")
        .padding(.vertical)
        .frame(maxWidth: .infinity, alignment: .center)
    
        //Rounded border outline as a background
        .background {
            RoundedRectangle(cornerRadius: 20)
                .stroke(Color.primary, lineWidth: 0.5) 
            
                //Discount offer label as an overlay to the background
                .overlay(alignment: .topLeading) {
                    Label("Save 50%", systemImage: "exclamationmark.circle")
                    // ... additional styling as needed
                }
        }
    

    This makes the layout much clearer and more readable, instead of having a ZStack with four layered views in it and trying to understand what goes on top of what.


    A more dynamic version

    As a peace offering for all these "suggestions", here's the full code that incorporates all the above, enhanced to be reusable by accepting the price and discount as parameters, and then calculating and formatting everything for display.

    It can be simply used like this:

    BorderedLabelView(price: 19.99, discount: 0.50, interval: "month")
    

    Full code:

    import SwiftUI
    
    struct BorderedLabelView: View {
        
        //Parameters - Required
        let price: Double
        let discount: Double
        let interval: String
        
        //Parameters - Optional
        var currencyCode: String = "USD"
        
        //Computed properties
        private var discountedPrice: Double {
            return floor(price * 100 * (1 - discount)) / 100 // prevents rounding of 9.99 to 10.00
        }
        
        //Body
        var body: some View {
            
            HStack {
                //Regular price
                Text(priceFormat(price))
                    .strikethrough()
                    .foregroundStyle(.secondary)
                
                //Discounted price
                Text(priceFormat(discountedPrice))
                    .foregroundStyle(.green)
                    .font(.title2)
            }
            .frame(maxWidth: .infinity, alignment: .center)
            .padding(.vertical)
            
            //Rounded border outline
            .background {
                RoundedRectangle(cornerRadius: 20)
                    .stroke(Color.primary, lineWidth: 0.5) // <- use 0.5 instead of 1 to make it thinner if needed
            
                    //Discount offer label as an overlay to the background
                    .overlay(alignment: .topLeading) {
                        Label("Save **\(discount, format: .percent)**", systemImage: "exclamationmark.circle")
                            .font(.caption)
                            .padding(.horizontal, 5)
                            .background() // <- remove color so it adapts to color scheme
                            .alignmentGuide(.top) { $0[VerticalAlignment.center] }
                            .offset(x: 20)
                    }
            }
        }
        
        //Method that formats a given price for display, based on currency code (e.g. "US$9.99/month)
        private func priceFormat(_ price: Double) -> String {
            return "\(price.formatted(.currency(code: currencyCode)))/\(interval)"
        }
    }
    
    //Preview
    #Preview {
        
        //Light mode
        VStack(spacing: 20) {
            BorderedLabelView(price: 19.99, discount: 0.50, interval: "month")
            BorderedLabelView(price: 50, discount: 0.25, interval: "month")
        }
        .padding()
        .frame(maxHeight: .infinity)
        
        //Dark mode
        VStack(spacing: 20) {
            BorderedLabelView(price: 19.99, discount: 0.50, interval: "month")
            BorderedLabelView(price: 50, discount: 0.25, interval: "month")
        }
        .padding()
        .frame(maxHeight: .infinity)
        .background()
        .environment(\.colorScheme, .dark)
    }
    
    

    ![enter image description here