带有手势识别器的 UITextView - 有条件地将触摸转发到父视图

UITextView with Gesture Recognizer - Conditionally Forward Touch to Parent View

我在 UITableViewCell 中嵌入了 UITextView

文本视图已禁用滚动,并随着其中的文本增加高度。

文本视图有一个类似于 link 的文本部分,它具有不同的颜色并带有下划线,我有一个 点击手势识别器 附加到检测用户是否点击文本的 "link" 部分的文本视图 (这是通过使用文本视图的 layoutManagertextContainerInset 检测是否点击来完成的是否落在'link'之内。它基本上是一个自定义命中测试功能)。


我希望 table 视图单元格在用户 "misses" 文本视图的 link 部分时接收点击并被选中, 但不知道该怎么做。


文本视图已将 userInteractionEnabled 设置为 true。但是,当没有附加手势识别器时,这不会阻止触摸到达 table 视图单元格。

相反,如果我将其设置为 false,由于某种原因,单元格选择会完全停止,即使在文本视图边界的 外部 (但手势识别器仍然有效... 为什么?).


我尝试过的

我已经尝试覆盖 gestureRecognizer(_ :shouldReceive:),但即使我 return false,table 视图单元格也没有被选中...

我也尝试过实施 gestureRecognizerShouldBegin(_:),但即使我执行命中测试和 return false,单元格也没有得到点击。


如何将错过的点击转发回单元格,以突出显示它?

保持所有视图处于活动状态(即启用用户交互)。

遍历文本视图的手势并禁用不需要的手势。

遍历 table 视图的 gestureRecognisers 数组,并使用 requireGestureRecognizerToFail 使它们依赖于文本视图的自定义点击手势。

如果它是静态 table 视图,您可以在已加载的视图中执行此操作。对于动态 table 视图,在文本视图单元格的 'willDisplayCell' 中执行此操作。

在尝试 (至少在我理解的范围内)无济于事之后,以及以下所有可能的组合:

  • 实施UIGestureRecognizerDelegate
  • 的方法
  • 覆盖 UITapGestureRecognizer
  • 有条件调用ignore(_:for:)

(也许在绝望中我错过了一些明显的东西,但谁知道呢...)

...我放弃并决定按照@danyapata 在对我的问题的评论中提出的建议,子类 UITextView

部分基于 this Medium post 上的代码,我想出了这个 UITextView 子类:

import UIKit

/**
 Detects taps on subregions of its attributed text that correspond to custom,
 named attributes.

 - note: If no tap is detected, the behavior is equivalent to a text view with
 `isUserInteractionEnabled` set to `false` (i.e., touches "pass through"). The
 same behavior doesn't seem to be easily implemented using just stock
 `UITextView` and gesture recognizers (hence the need to subclass).
 */
class LinkTextView: UITextView {

    private var tapHandlersByName: [String: [(() -> Void)]] = [:]

    /**
     Adds a custom block to be executed wjhen a tap is detected on a subregion
     of the **attributed** text that contains the attribute named accordingly.
     */
    public func addTapHandler(_ handler: @escaping(() -> Void), forAttribute attributeName: String) {
        var handlers = tapHandlersByName[attributeName] ?? []
        handlers.append(handler)
        tapHandlersByName[attributeName] = handlers
    }

    // MARK: - Initialization

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonSetup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        commonSetup()
    }

    private func commonSetup() {
        self.delaysContentTouches = false
        self.isScrollEnabled = false
        self.isEditable = false
        self.isUserInteractionEnabled = true
    }

    // MARK: - UIView

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard let attributeName = self.attributeName(at: point), let handlers = tapHandlersByName[attributeName], handlers.count > 0 else {
            return nil // Ignore touch
        }
        return self // Claim touch
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)

        // find attribute name
        guard let touch = touches.first, let attributeName = self.attributeName(at: touch.location(in: self)) else {
            return
        }

        // Execute all handlers for that attribute, once:
        tapHandlersByName[attributeName]?.forEach({ (handler) in
            handler()
        })
    }

    // MARK: - Internal Support

    private func attributeName(at point: CGPoint) -> String? {
        let location = CGPoint(
            x: point.x - self.textContainerInset.left,
            y: point.y - self.textContainerInset.top)

        let characterIndex = self.layoutManager.characterIndex(
            for: location,
            in: self.textContainer,
            fractionOfDistanceBetweenInsertionPoints: nil)

        guard characterIndex < self.textStorage.length else {
            return nil
        }

        let firstAttributeName = tapHandlersByName.allKeys.first { (attributeName) -> Bool in
            if self.textStorage.attribute(NSAttributedStringKey(rawValue: attributeName), at: characterIndex, effectiveRange: nil) != nil {
                return true
            }
            return false
        }
        return firstAttributeName
    }
}

像往常一样,我会等几天再接受我自己的答案,以防万一出现更好的东西...