Today we will build chat bubble custom shape in SwiftUI. Built shape can easily be integrated into any chat view. Here is how the chat bubble will look like.
We will start with a custom Shape with name ChatBubbleShape. This struct will conform to the Shape protocol.
Shape protocol is used to draw 2D shapes that we can use when drawing a view. The protocol requires path function to be implemented which describes the shape as a path within a rectangular frame of reference.
struct ChatBubbleShape: Shape
We will create an enum to indicate chat bubble direction. For incoming message, our bubble’s tail will point in bottom left direction. For outgoing message bubble’s tail will point in bottom right direction.
enum Direction {
case left
case right
}
We will put two helper functions together to draw left and right bubble shapes.
Here is how left side of path will look like
private func leftBubble(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
let path = Path { p in
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: width - 20, y: height))
p.addCurve(to: CGPoint(x: width, y: height - 20),
control1: CGPoint(x: width - 8, y: height),
control2: CGPoint(x: width, y: height - 8))
p.addLine(to: CGPoint(x: width, y: 20))
p.addCurve(to: CGPoint(x: width - 20, y: 0),
control1: CGPoint(x: width, y: 8),
control2: CGPoint(x: width - 8, y: 0))
p.addLine(to: CGPoint(x: 21, y: 0))
p.addCurve(to: CGPoint(x: 4, y: 20),
control1: CGPoint(x: 12, y: 0),
control2: CGPoint(x: 4, y: 8))
p.addLine(to: CGPoint(x: 4, y: height - 11))
p.addCurve(to: CGPoint(x: 0, y: height),
control1: CGPoint(x: 4, y: height - 1),
control2: CGPoint(x: 0, y: height))
p.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
p.addCurve(to: CGPoint(x: 11.0, y: height - 4.0),
control1: CGPoint(x: 4.0, y: height + 0.5),
control2: CGPoint(x: 8, y: height - 1))
p.addCurve(to: CGPoint(x: 25, y: height),
control1: CGPoint(x: 16, y: height),
control2: CGPoint(x: 20, y: height))
}
return path
}
Right side path will look like this:
private func rightBubble(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
let path = Path { p in
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: 20, y: height))
p.addCurve(to: CGPoint(x: 0, y: height - 20),
control1: CGPoint(x: 8, y: height),
control2: CGPoint(x: 0, y: height - 8))
p.addLine(to: CGPoint(x: 0, y: 20))
p.addCurve(to: CGPoint(x: 20, y: 0),
control1: CGPoint(x: 0, y: 8),
control2: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: width - 21, y: 0))
p.addCurve(to: CGPoint(x: width - 4, y: 20),
control1: CGPoint(x: width - 12, y: 0),
control2: CGPoint(x: width - 4, y: 8))
p.addLine(to: CGPoint(x: width - 4, y: height - 11))
p.addCurve(to: CGPoint(x: width, y: height),
control1: CGPoint(x: width - 4, y: height - 1),
control2: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
p.addCurve(to: CGPoint(x: width - 11, y: height - 4),
control1: CGPoint(x: width - 4, y: height + 0.5),
control2: CGPoint(x: width - 8, y: height - 1))
p.addCurve(to: CGPoint(x: width - 25, y: height),
control1: CGPoint(x: width - 16, y: height),
control2: CGPoint(x: width - 20, y: height))
}
return path
}
With these two helper functions in place, our path function implementation will become easy.
func path(in rect: CGRect) -> Path {
return (direction == .left) ? leftBubble(in: rect) : rightBubble(in: rect)
}
Here is how complete code for shape should look like:
struct ChatBubbleShape: Shape {
enum Direction {
case left
case right
}
let direction: Direction
func path(in rect: CGRect) -> Path {
return (direction == .left) ? leftBubble(in: rect) : rightBubble(in: rect)
}
private func leftBubble(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
let path = Path { p in
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: width - 20, y: height))
p.addCurve(to: CGPoint(x: width, y: height - 20),
control1: CGPoint(x: width - 8, y: height),
control2: CGPoint(x: width, y: height - 8))
p.addLine(to: CGPoint(x: width, y: 20))
p.addCurve(to: CGPoint(x: width - 20, y: 0),
control1: CGPoint(x: width, y: 8),
control2: CGPoint(x: width - 8, y: 0))
p.addLine(to: CGPoint(x: 21, y: 0))
p.addCurve(to: CGPoint(x: 4, y: 20),
control1: CGPoint(x: 12, y: 0),
control2: CGPoint(x: 4, y: 8))
p.addLine(to: CGPoint(x: 4, y: height - 11))
p.addCurve(to: CGPoint(x: 0, y: height),
control1: CGPoint(x: 4, y: height - 1),
control2: CGPoint(x: 0, y: height))
p.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
p.addCurve(to: CGPoint(x: 11.0, y: height - 4.0),
control1: CGPoint(x: 4.0, y: height + 0.5),
control2: CGPoint(x: 8, y: height - 1))
p.addCurve(to: CGPoint(x: 25, y: height),
control1: CGPoint(x: 16, y: height),
control2: CGPoint(x: 20, y: height))
}
return path
}
private func rightBubble(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
let path = Path { p in
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: 20, y: height))
p.addCurve(to: CGPoint(x: 0, y: height - 20),
control1: CGPoint(x: 8, y: height),
control2: CGPoint(x: 0, y: height - 8))
p.addLine(to: CGPoint(x: 0, y: 20))
p.addCurve(to: CGPoint(x: 20, y: 0),
control1: CGPoint(x: 0, y: 8),
control2: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: width - 21, y: 0))
p.addCurve(to: CGPoint(x: width - 4, y: 20),
control1: CGPoint(x: width - 12, y: 0),
control2: CGPoint(x: width - 4, y: 8))
p.addLine(to: CGPoint(x: width - 4, y: height - 11))
p.addCurve(to: CGPoint(x: width, y: height),
control1: CGPoint(x: width - 4, y: height - 1),
control2: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
p.addCurve(to: CGPoint(x: width - 11, y: height - 4),
control1: CGPoint(x: width - 4, y: height + 0.5),
control2: CGPoint(x: width - 8, y: height - 1))
p.addCurve(to: CGPoint(x: width - 25, y: height),
control1: CGPoint(x: width - 16, y: height),
control2: CGPoint(x: width - 20, y: height))
}
return path
}
}
We can use the shape directly, but we can also build a generic view which will take direction and content as parameter.
This is where we will use our custom shape to clip our view to shape the bubble shape.
struct ChatBubble<Content>: View where Content: View {
let direction: ChatBubbleShape.Direction
let content: () -> Content
init(direction: ChatBubbleShape.Direction, @ViewBuilder content: @escaping () -> Content) {
self.content = content
self.direction = direction
}
var body: some View {
HStack {
if direction == .right {
Spacer()
}
content()
.clipShape(ChatBubbleShape(direction: direction))
if direction == .left {
Spacer()
}
}.padding([(direction == .left) ? .leading : .trailing, .top, .bottom], 20)
.padding((direction == .right) ? .leading : .trailing, 50)
}
}
Our view is ready to be used. Here is how we can use this custom shape and generic view around that shape.
struct DevTechieChatBubbleExample: View {
var body: some View {
NavigationStack {
VStack {
ScrollView {
VStack {
ChatBubble(direction: .left) {
Text("Hello DevTechie!")
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.green)
}
ChatBubble(direction: .left) {
Text("Do you have any video courses?")
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.green)
}
ChatBubble(direction: .right) {
Text("Yeah for sure, please checkout DevTechie.com")
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.blue)
}
ChatBubble(direction: .left) {
Text("Thanks! Will check it out soon.")
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.green)
}
ChatBubble(direction: .left) {
Text("Can you send your logo for branding?")
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.green)
}
ChatBubble(direction: .right) {
VStack {
Image("DT")
Text("Here you go 👆")
}
.padding(.all, 20)
.foregroundColor(Color.white)
.background(Color.blue)
}
}
}
.navigationTitle("DevTechie Chat")
HStack {
TextField("Enter message here", text: .constant(""))
.textFieldStyle(.roundedBorder)
Button(action: {}) {
Image(systemName: "paperplane")
}
}
.padding()
}
}
}
}
Build and run: