mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-29 22:50:14 +00:00
ios: scrolling user profiles (#4909)
* ios: scrolling user profiles --------- Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
This commit is contained in:
co-authored by
Levitating Pineapple
parent
c849f5356d
commit
560b521673
@@ -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<UserInfo>(\.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<V: View>(label: () -> V, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action, label: label)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -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<Content: View>: 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<Content>
|
||||
|
||||
init(content: Content) {
|
||||
self.hostingController = UIHostingController(rootView: content)
|
||||
}
|
||||
|
||||
func scrollViewWillEndDragging(
|
||||
_ scrollView: UIScrollView,
|
||||
withVelocity velocity: CGPoint,
|
||||
targetContentOffset: UnsafeMutablePointer<CGPoint>
|
||||
) {
|
||||
if targetContentOffset.pointee.x < 64 {
|
||||
targetContentOffset.pointee.x = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
CE7548092C622630009579B7 /* SwipeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeLabel.swift; sourceTree = "<group>"; };
|
||||
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = "<group>"; };
|
||||
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyScrollView.swift; sourceTree = "<group>"; };
|
||||
CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -796,6 +798,7 @@
|
||||
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */,
|
||||
CE7548092C622630009579B7 /* SwipeLabel.swift */,
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
|
||||
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -834,6 +834,7 @@ data class User(
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true,
|
||||
activeOrder = 0,
|
||||
showNtfs = true,
|
||||
sendRcptsContacts = true,
|
||||
sendRcptsSmallGroups = false,
|
||||
|
||||
Reference in New Issue
Block a user