Skip to content

Instantly share code, notes, and snippets.

@glm4
Created April 2, 2024 17:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save glm4/73acf0684d41e74b02c9f834ddfe26fc to your computer and use it in GitHub Desktop.
Save glm4/73acf0684d41e74b02c9f834ddfe26fc to your computer and use it in GitHub Desktop.
Chat Message Bubbles Styles using SwiftUI Shapes
//
// MessageBubblePath.swift
// Sandbox
//
// Created by German on 2/4/24.
//
import SwiftUI
internal enum BubbleStyle: String, CaseIterable {
case appleiMessage
case thoughtBubbles
case sharpCorner
}
internal enum BubbleTailPosition: CaseIterable {
case topLeft
case topRight
case bottomLeft
case bottomRight
var isAtLeft: Bool {
self == .topLeft || self == .bottomLeft
}
var isAtTop: Bool {
self == .topLeft || self == .topRight
}
}
internal struct BubbleShape: Shape {
var cornerRadius: CGFloat = 16.0
var tailPosition: BubbleTailPosition = .bottomRight
var tailSize: CGFloat = 16.0
var style: BubbleStyle = .appleiMessage
private var isTailAtLeft: Bool { tailPosition.isAtLeft }
private var isTailAtTop: Bool { tailPosition.isAtTop }
func path(in rect: CGRect) -> Path {
switch style {
case .appleiMessage:
return iMessageBubblePath(in: rect)
case .thoughtBubbles:
return thoughtBubblesPath(in: rect)
case .sharpCorner:
return sharpCornerPath(in: rect)
}
}
private func iMessageBubblePath(in rect: CGRect) -> Path {
let baseRect = rect.inset(by: UIEdgeInsets(
top: 0,
left: isTailAtLeft ? tailSize / 2 : 0,
bottom: 0,
right: isTailAtLeft ? 0 : tailSize / 2)
)
let tailRect = CGRect(
x: isTailAtLeft ? 0 : rect.width - tailSize,
y: isTailAtTop ? 0 : rect.height - tailSize,
width: tailSize,
height: tailSize
)
let originX = tailRect.origin.x
let originY = tailRect.origin.y
let startPoint = CGPoint(
x: tailRect.midX,
y: isTailAtTop ? tailRect.maxY : tailRect.minY
)
let midPoint: CGPoint = CGPoint(
x: isTailAtLeft ? originX : tailRect.maxX,
y: isTailAtTop ? originY : tailRect.maxY
)
let endPoint = CGPoint(
x: isTailAtLeft ? tailRect.maxX : originX,
y: isTailAtTop ? tailRect.maxY : originY
)
let controlPoint1 = isTailAtTop
? CGPoint(x: tailRect.midX, y: originY)
: CGPoint(x: tailRect.midX, y: tailRect.maxY)
let controlPoint2 = CGPoint(
x: isTailAtLeft ? tailRect.maxX : originX,
y: isTailAtTop ? originY : tailRect.maxY
)
var tailPath = Path()
tailPath.move(to: startPoint)
tailPath.addQuadCurve(to: midPoint, control: controlPoint1)
tailPath.addQuadCurve(to: endPoint, control: controlPoint2)
tailPath.closeSubpath()
let bubble = Path(roundedRect: baseRect, cornerRadius: cornerRadius)
return bubble.union(tailPath)
}
private func thoughtBubblesPath(in rect: CGRect) -> Path {
let baseRect = rect.inset(by: UIEdgeInsets(
top: 0,
left: isTailAtLeft ? tailSize / 2 : 0,
bottom: 0,
right: isTailAtLeft ? 0 : tailSize / 2)
)
let tailRect = CGRect(
x: isTailAtLeft ? 0 : rect.width - tailSize,
y: isTailAtTop ? 0 : rect.height - tailSize,
width: tailSize,
height: tailSize
)
let smallCircleSize = tailRect.width / 4
let bigCircleRect = tailRect.insetBy(dx: tailSize / 5, dy: tailSize / 5)
let smallCircleYPos = (isTailAtTop ? bigCircleRect.minY : bigCircleRect.maxY) - smallCircleSize / 2
let smallCircle = Path(
ellipseIn: CGRect(
x: isTailAtLeft ? tailRect.minX : tailRect.maxX - smallCircleSize,
y: smallCircleYPos,
width: smallCircleSize,
height: smallCircleSize
)
)
let tailPath = Path(ellipseIn: bigCircleRect).union(smallCircle)
let bubble = Path(roundedRect: baseRect, cornerRadius: cornerRadius)
return bubble.union(tailPath)
}
private func sharpCornerPath(in rect: CGRect) -> Path {
let tailCornerRadius: CGFloat = 2
let tailPath = Path(
roundedRect: rect,
cornerRadii: RectangleCornerRadii(
topLeading: tailPosition == .topLeft ? tailCornerRadius : cornerRadius,
bottomLeading: tailPosition == .bottomLeft ? tailCornerRadius : cornerRadius,
bottomTrailing: tailPosition == .bottomRight ? tailCornerRadius : cornerRadius,
topTrailing: tailPosition == .topRight ? tailCornerRadius : cornerRadius
)
)
let bubble = Path(roundedRect: rect, cornerRadius: cornerRadius)
return bubble.union(tailPath)
}
}
#Preview {
PreviewBubblesView()
}
fileprivate struct PreviewBubblesView: View {
@State var tailSize: CGFloat = 16
@State var cornerRadius: CGFloat = 16
var body: some View {
VStack {
Spacer()
HStack {
Text("Tail Size")
Slider(value: $tailSize, in: 1...40)
}
HStack {
Text("Corner Radius")
Slider(value: $cornerRadius, in: 1...40)
}
TabView {
ForEach(BubbleStyle.allCases, id: \.self) { style in
VStack {
ForEach(BubbleTailPosition.allCases, id: \.self) { position in
BubbleShape(cornerRadius: cornerRadius, tailPosition: position, tailSize: tailSize, style: style)
.fill(position.fill)
.frame(width: 240, height: 60)
}
}
.tag(style.rawValue)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
Spacer()
}
.padding()
}
}
fileprivate extension BubbleTailPosition {
var fill: some ShapeStyle {
switch self {
case .topLeft:
return Color.indigo.gradient
case .topRight:
return Color.orange.gradient
case .bottomLeft:
return Color.blue.gradient
case .bottomRight:
return Color.green.gradient
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment