From 560b52167329f8064abfc95b213f8ae6cfe0931c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 21 Sep 2024 19:12:53 +0100 Subject: [PATCH] ios: scrolling user profiles (#4909) * ios: scrolling user profiles --------- Co-authored-by: Levitating Pineapple --- .../Shared/Views/ChatList/UserPicker.swift | 227 +++++++----------- .../Views/Helpers/StickyScrollView.swift | 52 ++++ .../Views/UserSettings/SettingsView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 1 + 6 files changed, 159 insertions(+), 144 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/StickyScrollView.swift diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index efe54cb036..b3baa62f01 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -14,11 +14,20 @@ struct UserPicker: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.dismiss) private var dismiss: DismissAction @Binding var activeSheet: UserPickerSheet? + @State private var currentUser: Int64? @State private var switchingProfile = false + @State private var frameWidth: CGFloat = 0 + + // Inset grouped list dimensions + private let imageSize: CGFloat = 44 + private let rowPadding: CGFloat = 16 + private let sectionSpacing: CGFloat = 35 + private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 } + private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous) var body: some View { if #available(iOS 16.0, *) { - let v = viewBody.presentationDetents([.height(420)]) + let v = viewBody.presentationDetents([.height(400)]) if #available(iOS 16.4, *) { v.scrollBounceBehavior(.basedOnSize) } else { @@ -28,88 +37,80 @@ struct UserPicker: View { viewBody } } - + + @ViewBuilder private var viewBody: some View { - let otherUsers = m.users.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId } - return List { - Section(header: Text("You").foregroundColor(theme.colors.secondary)) { - if let user = m.currentUser { - openSheetOnTap(label: { - ZStack { - let v = ProfilePreview(profileOf: user) - .foregroundColor(.primary) - .padding(.leading, -8) - if #available(iOS 16.0, *) { - v + let otherUsers: [UserInfo] = m.users + .filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId } + .sorted(using: KeyPathComparator(\.user.activeOrder, order: .reverse)) + let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0) + let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0) + VStack(spacing: 0) { + if let user = m.currentUser { + StickyScrollView { + HStack(spacing: rowPadding) { + HStack { + ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground)) + .padding(.trailing, 6) + profileName(user).lineLimit(1) + } + .padding(rowPadding) + .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(sectionShape) + .onTapGesture { activeSheet = .currentProfile } + ForEach(otherUsers) { u in + userView(u, size: imageSize) + .frame(maxWidth: sectionWidth * 0.618) + .fixedSize() + } + } + .padding(.horizontal, sectionHorizontalPadding) + } + .frame(height: 2 * rowPadding + imageSize) + .padding(.top, sectionSpacing) + .overlay(DetermineWidth()) + .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + } + List { + Section { + openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address) + openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences) + openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles) + openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop) + + ZStack(alignment: .trailing) { + openSheetOnTap("gearshape", title: "Settings", sheet: .settings) + Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: 20, maxHeight: 20) + .onTapGesture { + if (colorScheme == .light) { + ThemeManager.applyTheme(systemDarkThemeDefault.get()) } else { - v.padding(.vertical, 4) + ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName) } } - }) { - activeSheet = .currentProfile - } - - openSheetOnTap(title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", icon: "qrcode") { - activeSheet = .address - } - - openSheetOnTap(title: "Chat preferences", icon: "switch.2") { - activeSheet = .chatPreferences - } - } - } - - Section { - if otherUsers.isEmpty { - openSheetOnTap(title: "Your chat profiles", icon: "person.crop.rectangle.stack") { - activeSheet = .chatProfiles - } - } else { - let v = userPickerRow(otherUsers, size: 44) - .padding(.leading, -11) - if #available(iOS 16.0, *) { - v - } else { - v.padding(.vertical, 4) - } - } - - openSheetOnTap(title: "Use from desktop", icon: "desktopcomputer") { - activeSheet = .useFromDesktop - } - - ZStack(alignment: .trailing) { - openSheetOnTap(title: "Settings", icon: "gearshape") { - activeSheet = .settings - } - Label {} icon: { - Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundColor(theme.colors.secondary) - .frame(maxWidth: 20, maxHeight: 20) - } - .onTapGesture { - if (colorScheme == .light) { - ThemeManager.applyTheme(systemDarkThemeDefault.get()) - } else { - ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName) + .onLongPressGesture { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) } } - .onLongPressGesture { - ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) - } } } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { // This check prevents the call of listUsers after the app is suspended, and the database is closed. if case .active = scenePhase { + currentUser = m.currentUser?.userId Task { do { let users = try await listUsersAsync() - await MainActor.run { m.users = users } + await MainActor.run { + m.users = users + currentUser = m.currentUser?.userId + } } catch { logger.error("Error loading users \(responseError(error))") } @@ -119,71 +120,34 @@ struct UserPicker: View { .modifier(ThemedBackground(grouped: true)) .disabled(switchingProfile) } - - private func userPickerRow(_ users: [UserInfo], size: CGFloat) -> some View { - HStack(spacing: 6) { - let s = ScrollView(.horizontal) { - HStack(spacing: 27) { - ForEach(users) { u in - if !u.user.hidden && u.user.userId != m.currentUser?.userId { - userView(u, size: size) - } - } - } - .padding(.leading, 4) - .padding(.trailing, 22) - } - ZStack(alignment: .trailing) { - if #available(iOS 16.0, *) { - s.scrollIndicators(.hidden) - } else { - s - } - LinearGradient( - colors: [.clear, .black], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: size, height: size + 3) - .blendMode(.destinationOut) - .allowsHitTesting(false) - } - .compositingGroup() - .padding(.top, -3) // to fit unread badge - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(theme.colors.secondary) - .padding(.trailing, 4) - .onTapGesture { - activeSheet = .chatProfiles - } - } - } private func userView(_ u: UserInfo, size: CGFloat) -> some View { - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) - .padding([.top, .trailing], 3) - if (u.unreadCount > 0) { - unreadBadge(u) + HStack { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) + if (u.unreadCount > 0) { + unreadBadge(u).offset(x: 4, y: -4) + } } + .padding(.trailing, 6) + Text(u.user.displayName).font(.title2).lineLimit(1) } - .frame(width: size) + .padding(rowPadding) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(sectionShape) .onTapGesture { switchingProfile = true + dismiss() Task { do { try await changeActiveUserAsync_(u.user.userId, viewPwd: nil) - await MainActor.run { - switchingProfile = false - dismiss() - } + await MainActor.run { switchingProfile = false } } catch { await MainActor.run { switchingProfile = false - AlertManager.shared.showAlertMsg( - title: "Error switching profile!", - message: "Error: \(responseError(error))" + showAlert( + NSLocalizedString("Error switching profile!", comment: "alertTitle"), + message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) ) } } @@ -191,21 +155,14 @@ struct UserPicker: View { } } - private func openSheetOnTap(title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { - openSheetOnTap(label: { - ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) - .symbolRenderingMode(.monochrome) - .foregroundColor(theme.colors.secondary) - Text(title) - .foregroundColor(.primary) - .padding(.leading, 36) + private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet) -> some View { + Button { + activeSheet = sheet + } label: { + settingsRow(icon, color: theme.colors.secondary) { + Text(title).foregroundColor(.primary) } - }, action: action) - } - - private func openSheetOnTap(label: () -> V, action: @escaping () -> Void) -> some View { - Button(action: action, label: label) + } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } diff --git a/apps/ios/Shared/Views/Helpers/StickyScrollView.swift b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift new file mode 100644 index 0000000000..0ba539772f --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift @@ -0,0 +1,52 @@ +// +// StickyScrollView.swift +// SimpleX (iOS) +// +// Created by user on 20/09/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct StickyScrollView: UIViewRepresentable { + @ViewBuilder let content: () -> Content + + func makeUIView(context: Context) -> UIScrollView { + let hc = context.coordinator.hostingController + hc.view.backgroundColor = .clear + let sv = UIScrollView() + sv.showsHorizontalScrollIndicator = false + sv.addSubview(hc.view) + sv.delegate = context.coordinator + return sv + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + let hc = context.coordinator.hostingController + hc.rootView = content() + hc.view.frame.size = hc.view.intrinsicContentSize + scrollView.contentSize = hc.view.intrinsicContentSize + } + + func makeCoordinator() -> Coordinator { + Coordinator(content: content()) + } + + class Coordinator: NSObject, UIScrollViewDelegate { + let hostingController: UIHostingController + + init(content: Content) { + self.hostingController = UIHostingController(rootView: content) + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + if targetContentOffset.pointee.x < 64 { + targetContentOffset.pointee.x = 0 + } + } + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f1140575b7..e018b181e6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -507,18 +507,18 @@ struct ProfilePreview: View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - profileName().lineLimit(1) + profileName(profileOf).lineLimit(1) } } - - private func profileName() -> Text { - var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) - if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { - t = t + Text(" (" + profileOf.fullName + ")") +} + +func profileName(_ profileOf: NamedChat) -> Text { + var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) + if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { + t = t + Text(" (" + profileOf.fullName + ")") // .font(.callout) - } - return t - } + } + return t } struct SettingsView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a6bfd9d80e..a73b3febcd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = CE38A29B2C3FCD72005ED185 /* SwiftyGif */; }; CE75480A2C622630009579B7 /* SwipeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7548092C622630009579B7 /* SwipeLabel.swift */; }; CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; }; + CEDB245B2C9CD71800FBC5F6 /* StickyScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */; }; CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE70212C48FD9500233B1F /* SEChatState.swift */; }; CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */; }; CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -543,6 +544,7 @@ CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; CE7548092C622630009579B7 /* SwipeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeLabel.swift; sourceTree = ""; }; CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = ""; }; + CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyScrollView.swift; sourceTree = ""; }; CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = ""; }; CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX SE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -796,6 +798,7 @@ CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */, CE7548092C622630009579B7 /* SwipeLabel.swift */, CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */, + CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, ); path = Helpers; sourceTree = ""; @@ -1496,6 +1499,7 @@ 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, + CEDB245B2C9CD71800FBC5F6 /* StickyScrollView.swift in Sources */, 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 272e487214..0f319f2f9d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -50,6 +50,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { profile: LocalProfile.sampleData, fullPreferences: FullPreferences.sampleData, activeUser: true, + activeOrder: 0, showNtfs: true, sendRcptsContacts: true, sendRcptsSmallGroups: false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 9f52694f91..8d942222c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -834,6 +834,7 @@ data class User( profile = LocalProfile.sampleData, fullPreferences = FullChatPreferences.sampleData, activeUser = true, + activeOrder = 0, showNtfs = true, sendRcptsContacts = true, sendRcptsSmallGroups = false,