damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 5de745fb19ecacaa57c4e501be3a477722ae5347
parent 1baae90bebb9bb1fab8f05377aa0e480f1cdf0f1
Author: OlegAba <mail@olegaba.com>
Date:   Wed, 25 Jan 2023 16:25:55 -0500

Add double tap gesture and fix bugs

Changlog-Added: Image double-tap gesture
Closes: #397

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Components/ImageCarousel.swift | 81+++++++++++++++++--------------------------------------------------------------
Adamus/Components/ZoomableScrollView.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Modifiers/SwipeToDismiss.swift | 8++++++--
4 files changed, 179 insertions(+), 66 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -174,6 +174,7 @@ 7C45AE6D297352F90031D7BC /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C45AE6C297352F90031D7BC /* SVGKit */; }; 7C45AE6F297352F90031D7BC /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7C45AE6E297352F90031D7BC /* SVGKitSwift */; }; 7C45AE71297353390031D7BC /* KFImageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C45AE70297353390031D7BC /* KFImageModel.swift */; }; + 7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; }; 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; @@ -423,6 +424,7 @@ 647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = "<group>"; }; 64FBD06E296255C400D9D3B2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; }; 7C45AE70297353390031D7BC /* KFImageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFImageModel.swift; sourceTree = "<group>"; }; + 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; }; 9609F057296E220800069BF3 /* BannerImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerImageView.swift; sourceTree = "<group>"; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; @@ -724,6 +726,7 @@ 4CBCA92F297DB57F00EC6B2F /* WebsiteLink.swift */, 4CC7AAEC297F0B9E00430951 /* Highlight.swift */, 4CF0ABE22981BC7D00D66079 /* UserView.swift */, + 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */, ); path = Components; sourceTree = "<group>"; @@ -996,6 +999,7 @@ 4C363AA828297703006E126D /* InsertSort.swift in Sources */, 4C285C86283892E7008A31F1 /* CreateAccountModel.swift in Sources */, 4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */, + 7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */, 4C363A8C28236B92006E126D /* PubkeyView.swift in Sources */, 4C5C7E68284ED36500A22DF5 /* SearchHomeModel.swift in Sources */, 4C75EFB728049D990006080F /* RelayPool.swift in Sources */, diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift @@ -115,56 +115,6 @@ private struct ImageContainerView: View { // TODO: Update ImageCarousel with serializer and processor // .serialize(by: imageModel.serializer) // .setProcessor(imageModel.processor) - - } -} - -struct ZoomableScrollView<Content: View>: 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 = 20 - scrollView.minimumZoomScale = 1 - scrollView.bouncesZoom = true - scrollView.showsVerticalScrollIndicator = false - scrollView.showsHorizontalScrollIndicator = false - - let hostedView = context.coordinator.hostingController.view! - hostedView.translatesAutoresizingMaskIntoConstraints = true - hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - hostedView.frame = scrollView.bounds - hostedView.backgroundColor = .clear - scrollView.addSubview(hostedView) - - return scrollView - } - - func makeCoordinator() -> Coordinator { - return 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<Content> - - init(hostingController: UIHostingController<Content>) { - self.hostingController = hostingController - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return hostingController.view - } } } @@ -177,6 +127,14 @@ struct ImageView: View { @State private var selectedIndex = 0 @State var showMenu = true + var safeAreaInsets: UIEdgeInsets? { + return UIApplication + .shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow }?.safeAreaInsets + } + var navBarView: some View { VStack { HStack { @@ -222,22 +180,24 @@ struct ImageView: View { ZoomableScrollView { ImageContainerView(url: urls[index]) .aspectRatio(contentMode: .fit) + .padding(.top, safeAreaInsets?.top) + .padding(.bottom, safeAreaInsets?.bottom) } - .ignoresSafeArea() - .tag(index) .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { presentationMode.wrappedValue.dismiss() })) + .ignoresSafeArea() + .tag(index) } } .ignoresSafeArea() .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) - .onChange(of: selectedIndex, perform: { _ in - showMenu = true + .gesture(TapGesture(count: 2).onEnded { + // Prevents menu from hiding on double tap }) - .onTapGesture { + .gesture(TapGesture(count: 1).onEnded { showMenu.toggle() - } + }) .overlay( VStack { if showMenu { @@ -250,14 +210,7 @@ struct ImageView: View { } } .animation(.easeInOut, value: showMenu) - .padding( - .bottom, - UIApplication - .shared - .connectedScenes - .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } - .first { $0.isKeyWindow }?.safeAreaInsets.bottom - ) + .padding(.bottom, safeAreaInsets?.bottom) ) } } diff --git a/damus/Components/ZoomableScrollView.swift b/damus/Components/ZoomableScrollView.swift @@ -0,0 +1,152 @@ +// +// ZoomableScrollView.swift +// damus +// +// Created by Oleg Abalonski on 1/25/23. +// + +import SwiftUI + +struct ZoomableScrollView<Content: View>: UIViewRepresentable { + + private var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = GesturedScrollView() + scrollView.delegate = context.coordinator + scrollView.maximumZoomScale = 20 + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + + let hostedView = context.coordinator.hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostedView.frame = scrollView.bounds + hostedView.backgroundColor = .clear + scrollView.addSubview(hostedView) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + return Coordinator(hostingController: UIHostingController(rootView: self.content, ignoreSafeArea: true)) + } + + 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<Content> + + init(hostingController: UIHostingController<Content>) { + self.hostingController = hostingController + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return hostingController.view + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + let viewSize = hostingController.view.frame.size + guard let imageSize = scrollView.subviews[0].subviews.last?.frame.size else { return } + + if scrollView.zoomScale > 1 { + + let ratioW = viewSize.width / imageSize.width + let ratioH = viewSize.height / imageSize.height + + let ratio = ratioW < ratioH ? ratioW:ratioH + + let newWidth = imageSize.width * ratio + let newHeight = imageSize.height * ratio + + let left = 0.5 * (newWidth * scrollView.zoomScale > viewSize.width ? (newWidth - viewSize.width) : (scrollView.frame.width - scrollView.contentSize.width)) + let top = 0.5 * (newHeight * scrollView.zoomScale > viewSize.height ? (newHeight - viewSize.height) : (scrollView.frame.height - scrollView.contentSize.height)) + + scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left) + } else { + scrollView.contentInset = .zero + } + } + } +} + +fileprivate class GesturedScrollView: UIScrollView, UIGestureRecognizerDelegate { + + let doubleTapGesture: UITapGestureRecognizer + + override init(frame: CGRect) { + doubleTapGesture = UITapGestureRecognizer() + super.init(frame: frame) + doubleTapGesture.addTarget(self, action: #selector(handleDoubleTap)) + doubleTapGesture.numberOfTapsRequired = 2 + addGestureRecognizer(doubleTapGesture) + doubleTapGesture.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + if self.zoomScale == 1 { + let pointInView = gesture.location(in: self.subviews.first) + let newZoomScale = self.maximumZoomScale / 4.0 + let scrollViewSize = self.bounds.size + let width = scrollViewSize.width / newZoomScale + let height = scrollViewSize.height / newZoomScale + let originX = pointInView.x - (width / 2.0) + let originY = pointInView.y - (height / 2.0) + let zoomRect = CGRect(x: originX, y: originY, width: width, height: height) + self.zoom(to: zoomRect, animated: true) + } else { + self.setZoomScale(self.minimumZoomScale, animated: true) + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer == doubleTapGesture + } +} + +fileprivate extension UIHostingController { + + convenience init(rootView: Content, ignoreSafeArea: Bool) { + self.init(rootView: rootView) + + if ignoreSafeArea { + disableSafeArea() + } + } + + func disableSafeArea() { + guard let viewClass = object_getClass(view) else { return } + + let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea") + if let viewSubclass = NSClassFromString(viewSubclassName) { + object_setClass(view, viewSubclass) + } + else { + guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return } + guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return } + + if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { + let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in + return .zero + } + class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method)) + } + + objc_registerClassPair(viewSubclass) + object_setClass(view, viewSubclass) + } + } +} diff --git a/damus/Modifiers/SwipeToDismiss.swift b/damus/Modifiers/SwipeToDismiss.swift @@ -11,13 +11,17 @@ struct SwipeToDismissModifier: ViewModifier { let minDistance: CGFloat? var onDismiss: () -> Void @State private var offset: CGSize = .zero + @GestureState private var viewOffset: CGSize = .zero func body(content: Content) -> some View { content - .offset(y: offset.height) - .animation(.interactiveSpring(), value: offset) + .offset(y: viewOffset.height) + .animation(.interactiveSpring(), value: viewOffset) .simultaneousGesture( DragGesture(minimumDistance: minDistance ?? 10) + .updating($viewOffset, body: { value, gestureState, transaction in + gestureState = CGSize(width: value.location.x - value.startLocation.x, height: value.location.y - value.startLocation.y) + }) .onChanged { gesture in if gesture.translation.width < 50 { offset = gesture.translation