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?
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).
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
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
.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)
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.
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.
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")
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)
}