UITextView: Disable selection, allow links

不羁的心 提交于 2019-11-27 10:47:50

问题


I have a UITextView which displays an NSAttributedString. The textView's editable and selectable properties are both set to false.

The attributedString contains a URL and I'd like to allow tapping the URL to open a browser. But interaction with the URL is only possible if the selectable attribute is set to true.

How can I allow user interaction only for tapping links, but not for selecting text?


回答1:


I find the concept of fiddling with the internal gesture recognizers a little scary, so tried to find another solution. I've discovered that we can override point(inside:with:) to effectively allow a "tap-through" when the user isn't touching down on text with a link inside it:

// Inside a UITextView subclass:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {

    guard let pos = closestPosition(to: point) else { return false }

    guard let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: .layout(.left)) else { return false }

    let startIndex = offset(from: beginningOfDocument, to: range.start)

    return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil
}   

This also means that if you have a UITextView with a link inside a UITableViewCell, tableView(didSelectRowAt:) still gets called when tapping the non-linked portion of the text :)




回答2:


As Cœur has said, you can subclass the UITextView overriding the method of selectedTextRange, setting it to nil. And the links will still be clickable, but you won't be able to select the rest of the text.

class PIUnselectableTextView: PITextView {
    override public var selectedTextRange: UITextRange? {
        get {
            return nil
        }
        set { }
    }
}



回答3:


So after some research I've been able to find a solution. It's a hack and I don't know if it'll work in future iOS versions, but it works as of right now (iOS 9.3).

Just add this UITextView category (Gist here):

@implementation UITextView (NoFirstResponder)

- (void)addGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer {
    if ([gestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {

        @try {
            id targetAndAction = ((NSMutableArray *)[gestureRecognizer valueForKey:@"_targets"]).firstObject;
            NSArray <NSString *>*actions = @[@"action=loupeGesture:",           // link: no, selection: shows circle loupe and blue selectors for a second
                                             @"action=longDelayRecognizer:",    // link: no, selection: no
                                             /*@"action=smallDelayRecognizer:", // link: yes (no long press), selection: no*/
                                             @"action=oneFingerForcePan:",      // link: no, selection: shows rectangular loupe for a second, no blue selectors
                                             @"action=_handleRevealGesture:"];  // link: no, selection: no
            for (NSString *action in actions) {
                if ([[targetAndAction description] containsString:action]) {
                    [gestureRecognizer setEnabled:false];
                }
            }

        }

        @catch (NSException *e) {
        }

        @finally {
            [super addGestureRecognizer: gestureRecognizer];
        }
    }
}



回答4:


if your minimum deployment target is iOS 11.2 or newer

You can disable text selection by subclassing UITextView and forbidding the gestures that can select something.

The below solution is:

  • compatible with isEditable
  • compatible with isScrollEnabled
  • compatible with links
/// Class to allow links but no selection.
/// Basically, it disables unwanted UIGestureRecognizer from UITextView.
/// https://stackoverflow.com/a/49443814/1033581
class UnselectableTappableTextView: UITextView {

    // required to prevent blue background selection from any situation
    override var selectedTextRange: UITextRange? {
        get { return nil }
        set {}
    }

    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if gestureRecognizer is UIPanGestureRecognizer {
            // required for compatibility with isScrollEnabled
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }
        if let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer,
            tapGestureRecognizer.numberOfTapsRequired == 1 {
            // required for compatibility with links
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }
        // allowing smallDelayRecognizer for links
        // https://stackoverflow.com/questions/46143868/xcode-9-uitextview-links-no-longer-clickable
        if let longPressGestureRecognizer = gestureRecognizer as? UILongPressGestureRecognizer,
            // comparison value is used to distinguish between 0.12 (smallDelayRecognizer) and 0.5 (textSelectionForce and textLoupe)
            longPressGestureRecognizer.minimumPressDuration < 0.325 {
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }
        // preventing selection from loupe/magnifier (_UITextSelectionForceGesture), multi tap, tap and a half, etc.
        gestureRecognizer.isEnabled = false
        return false
    }
}

if your minimum deployment target is iOS 11.1 or older

Native UITextView links gesture recognizers are broken on iOS 11.0-11.1 and require a small delay long press instead of a tap: Xcode 9 UITextView links no longer clickable

You can properly support links with your own gesture recognizer and you can disable text selection by subclassing UITextView and forbidding the gestures that can select something or tap something.

The below solution will disallow selection and is:

  • compatible with isScrollEnabled
  • compatible with links
  • workaround limitations of iOS 11.0 and iOS 11.1, but loses the UI effect when tapping on text attachments
/// Class to support links and to disallow selection.
/// It disables most UIGestureRecognizer from UITextView and adds a UITapGestureRecognizer.
/// https://stackoverflow.com/a/49443814/1033581
class UnselectableTappableTextView: UITextView {

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

        // Native UITextView links gesture recognizers are broken on iOS 11.0-11.1:
        // https://stackoverflow.com/questions/46143868/xcode-9-uitextview-links-no-longer-clickable
        // So we add our own UITapGestureRecognizer.
        linkGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped))
        linkGestureRecognizer.numberOfTapsRequired = 1
        addGestureRecognizer(linkGestureRecognizer)
        linkGestureRecognizer.isEnabled = true
    }

    var linkGestureRecognizer: UITapGestureRecognizer!

    // required to prevent blue background selection from any situation
    override var selectedTextRange: UITextRange? {
        get { return nil }
        set {}
    }

    override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
        // Prevents drag and drop gestures,
        // but also prevents a crash with links on iOS 11.0 and 11.1.
        // https://stackoverflow.com/a/49535011/1033581
        gestureRecognizer.isEnabled = false
        super.addGestureRecognizer(gestureRecognizer)
    }

    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if gestureRecognizer == linkGestureRecognizer {
            // Supporting links correctly.
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }
        if gestureRecognizer is UIPanGestureRecognizer {
            // Compatibility support with isScrollEnabled.
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }
        // Preventing selection gestures and disabling broken links support.
        gestureRecognizer.isEnabled = false
        return false
    }

    @objc func textTapped(recognizer: UITapGestureRecognizer) {
        guard recognizer == linkGestureRecognizer else {
            return
        }
        var location = recognizer.location(in: self)
        location.x -= textContainerInset.left
        location.y -= textContainerInset.top
        let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        let characterRange = NSRange(location: characterIndex, length: 1)

        if let attachment = attributedText?.attribute(.attachment, at: index, effectiveRange: nil) as? NSTextAttachment {
            if #available(iOS 10.0, *) {
                _ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange, interaction: .invokeDefaultAction)
            } else {
                _ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange)
            }
        }
        if let url = attributedText?.attribute(.link, at: index, effectiveRange: nil) as? URL {
            if #available(iOS 10.0, *) {
                _ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange, interaction: .invokeDefaultAction)
            } else {
                _ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange)
            }
        }
    }
}



回答5:


Here is an Objective C version of the answer posted by Max Chuquimia.

- (BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    UITextPosition *position = [self closestPositionToPoint:point];
    if (!position) {
        return NO;
    }
    UITextRange *range = [self.tokenizer rangeEnclosingPosition:position
                                                withGranularity:UITextGranularityCharacter
                                                    inDirection:UITextLayoutDirectionLeft];
    if (!range) {
        return NO;
    }

    NSInteger startIndex = [self offsetFromPosition:self.beginningOfDocument
                                         toPosition:range.start];
    return [self.attributedText attribute:NSLinkAttributeName
                                  atIndex:startIndex
                           effectiveRange:nil] != nil;
}



回答6:


Swift 3.0

For above Objective-C Version via @Lukas

extension UITextView {

        override open func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
            if gestureRecognizer.isKind(of: UILongPressGestureRecognizer.self) {
                do {
                   let array = try gestureRecognizer.value(forKey: "_targets") as! NSMutableArray
                    let targetAndAction = array.firstObject
                    let actions = ["action=oneFingerForcePan:",
                                   "action=_handleRevealGesture:",
                                   "action=loupeGesture:",
                                   "action=longDelayRecognizer:"]

                    for action in actions {
                         print("targetAndAction.debugDescription: \(targetAndAction.debugDescription)")
                        if targetAndAction.debugDescription.contains(action) {
                            gestureRecognizer.isEnabled = false
                        }
                    }

                } catch let exception {
                    print("TXT_VIEW EXCEPTION : \(exception)")
                }
                defer {
                    super.addGestureRecognizer(gestureRecognizer)
                }
            }
        }

    }



回答7:


Here's a Swift 4 solution that allows taps to pass trough except for when a link is pressed;

In the parent view

private(set) lazy var textView = YourCustomTextView()

func setupView() {
    textView.isScrollEnabled = false
    textView.isUserInteractionEnabled = false

    let tapGr = UITapGestureRecognizer(target: textView, action: nil)
    tapGr.delegate = textView
    addGestureRecognizer(tapGr)

    textView.translatesAutoresizingMaskIntoConstraints = false
    addSubview(textView)
    NSLayoutConstraint.activate(textView.edges(to: self))
}

The custom UITextView

class YourCustomTextView: UITextView, UIGestureRecognizerDelegate {

    var onLinkTapped: (URL) -> Void = { print($0) }

    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let gesture = gestureRecognizer as? UITapGestureRecognizer else {
            return true
        }

        let location = gesture.location(in: self)

        guard let closest = closestPosition(to: location), let startPosition = position(from: closest, offset: -1), let endPosition = position(from: closest, offset: 1) else {
            return false
        }

        guard let textRange = textRange(from: startPosition, to: endPosition) else {
            return false
        }

        let startOffset = offset(from: beginningOfDocument, to: textRange.start)
        let endOffset = offset(from: beginningOfDocument, to: textRange.end)
        let range = NSRange(location: startOffset, length: endOffset - startOffset)

        guard range.location != NSNotFound, range.length != 0 else {
            return false
        }

        guard let linkAttribute = attributedText.attributedSubstring(from: range).attribute(.link, at: 0, effectiveRange: nil) else {
            return false
        }

        guard let linkString = linkAttribute as? String, let url = URL(string: linkString) else {
            return false
        }

        guard delegate?.textView?(self, shouldInteractWith: url, in: range, interaction: .invokeDefaultAction) ?? true else {
            return false
        }

        onLinkTapped(url)

        return true
    }
}



回答8:


This works for me:

@interface MessageTextView : UITextView <UITextViewDelegate>

@end

@implementation MessageTextView

-(void)awakeFromNib{
    [super awakeFromNib];
    self.delegate = self;
}

- (BOOL)canBecomeFirstResponder {
    return NO;
}

- (void)textViewDidChangeSelection:(UITextView *)textView
{
    textView.selectedTextRange = nil;
    [textView endEditing:YES];
}

@end



回答9:


I ended up combining solutions from https://stackoverflow.com/a/44878203/2015332 and https://stackoverflow.com/a/49443814/2015332 (iOS < 11 variant). This works as expected: a read-only, non selectable UITextView on which hyperlinks are still working. One of the advantages from Coeur's solution is that touch detection is immediate and does not display highlight nor allow drag&drop of a link.

Here is the resulting code:

class HyperlinkEnabledReadOnlyTextView: UITextView {

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        isEditable = false
        isSelectable = false
        initHyperLinkDetection()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        isEditable = false
        isSelectable = false
        initHyperLinkDetection()
    }



    // MARK: - Prevent interaction except on hyperlinks

    // Combining https://stackoverflow.com/a/44878203/2015332 and https://stackoverflow.com/a/49443814/1033581

    private var linkGestureRecognizer: UITapGestureRecognizer!

    private func initHyperLinkDetection() {
        // Native UITextView links gesture recognizers are broken on iOS 11.0-11.1:
        // https://stackoverflow.com/questions/46143868/xcode-9-uitextview-links-no-longer-clickable

        // So we add our own UITapGestureRecognizer, which moreover detects taps faster than native one
        linkGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped))
        linkGestureRecognizer.numberOfTapsRequired = 1
        addGestureRecognizer(linkGestureRecognizer)
        linkGestureRecognizer.isEnabled = true // because previous call sets it to false
    }

    override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
        // Prevents drag and drop gestures, but also prevents a crash with links on iOS 11.0 and 11.1.
        // https://stackoverflow.com/a/49535011/1033581
        gestureRecognizer.isEnabled = false
        super.addGestureRecognizer(gestureRecognizer)
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // Allow only taps located over an hyperlink
        var location = point
        location.x -= textContainerInset.left
        location.y -= textContainerInset.top
        guard location.x >= bounds.minX, location.x <= bounds.maxX, location.y >= bounds.minY, location.y <= bounds.maxY else { return false }

        let charIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        return attributedText.attribute(.link, at: charIndex, effectiveRange: nil) != nil
    }

    @objc private func textTapped(recognizer: UITapGestureRecognizer) {
        guard recognizer == linkGestureRecognizer else { return }

        var location = recognizer.location(in: self)
        location.x -= textContainerInset.left
        location.y -= textContainerInset.top
        guard location.x >= bounds.minX, location.x <= bounds.maxX, location.y >= bounds.minY, location.y <= bounds.maxY else { return }

        let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        let characterRange = NSRange(location: characterIndex, length: 1)

        if let attachment = attributedText?.attribute(.attachment, at: index, effectiveRange: nil) as? NSTextAttachment {
            if #available(iOS 10.0, *) {
                _ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange, interaction: .invokeDefaultAction)
            } else {
                _ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange)
            }
        }

        if let url = attributedText?.attribute(.link, at: characterIndex, effectiveRange: nil) as? URL {
            if #available(iOS 10.0, *) {
                _ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange, interaction: .invokeDefaultAction)
            } else {
                _ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange)
            }
        }
    }
}

Please not that I had some trouble compiling the .attachment enum case, I removed it because I'm not using it.




回答10:


Overide UITextView like below and use it to render tappable link with preserving html styling.

public class LinkTextView: UITextView {

override public var selectedTextRange: UITextRange? {
    get {
        return nil
    }
    set {}
}

public init() {
    super.init(frame: CGRect.zero, textContainer: nil)
    commonInit()
}

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

private func commonInit() {
    self.tintColor = UIColor.black
    self.isScrollEnabled = false
    self.delegate = self
    self.dataDetectorTypes = []
    self.isEditable = false
    self.delegate = self
    self.font = Style.font(.sansSerif11)
    self.delaysContentTouches = true
}


@available(iOS 10.0, *)
public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
    // Handle link
    return false
}

public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool {
    // Handle link
    return false
}

}




回答11:


Swift 4, Xcode 9.2

Below is something different approach for link, make isSelectable property of UITextView to false

class TextView: UITextView {
    //MARK: Properties    
    open var didTouchedLink:((URL,NSRange,CGPoint) -> Void)?

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

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

    override func draw(_ rect: CGRect) {
        super.draw(rect)
    }

    open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = Array(touches)[0]
        if let view = touch.view {
            let point = touch.location(in: view)
            self.tapped(on: point)
        }
    }
}

extension TextView {
    fileprivate func tapped(on point:CGPoint) {
        var location: CGPoint = point
        location.x -= self.textContainerInset.left
        location.y -= self.textContainerInset.top
        let charIndex = layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        guard charIndex < self.textStorage.length else {
            return
        }
        var range = NSRange(location: 0, length: 0)
        if let attributedText = self.attributedText {
            if let link = attributedText.attribute(NSAttributedStringKey.link, at: charIndex, effectiveRange: &range) as? URL {
                print("\n\t##-->You just tapped on '\(link)' withRange = \(NSStringFromRange(range))\n")
                self.didTouchedLink?(link, range, location)
            }
        }

    }
}

HOW TO USE,

let textView = TextView()//Init your textview and assign attributedString and other properties you want.
textView.didTouchedLink = { (url,tapRange,point) in
//here goes your other logic for successfull URL location
}



回答12:


Here's how I solved this problem- I make my selectable textview a subclass that overrides canPerformAction to return false.

class CustomTextView: UITextView {

override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        return false
    }

}



回答13:


What I do for Objective C is create a subclass and overwrite textViewdidChangeSelection: delegate method, so in the implementation class:

#import "CustomTextView.h"

@interface CustomTextView()<UITextViewDelegate>
@end

@implementation CustomTextView

. . . . . . .

- (void) textViewDidChangeSelection:(UITextView *)textView
{
    UITextRange *selectedRange = [textView selectedTextRange];
    NSString *selectedText = [textView textInRange:selectedRange];
    if (selectedText.length > 1 && selectedText.length < textView.text.length)
    {
        textView.selectedRange = NSMakeRange(0, 0);
    }
}

Don't forget to set self.delegate = self




回答14:


Swift 4.2

Simple

class MyTextView: UITextView {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {

        guard let pos = closestPosition(to: point) else { return false }

        guard let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: UITextDirection(rawValue: UITextLayoutDirection.left.rawValue)) else { return false }

        let startIndex = offset(from: beginningOfDocument, to: range.start)

        return attributedText.attribute(NSAttributedString.Key.link, at: startIndex, effectiveRange: nil) != nil
    }
}



回答15:


An ugly but a goodie.

private class LinkTextView: UITextView {
    override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
        []
    }

    override func caretRect(for position: UITextPosition) -> CGRect {
        CGRect.zero.offsetBy(dx: .greatestFiniteMagnitude, dy: .greatestFiniteMagnitude)
    }
}

Tested with a text view where scrolling was disabled.



来源:https://stackoverflow.com/questions/36198299/uitextview-disable-selection-allow-links

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!