69

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?

lukas
  • 2,300
  • 6
  • 28
  • 41
  • Check this answer: http://stackoverflow.com/a/4038943/1949494 – Jelly Mar 24 '16 at 10:49
  • 1
    These answers make this way more complicated than it needs to be.... just enable the delegate callback for selection change and remove any selections the instant they occur (before UI even updates), see here: https://stackoverflow.com/a/62318084/2057171 – Albert Renshaw Jan 22 '21 at 08:15

22 Answers22

157

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

Max Chuquimia
  • 7,494
  • 2
  • 40
  • 59
  • 14
    this is working so well you deserve 1.000 SO points for your superb answer! No magnifying glass, no cut/copy/paste pop-up menu, no finicky business playing around with Internal API classes (Gesture Recognizers), **and**, **best of all**, I can long-press-through a `UITextView` as if it were just a `UILabel` supporting tappable links and `TextKit` in a `UITableViewCell` !!! – dinesharjani Jul 22 '17 at 19:23
  • 4
    @dinesharjani cheers mate - just one thing to note that I discovered recently: double-tapping on a word highlights it :( – Max Chuquimia Jul 25 '17 at 05:55
  • need objc code plz tokenizer.rangeEnclosingPosition don't work. Always return NO. – zszen Oct 15 '17 at 18:16
  • 1
    @Jugale trye add this, disable the double press:) override func becomeFirstResponder() -> Bool { return false } – slboat Dec 04 '17 at 10:01
  • 1
    It should be noted this does not allow for scrolling. – cnotethegr8 Feb 04 '18 at 07:30
  • If you tap FAR BELOW from the textview, `closestPosition(to: point)` is giving you the last character; if it is a link, then gestures are accepted: so double-tap and drag and selection will become possible. Gestures on the link itself are also a problem. – Cœur Mar 22 '18 at 13:54
  • 1
    `closestPosition(to:)` actually suffers from other issues: it gives you the position before the character if you tap on the left half, and the position after the character if you tap on the right half, making it unreliable to determine which character was actually tapped: you shouldn't use it in conjunction with `UITextGranularity.character`. Please see this post for a better calculation of `startIndex` with `layoutManager.characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:)`: https://stackoverflow.com/a/49554534/1033581 – Cœur Mar 29 '18 at 11:25
  • 3
    Swift 4.1 compatible (Xcode 9.4.1): ```override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { guard let pos = closestPosition(to: point), let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: UITextLayoutDirection.left.rawValue) else { return false } let startIndex = offset(from: beginningOfDocument, to: range.start) return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil }``` – jcislinsky Jul 25 '18 at 04:49
  • Add `resignFirstResponder()` at the first line can perform better. – hstdt Nov 08 '18 at 01:22
  • Warning: Using this with the following combination will cause crashes: `textView.isScrollEnabled = false textView.isEditable = false textView.textContainer.maximumNumberOfLines = 3 textView.textContainer.lineBreakMode = .byTruncatingTail` The crashes will appear as `JavaScriptCore` crashes on Fabric and will not have much of a stack trace to follow. – Jacob Apr 14 '19 at 15:10
  • 10
    Thank you very much for the solution, this works pretty well. But it's better to check if `point` is within its `bounds` by putting `guard super.point(inside: point, with: event) else { return false }` (or check `bounds.contains(point)`) in the very first line to prevent the case where the link is the very last part of the text. In that case tapping below the text view but outside of its bounds will prevent other view to receive the touch events. – yusuke024 Jun 19 '19 at 08:55
  • Thank you so much! Will keep this solution for use later as well! :) – CoderSulemani Oct 09 '19 at 09:48
  • Cannot prevent the user from selecting the link text, and having the 3D touch gesture. I'm using both the subclass and delegate to solve this interaction. https://stackoverflow.com/a/62024741/4162542 – funclosure May 26 '20 at 14:56
  • 1
    What's wrong with `func textViewDidChangeSelection(_ textView: UITextView) { textView.selectedTextRange = nil }`? – Parth Jan 22 '21 at 07:45
  • I would also add this check `guard startIndex < self.attributedText.length-1 else { return false }` to handle the case where the text ends with a link and the user taps in the space after the link. – Blago Jul 08 '21 at 09:38
  • You can still long tap on the link and start selecting/open context menu – surfrider Dec 10 '21 at 13:06
  • By using this subclass method, long touch gestures also work on UItextview – Waseem Sarwar Dec 02 '22 at 04:59
25

Try it please:

func textViewDidChangeSelection(_ textView: UITextView) {
    textView.selectedTextRange = nil
}
Carson Vo
  • 476
  • 6
  • 20
  • This looks simplest and most reliable. It works perfectly for me, but may be somebody found any drawbacks in it? – Vladimir Feb 01 '21 at 17:47
  • Added it to my solution - https://stackoverflow.com/a/65980444/286361 – Vladimir Feb 01 '21 at 18:02
  • Was looking for a way to do this without subclassing, this fits the bill perfectly ! – thibaut noah Jul 01 '21 at 15:00
  • 2
    Beware of an infinite loop on older iOS versions. At least on iOS 11, I've to nil the delegate before setting the `selectedTextRange` and then restore it. iOS 14 doesn't seem to be affected, haven't tested 12 and 13. – Ortwin Gentz Aug 02 '21 at 09:49
  • 1
    Thanks! But noticed 1 downside: long pressing still makes a haptic feedback. – Tung Fam Aug 26 '21 at 12:26
11

Enable selectable so that links are tappable, then just unselect as soon as a selection is detected. It will take effect before the UI has a chance to update.

yourTextView.selectable = YES;//required for tappable links
yourTextView.delegate = self;//use <UITextViewDelegate> in .h

- (void)textViewDidChangeSelection:(UITextView *)textView {
    if (textView == yourTextView && textView.selectedTextRange != nil) {
        // `selectable` is required for tappable links but we do not want
        // regular text selection, so clear the selection immediately.
        textView.delegate = nil;//Disable delegate while we update the selectedTextRange otherwise this method will get called again, circularly, on some architectures (e.g. iPhone7 sim)
        textView.selectedTextRange = nil;//clear selection, will happen before copy/paste/etc GUI renders
        textView.delegate = self;//Re-enable delegate
    }
}

Now, in newer iOS versions if you press and hold and drag on a UITextView the cursor can now flash and flicker using the above method, so to solve this we will simply make the cursor and selections (highlights) clear by adjusting the tint color, and then setting the link color back to whatever we desire (since it was previously using the tint color as well).

UIColor *originalTintColor = textView.tintColor;
[textView setTintColor:[UIColor clearColor]];//hide selection and highlight which now appears for a split second when tapping and holding in newer iOS versions
[textView setLinkTextAttributes:@{NSForegroundColorAttributeName: originalTintColor}];//manually set link color since it was using tint color before
Albert Renshaw
  • 17,282
  • 18
  • 107
  • 195
9

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)
            }
        }
    }
}
Cœur
  • 37,241
  • 25
  • 195
  • 267
  • This works pretty well, but still having the 3D touch, if you only want the tappable gesture without 3D touch, you can [take this answer](https://stackoverflow.com/a/62024741/4162542) as the reference, which is based on @Cœur's answer. – funclosure May 26 '20 at 14:59
  • @specialvict good point about 3D touch. I should look at it one day. – Cœur May 27 '20 at 02:13
9

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 { }
    }
}
enadun
  • 3,107
  • 3
  • 31
  • 34
Pablo Sanchez Gomez
  • 1,438
  • 16
  • 28
  • This solution solved my problem, I needed to disable the text selection, but keep the scroll on the tableview and links selectable. thanks a lot – FilipeFaria Oct 05 '18 at 14:38
  • 7
    Unfortunately double tapping on some text that is not a link will still select it – Fmessina Mar 14 '19 at 08:19
8

The solution for only tappable links without selection.

  1. Subclass UITextView to handle gestures which makes it only tappable. Based on the answer from Cœur
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 let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer,
            tapGestureRecognizer.numberOfTapsRequired == 1 {
            // required for compatibility with links
            return super.gestureRecognizerShouldBegin(gestureRecognizer)
        }

        return false
    }

}
  1. Set up delegate to disable the .preview from 3D Touch. Taking the reference from hackingwithswift
class ViewController: UIViewController, UITextViewDelegate {
    @IBOutlet var textView: UITextView!

    override func viewDidLoad() {
        //...
        textView.delegate = self
    }

    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        UIApplication.shared.open(URL)

        // Disable `.preview` by 3D Touch and other interactions
        return false
    }
}

If you want to have a UITextView only for embedding the links without scrolling gesture, this can be a good solution.

funclosure
  • 498
  • 6
  • 15
7

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];
        }
    }
}
lukas
  • 2,300
  • 6
  • 28
  • 41
  • The only 100% working solution I've found so far, thanks! Do you know about any problems with AppStore submissions using this solution? – Quxflux Oct 17 '16 at 15:00
  • @Lukas I haven't tried it. This fix does actually accesse a private iVar, so if Apple renamed or removed the `_targets` iVar it would stop working. – lukas Oct 17 '16 at 15:11
  • @Lukas you could add a check if `gestureRecognizer` has a `_targets` key and only proceed if the key is present – lukas Oct 17 '16 at 15:12
  • @Lukas but since `-[NSObject valueForKey]` isn't private API, there shouldn't be a problem submitting this to the App Store – lukas Oct 17 '16 at 15:13
  • I tried all the other solutions, this is the only one that worked. – Cloud9999Strife May 30 '22 at 10:06
7

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;
}
Marcin Kuptel
  • 2,674
  • 1
  • 17
  • 22
3

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
    }
}
Oscar Apeland
  • 6,422
  • 7
  • 44
  • 92
3

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
jegorenkov
  • 153
  • 1
  • 5
2

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
}
Vatsal Shukla
  • 1,274
  • 12
  • 25
2

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
    }
}
Ahmed Safadi
  • 4,402
  • 37
  • 33
1

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)
                }
            }
        }
        
    }
Community
  • 1
  • 1
Abhishek Thapliyal
  • 3,497
  • 6
  • 30
  • 69
1

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.

fred
  • 321
  • 2
  • 6
1

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.

paulvs
  • 11,963
  • 3
  • 41
  • 66
1

SWIFT 5

Here's a combination of the different answers and comments that worked for me:

Subclass of UITextView:

class DescriptionAndLinkTextView: UITextView {
    
    // MARK: - Initialization

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        dataDetectorTypes = .all
        backgroundColor = .clear
        isSelectable = true
        isEditable = false
        isScrollEnabled = false
        contentInset = .zero
        textContainerInset = UIEdgeInsets.zero
        textContainer.lineFragmentPadding = 0
        linkTextAttributes = [.foregroundColor: UIColor.red,
                              .font: UIFont.systemFontSize,
                              .underlineStyle: 0,
                              .underlineColor: UIColor.clear]
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        guard super.point(inside: point, with: event) else { return false }
        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)
        guard startIndex < self.attributedText.length - 1 else { return false } // to handle the case where the text ends with a link and the user taps in the space after the link.
        return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil
    }
    
}

How to use it (in this case, in a tableview cell):

class MyTableViewCell: UITableViewCell {
    
    // MARK: - IBOutlets
    @IBOutlet weak var infoTextView: DescriptionAndLinkTextView! {
        didSet {
            infoTextView.delegate = self
        }
    }
    
    // MARK: - Lifecycle
    override func awakeFromNib() {
        super.awakeFromNib()
        selectionStyle = .none
    }
    
}

// MARK: - UITextViewDelegate

extension MyTableViewCell: UITextViewDelegate {
    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        DispatchQueue.main.async {
            UIApplication.shared.open(URL)
        }
        // Returning false, to prevent long-press-preview.
        return false
    }
    
    func textViewDidChangeSelection(_ textView: UITextView) {
        if (textView == infoTextView && textView.selectedTextRange != nil) {
            // `selectable` is required for tappable links but we do not want
            // regular text selection, so clear the selection immediately.
            textView.delegate = nil // Disable delegate while we update the selectedTextRange otherwise this method will get called again, circularly, on some architectures (e.g. iPhone7 sim)
            textView.selectedTextRange = nil // clear selection, will happen before copy/paste/etc GUI renders
            textView.delegate = self // Re-enable delegate
        }
    }
}
Nicolai Harbo
  • 1,064
  • 12
  • 25
0

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
}

}

deepax11
  • 199
  • 3
  • 7
0

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
    }

}
0

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

JFCa
  • 79
  • 1
  • 6
0

@Max Chuquimia answer will solve the problem. But double tap will still show option menu of textView. Just add this below code inside your custom view.

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

       UIMenuController.shared.hideMenu()
       //do not display the menu
       self.resignFirstResponder()
       //do not allow the user to selected anything
       return false
}
0

My solution is as follows by Cœur solution.

My problem is to add a clickable link for UITableViewCell with no 3D preview. My solution might help those who are looking for a solution to tableView.

For that, I just need to add delegate to my TextView variable from my tableView which is a UITableViewCell instance variable. Here is my tableView code

 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
         guard let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as? TableViewCell else {
               return UITableViewCell()
         }
         cell.update(text: text)
         cell.textView.delegate = self
         return cell
    }

Here is my custom TaleViewCell

final class TableViewCell: UITableViewCell, UITextViewDelegate {

    @IBOutlet weak var textView: UITextView!
    func update(text: text) {
        textView.isEditable = false
        textView.isUserInteractionEnabled = true
    }

}

Here is extension

extension UITextView {
        // To prevent blue background selection from any situation
        open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    
            if let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer,
               tapGestureRecognizer.numberOfTapsRequired == 1 {
                // required for compatibility with links
                return super.gestureRecognizerShouldBegin(gestureRecognizer)
            }
            return false
        }
    }
0

I solved the problem of my picker for a long time, I found a solution in this thread: Enough "CGRect.null" to turn off "selectable" inside UITextField

override func caretRect(for position: UITextPosition) -> CGRect {
    return CGRect.null
}

override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
    return []
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    return false
}
zef_s
  • 11
  • 2
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Jan 30 '23 at 05:14