From a35b20f54c40330e7ac41dcebfa294fbbac8d309 Mon Sep 17 00:00:00 2001 From: Levitating Pineapple Date: Mon, 23 Sep 2024 18:43:05 +0300 Subject: [PATCH] ios: hide user picker sheet instantly, when opening another sheet --- .../Shared/Views/ChatList/ChatListView.swift | 75 ++++----- .../Shared/Views/ChatList/UserPicker.swift | 63 ++++---- .../Shared/Views/Helpers/SwiftUISheet.swift | 142 ++++++++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + 4 files changed, 215 insertions(+), 69 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/SwiftUISheet.swift diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 4d1c182554..cad83784b6 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -58,43 +58,48 @@ struct ChatListView: View { destination: chatView ) { chatListView } } - .sheet(isPresented: $userPickerShown) { - UserPicker(activeSheet: $activeUserPickerSheet) - .sheet(item: $activeUserPickerSheet) { sheet in - if let currentUser = chatModel.currentUser { - switch sheet { - case .address: - NavigationView { - UserAddressView(shareViaProfile: currentUser.addressShared) - .navigationTitle("SimpleX address") - .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) - } - case .chatProfiles: - NavigationView { - UserProfilesView() - } - case .currentProfile: - NavigationView { - UserProfile() - .navigationTitle("Your current profile") - .modifier(ThemedBackground(grouped: true)) - } - case .chatPreferences: - NavigationView { - PreferencesView(profile: currentUser.profile, preferences: currentUser.fullPreferences, currentPreferences: currentUser.fullPreferences) - .navigationTitle("Your preferences") - .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) - } - case .useFromDesktop: - ConnectDesktopView(viaSettings: false) - case .settings: - SettingsView(showSettings: $showSettings) - .navigationBarTitleDisplayMode(.large) - } + .modifier( + SwiftUISheet(height: 400, isPresented: $userPickerShown) { + UserPicker(activeSheet: $activeUserPickerSheet) + } + ) + .sheet(item: $activeUserPickerSheet) { sheet in + if let currentUser = chatModel.currentUser { + switch sheet { + case .address: + NavigationView { + UserAddressView(shareViaProfile: currentUser.addressShared) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) } + case .chatProfiles: + NavigationView { + UserProfilesView() + } + case .currentProfile: + NavigationView { + UserProfile() + .navigationTitle("Your current profile") + .modifier(ThemedBackground(grouped: true)) + } + case .chatPreferences: + NavigationView { + PreferencesView(profile: currentUser.profile, preferences: currentUser.fullPreferences, currentPreferences: currentUser.fullPreferences) + .navigationTitle("Your preferences") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + case .useFromDesktop: + ConnectDesktopView(viaSettings: false) + case .settings: + SettingsView(showSettings: $showSettings) + .navigationBarTitleDisplayMode(.large) } + } + } + .onChange(of: activeUserPickerSheet) { + if $0 != nil { userPickerShown = false } } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b3baa62f01..8cb2d9e000 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -21,31 +21,18 @@ struct UserPicker: View { // Inset grouped list dimensions private let imageSize: CGFloat = 44 private let rowPadding: CGFloat = 16 + private let rowVerticalPadding: CGFloat = 10 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(400)]) - if #available(iOS 16.4, *) { - v.scrollBounceBehavior(.basedOnSize) - } else { - v - } - } else { - viewBody - } - } - - @ViewBuilder - private var viewBody: some View { 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) { + VStack(spacing: sectionSpacing) { if let user = m.currentUser { StickyScrollView { HStack(spacing: rowPadding) { @@ -56,7 +43,7 @@ struct UserPicker: View { } .padding(rowPadding) .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) - .background(Color(.secondarySystemGroupedBackground)) + .background(elevatedSecondarySystemGroupedBackground(colorScheme)) .clipShape(sectionShape) .onTapGesture { activeSheet = .currentProfile } ForEach(otherUsers) { u in @@ -72,20 +59,23 @@ struct UserPicker: View { .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") + VStack(spacing: 0) { + openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address) + listDivider + openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences) + listDivider + openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles) + listDivider + openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop) + listDivider + 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) + .padding(.horizontal, rowPadding) .onTapGesture { if (colorScheme == .light) { ThemeManager.applyTheme(systemDarkThemeDefault.get()) @@ -96,9 +86,11 @@ struct UserPicker: View { .onLongPressGesture { ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) } - } } } + .background(elevatedSecondarySystemGroupedBackground(colorScheme)) + .clipShape(sectionShape) + .padding(.horizontal, sectionHorizontalPadding) } .onAppear { // This check prevents the call of listUsers after the app is suspended, and the database is closed. @@ -121,6 +113,10 @@ struct UserPicker: View { .disabled(switchingProfile) } + private var listDivider: some View { + Divider().padding(.leading, 48) + } + private func userView(_ u: UserInfo, size: CGFloat) -> some View { HStack { ZStack(alignment: .topTrailing) { @@ -133,7 +129,7 @@ struct UserPicker: View { Text(u.user.displayName).font(.title2).lineLimit(1) } .padding(rowPadding) - .background(Color(.secondarySystemGroupedBackground)) + .background(elevatedSecondarySystemGroupedBackground(colorScheme)) .clipShape(sectionShape) .onTapGesture { switchingProfile = true @@ -156,15 +152,14 @@ struct UserPicker: View { } 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) - } + settingsRow(icon, color: theme.colors.secondary) { + Text(title).foregroundColor(.primary) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, rowPadding) + .padding(.vertical, rowVerticalPadding) .contentShape(Rectangle()) + .onTapGesture { activeSheet = sheet } } private func unreadBadge(_ u: UserInfo) -> some View { diff --git a/apps/ios/Shared/Views/Helpers/SwiftUISheet.swift b/apps/ios/Shared/Views/Helpers/SwiftUISheet.swift new file mode 100644 index 0000000000..1de7938823 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/SwiftUISheet.swift @@ -0,0 +1,142 @@ +// +// SwiftUISheet.swift +// SimpleX (iOS) +// +// Created by user on 23/09/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SwiftUISheet: ViewModifier { + let height: Double + @Binding var isPresented: Bool + @ViewBuilder let sheetContent: () -> SheetContent + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + // Represents offset relative to the height of the sheet + // - 0: Collapsed + // - 1: Fully expanded + @State private var relativeOffset: Double = 0 + private let radius: Double = 16 + + func body(content: Content) -> some View { + let safeAreaInset: Double = + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.safeAreaInsets.bottom + } else { 0 } + let sheetHeight = height + safeAreaInset + ZStack { + content + Color.black.opacity(0.35 * relativeOffset) + .ignoresSafeArea() + .onTapGesture { isPresented = false } + sheetContent() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(elevatedSystemGroupedBackground(colorScheme)) + .clipShape(ClipShape(bottomSafeAreaInset: safeAreaInset)) + .frame(height: height) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { + relativeOffset = min(max(relativeOffset - $0.translation.height / sheetHeight, 0), 1) + } + .onEnded { + isPresented = relativeOffset + $0.predictedEndTranslation.height / sheetHeight > 0.5 + animate(with: $0.velocity.height) + } + ) + .frame(maxHeight: .infinity, alignment: .bottom) + .offset(y: sheetHeight - sheetHeight * relativeOffset) + } + .onChange(of: isPresented) { _ in animate() } + } + + private var bottomSafeAreaInset: Double { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.safeAreaInsets.bottom + } else { 0 } + } + + private func animate(with releaseVelocity: CGFloat? = nil) { + // TODO: Tune animation speed depending on drag gesture's final velocity + withAnimation { relativeOffset = isPresented ? 1 : 0 } + } + + struct ClipShape: Shape { + let bottomSafeAreaInset: Double + + func path(in rect: CGRect) -> Path { + Path( + UIBezierPath( + roundedRect: CGRect( + origin: .zero, + size: CGSize(width: rect.width, height: rect.height + bottomSafeAreaInset) + ), + byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize(width: 10, height: 10) + ).cgPath + ) + } + } +} + +func elevatedSystemGroupedBackground(_ colorScheme: ColorScheme) -> Color { + switch colorScheme { + case .dark: Color(0xFF1C1C1E) + default: Color(0xFFF2F2F7) + } +} + +func elevatedSecondarySystemGroupedBackground(_ colorScheme: ColorScheme) -> Color { + switch colorScheme { + case .dark: Color(0xFF2C2C2E) + default: Color(0xFFFFFFFF) + } +} + +/// # Extracting Sheet Colors Programatically +/// +/// System colors are returned dynamically, depending on the context: +/// +/// struct ColorResolverView: View { +/// @Environment(\.self) var environment +/// let colors: [Color] +/// +/// var body: some View { +/// HStack { +/// column.environment(\.colorScheme, .dark) +/// column.environment(\.colorScheme, .light) +/// } +/// } +/// +/// var column: some View { +/// VStack { +/// ForEach(colors, id: \.self) { +/// Text("\($0.resolve(in: environment))") +/// .frame(maxWidth: .infinity, maxHeight: .infinity) +/// .background($0) +/// } +/// } +/// } +/// } +/// +/// Place `ColorResolverView` inside a sheet to acquire elevated color versions: +/// +/// struct ContentView: View { +/// var body: some View { +/// EmptyView() +/// .sheet(isPresented: .constant(true)) { +/// ColorResolverView( +/// colors: [ +/// Color(.systemGroupedBackground), +/// Color(.secondarySystemGroupedBackground) +/// ] +/// ) +/// } +/// } +/// } + + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a73b3febcd..199e4f2607 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; }; CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; }; CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; }; + CEFB2EDF2CA1BCC7004B1ECE /* SwiftUISheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -553,6 +554,7 @@ CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = ""; }; CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = ""; }; + CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUISheet.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -799,6 +801,7 @@ CE7548092C622630009579B7 /* SwipeLabel.swift */, CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */, CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, + CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */, ); path = Helpers; sourceTree = ""; @@ -1449,6 +1452,7 @@ 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, + CEFB2EDF2CA1BCC7004B1ECE /* SwiftUISheet.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,