diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 31d057df8e..352b01a78c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -47,22 +47,24 @@ struct ChatListView: View { } private var viewBody: some View { - ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) { - NavStackCompat( - isActive: Binding( - get: { chatModel.chatId != nil }, - set: { active in - if !active { chatModel.chatId = nil } - } - ), - destination: chatView - ) { chatListView } - } - .modifier( - SwiftUISheet(height: 400, isPresented: $userPickerShown) { + ZStack { + ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) { + NavStackCompat( + isActive: Binding( + get: { chatModel.chatId != nil }, + set: { active in + if !active { chatModel.chatId = nil } + } + ), + destination: chatView + ) { chatListView } + } + SheetRepresentable(isPresented: $userPickerShown) { UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet) } - ) + .allowsHitTesting(userPickerShown) + .ignoresSafeArea() + } .sheet(item: $activeUserPickerSheet) { sheet in if let currentUser = chatModel.currentUser { switch sheet { diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index e9d3e6c9ab..1c30da34f0 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -109,7 +109,7 @@ struct UserPicker: View { } } } - .modifier(ThemedBackground(grouped: true)) +// .modifier(ThemedBackground(grouped: true)) .disabled(switchingProfile) } @@ -133,11 +133,14 @@ struct UserPicker: View { .clipShape(sectionShape) .onTapGesture { switchingProfile = true - userPickerShown = false + 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 diff --git a/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift new file mode 100644 index 0000000000..9c3892670f --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/SheetRepresentable.swift @@ -0,0 +1,179 @@ +// +// 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.3 + +struct SheetRepresentable: UIViewControllerRepresentable { + @Binding var isPresented: Bool + @ViewBuilder let content: () -> Content + + func makeUIViewController(context: Context) -> Controller { + Controller(content: content(), representer: self) + } + + func updateUIViewController(_ sheetController: Controller, context: Context) { + sheetController.animate(isPresented: isPresented) + sheetController.hostingController.rootView = content() + } + + class Controller: UIViewController { + let hostingController: UIHostingController + private let animator = UIViewPropertyAnimator(duration: sheetAnimationDuration, curve: .easeInOut) + private let representer: SheetRepresentable + private var retainedFraction: CGFloat = 0 + + init(content: Content, 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 sheetDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0) + if isPresented || !sheetDismissed { + animator.pauseAnimation() + animator.isReversed = !isPresented + animator.continueAnimation(withTimingParameters: nil, durationFactor: 1) + } + } + + override func viewDidLoad() { + view.backgroundColor = .clear + view.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(tap(gesture:))) + ) + addChild(hostingController) + hostingController.didMove(toParent: self) + if let sheet = hostingController.view { + sheet.backgroundColor = UIColor { traits in + let elevated = switch traits.userInterfaceStyle { + case .dark: elevatedSystemGroupedBackground(.dark) + default: elevatedSystemGroupedBackground(.light) + } + return elevated.cgColor.map { UIColor(cgColor: $0) } + ?? .secondarySystemBackground + } + 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.topAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + animator.pausesOnCompletion = true + animator.scrubsLinearly = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.animator.addAnimations { + sheet.transform = CGAffineTransform(translationX: 0, y: -sheet.frame.height) + self?.view.backgroundColor = .black.withAlphaComponent(0.3) + } + } + } + } + + @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 / hostingController.view.frame.height + case .ended, .cancelled: + let velocity = gesture.velocity(in: view).y + animator.isReversed = velocity.sign == .plus + let defaultVelocity = hostingController.view.frame.height / sheetAnimationDuration + let fractionRemaining = 1 - animator.fractionComplete + let durationFactor = min(fractionRemaining / (abs(velocity) / defaultVelocity), 1) + animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor) + DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) { + self.representer.isPresented = !self.animator.isReversed + } + default: break + } + } + + @objc + func tap(gesture: UITapGestureRecognizer) { + switch gesture.state { + case .ended: + representer.isPresented = false + default: break + } + } + } +} + +// MARK: Sheet Colors + +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/Shared/Views/Helpers/SwiftUISheet.swift b/apps/ios/Shared/Views/Helpers/SwiftUISheet.swift deleted file mode 100644 index 92f0bb6284..0000000000 --- a/apps/ios/Shared/Views/Helpers/SwiftUISheet.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// 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.3 * 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 { _ in - let ip = relativeOffset > 0.6 - if ip == isPresented { - animate() - } else { - isPresented = ip - } - } - ) - .frame(maxHeight: .infinity, alignment: .bottom) - .offset(y: sheetHeight - sheetHeight * relativeOffset) - } - .onChange(of: isPresented) { _ in animate() } - } - - private func animate() { - let newOffset: Double = isPresented ? 1 : 0 - let distance: Double = abs(newOffset - relativeOffset) - if distance != 0 { - withAnimation( - .easeOut(duration: max(0.1, distance * 0.3)) - ) { 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 199e4f2607..b005fe2f94 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -211,7 +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 */; }; + 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 */; }; @@ -554,7 +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 = ""; }; + 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; }; @@ -801,7 +801,7 @@ CE7548092C622630009579B7 /* SwipeLabel.swift */, CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */, CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, - CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */, + CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */, ); path = Helpers; sourceTree = ""; @@ -1452,7 +1452,7 @@ 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, - CEFB2EDF2CA1BCC7004B1ECE /* SwiftUISheet.swift in Sources */, + CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,