ios – Drawing a SwiftUI speech bubble shape with rounded tip based on screen positioning


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:

enter image description here

Sadly, for the opposite case it doesnt work as it draws the pointer past the middle:

enter image description here

My questions are:

  1. How can I make it point to the exact middle for the case where the speech bubble is located above the other View?
  2. How can I make the tip of the pointer rounded (like this: https://imgur.com/a/kceYOp7)
  3. 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!

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img