style(onboarding): refine bubble tails
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user