Преглед изворни кода

feat: 调整 UIScrollView 嵌套滚动逻辑处理

陈文艺 пре 4 недеља
родитељ
комит
8991dade5b

+ 9 - 2
Lanu.xcodeproj/project.pbxproj

@@ -82,6 +82,7 @@
 				Common/Views/LNCircleProgressView.swift,
 				Common/Views/LNCountrySelectPanel.swift,
 				Common/Views/LNGenderView.swift,
+				Common/Views/LNNestedScrollView.swift,
 				Common/Views/LNOnlineView.swift,
 				Common/Views/LNPopupView.swift,
 				Common/Views/LNSortedEditView.swift,
@@ -382,8 +383,6 @@
 		};
 		FBB67E232EC48B440070E686 /* ThirdParty */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
-			exceptions = (
-			);
 			path = ThirdParty;
 			sourceTree = "<group>";
 		};
@@ -536,10 +535,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Copy Pods Resources";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources.sh\"\n";
@@ -575,10 +578,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks.sh\"\n";

+ 0 - 121
Lanu/Common/Extension/UIScrollView+Extension.swift

@@ -60,124 +60,3 @@ extension UIScrollView: LNKeyboardNotify {
         contentInset = originContentInset
     }
 }
-
-private var scrollViewNestedKey: UInt8 = 0
-extension UIScrollView: UIGestureRecognizerDelegate {
-    private var isNested: Bool {
-        get {
-            objc_getAssociatedObject(self, &scrollViewNestedKey) as? Bool ?? false
-        }
-        set {
-            objc_setAssociatedObject(self, &scrollViewNestedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
-        }
-    }
-    func toBeNested() {
-        panGestureRecognizer.delegate = self
-        bounces = false
-        isNested = true
-    }
-    
-    // MARK: - UIGestureRecognizerDelegate
-    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
-        return true
-    }
-    
-    open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
-        if !isNested {
-            return super.gestureRecognizerShouldBegin(gestureRecognizer)
-        }
-        guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer,
-              let currentScrollView = panGesture.view as? UIScrollView else {
-            return true
-        }
-        
-        guard currentScrollView.isGestureWithinSelfBounds(panGesture) else {
-            return false // 不在范围内,直接忽略手势
-        }
-        
-        // 获取滑动方向(向上:y 负,向下:y 正)
-        let translation = panGesture.translation(in: currentScrollView)
-        let isScrollingUp = translation.y < 0 // 向上滑
-        let isScrollingDown = translation.y > 0 // 向下滑
-        
-        // 根据滑动方向判断当前 ScrollView 是否可以响应手势
-        if isScrollingUp {
-            return currentScrollView.checkOuterScrollViewsAtBottom(panGesture: panGesture)
-        } else if isScrollingDown {
-            return currentScrollView.checkInnerScrollViewsAtTop(panGesture: panGesture)
-        }
-        
-        return true
-    }
-    
-    // MARK: - UI 树遍历核心逻辑
-    /// 上滑:检查所有外层 ScrollView 是否都滚到底部
-    private func checkOuterScrollViewsAtBottom(panGesture: UIPanGestureRecognizer) -> Bool {
-        var currentSuperview = self.superview
-        // 向上遍历父视图,找所有外层 ScrollView
-        while let superview = currentSuperview {
-            if let outerScrollView = superview as? UIScrollView {
-                // 只要有一个外层没滚到底,当前 ScrollView 就不能响应
-                if !outerScrollView.isAtBottom() {
-                    return false
-                }
-            }
-            currentSuperview = superview.superview
-        }
-        // 所有外层都滚到底,当前可以响应
-        return !isAtBottom()
-    }
-    
-    /// 下滑:检查所有内层 ScrollView 是否都滚到顶部
-    private func checkInnerScrollViewsAtTop(panGesture: UIPanGestureRecognizer) -> Bool {
-        // 向下遍历子视图,找所有内层 ScrollView
-        // 递归遍历子视图的子视图(深层嵌套)
-        if findFirstNotAtTopScrollView(in: self, panGesture: panGesture) != nil {
-            return false
-        }
-        // 所有内层都滚到顶,当前可以响应
-        return !isAtTop()
-    }
-    
-    /// 递归查找子视图中的第一个 ScrollView(处理深层嵌套)
-    private func findFirstNotAtTopScrollView(
-        in view: UIView,
-        panGesture: UIPanGestureRecognizer) -> UIScrollView? {
-        for subview in view.subviews {
-            if let scrollView = subview as? UIScrollView,
-                scrollView.isGestureWithinSelfBounds(panGesture),
-                !scrollView.isAtTop() {
-                return scrollView
-            }
-            if let found = findFirstNotAtTopScrollView(in: subview, panGesture: panGesture) {
-                return found
-            }
-        }
-        return nil
-    }
-    
-    // MARK: - 滚动边界判断
-    private func isAtTop() -> Bool {
-        let topOffset = contentOffset.y + adjustedContentInset.top
-        return topOffset <= 1 // 允许1pt误差
-    }
-    
-    private func isAtBottom() -> Bool {
-        let contentHeight = contentSize.height + adjustedContentInset.bottom
-        let bottomOffset = contentOffset.y + bounds.height
-        return bottomOffset >= contentHeight - 1
-    }
-    
-    /// 判断手势触摸点是否在当前 ScrollView 的可视范围内
-    private func isGestureWithinSelfBounds(_ panGesture: UIPanGestureRecognizer) -> Bool {
-        // 1. 获取手势的触摸点(相对于当前 ScrollView)
-        let touchLocation = panGesture.location(in: self)
-        // 2. 转换为 ScrollView 的 bounds 坐标系(处理 contentOffset 和 contentInset)
-        let convertedPoint = CGPoint(
-            x: touchLocation.x - adjustedContentInset.left,
-            y: touchLocation.y - adjustedContentInset.top
-        )
-        // 3. 校验点是否在 ScrollView 的可视区域内(排除超出 bounds 的部分)
-        return bounds.contains(convertedPoint)
-    }
-}

+ 199 - 0
Lanu/Common/Views/LNNestedScrollView.swift

@@ -0,0 +1,199 @@
+//
+//  LNNestedScrollView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/3/12.
+//
+
+import Foundation
+import UIKit
+
+
+private class ScrollViewWeakWrapper {
+    weak var delegate: LNNestedScrollViewDelegate?
+    weak var childListView: UIScrollView?
+}
+
+private var scrollViewWeakWrapperKey: UInt8 = 0
+private extension UIScrollView {
+    var minTopOffset: CGFloat {
+        contentSize.height - bounds.height - contentInset.top + contentInset.bottom
+    }
+    var weakWrapper: ScrollViewWeakWrapper {
+        get {
+            var wrapper = objc_getAssociatedObject(self, &scrollViewWeakWrapperKey)
+                as? ScrollViewWeakWrapper
+            if wrapper == nil {
+                wrapper = ScrollViewWeakWrapper()
+                objc_setAssociatedObject(
+                    self,
+                    &scrollViewWeakWrapperKey,
+                    wrapper,
+                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+                )
+            }
+            return wrapper!
+        }
+        set {
+            objc_setAssociatedObject(
+                self,
+                &scrollViewWeakWrapperKey,
+                newValue,
+                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+            )
+        }
+    }
+    
+    weak var curChildListView: UIScrollView? {
+        get { weakWrapper.childListView }
+        set { weakWrapper.childListView = newValue }
+    }
+}
+
+extension UIScrollView: LNNestedScrollViewDelegate {
+    func listViewDidScroll(_ scrollView: UIScrollView) -> Bool {
+        curChildListView = scrollView
+        
+        if contentOffset.y < minTopOffset - 0.001 {
+            return true
+        }
+        
+        contentOffset.y = minTopOffset
+        return false
+    }
+}
+
+extension UIScrollView {
+    weak var observerDelegate: LNNestedScrollViewDelegate? {
+        get { weakWrapper.delegate }
+        set { weakWrapper.delegate = newValue }
+    }
+}
+
+
+protocol LNNestedScrollViewDelegate: NSObject {
+    func listViewDidScroll(_ scrollView: UIScrollView) -> Bool
+}
+
+
+private class ScrollViewDelegateProxy: NSObject, UIScrollViewDelegate {
+    weak var originalDelegate: UIScrollViewDelegate?
+    weak var observerDelegate: UIScrollViewDelegate?
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        originalDelegate?.scrollViewDidScroll?(scrollView)
+        observerDelegate?.scrollViewDidScroll?(scrollView)
+    }
+    
+    override func forwardingTarget(for aSelector: Selector!) -> Any? {
+        if originalDelegate?.responds(to: aSelector) == true {
+            return originalDelegate
+        }
+        return super.forwardingTarget(for: aSelector)
+    }
+    
+    override func responds(to aSelector: Selector!) -> Bool {
+        return super.responds(to: aSelector) || originalDelegate?.responds(to: aSelector) == true
+    }
+}
+
+
+class LNNestedScrollView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
+    weak var scrollDelegate: UIScrollViewDelegate? {
+        didSet {
+            proxy.originalDelegate = scrollDelegate
+            delegate = nil
+            delegate = proxy
+        }
+    }
+    private var proxy = ScrollViewDelegateProxy()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        proxy.originalDelegate = scrollDelegate
+        proxy.observerDelegate = self
+        delegate = proxy
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        if observerDelegate?.listViewDidScroll(self) == true {
+            scrollView.contentOffset.y = -contentInset.top
+        }
+        
+        if let curChildListView,
+            curChildListView.contentOffset.y > -curChildListView.contentInset.top {
+            scrollView.contentOffset.y = minTopOffset
+        }
+    }
+    
+    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        gestureRecognizer.isKind(of: UIPanGestureRecognizer.self) && otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self)
+    }
+}
+
+private class TableViewDelegateProxy: NSObject, UITableViewDelegate {
+    weak var originalDelegate: UITableViewDelegate?
+    weak var observerDelegate: UITableViewDelegate?
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        originalDelegate?.scrollViewDidScroll?(scrollView)
+        observerDelegate?.scrollViewDidScroll?(scrollView)
+    }
+    
+    override func forwardingTarget(for aSelector: Selector!) -> Any? {
+        if originalDelegate?.responds(to: aSelector) == true {
+            return originalDelegate
+        }
+        return super.forwardingTarget(for: aSelector)
+    }
+    
+    override func responds(to aSelector: Selector!) -> Bool {
+        super.responds(to: aSelector)
+            || originalDelegate?.responds(to: aSelector) == true
+    }
+}
+
+
+class LNNestedTableView: UITableView, UITableViewDelegate, UIGestureRecognizerDelegate {
+    weak var tableDelegate: UITableViewDelegate? {
+        didSet {
+            proxy.originalDelegate = tableDelegate
+            delegate = nil
+            delegate = proxy
+        }
+    }
+    private var proxy = TableViewDelegateProxy()
+    
+    override init(frame: CGRect, style: UITableView.Style) {
+        super.init(frame: frame, style: style)
+        
+        proxy.originalDelegate = tableDelegate
+        proxy.observerDelegate = self
+        delegate = proxy
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        if observerDelegate?.listViewDidScroll(self) == true {
+            scrollView.contentOffset.y = -contentInset.top
+        }
+        
+        
+        if let curChildListView,
+            curChildListView.contentOffset.y > -curChildListView.contentInset.top {
+            scrollView.contentOffset.y = minTopOffset
+        }
+    }
+    
+    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        gestureRecognizer.isKind(of: UIPanGestureRecognizer.self) && otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self)
+    }
+}

+ 6 - 1
Lanu/Views/Profile/Profile/LNProfileInfosView.swift

@@ -34,7 +34,12 @@ class LNProfileInfosView: UIView {
         }
     }
     
-    weak var outerScrollView: UIScrollView?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            detailView.outerScrollView = outerScrollView
+            photoWall.outerScrollView = outerScrollView
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)

+ 10 - 6
Lanu/Views/Profile/Profile/LNProfilePhotoWall.swift

@@ -26,7 +26,7 @@ class LNProfilePhotoWall: UIView {
     private let columnCount = 2
     private var allPhotos: [LNProfilePhotoWallMode] = []
     private var columns: [[LNProfilePhotoWallMode]] = []
-    private var tableViews: [UITableView] = []
+    private var tableViews: [LNNestedTableView] = []
     
     private(set) var contentHeight: CGFloat = 0.0 {
         didSet {
@@ -35,6 +35,11 @@ class LNProfilePhotoWall: UIView {
     }
     
     weak var delegate: LNProfilePhotoWallDelegate?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            tableViews.forEach { $0.observerDelegate = outerScrollView }
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)
@@ -151,7 +156,7 @@ extension LNProfilePhotoWall: UITableViewDataSource {
         }
         
         let mode = columns[index][indexPath.row]
-        cell.update(mode, isFirst: indexPath.row == 0)
+        cell.update(mode)
         cell.handler = { [weak self] mode in
             guard let self else { return }
             let urls = allPhotos.map { $0.url }
@@ -183,13 +188,13 @@ extension LNProfilePhotoWall {
         }
         
         for _ in 0..<columnCount {
-            let tableView = UITableView()
+            let tableView = LNNestedTableView()
             tableView.separatorStyle = .none
             tableView.showsHorizontalScrollIndicator = false
             tableView.showsVerticalScrollIndicator = false
             tableView.register(LNProfilePhotoWallCell.self, forCellReuseIdentifier: LNProfilePhotoWallCell.className)
             tableView.dataSource = self
-            tableView.delegate = self
+            tableView.tableDelegate = self
             tableView.allowsSelection = false
             tableView.contentInset = .init(top: 6, left: 0, bottom: 0, right: 0)
             tableView.backgroundColor = .clear
@@ -202,7 +207,6 @@ extension LNProfilePhotoWall {
                 })
                 contentHeight = (maxHeightItem?.contentInset.top ?? 0) + (maxHeightItem?.contentSize.height ?? 0)
             }.store(in: &bag)
-            tableView.toBeNested()
             stackView.addArrangedSubview(tableView)
             tableViews.append(tableView)
         }
@@ -230,7 +234,7 @@ private class LNProfilePhotoWallCell: UITableViewCell {
         }
     }
     
-    func update(_ mode: LNProfilePhotoWallMode, isFirst: Bool) {
+    func update(_ mode: LNProfilePhotoWallMode) {
         photo.snp.remakeConstraints { make in
             make.horizontalEdges.equalToSuperview()
             make.top.equalToSuperview().offset(10)

+ 7 - 3
Lanu/Views/Profile/Profile/LNProfileUserDetailView.swift

@@ -23,7 +23,7 @@ class LNProfileUserDetailView: UIView {
         }
     }
     
-    private let tableView = UITableView(frame: .zero, style: .grouped)
+    private let tableView = LNNestedTableView(frame: .zero, style: .grouped)
     private let providers: [LNProfileBaseProvider] = [
         LNProfileUserInfoProvider(),
         LNProfileSkillListProvider(),
@@ -33,6 +33,11 @@ class LNProfileUserDetailView: UIView {
     
     private var curDetail: LNUserProfileVO?
     weak var delegate: LNProfileUserDetailViewDelegate?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            tableView.observerDelegate = outerScrollView
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)
@@ -118,8 +123,8 @@ extension LNProfileUserDetailView: UITableViewDataSource, UITableViewDelegate {
 
 extension LNProfileUserDetailView {
     private func setupViews() {
+        tableView.tableDelegate = self
         tableView.dataSource = self
-        tableView.delegate = self
         tableView.allowsSelection = false
         tableView.backgroundColor = .white
         tableView.showsVerticalScrollIndicator = false
@@ -132,7 +137,6 @@ extension LNProfileUserDetailView {
             contentHeight = newValue.height + tableView.contentInset.top + tableView.contentInset.bottom
         }.store(in: &bag)
         addSubview(tableView)
-        tableView.toBeNested()
         tableView.snp.makeConstraints { make in
             make.edges.equalToSuperview()
         }

+ 4 - 3
Lanu/Views/Profile/Profile/LNProfileViewController.swift

@@ -22,7 +22,6 @@ extension UIView {
 class LNProfileViewController: LNViewController {
     private let uid: String
     
-    
     private let avatar = UIImageView()
     private let titleLabel = UILabel()
     
@@ -35,7 +34,7 @@ class LNProfileViewController: LNViewController {
     
     private var hasFollow = false
     
-    private let scrollView = UIScrollView()
+    private let scrollView = LNNestedScrollView()
     private let cover = UIImageView()
     private let voiceBar = LNProfileVoiceBarView()
     private let userInfoView = LNProfileUserInfoView()
@@ -216,7 +215,7 @@ extension LNProfileViewController {
         scrollView.showsVerticalScrollIndicator = false
         scrollView.showsHorizontalScrollIndicator = false
         scrollView.backgroundColor = .fill
-        scrollView.delegate = self
+        scrollView.scrollDelegate = self
         scrollView.contentInset = .init(top: 0, left: 0, bottom: uid.isMyUid ? 0 : 80, right: 0)
         scrollView.clipsToBounds = false
         scrollView.bounces = false
@@ -277,6 +276,8 @@ extension LNProfileViewController {
             make.centerY.equalToSuperview()
             make.trailing.equalToSuperview().offset(-8)
         }
+        
+        infoView.outerScrollView = scrollView
     }
     
     private func buildCover() -> UIView {