From bdb6bd6e2083c455f3911d80c7081368b28c9deb Mon Sep 17 00:00:00 2001 From: Arturs Krumins Date: Mon, 7 Oct 2024 20:30:17 +0300 Subject: [PATCH] ios: hide user picker sheet instantly, when opening another sheet (#4927) * ios: hide user picker sheet instantly, when opening another sheet * tweak appearance * distance based animation duration * cleanup; dismiss * implement UIViewPropertyAnimator * resolve warning * user picker bottom padding * reset user scroll position on dismiss; cleanup * reduce dif * delay user picker closing * touchable list row; prevent tap gesture passtrough * fix dark mode tap target; retain highlight; highlight in user scroller * fix layout loop; add upper animation speed constraint * refactor separators * instantanious longPress; tweak animations * cubic animation curve; dynamic backdrop opacity * remove touchdown animation * ios: user picker sheet concurent animation (#4955) * ios: user picker sheet concurent animation * bind showSettings; cleanup * async qr code generation * fix iOS15 sheet animation when presenting sheet multiple times * async camera authorization in 'Use from desktop' sheet * load sheet navigation titles before presenting (#4963) * load sheet navigation titles before presenting * list background during loading * improve handling of repeated sheet presentation state changes * fix keyboard related glitches * ios: remove `showSettings` and `withNavigation` (#4980) * remove showSettings * pass dismiss action trough navigation links * move auth to all sheets --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/ContentView.swift | 9 +- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 9 +- .../Shared/Views/ChatList/ChatListView.swift | 117 +++++++---- .../Shared/Views/ChatList/UserPicker.swift | 172 +++++++++++----- .../Shared/Views/Database/DatabaseView.swift | 8 +- .../Views/Helpers/SheetRepresentable.swift | 183 ++++++++++++++++++ .../Views/Helpers/StickyScrollView.swift | 9 + .../Shared/Views/NewChat/NewChatView.swift | 13 +- apps/ios/Shared/Views/NewChat/QRCode.swift | 46 ++--- .../RemoteAccess/ConnectDesktopView.swift | 11 -- .../Views/UserSettings/SettingsView.swift | 19 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + 12 files changed, 452 insertions(+), 148 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/SheetRepresentable.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 1f41e7c31d..58a552c1a0 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -29,12 +29,12 @@ struct ContentView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false - @State private var showSettings = false @State private var showWhatsNew = false @State private var showChooseLAMode = false @State private var showSetPasscode = false @State private var waitingForOrPassedAuth = true @State private var chatListActionSheet: ChatListActionSheet? = nil + @State private var chatListUserPickerSheet: UserPickerSheet? = nil private let callTopPadding: CGFloat = 40 @@ -86,7 +86,7 @@ struct ContentView: View { callView(call) } - if !showSettings, let la = chatModel.laRequest { + if chatListUserPickerSheet == nil, let la = chatModel.laRequest { LocalAuthView(authRequest: la) .onDisappear { // this flag is separate from accessAuthenticated to show initializationView while we wait for authentication @@ -109,9 +109,6 @@ struct ContentView: View { } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } - .sheet(isPresented: $showSettings) { - SettingsView(showSettings: $showSettings) - } .confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) { Button("System authentication") { initialEnableLA() } Button("Passcode entry") { showSetPasscode = true } @@ -253,7 +250,7 @@ struct ContentView: View { private func mainView() -> some View { ZStack(alignment: .top) { - ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) + ChatListView(activeUserPickerSheet: $chatListUserPickerSheet).privacySensitive(protectScreen) .onAppear { requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index c84cdb0b97..a01c81bafb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -10,7 +10,7 @@ import SwiftUI struct ChatHelp: View { @EnvironmentObject var chatModel: ChatModel - @Binding var showSettings: Bool + let dismissSettingsSheet: DismissAction var body: some View { ScrollView { chatHelp() } @@ -23,7 +23,7 @@ struct ChatHelp: View { VStack(alignment: .leading, spacing: 0) { Text("To ask any questions and to receive updates:") Button("connect to SimpleX Chat developers.") { - showSettings = false + dismissSettingsSheet() DispatchQueue.main.async { UIApplication.shared.open(simplexTeamURL) } @@ -61,8 +61,9 @@ struct ChatHelp: View { } struct ChatHelp_Previews: PreviewProvider { + @Environment(\.dismiss) static var mockDismiss + static var previews: some View { - @State var showSettings = false - return ChatHelp(showSettings: $showSettings) + ChatHelp(dismissSettingsSheet: mockDismiss) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 4d1c182554..4edc8a45f1 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -18,19 +18,77 @@ enum UserPickerSheet: Identifiable { case settings var id: Self { self } + + var navigationTitle: LocalizedStringKey { + switch self { + case .address: "SimpleX address" + case .chatPreferences: "Your preferences" + case .chatProfiles: "Your chat profiles" + case .currentProfile: "Your current profile" + case .useFromDesktop: "Connect to desktop" + case .settings: "Your settings" + } + } +} + +struct UserPickerSheetView: View { + let sheet: UserPickerSheet + @EnvironmentObject var chatModel: ChatModel + @State private var loaded = false + + var body: some View { + NavigationView { + ZStack { + if loaded, let currentUser = chatModel.currentUser { + switch sheet { + case .address: + UserAddressView(shareViaProfile: currentUser.addressShared) + case .chatPreferences: + PreferencesView( + profile: currentUser.profile, + preferences: currentUser.fullPreferences, + currentPreferences: currentUser.fullPreferences + ) + case .chatProfiles: + UserProfilesView() + case .currentProfile: + UserProfile() + case .useFromDesktop: + ConnectDesktopView() + case .settings: + SettingsView() + } + } + Color.clear // Required for list background to be rendered during loading + } + .navigationTitle(sheet.navigationTitle) + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + .overlay { + if let la = chatModel.laRequest { + LocalAuthView(authRequest: la) + } + } + .task { + withAnimation( + .easeOut(duration: 0.1), + { loaded = true } + ) + } + } } struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme - @Binding var showSettings: Bool + @Binding var activeUserPickerSheet: UserPickerSheet? @State private var searchMode = false @FocusState private var searchFocussed @State private var searchText = "" @State private var searchShowingSimplexLink = false @State private var searchChatFilteredBySimplexLink: String? = nil @State private var scrollToSearchBar = false - @State private var activeUserPickerSheet: UserPickerSheet? = nil @State private var userPickerShown: Bool = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @@ -58,43 +116,20 @@ 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( + Sheet(isPresented: $userPickerShown) { + UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet) + } + ) + .sheet(item: $activeUserPickerSheet) { + UserPickerSheetView(sheet: $0) + } + .onChange(of: activeUserPickerSheet) { + if $0 != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + userPickerShown = false } + } } } @@ -544,6 +579,8 @@ func chatStoppedIcon() -> some View { } struct ChatListView_Previews: PreviewProvider { + @State static var userPickerSheet: UserPickerSheet? = .none + static var previews: some View { let chatModel = ChatModel() chatModel.updateChats([ @@ -562,9 +599,9 @@ struct ChatListView_Previews: PreviewProvider { ]) return Group { - ChatListView(showSettings: Binding.constant(false)) + ChatListView(activeUserPickerSheet: $userPickerSheet) .environmentObject(chatModel) - ChatListView(showSettings: Binding.constant(false)) + ChatListView(activeUserPickerSheet: $userPickerSheet) .environmentObject(ChatModel()) } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b3baa62f01..b7818bfac4 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -12,42 +12,30 @@ struct UserPicker: View { @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize @Environment(\.scenePhase) private var scenePhase: ScenePhase @Environment(\.colorScheme) private var colorScheme: ColorScheme - @Environment(\.dismiss) private var dismiss: DismissAction + @Binding var userPickerShown: Bool @Binding var activeSheet: UserPickerSheet? @State private var currentUser: Int64? @State private var switchingProfile = false @State private var frameWidth: CGFloat = 0 + @State private var resetScroll = ResetScrollAction() // Inset grouped list dimensions private let imageSize: CGFloat = 44 private let rowPadding: CGFloat = 16 + private let rowVerticalPadding: CGFloat = 11 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 { + StickyScrollView(resetScroll: $resetScroll) { HStack(spacing: rowPadding) { HStack { ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground)) @@ -56,9 +44,8 @@ struct UserPicker: View { } .padding(rowPadding) .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) - .background(Color(.secondarySystemGroupedBackground)) + .modifier(ListRow { activeSheet = .currentProfile }) .clipShape(sectionShape) - .onTapGesture { activeSheet = .currentProfile } ForEach(otherUsers) { u in userView(u, size: imageSize) .frame(maxWidth: sectionWidth * 0.618) @@ -72,20 +59,21 @@ 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) + 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, showDivider: false) + Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") .resizable() + .scaledToFit() .symbolRenderingMode(.monochrome) .foregroundColor(theme.colors.secondary) - .frame(maxWidth: 20, maxHeight: 20) + .frame(maxWidth: 20, maxHeight: .infinity) + .padding(.horizontal, rowPadding) + .background(Color(.systemBackground).opacity(0.01)) .onTapGesture { if (colorScheme == .light) { ThemeManager.applyTheme(systemDarkThemeDefault.get()) @@ -96,9 +84,11 @@ struct UserPicker: View { .onLongPressGesture { ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) } - } } } + .clipShape(sectionShape) + .padding(.horizontal, sectionHorizontalPadding) + .padding(.bottom, sectionSpacing) } .onAppear { // This check prevents the call of listUsers after the app is suspended, and the database is closed. @@ -117,6 +107,9 @@ struct UserPicker: View { } } } + .onChange(of: userPickerShown) { + if !$0 { resetScroll() } + } .modifier(ThemedBackground(grouped: true)) .disabled(switchingProfile) } @@ -133,15 +126,15 @@ struct UserPicker: View { Text(u.user.displayName).font(.title2).lineLimit(1) } .padding(rowPadding) - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(sectionShape) - .onTapGesture { + .modifier(ListRow { switchingProfile = true - dismiss() Task { do { try await changeActiveUserAsync_(u.user.userId, viewPwd: nil) - await MainActor.run { switchingProfile = false } + await MainActor.run { + switchingProfile = false + userPickerShown = false + } } catch { await MainActor.run { switchingProfile = false @@ -152,19 +145,23 @@ struct UserPicker: View { } } } - } + }) + .clipShape(sectionShape) } - - private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet) -> some View { - Button { - activeSheet = sheet - } label: { + + private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true) -> some View { + ZStack(alignment: .bottom) { settingsRow(icon, color: theme.colors.secondary) { Text(title).foregroundColor(.primary) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, rowPadding) + .padding(.vertical, rowVerticalPadding) + .modifier(ListRow { activeSheet = sheet }) + if showDivider { + Divider().padding(.leading, 52) + } } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } private func unreadBadge(_ u: UserInfo) -> some View { @@ -179,6 +176,92 @@ struct UserPicker: View { } } +struct ListRow: ViewModifier { + @Environment(\.colorScheme) private var colorScheme: ColorScheme + @State private var touchDown = false + let action: () -> Void + + func body(content: Content) -> some View { + ZStack { + elevatedSecondarySystemGroupedBackground + Color(.systemGray4).opacity(touchDown ? 1 : 0) + content + TouchOverlay(touchDown: $touchDown, action: action) + } + } + + var elevatedSecondarySystemGroupedBackground: Color { + switch colorScheme { + case .dark: Color(0xFF2C2C2E) + default: Color(0xFFFFFFFF) + } + } + + struct TouchOverlay: UIViewRepresentable { + @Binding var touchDown: Bool + let action: () -> Void + + func makeUIView(context: Context) -> TouchView { + let touchView = TouchView() + let gesture = UILongPressGestureRecognizer( + target: touchView, + action: #selector(touchView.longPress(gesture:)) + ) + gesture.delegate = touchView + gesture.minimumPressDuration = 0 + touchView.addGestureRecognizer(gesture) + return touchView + } + + func updateUIView(_ touchView: TouchView, context: Context) { + touchView.representer = self + } + + class TouchView: UIView, UIGestureRecognizerDelegate { + var representer: TouchOverlay? + private var startLocation: CGPoint? + private var task: Task? + + @objc + func longPress(gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + startLocation = gesture.location(in: nil) + task = Task { + do { + try await Task.sleep(nanoseconds: 200_000000) + await MainActor.run { representer?.touchDown = true } + } catch { } + } + case .ended: + if hitTest(gesture.location(in: self), with: nil) == self { + representer?.action() + } + task?.cancel() + representer?.touchDown = false + case .changed: + if let startLocation { + let location = gesture.location(in: nil) + let dx = location.x - startLocation.x + let dy = location.y - startLocation.y + if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed } + } + case .cancelled, .failed: + task?.cancel() + representer?.touchDown = false + default: break + } + } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith: UIGestureRecognizer + ) -> Bool { true } + } + } +} + + struct UserPicker_Previews: PreviewProvider { static var previews: some View { @State var activeSheet: UserPickerSheet? @@ -186,6 +269,7 @@ struct UserPicker_Previews: PreviewProvider { let m = ChatModel() m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( + userPickerShown: .constant(true), activeSheet: $activeSheet ) .environmentObject(m) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d0de0e1bd3..804f2307ef 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -44,7 +44,7 @@ enum DatabaseAlert: Identifiable { struct DatabaseView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme - @Binding var showSettings: Bool + let dismissSettingsSheet: DismissAction @State private var runChat = false @State private var alert: DatabaseAlert? = nil @State private var showFileImporter = false @@ -439,7 +439,7 @@ struct DatabaseView: View { private func startChat() { if m.chatDbChanged { - showSettings = false + dismissSettingsSheet() DispatchQueue.main.asyncAfter(deadline: .now() + 1) { resetChatCtrl() do { @@ -533,7 +533,9 @@ func deleteChatAsync() async throws { } struct DatabaseView_Previews: PreviewProvider { + @Environment(\.dismiss) static var mockDismiss + static var previews: some View { - DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none) + DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none) } } diff --git a/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift new file mode 100644 index 0000000000..580e8e940d --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift @@ -0,0 +1,183 @@ +// +// SwiftUISheet.swift +// SimpleX (iOS) +// +// Created by user on 23/09/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let sheetAnimationDuration: Double = 0.35 + +// Refrence: https://easings.net/ +private let easeOutCubic = UICubicTimingParameters( + controlPoint1: CGPoint(x: 0.215, y: 0.61), + controlPoint2: CGPoint(x: 0.355, y: 1) +) + +struct Sheet: ViewModifier { + @Binding var isPresented: Bool + @ViewBuilder let sheetContent: () -> SheetContent + + func body(content: Content) -> some View { + ZStack { + content + SheetRepresentable(isPresented: $isPresented, content: sheetContent()) + .allowsHitTesting(isPresented) + .ignoresSafeArea() + } + } +} + +struct SheetRepresentable: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let content: Content + + func makeUIViewController(context: Context) -> Controller { + Controller(content: content, representer: self) + } + + func updateUIViewController(_ sheetController: Controller, context: Context) { + sheetController.animate(isPresented: isPresented) + } + + class Controller: UIViewController { + let hostingController: UIHostingController + private let animator = UIViewPropertyAnimator( + duration: sheetAnimationDuration, + timingParameters: easeOutCubic + ) + private let representer: SheetRepresentable + private var retainedFraction: CGFloat = 0 + private var sheetHeight: Double { hostingController.view.frame.height } + private var task: Task? + + init(content: C, representer: SheetRepresentable) { + self.representer = representer + self.hostingController = UIHostingController(rootView: content) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) missing") } + + func animate(isPresented: Bool) { + let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed + let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0) + let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1) + + if !isPresented && sheetFullyDismissed || + isPresented && sheetFullyPresented || + alreadyAnimating { + return + } + + animator.pauseAnimation() + animator.isReversed = !isPresented + animator.continueAnimation( + withTimingParameters: isPresented + ? easeOutCubic + : UICubicTimingParameters(animationCurve: .easeIn), + durationFactor: 1 - animator.fractionComplete + ) + handleVisibility() + } + + func handleVisibility() { + if animator.isReversed { + task = Task { + do { + let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC)) + try await Task.sleep(nanoseconds: sleepDuration) + view.isHidden = true + } catch { } + } + } else { + task?.cancel() + task = nil + view.isHidden = false + } + } + + override func viewDidLoad() { + view.isHidden = true + view.backgroundColor = .clear + view.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(tap(gesture:))) + ) + addChild(hostingController) + hostingController.didMove(toParent: self) + if let sheet = hostingController.view { + sheet.isHidden = true + sheet.clipsToBounds = true + sheet.layer.cornerRadius = 10 + sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] + sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:)))) + sheet.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sheet) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + } + + override func viewDidAppear(_ animated: Bool) { + // Ensures animations are only setup once + // on some iOS version `viewDidAppear` can get called on each state change. + if hostingController.view.isHidden { + hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) + hostingController.view.isHidden = false + animator.pausesOnCompletion = true + animator.addAnimations { + self.hostingController.view.transform = .identity + self.view.backgroundColor = UIColor { + switch $0.userInterfaceStyle { + case .dark: .black.withAlphaComponent(0.290) + default: .black.withAlphaComponent(0.121) + } + } + } + animator.startAnimation() + animator.pauseAnimation() + } + } + + @objc + func pan(gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .began: + animator.isReversed = false + animator.pauseAnimation() + retainedFraction = animator.fractionComplete + case .changed: + animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight + case .ended, .cancelled: + let velocity = gesture.velocity(in: view).y + animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus + let defaultVelocity = sheetHeight / sheetAnimationDuration + let fractionRemaining = 1 - animator.fractionComplete + let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1) + animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining) + handleVisibility() + DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) { + self.representer.isPresented = !self.animator.isReversed + } + default: break + } + } + + @objc + func tap(gesture: UITapGestureRecognizer) { + switch gesture.state { + case .ended: + if gesture.location(in: view).y < view.frame.height - sheetHeight { + representer.isPresented = false + } + default: break + } + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/StickyScrollView.swift b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift index 71bee4f548..5799962778 100644 --- a/apps/ios/Shared/Views/Helpers/StickyScrollView.swift +++ b/apps/ios/Shared/Views/Helpers/StickyScrollView.swift @@ -9,6 +9,7 @@ import SwiftUI struct StickyScrollView: UIViewRepresentable { + @Binding var resetScroll: ResetScrollAction @ViewBuilder let content: () -> Content func makeUIView(context: Context) -> UIScrollView { @@ -18,6 +19,9 @@ struct StickyScrollView: UIViewRepresentable { sv.showsHorizontalScrollIndicator = false sv.addSubview(hc.view) sv.delegate = context.coordinator + DispatchQueue.main.async { + resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) } + } return sv } @@ -50,3 +54,8 @@ struct StickyScrollView: UIViewRepresentable { } } } + +struct ResetScrollAction { + var action = { } + func callAsFunction() { action() } +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f07ddf1420..63f2e789db 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -241,7 +241,6 @@ private struct InviteView: View { @Binding var choosingProfile: Bool @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false - @State private var showSettings: Bool = false var body: some View { List { @@ -693,6 +692,7 @@ struct ScannerInView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .foregroundColor(Color.clear) switch cameraAuthorizationStatus { + case .authorized, nil: EmptyView() case .restricted: Text("Camera not available") case .denied: Label("Enable camera access", systemImage: "camera") default: Label("Tap to scan", systemImage: "qrcode") @@ -712,21 +712,26 @@ struct ScannerInView: View { .disabled(cameraAuthorizationStatus == .restricted) } } - .onAppear { + .task { let status = AVCaptureDevice.authorizationStatus(for: .video) cameraAuthorizationStatus = status if showQRCodeScanner { switch status { - case .notDetermined: askCameraAuthorization() + case .notDetermined: await askCameraAuthorizationAsync() case .restricted: showQRCodeScanner = false case .denied: showQRCodeScanner = false case .authorized: () - @unknown default: askCameraAuthorization() + @unknown default: await askCameraAuthorizationAsync() } } } } + func askCameraAuthorizationAsync() async { + await AVCaptureDevice.requestAccess(for: .video) + cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + func askCameraAuthorization(_ cb: (() -> Void)? = nil) { AVCaptureDevice.requestAccess(for: .video) { allowed in cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index e3bae9287a..bc1dc4b5bc 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -49,34 +49,34 @@ struct QRCode: View { ZStack { if let image = image { qrCodeImage(image) - } - GeometryReader { geo in - ZStack { - if withLogo { - let w = geo.size.width - Image("icon-light") - .resizable() - .scaledToFit() - .frame(width: w * 0.16, height: w * 0.16) - .frame(width: w * 0.165, height: w * 0.165) - .background(.white) - .clipShape(Circle()) + GeometryReader { geo in + ZStack { + if withLogo { + let w = geo.size.width + Image("icon-light") + .resizable() + .scaledToFit() + .frame(width: w * 0.16, height: w * 0.16) + .frame(width: w * 0.165, height: w * 0.165) + .background(.white) + .clipShape(Circle()) + } } - } - .onAppear { - makeScreenshotFunc = { - let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) - showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) - onShare?() + .onAppear { + makeScreenshotFunc = { + let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) + showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) + onShare?() + } } + .frame(width: geo.size.width, height: geo.size.height) } - .frame(width: geo.size.width, height: geo.size.height) + } else { + Color.clear.aspectRatio(1, contentMode: .fit) } } .onTapGesture(perform: makeScreenshotFunc) - .onAppear { - image = image ?? generateImage(uri, tintColor: tintColor) - } + .task { image = await generateImage(uri, tintColor: tintColor) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -89,7 +89,7 @@ private func qrCodeImage(_ image: UIImage) -> some View { .textSelection(.enabled) } -private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? { +private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(uri.utf8) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index b1f68c09f4..b99c054abb 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -14,7 +14,6 @@ struct ConnectDesktopView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction - var viaSettings = false @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true @@ -57,16 +56,6 @@ struct ConnectDesktopView: View { } var body: some View { - if viaSettings { - viewBody - } else { - NavigationView { - viewBody - } - } - } - - var viewBody: some View { Group { let discovery = m.remoteCtrlSession?.discovery if discovery == true || (discovery == nil && !showConnectScreen) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index e018b181e6..a7938b86c3 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -257,23 +257,18 @@ let networkProxyDefault: CodableDefault = CodableDefault(defaults: struct SettingsView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var sceneDelegate: SceneDelegate @EnvironmentObject var theme: AppTheme - @Binding var showSettings: Bool @State private var showProgress: Bool = false var body: some View { ZStack { - NavigationView { - settingsView() - } + settingsView() if showProgress { progressView() } - if let la = chatModel.laRequest { - LocalAuthView(authRequest: la) - } } } @@ -347,7 +342,7 @@ struct SettingsView: View { Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { if let user = user { NavigationLink { - ChatHelp(showSettings: $showSettings) + ChatHelp(dismissSettingsSheet: dismiss) .navigationTitle("Welcome \(user.displayName)!") .modifier(ThemedBackground()) .frame(maxHeight: .infinity, alignment: .top) @@ -372,7 +367,7 @@ struct SettingsView: View { } settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { - showSettings = false + dismiss() DispatchQueue.main.async { UIApplication.shared.open(simplexTeamURL) } @@ -429,7 +424,7 @@ struct SettingsView: View { private func chatDatabaseRow() -> some View { NavigationLink { - DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL) + DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL) .navigationTitle("Your chat database") .modifier(ThemedBackground(grouped: true)) } label: { @@ -525,9 +520,7 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = User.sampleData - @State var showSettings = false - - return SettingsView(showSettings: $showSettings) + return SettingsView() .environmentObject(chatModel) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 74c2020a4e..270dbfd8d7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -216,6 +216,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 /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.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 */; }; @@ -558,6 +559,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 /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.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 /* SheetRepresentable.swift */, ); path = Helpers; sourceTree = ""; @@ -1449,6 +1452,7 @@ 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, + CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,