Preamble
Honestly, I never thought I’d need to write an entire article on borders in SwiftUI, but this week at work, I was tasked with updating the UI of some photo selection screens. Essentially, I would need to add a border to selected images. One of the requirements was that the border needed to be able to handle different corner types, rounded and square, but ideally would also be open for extension.
As I dove into implementing the UI, I quickly realized that choosing the right modifier for building a border/outline requires a bit more consideration - a bit more than slapping .border() onto each view.
In this article, I’ll go over modifiers available for creating borders in SwiftUI with some examples. At the end, I’ll share the custom view modifier I created at work to meet the requirements above.
Key Modifiers for Borders in SwiftUI
SwiftUI provides several modifiers for creating borders, each suited to different use cases. Let’s go through the most common ones and check out their behavior.
.border(_:width:)
The .border(_:width:) modifier is the simplest way to add a border around a view. It draws the border inside the frame of the view and defaults to a width of 1 pixel when no value is provided.
VStack(spacing: 16) {
Rectangle()
.frame(width: 100, height: 100)
.border(Color.blue, width: 4)
RoundedRectangle(cornerRadius: 20)
.frame(width: 100, height: 100)
.border(Color.green)
Circle()
.frame(width: 100, height: 100)
.border(Color.purple, width: 6)
Text("This is a sentence with a border.")
.padding()
.border(Color.red)
Image(systemName: "eyes")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.padding()
.border(Color.yellow, width: 12)
}
At first glance, .border() seems simple, but it comes with a pretty significant limitation: it only outlines the frame of a view, not the actual shape. This is why, in the example above, the RoundedRectangle and Circle views don’t have borders that follow their curves. Instead, the border is applied to their rectangular frames.
From the docs:
Use this modifier to draw a border of a specified width around the view’s frame.
Of course there are cases where that is adequate, but I would like a to know a solution that could border the Shape.
This leads to the next modifier.
.stroke(_:style:)
and .stroke(lineWidth:)
The .stroke() modifiers are specifically designed for views that conform to the Shape protocol. They add an outline (or stroke) that matches the shape of the view.
You will notice one of the modifiers has a parameter, style
. This refers to StrokeStyle. You can customize the stroke quite extensively using the following parameters.
var lineWidth: CGFloat
The width of the stroked path.
var lineCap: CGLineCap
The endpoint style of a line.
var lineJoin: CGLineJoin
The join type of a line.
var miterLimit: CGFloat
A threshold used to determine whether to use a bevel instead of a miter at a join.
var dash: [CGFloat]
The lengths of painted and unpainted segments used to make a dashed line.
var dashPhase: CGFloat
How far into the dash pattern the line starts.
I will only use lineWidth in this article as StrokeStyle is beyond its scope.
Let's give the .stroke() modifiers a whirl.
VStack {
Rectangle()
.stroke(.blue, lineWidth: 4)
.frame(width: 100, height: 100)
RoundedRectangle(cornerRadius: 20)
.stroke(.green, lineWidth: 4)
.frame(width: 100, height: 100)
Circle()
.stroke(.black, style: StrokeStyle(lineWidth: 6))
.frame(width: 100, height: 100)
}
You have probably noticed 2 things.
- Where is Text() and Image()
- .stroke() is written before .frame()
Let me address each one.
- Unfortunately stroke can only be used with views that conform to Shape. It doesn’t apply to standard views like Text or Image. If you are looking to add a rounded borders or circular border to a view, I suggest using
.overlay
. Let me show you an example of a rounded corner border around Text and a circular border around an Image.
VStack(spacing: 16) {
Text("This is a sentence with a border.")
.padding()
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(.yellow, lineWidth: 4)
}
Image(systemName: "eyes")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.padding(32)
.overlay {
Circle()
.stroke(.purple, lineWidth: 4)
}
}
2.The .stroke modifier applies a stroke to a Shape. However, if you apply a .frame modifier before calling .stroke, the Shape is converted into a generic View. Since View does not have a .stroke modifier, attempting to call .stroke after .frame results in an error: Value of type 'some View' has no member 'stroke'
.
In these cases it is always important to remember that view modifiers are called sequentially from top to bottom - messing up the order can cause unexpected behavior.
One More Thing
There is actually one more issue. The following code will make it clear.
Rectangle()
.fill(.white)
.border(.red, width: 1)
.frame(width: 150, height: 150)
Here is Rectangle with a 1 pixel border, representing its frame.
Ok let's now add a blue stroke with 15 pixels
Rectangle()
.stroke(.blue, lineWidth: 15)
.border(.red, width: 1)
.frame(width: 150, height: 150)
Hmm.. stroke goes beyond the views frame???
The explanation is ridiculously simple: .stroke draws its border centered on the edge of the shape’s frame, meaning the stroke grows both outward and inward from the edge. On the other hand, .border only draws the border inside the view’s frame. So even if I made the border 15 pixels as well it would add 15 pixels with in the view.
Thanks to .stroke's behavior, we get to learn about one more key modifier.
.strokeBorder(_:lineWidth:antialiased:)
This modifier is very similar to stroke, except it draws within the frame - it acts similar to .border.
let's contrast it to stroke to see how it works.
HStack(spacing: 24) {
Rectangle()
.strokeBorder(.blue, lineWidth: 15)
.border(.red)
Rectangle()
.stroke(.blue, lineWidth: 15)
.border(.red)
}
.frame(height: 150)
.padding()
Ok. So there you go that is the general overview of most use cases using border and stroke. Let's jump into what I did at work.
What did I do at work?
At work I was tasked with creating a image selection modifier for the two screens below. Pretend the blue are photos, the first one similar to photos app and the second one to choose panorama images.
Screen 1 Before
Screen 2 Before
Essentially I had to build below:
- Add a 2-pixel border to selected images.
- Handle both rounded and square corners.
- Add a semi-transparent black mask when selected.
- Display a checkmark in the bottom-right corner when selected.
Here is what it looks like:
Screen 1 After
Screen 2 After
Here’s how I implemented it using .strokeBorder(), .overlay() and a custom ViewModifier:
struct SelectedImageStyleModifier: ViewModifier {
enum CornerStyle {
case rounded(CGFloat)
case square
}
private static let iconSize = CGSize(width: 20, height: 20)
private static let frameSize = CGSize(width: 24, height: 24)
private static let opacity: Double = 0.15
private static let borderWidth: CGFloat = 2
private static let roundedCornerRadius: CGFloat = 8
let isSelected: Bool
let cornerStyle: CornerStyle
func body(content: Content) -> some View {
content
.overlay {
switch cornerStyle {
case .rounded(let cornerRadius):
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? Color.red : Color.clear, lineWidth: Self.borderWidth)
case .square:
Rectangle()
.strokeBorder(isSelected ? Color.red : Color.clear, lineWidth: Self.borderWidth)
}
}
.overlay {
if isSelected {
switch cornerStyle {
case .rounded(let cornerRadius):
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.black.opacity(Self.opacity))
case .square:
Rectangle()
.fill(Color.black.opacity(Self.opacity))
}
}
}
.overlay(alignment: .bottomTrailing) {
if isSelected {
ZStack {
Circle()
.fill(.white)
.frame(size: Self.frameSize)
Image(systemName: "checkmark.circle.fill")
.resizable()
.scaledToFit()
.foregroundStyle(Color.red)
.frame(size: Self.iconSize)
}
.padding(8)
}
}
}
}
extension View {
func selectedImageStyle(
isSelected: Bool,
cornerStyle: SelectedImageStyleModifier.CornerStyle
) -> some View {
self.modifier(
SelectedImageStyleModifier(
isSelected: isSelected,
cornerStyle: cornerStyle
)
)
}
func frame(size: CGSize) -> some View {
self.frame(width: size.width, height: size.height)
}
}
Note that the .rounded case takes CGFloat as an associated value so we can use this modifier with any corner radius.
Below is the Mock Photo views' code.
struct MockPhotoGrid: View {
private let numbers: [Int] = Array(1...20)
@State private var selectedNumbers: [Int] = []
private static let spacing: CGFloat = 2
private let columns: [GridItem] = Array(repeating: .init(.flexible(), spacing: Self.spacing), count: 3)
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: Self.spacing) {
ForEach(numbers, id: \.self) { num in
Button {
select(num)
} label: {
Rectangle()
.frame(height: 100)
.selectedImageStyle(isSelected: selectedNumbers.contains(num), cornerStyle: .square)
}
}
}
}
}
private func select(_ num: Int) {
if selectedNumbers.contains(num) {
selectedNumbers.removeAll(where: { $0 == num })
} else {
selectedNumbers.append(num)
}
}
}
struct MockPanoramaView: View {
private let numbers: [Int] = Array(1...20)
@State private var selectedNumbers: [Int] = []
private static let spacing: CGFloat = 16
private static let cornerRadius: CGFloat = 8
var body: some View {
ScrollView {
LazyVStack(spacing: Self.spacing) {
ForEach(numbers, id: \.self) { num in
Button {
select(num)
} label: {
Rectangle()
.frame(height: 100)
.cornerRadius(Self.cornerRadius)
.selectedImageStyle(isSelected: selectedNumbers.contains(num), cornerStyle: .rounded(Self.cornerRadius))
}
}
}
}
.padding()
}
private func select(_ num: Int) {
if selectedNumbers.contains(num) {
selectedNumbers.removeAll(where: { $0 == num })
} else {
selectedNumbers.append(num)
}
}
}
Conclusion
Borders in SwiftUI are cheeky. While .border() works for simple cases, .stroke() and .strokeBorder() offer the precision needed for more advanced designs. Combining these techniques with .overlay() lets you handle a wide range of use cases, from shapes to views.
Thanks for checking out the post. If you enjoyed the article, feel free to give it a like!
Dean Thompson
Follow me!
LinkedIn
Twitter
Instagram
Top comments (0)