Custom PopoverView in SwiftUI — Popover with arrow and rounded corners.(Shape)

Aisultan Askarov
6 min readJan 5, 2023

--

Hi Everyone! While writing a bar chart for my personal project I decided to also implement a period selection using custom popover and share it with you. Now lets learn how to create a custom Popover with arrow in SwiftUI using Shape protocol.

As can be seen on the photos the position, height and the edge where the arrow is placed can be changed, and also the corners of the rectangle.

Making this custom shape in SwiftUI is much easier to both write and maintain. The main point that makes SwiftUI much easier in this case is that UIKit does not have a built-in system for automatically rendering and laying out shapes, which means you will need to handle these tasks manually. instead of creating a Path object, you will need to use Core Graphics functions to draw the path onto the view's context.

To handle the layout of the shape, you will need to override the sizeThatFits(_ size: CGSize) -> CGSize method of your custom view and return the size that the view needs to be in order to fit the shape. You can then use this size when positioning the view within your UI.

Lets start by creating our PopupArrow Shape and set an initializer with the properties it will take to handle setting the arrows position, corner radius, arrowOffset, and height of the arrow:

Create the new swift file called PopupArrow.

struct PopupArrow : Shape {

let cornerRadius: CGFloat
let arrowHeight: CGFloat
let arrowEdge: Edge
let arrowOffset: CGFloat

init(cornerRadius: CGFloat = 6, arrowEdge: Edge = .trailing, arrowHeight: CGFloat = 13, arrowOffset: CGFloat = 25) {
self.cornerRadius = cornerRadius
self.arrowHeight = arrowHeight
self.arrowEdge = arrowEdge
self.arrowOffset = arrowOffset
}

func path(in rect: CGRect) -> Path {
//...
}

}

path(in rect: CGRect) -> Path is a function that belongs to the Shape protocol in SwiftUI. We will draw our shape in path function.

Set up variables that we are gonna be using inside the path():

var rect = rect
var fit: ((CGPoint) -> (CGPoint)) = { point in return point }
let arrow: CGSize = CGSize(width: (arrowHeight/811)*2000, height: arrowHeight)

var clockwise = false
var arc1 = (start: Angle.radians(-.pi*0.5), end: Angle.radians(.pi*0.0))
var arc2 = (start: Angle.radians(.pi*0.0), end: Angle.radians(.pi*0.5))
var arc3 = (start: Angle.radians(.pi*0.5), end: Angle.radians(.pi*1.0))
var arc4 = (start: Angle.radians(.pi*1.0), end: Angle.radians(-.pi*0.5))

var path = Path()

if arrowEdge == .leading || arrowEdge == .trailing {
clockwise = true
rect = CGRect(x: rect.origin.y, y: rect.origin.x, width: rect.height, height: rect.width)
fit = { point in return CGPoint(x: point.y, y: point.x)}
let newArc1 = (arc3.end, arc3.start)
let newArc2 = (arc2.end, arc2.start)
let newArc3 = (arc1.end, arc1.start)
let newArc4 = (arc4.end, arc4.start)
arc1 = newArc1; arc2 = newArc2; arc3 = newArc3; arc4 = newArc4
}

Here we are drawing our initial rectangle and if the arrowEdge is set to .leading/.trailing we are swapping the end and start points of arcs and changing clockwise variable to true to flip the rectangle. The fit function is used to transform the points of the path to their correct positions in the rotated rectangle.

Now we have to transform our rectangle by adding lines and curves to implement our arrow.

// Move to beginning of Arc 1

rect = CGRect(x: rect.origin.x + arrowOffset, y: rect.origin.y, width: rect.width, height: rect.height)

path.move(to: fit(CGPoint(x: rect.width*0.5 + arrow.width*0.5, y: arrow.height)) )

// Step 1 (arc1)
path.addArc(center: fit(CGPoint(x: rect.width - cornerRadius, y: cornerRadius + arrow.height)),
radius: cornerRadius,
startAngle: arc1.start,
endAngle: arc1.end,
clockwise: clockwise )
// Step 2 (arc2)
path.addArc(center: fit(CGPoint(x: rect.width - cornerRadius, y: rect.height - cornerRadius)),
radius: cornerRadius,
startAngle: arc2.start,
endAngle: arc2.end,
clockwise: clockwise )
// Step 3 (arc3)
path.addArc(center: fit(CGPoint(x: cornerRadius, y: rect.height - cornerRadius)),
radius: cornerRadius,
startAngle: arc3.start,
endAngle: arc3.end,
clockwise: clockwise )
// Step 4 (arc4)
path.addArc(center: fit(CGPoint(x: cornerRadius, y: cornerRadius + arrow.height)),
radius: cornerRadius,
startAngle: arc4.start,
endAngle: arc4.end,
clockwise: clockwise )

// arrow points where x = distance from arrow center, y = distance from top of rect
let apex = CGPoint(x: arrow.width*0.5*0.000, y: -arrow.height*0.1456)
let peak = CGPoint(x: arrow.width*0.5*0.149, y: arrow.height*0.0864)
let curv = CGPoint(x: arrow.width*0.5*0.600, y: arrow.height*0.7500)
let ctrl = CGPoint(x: arrow.width*0.5*0.750, y: arrow.height*1.0000)
let base = CGPoint(x: arrow.width*0.5*1.000, y: arrow.height*1.0000)

// Step 5
path.addLine(to: fit(CGPoint(x: rect.midX - base.x, y: base.y)))

// Step 6
path.addQuadCurve(to: fit(CGPoint(x: rect.midX - curv.x, y: curv.y)),
control: fit(CGPoint(x: rect.midX - ctrl.x, y: ctrl.y)))

// Step 7
path.addLine(to: fit(CGPoint(x: rect.midX - peak.x, y: peak.y)))

// Step 8
path.addQuadCurve(to: fit(CGPoint(x: rect.midX + peak.x, y: peak.y)),
control: fit(CGPoint(x: rect.midX + apex.x, y: apex.y)))

// Step 9
path.addLine(to: fit(CGPoint(x: rect.midX + curv.x, y: curv.y)))

// Step 10
path.addQuadCurve(to: fit(CGPoint(x: rect.midX + base.x, y: base.y)),
control: fit(CGPoint(x: rect.midX + ctrl.x, y: ctrl.y)))

var transform = CGAffineTransform(scaleX: 1, y: 1)
let bounds = path.boundingRect
if arrowEdge == .trailing {
// flip horizontally
transform = CGAffineTransform(scaleX: -1, y: 1)
transform = transform.translatedBy(x: -bounds.width, y: 0)
}
if arrowEdge == .bottom {
// flip vertically
transform = CGAffineTransform(scaleX: 1, y: -1)
transform = transform.translatedBy(x: 0, y: -bounds.height)
}
return path.applying(transform)

The transform variable is used to flip the path horizontally or vertically if the arrow is on the trailing or bottom edge, respectively.

If you want a more in depth explanation of how this custom Shape is drawn, check Aubree's article: https://betterprogramming.pub/cgaffinetransforms-arcs-and-quad-curves-in-swiftui-41e1dbfe6161

Now that we have our Shape lets create the logic for our PopupView.

In your contentView set a rounded Rectangle with HStack using this code:

@State var show: Bool = false

RoundedRectangle(cornerRadius: 10)
.fill(.gray.opacity(0.15))
.frame(height: 270)
.overlay() {

HStack() {

Text("Period:")
.foregroundColor(.black.opacity(0.5))
.font(.system(size: 25, weight: .black, design: .rounded))
.padding(.leading, 20)
.padding(.top, 25)

Spacer()
Button {
show.toggle()
} label: {

if show == true {
Text("\(viewModel.selectedPeriod)")
.foregroundColor(.black.opacity(1.0))
.font(.system(size: 25, weight: .black, design: .rounded))
.minimumScaleFactor(0.5)
} else {
Text("\(viewModel.selectedPeriod) ▼")
.foregroundColor(.black.opacity(1.0))
.font(.system(size: 25, weight: .black, design: .rounded))
.minimumScaleFactor(0.5)

}

}
.padding(.trailing, 20)
.padding(.top, 25)

}
}

Next up wrap the HStack in VStack and use this code to define a view that displays a pop-up menu with a list of time periods (e.g., “12 Days”, “3 Months”, “1 Year”), and present it if show Boolean is set to true:

if viewModel.isPeriodSelectorVisible == true {

PeriodPopOver()
.frame(width: 185, height: 195)
.offset(x: -15, y: 30)
.background(.white)
.clipShape(
PopupArrow(cornerRadius: 10, arrowEdge: .top, arrowHeight: 15, arrowOffset: 57.5)
.offset(x: -15, y: 22.5)
)

}

Now all thats left is to set the PeriodPopOver which will hold our buttons. Create a new swift file and use this code:

struct PeriodPopOver : View {

var periods = ["12 Days", "3 Months", "1 Year"]

var body : some View {

VStack(alignment: .leading, spacing: 11.0) {

Button {
//12 Days
//Some action
} label: {

HStack(spacing: 0) {

Text(periods[0])
.font(.system(size: 15.0, weight: .medium, design: .rounded))
.foregroundColor(.black)
.background(.white)
.multilineTextAlignment(.center)

Spacer()

Image(systemName: "circlebadge")
.resizable()
.scaledToFit()
.foregroundColor(.black.opacity(0.35))
.frame(width: 20, height: 20, alignment: .trailing)
.font(.system(size: 18.0, weight: .regular))


}
}
.padding([.leading, .trailing], 25)

Divider()
.padding([.leading, .trailing], 25)

Button {
//3 Months
//Some action
} label: {

HStack(spacing: 0) {

Text(periods[1])
.font(.system(size: 15.0, weight: .medium, design: .rounded))
.foregroundColor(.black)
.background(.white)
.multilineTextAlignment(.center)

Spacer()

Image(systemName: "circlebadge")
.resizable()
.scaledToFit()
.foregroundColor(.black.opacity(0.35))
.frame(width: 20, height: 20, alignment: .trailing)
.font(.system(size: 18.0, weight: .regular))

}
}
.padding([.leading, .trailing], 25)

Divider()
.padding([.leading, .trailing], 25)

Button {
//6 Months
//Some action
} label: {

HStack(spacing: 0) {

Text(periods[2])
.font(.system(size: 15.0, weight: .medium, design: .rounded))
.foregroundColor(.black)
.background(.white)
.multilineTextAlignment(.center)

Spacer()

Image(systemName: "circlebadge")
.resizable()
.scaledToFit()
.foregroundColor(.black.opacity(0.35))
.frame(width: 20, height: 20, alignment: .trailing)
.font(.system(size: 18.0, weight: .regular))

}
}
.padding([.leading, .trailing], 25)

}//: VSTACK
.frame(width: 185, height: 180)
.background(.white)
.padding()

}

}

Here we are setting a vertical stack of three button views, with a divider in between each one. Each button view has a label, which is a horizontal stack containing text and an image. The text displays one of the options from the periods array, and the image is a circle badge. You can set your logic for what should happen when the button is pressed, for your use case.

I hope this saved someones day!:)

--

--