Custom Rounded Bordering in SwiftUI

Lee young-jun
9 min readMay 8, 2024

--

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

https://www.fivestars.blog/articles/reverse-masks-how-to/

--

--

No responses yet