diff --git a/README.md b/README.md index f7c9914b..7b0cc54a 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ These use `AnyView`, so please try to keep them easy enough `showDateHeaders` - show section headers with dates between days, default is `true` `isScrollEnabled` - forbid scrolling for messages' `UITableView` `keyboardDismissMode` - set keyboard dismiss mode for the chat list (.interactive, .onDrag, or .none), default is .none +`autoFocusTextInputOnChatOpen` - automatically focus the inputTextView when the chat view is opened, default is `false` `showMessageMenuOnLongPress` - turn menu on long tap on/off `messageMenuAnimationDuration` - control how fast/snappy the message menu animations feel `contentInsets` - set additional content insets for the messages list diff --git a/Sources/ExyteChat/Utils/ZoomableContainer.swift b/Sources/ExyteChat/Utils/ZoomableContainer.swift new file mode 100644 index 00000000..cb76655b --- /dev/null +++ b/Sources/ExyteChat/Utils/ZoomableContainer.swift @@ -0,0 +1,121 @@ +// +// ZoomableContainer.swift +// Chat +// +// Created by Thilo Molitor on 10.10.2023. +// + +import SwiftUI + +public struct ZoomableContainer: View { + let content: Content + let maxScale: CGFloat + let doubleTapScale: CGFloat + @State private var currentScale: CGFloat = 1.0 + @State private var tapLocation: CGPoint = .zero + + public init(maxScale:CGFloat = 4.0, doubleTapScale:CGFloat = 4.0, @ViewBuilder content: () -> Content) { + self.content = content() + self.maxScale = maxScale + self.doubleTapScale = doubleTapScale + } + + public var body: some View { + ZoomableScrollView(maxScale: maxScale, scale: $currentScale, tapLocation: $tapLocation) { + content + } + .onTapGesture(count: 2, perform: { location in + tapLocation = location + currentScale = currentScale == 1.0 ? doubleTapScale : 1.0 + }) + } + + fileprivate struct ZoomableScrollView: UIViewRepresentable { + private var content: InnerContent + let maxScale: CGFloat + @Binding private var currentScale: CGFloat + @Binding private var tapLocation: CGPoint + + init(maxScale: CGFloat, scale: Binding, tapLocation: Binding, @ViewBuilder content: () -> InnerContent) { + self.maxScale = maxScale + _currentScale = scale + _tapLocation = tapLocation + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + // Setup the UIScrollView + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator // for viewForZooming(in:) + scrollView.maximumZoomScale = maxScale + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.clipsToBounds = false + + // Create a UIHostingController to hold our SwiftUI content + let hostedView = context.coordinator.hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostedView.frame = scrollView.bounds + scrollView.addSubview(hostedView) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + return Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale) + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + // Update the hosting controller's SwiftUI content + context.coordinator.hostingController.rootView = content + + if uiView.zoomScale > uiView.minimumZoomScale { // Scale out + uiView.setZoomScale(currentScale, animated: true) + } else if tapLocation != .zero { // Scale in to a specific point + uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true) + } + + // Reset the location to prevent scaling to it in case of a negative scale (manual pinch) + // Use the main thread to prevent unexpected behavior + DispatchQueue.main.async { tapLocation = .zero } + + assert(context.coordinator.hostingController.view.superview == uiView) + } + + // MARK: - Utils + + func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect { + let scrollViewSize = scrollView.bounds.size + + let width = scrollViewSize.width / scale + let height = scrollViewSize.height / scale + let x = center.x - (width / 2.0) + let y = center.y - (height / 2.0) + + return CGRect(x: x, y: y, width: width, height: height) + } + + // MARK: - Coordinator + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + @Binding var currentScale: CGFloat + + init(hostingController: UIHostingController, scale: Binding) { + self.hostingController = hostingController + _currentScale = scale + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return hostingController.view + } + + func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + currentScale = scale + } + } + } +} diff --git a/Sources/ExyteChat/Utils/ZoomableScrollView.swift b/Sources/ExyteChat/Utils/ZoomableScrollView.swift deleted file mode 100644 index c24a88af..00000000 --- a/Sources/ExyteChat/Utils/ZoomableScrollView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ZoomableScrollView.swift -// Chat -// -// Created by Alisa Mylnikova on 08.04.2026. -// - -import SwiftUI - -struct ZoomableScrollView: UIViewRepresentable { - private var content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - func makeUIView(context: Context) -> UIScrollView { - let scrollView = UIScrollView() - scrollView.delegate = context.coordinator - scrollView.maximumZoomScale = 10 - scrollView.minimumZoomScale = 1 - scrollView.bouncesZoom = true - - let hostedView = context.coordinator.hostingController.view! - hostedView.translatesAutoresizingMaskIntoConstraints = true - hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - hostedView.frame = scrollView.bounds - scrollView.addSubview(hostedView) - scrollView.showsVerticalScrollIndicator = false - scrollView.showsHorizontalScrollIndicator = false - - return scrollView - } - - func makeCoordinator() -> Coordinator { - Coordinator(hostingController: UIHostingController(rootView: self.content)) - } - - func updateUIView(_ uiView: UIScrollView, context: Context) { - context.coordinator.hostingController.rootView = self.content - assert(context.coordinator.hostingController.view.superview == uiView) - } - - class Coordinator: NSObject, UIScrollViewDelegate { - var hostingController: UIHostingController - - init(hostingController: UIHostingController) { - self.hostingController = hostingController - self.hostingController.view.backgroundColor = UIColor.clear - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - hostingController.view - } - } -} diff --git a/Sources/ExyteChat/Views/Attachments/AttachmentsPage.swift b/Sources/ExyteChat/Views/Attachments/AttachmentsPage.swift index 0681b6d8..02646d8e 100644 --- a/Sources/ExyteChat/Views/Attachments/AttachmentsPage.swift +++ b/Sources/ExyteChat/Views/Attachments/AttachmentsPage.swift @@ -13,7 +13,7 @@ struct AttachmentsPage: View { var body: some View { if attachment.type == .image { - ZoomableScrollView { + ZoomableContainer { CachedAsyncImage( url: attachment.full, cacheKey: attachment.fullCacheKey diff --git a/Sources/ExyteChat/Views/ChatCustomizationParameters.swift b/Sources/ExyteChat/Views/ChatCustomizationParameters.swift index 56d97cfe..b5246eef 100644 --- a/Sources/ExyteChat/Views/ChatCustomizationParameters.swift +++ b/Sources/ExyteChat/Views/ChatCustomizationParameters.swift @@ -14,6 +14,7 @@ struct ChatCustomizationParameters { var showNetworkConnectionProblem: Bool = false var showDateHeaders: Bool = true var isScrollEnabled: Bool = true + var autoFocusTextInputOnChatOpen: Bool = false var showMessageMenuOnLongPress: Bool = true var keyboardDismissMode: UIScrollView.KeyboardDismissMode = .none var messageMenuAnimationDuration: CGFloat = 0.3 diff --git a/Sources/ExyteChat/Views/ChatView.swift b/Sources/ExyteChat/Views/ChatView.swift index dda4700c..2050e9d8 100644 --- a/Sources/ExyteChat/Views/ChatView.swift +++ b/Sources/ExyteChat/Views/ChatView.swift @@ -310,6 +310,9 @@ public struct ChatView ChatView { + var view = self + view.chatCustomizationParameters.autoFocusTextInputOnChatOpen = autoFocus + return view + } + func showMessageMenuOnLongPress(_ show: Bool) -> ChatView { var view = self view.chatCustomizationParameters.showMessageMenuOnLongPress = show