15

I have a UITextView in a custom UITableViewCell. The textview works properly (scrolls, shows text, etc.) but I need the users to be able to tap the table cell and go to another screen. Right now, if you tap the edges of the table cell (i.e. outside the UItextView) the next view is properly called. But clearly inside the uitextview the touches are being captured and not forwarded to the table cell.

I found a post that talked about subclassing UITextView to forward the touches. I tried that without luck. The implementation is below. I'm wondering if maybe a) the super of my textview isn't the uitableviewcell and thus I need to pass the touch some other way or b) If the super is the uitableviewcell if I need to pass something else? Any help would be much appreciated.

#import "ScrollableTextView.h"

@implementation ScrollableTextView

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if (parentScrollView) {
        [parentScrollView touchesBegan:touches withEvent:event];
    }
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    if (parentScrollView) {
        [parentScrollView touchesCancelled:touches withEvent:event];
    }
    [super touchesCancelled:touches withEvent:event];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    if (parentScrollView) {
        [parentScrollView touchesEnded:touches withEvent:event];
    }
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    if (parentScrollView) {
        [parentScrollView touchesMoved:touches withEvent:event];
    }
    [super touchesMoved:touches withEvent:event];
}

- (BOOL)canBecomeFirstResponder {
    return YES;
}

@end
Martin
  • 1,570
  • 3
  • 19
  • 32

4 Answers4

36

Try [theTextView setUserInteractionEnabled:NO]; If the user needs to be able to edit the contents of the TextView, then you might have a design problem here.

Swift 3 : theTextView.isUserInteractionEnabled = false

Storyboard : tick the "User Interaction Enabled" checkbox.

Ryan A.
  • 30
  • 6
greg
  • 4,843
  • 32
  • 47
  • 1
    This might be kind of old, but I had this exact same problem and this line solved it. Thanks :) – gabaum10 Oct 19 '10 at 16:09
  • 3
    Thank you so much! BTW, if anyone prefers to set this in Interface Builder: Open the UITextView's attributes pane in the inspector, scroll to the bottom where the "View" pane is, and uncheck "User Interaction Enabled". – AndrewO Oct 21 '10 at 16:28
  • 4
    The problem I have is I want the user to be able to touch a phone number for instance but yet I need to segue too. I am still searching for the way to do both. If anyone has any suggestions please let me know. If I find out I will post it here. – DirectX Apr 17 '13 at 19:09
  • @DirectX why is the phone number in an editable field? Maybe put it in a `UILabel`, accept the touch, do what you need to do with it, then forward the touch on to the next responder in the responder chain. If that's not the case, open a new question on here. – greg Dec 12 '13 at 18:21
  • @greg The phone number is in an non editable field but it is a uitextview which has the nsdatadetectors turned on so user could touch the phone number and dial but at the same time that cell if touched should seque to another view that shows additional information etc. I did not find a solution so we just turned off the data detectors. – DirectX Dec 13 '13 at 12:44
  • @DirectX consider a `UITapGestureRecognizer` on a `UILabel` that launches the URL `tel://` for a similar outcome. – greg Dec 13 '13 at 15:29
  • @greg thanks, we thought of stuff like that but they decided it was no big deal to have the links "clickable" in the cell so we dropped it – DirectX Dec 14 '13 at 02:30
3

I know that this question has been asked 5 years ago, but the behaviour is still very much needed for some app to have a clickable Cell with UIDataDetectors.

So here's the UITextView subclass I made up to fit this particular behaviour in a UITableView

-(id) initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        self.delegate = self;
    }
    return self;
}

- (BOOL)canBecomeFirstResponder {
    return NO;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    UIView *obj = self;

    do {
        obj = obj.superview;
    } while (![obj isKindOfClass:[UITableViewCell class]]);
    UITableViewCell *cell = (UITableViewCell*)obj;

    do {
        obj = obj.superview;
    } while (![obj isKindOfClass:[UITableView class]]);
    UITableView *tableView = (UITableView*)obj;

    NSIndexPath *indePath = [tableView indexPathForCell:cell];
    [[tableView delegate] tableView:tableView didSelectRowAtIndexPath:indePath];
}

- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange {
    return YES;
}

You can modify this to fit your needs...

Hope it helps someone.

TheSquad
  • 7,385
  • 8
  • 40
  • 79
  • clever workaround. Although, when user just scrolling the `UITextView`, this code will also fires the delegate, which is not expected. Leading with your solution, I came up with mine which I put in another answer. – Ikhsan Assaat Mar 10 '15 at 10:58
  • @IkhsanAssaat never had any issue scrolling even when carefully touching the TextView with my finger to start the scroll... The only way in my App it won't scroll and fires, is when clicking on a URL present on the TextView, but it is still good to have more case studies, for different implementation, thanks. – TheSquad Mar 10 '15 at 13:19
2

The problem with your solution is that if you put UITextView inside UITableViewCell, its superview won't be the actual cell. There's even a slight difference between iOS 7 and iOS 8 on the cell's view structure. What you need to do is drill down (or drill up) through the hierarchy to get UITableViewCell instance.

I am using and modifying @TheSquad's while loop to get the UITableViewCell, and assign it to a property. Then override those touch methods, use the cell's touches method whenever needed, and just use super's touch method's implementations to get the default behaviour.

// set the cell as property
@property (nonatomic, assign) UITableViewCell *superCell;

- (UITableViewCell *)superCell {
    if (!_superCell) {
        UIView *object = self;

        do {
            object = object.superview;
        } while (![object isKindOfClass:[UITableViewCell class]] && (object != nil));

        if (object) {
            _superCell = (UITableViewCell *)object;
        }
    }

    return _superCell;
}

#pragma mark - Touch overrides

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if (self.superCell) {
        [self.superCell touchesBegan:touches withEvent:event];
    } else {
        [super touchesBegan:touches withEvent:event];
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    if (self.superCell) {
        [self.superCell touchesMoved:touches withEvent:event];
    } else {
        [super touchesMoved:touches withEvent:event];
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    if (self.superCell) {
        [self.superCell touchesEnded:touches withEvent:event];
    } else {
        [super touchesEnded:touches withEvent:event];
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    if (self.superCell) {
        [self.superCell touchesEnded:touches withEvent:event];
    } else {
        [super touchesCancelled:touches withEvent:event];
    }

}
Ikhsan Assaat
  • 900
  • 1
  • 9
  • 23
2

The answers above don't solve the problem if you have links in the UITextView and want them to work as usual when user taps a link, and pass the tap to the cell if user taps regular text. With the proposed method cell will be "selected" in both cases.

Here are some possible solutions:

  1. https://stackoverflow.com/a/59010352/11448489 - add a tap gesture recognizer to the cell, and set it to require UITextInteractionNameLinkTap recognizer failure. The problem is that UITextInteractionNameLinkTap string is from internal Apple API and can change. Also, we still have to directly call delegate's didSelectRowAtIndexPath, so the cell won't be animated.

  2. Implement override of touchesEnded in the text view. In it perform some selector after delay of at least 0.4s. In the text view delegate cancel this perform request if an interaction with url happened:

class TappableTextView: UITextView, UITextViewDelegate {

    var tapHandler: (() -> Void)?

    override var delegate: UITextViewDelegate? {
        get { self }
        set { }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.perform(#selector(onTap), with: nil, afterDelay: 0.5)
    }

    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        Self.cancelPreviousPerformRequests(withTarget: self, selector: #selector(onTap), object: nil)
        return true
    }

    @objc func onTap() {
        self.tapHandler?()
    }
}

It works, but delay is noticeable and annoying. It is not possible to reduce this delay because shouldInteractWith happens after 350ms after touchesEnded. And we still have to call didSelectRowAtIndexPath.

  1. I came to another solution, which seems to work perfectly if you need clickable links, but no other interactions (not scrollable, selectable etc). Essentially, we need to make the text view ignore all touches which are not in the links area:
class TapPassingTextView: UITextView, UITextViewDelegate {

    var clickableRects = [CGRect]()

    override func layoutSubviews() {
        super.layoutSubviews()
        self.updateClickableRects()
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        clickableRects.contains { $0.contains(point) } ? super.hitTest(point, with: event) : nil
    }

    private func updateClickableRects() {
        self.clickableRects = []
        let range = NSRange(location: 0, length: self.attributedText.string.count)
        self.attributedText.enumerateAttribute(.link, in: range) { link, range, _ in
            guard link != nil else { return }
            self.layoutManager.enumerateLineFragments(forGlyphRange: range) { rect, _, _, _, _ in
                self.clickableRects.append(rect)
            }
        }
    }
}

That's it! Taps on links are working and taps in other areas go below the text view, cells are selected natively.