Custom Rounded Bordering in SwiftUI
I’m creating a new app using SwiftUI. This time, I made a little special border.
The border should be rounded only on the outside. You might think this could be solved by using overlay
. However, I wanted to provide more special options.
Rounding
To create such a border, I draw a rectangle overlayed on the content.
Image.init(resource: "character-frame-1", ofType: "png")
.overlay {
Color.yellow
}
How to make the view rounded? You might think of using cornerRadius, but it is deprecated now.
Instead, we can use new Method named clipShape
. With this method we can clip views with any shape.
.overlay {
Color.yellow
.clipShape(RoundedRectangle(cornerRadius: 16))
}
However this rounded rectangle can’t clip the content.
Scaling up
To draw it outside of the content, the overlaid rectangle should be larger than the content. Padding
reduces the size of the content, so it can also expand?
.overlay {
Color.yellow
.padding(-20)
Unfortunately, it’s not working, but fixed sizing is working.
.overlay {
Color.yellow
.frame(width: 300)
To fix the size of the rectangle, we should get the width
of the content using GeometryReader. You might already know about it.
.overlay {
GeometryReader(content: { geometry in
Color.yellow
.frame(width: geometry.size.width + borderWidth * 2,
height: geometry.size.height + borderWidth * 2)
.clipShape(RoundedRectangle(cornerRadius: 16))
})
}
Now the rectangle covers the content, but the left corner is still revealed.
To fix this issue, moving the rectangle is required. However, before moving it, let’s make it transparent.
Moving
So how can we move the view in SwiftUI? There are many ways, but I just used a simple offset
.
.overlay {
GeometryReader(content: { geometry in
Color.yellow
...
.offset(x: -borderWidth, y: -borderWidth)
})
}
Masking
The current opacity
is to show where the rectangle is placed. To reveal the content, we need to cut out the area where the content is.
How do we make a hole in the view?
While there isn’t an inverted version of the mask
modifier, there is a mask modifier that works similarly to clipShape
. However, it can take views instead of shapes and apply alpha.
This means we can apply both clipping and alpha to the mask.
.mask(alignment: .center, {
LinearGradient(
colors: [.clear, .black],
startPoint: .leading, endPoint: .trailing
).clipShape(Circle())
})
BlendMode
I couldn’t find a way to cut a hole in the view even with a mask
. Is there no way?
There is a blendMode
graphic modifier. It can merge two graphic contents. The destination
is the background and the source is the new content.
Destination
Source
However, we need to place two contents into a ZStack
, and the compositingGroup
modifier is also required.
ZStack {
Destination
Source
.blendMode(...)
}.compositingGroup()
destinationOut
So which mode can solve our task?
destinationOut
removes the destination where the source is placed, thus I implemented it like this.
ZStack {
Color.yellow
.frame(width: geometry.size.width + borderWidth * 2,
height: geometry.size.height + borderWidth * 2)
.clipShape(RoundedRectangle(cornerRadius: 16))
.opacity(0.5)
Rectangle() // source
.frame(width: geometry.size.width,
height: geometry.size.height)
.border(.red)
.blendMode(.destinationOut)
}
.compositingGroup()
.offset(x: -borderWidth, y: -borderWidth)
Rounded Border
Finally, I attempted to draw a border on the rounded rectangle.
ZStack{ ...
}.compositingGroup()
.border(.black, width: 2)
But the border was not rounded.
So I drawed rounded Rectangle again..
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 2)
.frame(width: geometry.size.width + borderWidth * 2,
height: geometry.size.height + borderWidth * 2)
.offset(x: -borderWidth, y: -borderWidth)
Now the rounded corner is perfect!
Source Color
I removed opacity
from the colored background to see the final result. However, the content is not displayed clearly.
This is caused by the default color of the Shape
. When we draw a Rectangle, its color is not black. It appears transparent.
So I set the color to the rectangle.
Rectangle()
.frame(width: geometry.size.width,
height: geometry.size.height)
.background(.black)
.blendMode(.destinationOut)
Now the content is visible clearly!
Final Code
Image.init(resource: "character-frame-1", ofType: "png")
.overlay {
GeometryReader(content: { geometry in
ZStack {
Color.yellow // destination
.frame(width: geometry.size.width + borderWidth * 2,
height: geometry.size.height + borderWidth * 2)
.clipShape(RoundedRectangle(cornerRadius: 16))
Color.black // source
.frame(width: geometry.size.width,
height: geometry.size.height)
.blendMode(.destinationOut)
}
.compositingGroup()
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 2)
.frame(width: geometry.size.width + borderWidth * 2,
height: geometry.size.height + borderWidth * 2)
}).offset(x: -borderWidth, y: -borderWidth)
}
Custom Modifier
I created a custom modifier to reuse this later. There is already a built-in border
. So I named my modifier roundedBorder
.
extension View {
public func roundedBorder
The built-in border
uses ShapeStyle
as content,
but our roundedBorder
uses View
.
borderContent
.frame(width: geometry.size.width + borderSpace,
height: geometry.size.height + borderSpace)
However, a struct
can’t have a protocol property without being generic. So I made a property to store Border Content with AnyView
.
struct RoundedBorderModifier: ViewModifier {
let borderContent: AnyView
You might not be familiar with what a ViewModifier
is, but I won’t delve into it here.
struct RoundedBorderModifier: ViewModifier {
var width: CGFloat = 1
let borderContent: AnyView
let cornerRadius: CGFloat
Border Style
I also wanted to draw a thin border in addition to the thick border, and the border lines would have different colors and widths.
public struct RoundedBorderStyle<S: ShapeStyle> {
var content: S
var width: CGFloat
}
Why RoundedBorderStyle is generic? Because ShapeStyle
is protocol
and has associatedtype
.
AnyShapeStyle
can be used to stroke
or border
, but it is impossible to cast Color to AnyShapeStyle
.
I defined borderStyle members.
struct RoundedBorderModifier<S: ShapeStyle>: ViewModifier {
...
let insideStyle: RoundedBorderStyle<S>
let outsideStyle: RoundedBorderStyle<S>
This strategy leads to an unknown error if the content types of border style are different.
So we should separate them into two generic types: IN_S
for inside border, OUT_S
for outside border.
public func roundedBorder<IN_S, OUT_S>(_ content: AnyView,
...
in insideStyle: RoundedBorderStyle<IN_S> = DefaultRoundedBorderStyle,
out outsideStyle: RoundedBorderStyle<OUT_S> = DefaultRoundedBorderStyle)
-> some View where IN_S: ShapeStyle, OUT_S: ShapeStyle {
...
struct RoundedBorderModifier<IN_S: ShapeStyle, OUT_S: ShapeStyle>: ViewModifier {
...
let insideStyle: RoundedBorderStyle<IN_S>
let outsideStyle: RoundedBorderStyle<OUT_S>
Position
The rounded border is currently drawn outside of the view.
However, I wanted to add an option to draw it in the center or inside, so I appended a position parameter. outside
will be the default value.
public enum RoundedBorderPosition {
case inside
case center
case outside
}
struct RoundedBorderModifier: ViewModifier {
...
var position: RoundedBorderPosition = .center
Then, I implemented functions to calculate the size of borders based on the border position.
extension RoundedBorderModifier {
func makePositionedBorderSize(_ size: CGSize) -> CGSize {
return switch position {
case .outside: CGSize.init(width: size.width + width * 2, height: size.height + width * 2)
case .inside: size
case .center: CGSize.init(width: size.width + width, height: size.height + width)
}
}
func makePositionedOutsideBorderSize(_ size: CGSize) -> CGSize {
return switch position {
case .outside: CGSize.init(width: size.width + width * 2, height: size.height + width * 2)
case .inside: size
case .center: CGSize.init(width: size.width + width, height: size.height + width)
}
}
func makePositionedinsideBorderSize(_ size: CGSize) -> CGSize {
return switch position {
case .outside: size
case .inside: CGSize.init(width: size.width - width * 2, height: size.height - width * 2)
case .center: CGSize.init(width: size.width - width, height: size.height - width)
}
}
}
With these functions, I created sizes and applied them to each frame.
return content
.overlay {
GeometryReader(content: { geometry in
let borderSize = makePositionedBorderSize(geometry.size)
let outsideBorderSize = makePositionedOutsideBorderSize(geometry.size)
let insideBorderSize = makePositionedinsideBorderSize(geometry.size)
ZStack {
borderContent // desitination
.frame(width: borderSize.width,
height: borderSize.height)
...
Rectangle() // source
...
.frame(width: insideBorderSize.width,
height: insideBorderSize.height)
}
.compositingGroup()
RoundedRectangle(cornerRadius: cornerRadius)
...
.frame(width: outsideBorderSize.width,
height: outsideBorderSize.height)
})
}
Inside Corner Radius
Wait.. I missed one thing. What if we want to make a rounded inner border? To provide an option for it, I created a new property.
public struct RoundedBorderStyle<S: ShapeStyle> {
...
var cornerRadius: CGFloat = -1
}
The inside border’s corner radius should be shorter than the outside.
Color.black // source
...
.clipShape(RoundedRectangle(cornerRadius: makeInsideCornerRadius()))
...
extension RoundedBorderModifier {
func makeInsideCornerRadius() -> CGFloat {
return if insideStyle.cornerRadius > 0 { insideStyle.cornerRadius }
else{ cornerRadius }
}
}
Since the border
doesn’t follow clipShape
, I had to draw the border using overlay again.
GeometryReader(content: { geometry in
...
let insideCornerRadius = makeInsideCornerRadius()
ZStack {
...
Color.black // source
...
.clipShape(RoundedRectangle(cornerRadius: insideCornerRadius))
.overlay {
RoundedRectangle(cornerRadius: insideCornerRadius)
.stroke(insideBorderContent)
.frame(width: insideBorderSize.width,
height: insideBorderSize.height)
}
Why doesn’t Apple support borders that follow the shape? I don’t understand.
This looks a little bit unnatural. 🧐 The inside corner should be narrower. But how do we calculate the proper radius?
Let’s reduce the outside border to clearly see the boundary. The distance from the inside corner to the outside seems longer than the border width.
When I give 8 to the inner corner as the radius, it looks good.
However, the corner distance is still longer than the border width.
It seemed strange, so I looked into how the built-in border works using the opacity.
And I guessed the radius is based on the center of the border.
Swift Package
I made RoundedBorder
as Swift Package so you can see full source in the repository.
If you found any help in this post, please give it a round of applause 👏. Explore more iOS-related content in my other posts.
For additional insights and updates, check out my LinkedIn profile. Thank you for your support!
References
https://www.hackingwithswift.com/quick-start/swiftui/how-to-round-the-corners-of-a-view