Created
April 2, 2024 17:46
-
-
Save glm4/73acf0684d41e74b02c9f834ddfe26fc to your computer and use it in GitHub Desktop.
Chat Message Bubbles Styles using SwiftUI Shapes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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