From 4f9f2cecec0f2d7a3508dc3e6fc9e40ae1745da9 Mon Sep 17 00:00:00 2001 From: Levitating Pineapple Date: Sat, 24 Aug 2024 16:31:44 +0300 Subject: [PATCH] ios: add tails and roundness slider to message bubbles --- .../Chat/ChatItem/CIGroupInvitationView.swift | 1 + .../Views/Chat/ChatItem/FramedItemView.swift | 1 + .../Chat/ChatItem/MarkedDeletedItemView.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/Helpers/ChatItemClipShape.swift | 157 +++++++++++++----- .../UserSettings/AppearanceSettings.swift | 9 +- .../Views/UserSettings/SettingsView.swift | 1 + apps/ios/SimpleXChat/AppGroup.swift | 2 + 8 files changed, 130 insertions(+), 44 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 51c49832e9..7ccd07b027 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -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 } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 313ec0d419..4060840723 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -72,6 +72,7 @@ struct FramedItemView: View { .accessibilityLabel("") } } + .modifier(ChatBubblePadding(chatItem: chatItem)) .background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme)) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 25e06b9ea4..b3ba6e44b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -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) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 45b8760e97..d973fffadb 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift index 477dc567eb..34244ca05f 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift @@ -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 + } +} diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 73a789f108..415bd70f1a 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -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 { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a4908f628f..bc77c16aa5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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" diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index bd38f3568c..7ff0846937 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -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"