ios: add tails and roundness slider to message bubbles

This commit is contained in:
Levitating Pineapple
2024-08-24 16:31:44 +03:00
parent a5c1be3b8f
commit 4f9f2cecec
8 changed files with 130 additions and 44 deletions
@@ -70,6 +70,7 @@ struct CIGroupInvitationView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.modifier(ChatBubblePadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
@@ -72,6 +72,7 @@ struct FramedItemView: View {
.accessibilityLabel("")
}
}
.modifier(ChatBubblePadding(chatItem: chatItem))
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
@@ -22,6 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.modifier(ChatBubblePadding(chatItem: chatItem))
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
+1 -1
View File
@@ -916,7 +916,7 @@ struct ChatView: View {
allowMenu: $allowMenu
)
.environment(\.showTimestamp, timeSeparation.isTimestampShown)
.modifier(ChatItemClipped(ci))
.modifier(ChatItemClipped(ci, isTailVisible: timeSeparation.isGapLarge))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -14,55 +14,130 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
private let shapePath: ChatBubble.ShapePath
init() { shapePath = .roundRect(maxRadius: 8) }
init(_ chatItem: ChatItem, isTailVisible: Bool) {
shapePath = switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: .bubble(
padding: ChatBubble.paddingEdge(for: chatItem),
isTailVisible: Self.hidesTail(chatItem.content.msgContent) ? false : isTailVisible
)
default: .roundRect(maxRadius: 8)
}
}
init() {
clipShape = ClipShape(
maxCornerRadius: 18
)
// Tail is hidden for images and video without any text
private static func hidesTail(_ msgContent: MsgContent?) -> Bool {
if let msgContent, msgContent.isImageOrVideo && msgContent.text.isEmpty {
true
} else {
false
}
}
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View {
let shape = ChatBubble(roundness: roundness, shapePath: shapePath)
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
.clipShape(clipShape)
.contentShape(.dragPreview, shape)
.contentShape(.contextMenuPreview, shape)
.clipShape(shape)
}
}
struct ChatBubblePadding: ViewModifier {
let chatItem: ChatItem
func body(content: Content) -> some View {
content.padding(
chatItem.chatDir.sent ? .trailing : .leading,
ChatBubble.tailSize
)
}
}
struct ChatBubble: Shape {
enum ShapePath {
case bubble(padding: HorizontalEdge, isTailVisible: Bool)
case roundRect(maxRadius: Double)
}
static let tailSize: Double = 8
static let maxRadius: Double = 16
let roundness: Double
let shapePath: ShapePath
func path(in rect: CGRect) -> Path {
switch shapePath {
case .bubble(let padding, let isTailVisible):
let rMax = min(Self.maxRadius, min(rect.width, rect.height) / 2)
let r = roundness * rMax
let tailHeight = rect.height - (Self.tailSize + (rMax - Self.tailSize) * roundness)
var path = Path()
path.addArc(
center: CGPoint(x: r + Self.tailSize, y: r),
radius: r,
startAngle: .degrees(270),
endAngle: .degrees(180),
clockwise: true
)
if isTailVisible {
path.addLine(
to: CGPoint(x: Self.tailSize, y: tailHeight)
)
path.addQuadCurve(
to: CGPoint(x: 0, y: rect.height),
control: CGPoint(x: Self.tailSize, y: tailHeight + r * 0.64)
)
} else {
path.addArc(
center: CGPoint(x: r + Self.tailSize, y: rect.height - r),
radius: r,
startAngle: .degrees(180),
endAngle: .degrees(90),
clockwise: true
)
}
path.addArc(
center: CGPoint(x: rect.width - r, y: rect.height - r),
radius: r,
startAngle: .degrees(90),
endAngle: .degrees(0),
clockwise: true
)
path.addArc(
center: CGPoint(x: rect.width - r, y: r),
radius: r,
startAngle: .degrees(0),
endAngle: .degrees(270),
clockwise: true
)
return switch padding {
case .leading: path
case .trailing: path
.scale(x: -1, y: 1, anchor: .center)
.path(in: rect)
}
case .roundRect:
return Path(roundedRect: rect, cornerRadius: 8 * roundness)
}
}
static func paddingEdge(for chatItem: ChatItem) -> HorizontalEdge {
chatItem.chatDir.sent ? .trailing : .leading
}
}
@@ -33,6 +33,7 @@ struct AppearanceSettings: View {
}()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -179,6 +180,10 @@ struct AppearanceSettings: View {
}
}
Section(header: Text("Message roundness").foregroundColor(theme.colors.secondary)) {
Slider(value: $chatItemRoundness, in: 0...1, step: 0.1)
}
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" {
@@ -358,13 +363,13 @@ struct ChatThemePreview: View {
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
.modifier(ChatItemClipped(alice, isTailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
.modifier(ChatItemClipped(bob, isTailVisible: true))
.frame(alignment: .trailing)
}
} else {
@@ -47,6 +47,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
+2
View File
@@ -13,6 +13,8 @@ public let appSuspendTimeout: Int = 15 // seconds
public let defaultProfileImageCorner: Double = 22.5
public let defaultChatItemRoundness: Double = 0.8
let GROUP_DEFAULT_APP_STATE = "appState"
let GROUP_DEFAULT_NSE_STATE = "nseState"
let GROUP_DEFAULT_SE_STATE = "seState"