web-dev-qa-db-fra.com

UITextView: Désactiver la sélection, autoriser les liens

J'ai un UITextView qui affiche un NSAttributedString. Les propriétés editable et selectable de textView sont définies sur false.

Le attributString contient une URL et j'aimerais permettre d'appuyer sur l'URL pour ouvrir un navigateur. Cependant, l'interaction avec l'URL n'est possible que si l'attribut selectable est défini sur true.

Comment puis-je autoriser l'interaction utilisateur uniquement pour exploiter des liens, mais pas pour sélectionner du texte?

22
Lukas

Je trouve le concept de manipulation des identifiants de geste internes un peu effrayant, alors j'ai essayé de trouver une autre solution. J'ai découvert que nous pouvons remplacer la fonction point(inside:with:) afin d'autoriser efficacement un "accès direct" lorsque l'utilisateur ne touche pas au texte avec un lien à l'intérieur: 

// 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
}   

Cela signifie également que si vous avez une UITextView avec un lien dans une UITableViewCell, tableView(didSelectRowAt:) est toujours appelé lorsque vous tapez sur la partie non liée du texte :) 

48
Max Chuquimia

Comme Cœur l'a dit, vous pouvez sous-classer la UITextView en remplaçant la méthode selectedTextRange, en la réglant sur nil . Et les liens seront toujours cliquables, mais vous ne pourrez pas sélectionner le reste du texte.

class CustomTextView: UITextView {
override public var selectedTextRange: UITextRange? {
    get {
        return nil
    }
    set { }
}
6

Ainsi, après quelques recherches, j'ai pu trouver une solution. C'est un hack et je ne sais pas si cela fonctionnera dans les futures versions d'iOS, mais cela fonctionne à partir de maintenant (iOS 9.3).

Ajoutez simplement cette catégorie UITextView (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];
        }
    }
}
5
Lukas

Voici une version Objective C de la réponse publiée par 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;
}
1
Marcin Kuptel

Swift 3.0

Pour la version d'Objective-C ci-dessus 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)
                }
            }
        }

    }
1
Abhishek Thapliyal

si votre cible de déploiement minimale est iOS 11.2 ou plus récent

Vous pouvez désactiver la sélection de texte en sous-classant UITextView et en interdisant les gestes pouvant sélectionner quelque chose.

La solution ci-dessous est:

  • compatible avec isEditable
  • compatible avec isScrollEnabled
  • compatible avec les liens
/// 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
    }
}

si votre cible de déploiement minimale est iOS 11.1 ou une version antérieure

Les reconnaisseurs de mouvements de liens UITextView natifs sont interrompus sous iOS 11.0-11.1 et nécessitent un petit retard appuyez longuement sur au lieu d'un tap: Xcode 9 Les liens UITextView ne sont plus cliquables

Vous pouvez gérer correctement les liens avec votre propre outil de reconnaissance des gestes et vous pouvez désactiver la sélection de texte en sous-classant UITextView et en interdisant les gestes pouvant sélectionner ou appuyer sur quelque chose.

La solution ci-dessous interdira la sélection et est la suivante:

  • compatible avec isScrollEnabled
  • compatible avec les liens
  • contournement des limitations de iOS 11.0 et iOS 11.1, mais perd l'effet de l'interface utilisateur lorsque vous appuyez sur les pièces jointes au texte
/// 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)
            }
        }
    }
}
1
Cœur

Voici une solution Swift 4 qui permet aux robinets de passer au travers sauf lorsqu'un lien est pressé; 

Dans la vue parent

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

Le UITextView personnalisé

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
    }
}
0
Oscar Apeland

Voici comment j'ai résolu ce problème - je fais de ma vue textuelle sélectionnable une sous-classe qui substitue canPerformAction pour retourner false. 

class CustomTextView: UITextView {

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

}
0
Phillip English

Cela fonctionne pour moi:

@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
0
jegorenkov

Swift 4, Xcode 9.2

Vous trouverez ci-dessous une approche différente pour link. Définissez la propriété isSelectable de UITextView en 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)
            }
        }

    }
}

COMMENT UTILISER,

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
}
0
Vats

Remplacez UITextView comme ci-dessous et utilisez-le pour rendre le lien pouvant être saisi en préservant le style HTML.

classe publique 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
}

}

0
deepax11

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
    }
}
0
Safad Funy

Ce que je fais pour Objective C, c'est créer une sous-classe et écraser la méthode textViewdidChangeSelection: delegate, donc dans la classe d'implémentation:

#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);
    }
}

N'oubliez pas de définir self.delegate = self

0
JFCa