This is the continuation (and hopefully final) post to this
Basically I need to draw a Custom speech bubble shape that points to the exact middle of another view which is positioned via the position
modifier using a CGRect
. The speech bubble should be positioned below the first View if its located in the upper portion of the screen and above otherwise (while adjusting the pointer to the aproppiate location, of course).
My current code is this:
Custom Shape:
struct SpeechBubbleShape: Shape {
var cornerRadius: CGFloat
let testFrame: CGRect
let isCloserToTop: Bool
func path(in rect: CGRect) -> Path {
var path = Path()
// We have 20pts horizontal padding, thus 40 total. The triangle is 10px height, so the triangle should start - 30px before testFrame.midX.
let xOffset = testFrame.midX - 30
let minX = rect.minX
let minY = rect.minY
let maxX = rect.maxX
let maxY = rect.maxY
let width = rect.width
let height = rect.height
let cornerRadius = min(cornerRadius, min(width, height) / 2.0)
if isCloserToTop {
//This seems to work fine and positions the tip on the exact center of the other view.
//positions the drawing brush on origin + corner radius
path.move(to: CGPoint(x: minX + cornerRadius, y: minY))
//Draws line up until triangle start
path.addLine(to: .init(x: xOffset, y: minY))
//Draws left side of triangle (up)
path.addLine(to: .init(x: xOffset + 10, y: minY - 10))
//Draws right side of triangle (down)
path.addLine(to: .init(x: xOffset + 20, y: minY))
//Draws line up until the start of the first arc
path.addLine(to: CGPoint(x: maxX - cornerRadius, y: minY))
//Draws top trailing arc
path.addArc(center: CGPoint(x: maxX - cornerRadius, y: minY + cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
//Draws line up until the start of the bottom trailing arc
path.addLine(to: CGPoint(x: maxX, y: maxY - cornerRadius))
//Draws bottom trailing arc
path.addArc(center: CGPoint(x: maxX - cornerRadius, y: maxY - cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
//Draws line up until the start of bottom leading arc
path.addLine(to: CGPoint(x: minX + cornerRadius, y: maxY))
//Draws bottom leading arc
path.addArc(center: CGPoint(x: minX + cornerRadius, y: maxY - cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
// Draws line up until top leading arc
path.addLine(to: CGPoint(x: minX, y: minY + cornerRadius))
// Draws bottom leading arc
path.addArc(center: CGPoint(x: minX + cornerRadius, y: minY + cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false)
return path
} else {
// if we enter in this else clause, the speech bubble will be drawn above the first view and therefore the pointer should be drawn on the bottom part of the shape and pointing down. This implementation doesnt work as it draws the pointer not in the exact middle but a little bit further.
path.move(to: .init(x: minX + cornerRadius, y: minY))
path.addLine(to: .init(x: maxX - cornerRadius, y: minY))
path.addArc(center: CGPoint(x: maxX - cornerRadius, y: minY + cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
path.addLine(to: CGPoint(x: maxX, y: maxY - cornerRadius))
path.addArc(center: CGPoint(x: maxX - cornerRadius, y: maxY - cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
// This goes past the middle point, sadly.
path.addLine(to: .init(x: xOffset, y: maxY))
path.addLine(to: .init(x: xOffset - 10, y: maxY + 10))
path.addLine(to: .init(x: xOffset - 20, y: maxY))
path.addLine(to: CGPoint(x: minX + cornerRadius, y: maxY))
path.addArc(center: CGPoint(x: minX + cornerRadius, y: maxY - cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
path.addLine(to: CGPoint(x: minX, y: minY + cornerRadius))
path.addArc(center: CGPoint(x: minX + cornerRadius, y: minY + cornerRadius), radius: cornerRadius, startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false)
return path
}
}
}
Actual View:
struct ShapeTest: View {
let testFrame = CGRect(x: 180, y: 500, width: 100, height: 40)
var body: some View {
GeometryReader { g in
let isCloserToTop: Bool = testFrame.minY < g.size.height / 2
let overlayAlignment: Alignment = isCloserToTop ? .top : .bottom
let overlayYOffset: CGFloat = isCloserToTop ? testFrame.maxY + 20 : -g.size.height + testFrame.minY - 20
RoundedRectangle(cornerRadius: 16)
.stroke(.purple, lineWidth: 1)
.frame(width: testFrame.width, height: testFrame.height)
.position(x: testFrame.midX, y: testFrame.midY)
.overlay(alignment: overlayAlignment) {
VStack(alignment: .leading) {
Text("Some title")
Text("Some subtitle")
}
.frame(maxWidth: .infinity)
.padding()
.background {
SpeechBubbleShape(cornerRadius: 16, testFrame: testFrame, isCloserToTop: isCloserToTop)
.foregroundColor(.red)
}
.padding(.horizontal, 20)
.offset(y: overlayYOffset)
}
}
.edgesIgnoringSafeArea(.all)
}
}
#Preview {
ShapeTest()
}
This implementation seems to work fine for the case where the speech bubble needs to be positioned below the first view:
Sadly, for the opposite case it doesnt work as it draws the pointer past the middle:
My questions are:
- How can I make it point to the exact middle for the case where the speech bubble is located above the other View?
- How can I make the tip of the pointer rounded (like this: https://imgur.com/a/kceYOp7)
- Consider the case where the first view is positioned on the side of the screen so the middle point goes beyond the speech bubble width, how can I make the pointer have a maximum offset (something like positioning the pointer to the width of the component minus cornerRadius but not beyond ). The case I mean is this one: https://imgur.com/a/vMQVDuf
Theres a bunch of comments on the Shape code since it might not be super readable at first, but any further doubts do not hesitate to ask!
Learning Paths, while sorta tedious as well, has been a blast so far hah. Any tip is appreciated. Thanks!