style(onboarding): refine bubble tails

This commit is contained in:
Peter Steinberger
2025-12-20 22:23:06 +00:00
parent d613800516
commit 16e4a0c4bd

View File

@@ -6,70 +6,55 @@ private enum ChatUIConstants {
static let bubbleCorner: CGFloat = 18 static let bubbleCorner: CGFloat = 18
} }
private enum ChatBubbleTailSide { private struct BubbleTail: Shape {
case left enum TailDirection: CaseIterable, Identifiable {
case right case topRight
case none case topLeft
} case bottomLeft
case bottomRight
private struct ChatBubbleShape: Shape { var id: Self { self }
let cornerRadius: CGFloat }
let tailSide: ChatBubbleTailSide
private let tailWidth: CGFloat = 10 let direction: TailDirection
private let tailHeight: CGFloat = 16
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
let hasTail = self.tailSide != .none Path { path in
let bubbleRect: CGRect = { switch direction {
guard hasTail else { return rect } case .topRight:
switch self.tailSide { let startPoint = CGPoint(x: rect.minX, y: rect.maxY)
case .left: path.move(to: startPoint)
return CGRect( path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
x: rect.minX + tailWidth, path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
y: rect.minY, let control1 = CGPoint(x: rect.maxX - rect.maxX / 20, y: rect.minY - rect.maxY / 3)
width: rect.width - tailWidth, let control2 = CGPoint(x: rect.maxX * 5 / 6, y: rect.maxY * 5 / 6)
height: rect.height) path.addCurve(to: startPoint, control1: control1, control2: control2)
case .right: case .topLeft:
return CGRect( let startPoint = CGPoint(x: rect.maxX, y: rect.maxY)
x: rect.minX, path.move(to: startPoint)
y: rect.minY, path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
width: rect.width - tailWidth, path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
height: rect.height) let control1 = CGPoint(x: rect.maxX / 20, y: rect.minY - rect.maxY / 3)
case .none: let control2 = CGPoint(x: rect.maxX * 1 / 6, y: rect.maxY * 5 / 6)
return rect path.addCurve(to: startPoint, control1: control1, control2: control2)
case .bottomLeft:
let startPoint = CGPoint(x: rect.minX, y: rect.maxY)
path.move(to: startPoint)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
let control1 = CGPoint(x: rect.maxX / 6, y: rect.maxY / 6)
let control2 = CGPoint(x: rect.minX + rect.maxX / 20, y: rect.maxY + rect.maxY / 3)
path.addCurve(to: startPoint, control1: control1, control2: control2)
case .bottomRight:
let startPoint = CGPoint(x: rect.minX, y: rect.minY)
path.move(to: startPoint)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
let control1 = CGPoint(x: rect.maxX - rect.maxX / 20, y: rect.maxY + rect.maxY / 3)
let control2 = CGPoint(x: rect.maxX * 5 / 6, y: rect.maxY * 1 / 6)
path.addCurve(to: startPoint, control1: control1, control2: control2)
} }
}()
var path = Path(roundedRect: bubbleRect, cornerRadius: cornerRadius)
guard hasTail else { return path }
let tailBaseY = bubbleRect.maxY - tailHeight - 4
let tailTipY = bubbleRect.maxY - 6
switch self.tailSide {
case .left:
let start = CGPoint(x: bubbleRect.minX, y: tailBaseY)
let tip = CGPoint(x: rect.minX + 2, y: tailTipY)
let end = CGPoint(x: bubbleRect.minX + 2, y: tailTipY + 6)
path.move(to: start)
path.addQuadCurve(to: tip, control: CGPoint(x: bubbleRect.minX - 4, y: tailBaseY + 6))
path.addQuadCurve(to: end, control: CGPoint(x: bubbleRect.minX - 2, y: tailTipY + 8))
path.closeSubpath()
case .right:
let start = CGPoint(x: bubbleRect.maxX, y: tailBaseY)
let tip = CGPoint(x: rect.maxX - 2, y: tailTipY)
let end = CGPoint(x: bubbleRect.maxX - 2, y: tailTipY + 6)
path.move(to: start)
path.addQuadCurve(to: tip, control: CGPoint(x: bubbleRect.maxX + 4, y: tailBaseY + 6))
path.addQuadCurve(to: end, control: CGPoint(x: bubbleRect.maxX + 2, y: tailTipY + 8))
path.closeSubpath()
case .none:
break
} }
return path
} }
} }
@@ -144,7 +129,25 @@ private struct ChatMessageBody: View {
.background(self.bubbleBackground) .background(self.bubbleBackground)
.overlay(self.bubbleBorder) .overlay(self.bubbleBorder)
.clipShape(self.bubbleShape) .clipShape(self.bubbleShape)
.overlay(alignment: self.tailAlignment) {
if let tailDirection = self.tailDirection {
BubbleTail(direction: tailDirection)
.fill(self.bubbleFillColor)
.frame(width: self.tailSize.width, height: self.tailSize.height)
.offset(x: self.tailOffsetX, y: self.tailOffsetY)
}
}
.overlay(alignment: self.tailAlignment) {
if let tailDirection = self.tailDirection {
BubbleTail(direction: tailDirection)
.stroke(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
.frame(width: self.tailSize.width, height: self.tailSize.height)
.offset(x: self.tailOffsetX, y: self.tailOffsetY)
}
}
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset) .shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
.padding(.leading, self.tailPaddingLeading)
.padding(.trailing, self.tailPaddingTrailing)
} }
private var primaryText: String { private var primaryText: String {
@@ -156,45 +159,73 @@ private struct ChatMessageBody: View {
self.message.content.filter { ($0.type ?? "text") != "text" } self.message.content.filter { ($0.type ?? "text") != "text" }
} }
private var bubbleBackground: AnyShapeStyle { private var bubbleFillColor: Color {
let fill: Color
if self.isUser { if self.isUser {
fill = ClawdisChatTheme.userBubble return ClawdisChatTheme.userBubble
} else if self.style == .onboarding {
fill = ClawdisChatTheme.onboardingAssistantBubble
} else {
fill = ClawdisChatTheme.assistantBubble
} }
return AnyShapeStyle(fill) if self.style == .onboarding {
return ClawdisChatTheme.onboardingAssistantBubble
}
return ClawdisChatTheme.assistantBubble
}
private var bubbleBackground: AnyShapeStyle {
AnyShapeStyle(self.bubbleFillColor)
}
private var bubbleBorderColor: Color {
if self.isUser {
return Color.white.opacity(0.12)
}
if self.style == .onboarding {
return ClawdisChatTheme.onboardingAssistantBorder
}
return Color.black.opacity(0.08)
}
private var bubbleBorderWidth: CGFloat {
if self.isUser { return 0.5 }
if self.style == .onboarding { return 0.8 }
return 1
} }
private var bubbleBorder: some View { private var bubbleBorder: some View {
let borderColor: Color RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
let lineWidth: CGFloat .strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
if self.isUser {
borderColor = Color.white.opacity(0.12)
lineWidth = 0.5
} else if self.style == .onboarding {
borderColor = ClawdisChatTheme.onboardingAssistantBorder
lineWidth = 0.8
} else {
borderColor = Color.black.opacity(0.08)
lineWidth = 1
}
return RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
.strokeBorder(borderColor, lineWidth: lineWidth)
.clipShape(self.bubbleShape) .clipShape(self.bubbleShape)
} }
private var bubbleShape: ChatBubbleShape { private var bubbleShape: RoundedRectangle {
let tailSide: ChatBubbleTailSide RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
if self.style == .onboarding { }
tailSide = self.isUser ? .right : .left
} else { private var tailDirection: BubbleTail.TailDirection? {
tailSide = .none guard self.style == .onboarding else { return nil }
} return self.isUser ? .bottomRight : .bottomLeft
return ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tailSide: tailSide) }
private var tailAlignment: Alignment {
self.isUser ? .bottomTrailing : .bottomLeading
}
private var tailSize: CGSize {
CGSize(width: 16, height: 22)
}
private var tailOffsetX: CGFloat {
self.isUser ? 6 : -6
}
private var tailOffsetY: CGFloat {
4
}
private var tailPaddingLeading: CGFloat {
self.style == .onboarding && !self.isUser ? 6 : 0
}
private var tailPaddingTrailing: CGFloat {
self.style == .onboarding && self.isUser ? 6 : 0
} }
private var bubbleShadowColor: Color { private var bubbleShadowColor: Color {