mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-28 23:36:03 +00:00
ios: add tails and roundness slider to message bubbles
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user