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:
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