Last active
January 10, 2020 12:34
-
-
Save jawj/9773c827e26a9bac32fe199f8cc00fb1 to your computer and use it in GitHub Desktop.
UITextView with tappable links even when not selectable
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// LinkableTextView.h | |
// | |
// Created by George MacKerron on 2020/01/09. | |
// Copyright © 2020 George MacKerron. MIT licenced. | |
// | |
@import UIKit; | |
@interface LinkableTextView : UITextView | |
@property (nonatomic) NSDictionary* activeLinkTextAttributes; | |
@property (nonatomic) NSURL* activeLink; | |
@property (nonatomic) NSRange activeLinkRange; | |
@property (nonatomic) NSArray<UITextSelectionRect*>* activeLinkRects; | |
@end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// LinkableTextView.m | |
// | |
// Created by George MacKerron on 2020/01/09. | |
// Copyright © 2020 George MacKerron. MIT licenced | |
// | |
#import "LinkableTextView.h" | |
#define MaxMovementFromLink 64.0 | |
#define ActiveLinkOpacity 0.5 | |
CGFloat squaredDistanceFromPointToRect(CGPoint pt, CGRect rect) { | |
// explanation: https://stackoverflow.com/a/18157551/338196 | |
CGFloat dx = fmax(0, fmax(rect.origin.x - pt.x, pt.x - (rect.origin.x + rect.size.width))); | |
CGFloat dy = fmax(0, fmax(rect.origin.y - pt.y, pt.y - (rect.origin.y + rect.size.height))); | |
return dx * dx + dy * dy; | |
} | |
@implementation LinkableTextView | |
- (void)setActiveLink:(id)link range:(NSRange)range { | |
// called with a NSString or NSURL link (and NSRange range) to activate a link | |
// called with a nil link (and any old NSRange) to deactivate the active link | |
if (! self.activeLinkTextAttributes) { // set some default attributes if none provided | |
UIColor* linkColor = self.linkTextAttributes[NSForegroundColorAttributeName]; | |
self.activeLinkTextAttributes = @{NSForegroundColorAttributeName: | |
[linkColor colorWithAlphaComponent:ActiveLinkOpacity]}; | |
}; | |
NSMutableAttributedString* s = self.attributedText.mutableCopy; | |
if (link) { | |
[s removeAttribute:NSLinkAttributeName range:range]; // must remove the link, or its style takes precedence | |
[s addAttributes:self.activeLinkTextAttributes range:range]; | |
} else { | |
[s removeAttribute:NSForegroundColorAttributeName range:self.activeLinkRange]; | |
[s addAttribute:NSLinkAttributeName value:self.activeLink range:self.activeLinkRange]; | |
} | |
self.attributedText = s; | |
self.activeLink = [link isKindOfClass:NSString.class] ? [NSURL URLWithString:link] : link; | |
self.activeLinkRange = range; | |
if (self.activeLink) { | |
UITextPosition* posStart = [self positionFromPosition:self.beginningOfDocument | |
offset:self.activeLinkRange.location]; | |
UITextPosition* posEnd = [self positionFromPosition:posStart offset:self.activeLinkRange.length]; | |
UITextRange* textRange = [self textRangeFromPosition:posStart toPosition:posEnd]; | |
self.activeLinkRects = [self selectionRectsForRange:textRange]; | |
} | |
} | |
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { | |
if (self.activeLink) [self setActiveLink:nil range:NSMakeRange(0, 0)]; | |
UITouch* touch = touches.anyObject; | |
CGPoint touchPoint = [touch locationInView:self]; | |
UITextRange* tapRange = [self characterRangeAtPoint:touchPoint]; | |
NSInteger start = [self offsetFromPosition:self.beginningOfDocument toPosition:tapRange.start]; | |
NSInteger length = [self offsetFromPosition:tapRange.start toPosition:tapRange.end]; | |
NSRange targetRange = NSMakeRange(start, length); | |
NSRange totalRange = NSMakeRange(0, self.attributedText.length); | |
[self.attributedText enumerateAttribute:NSLinkAttributeName inRange:totalRange options:kNilOptions | |
usingBlock:^(id _Nullable link, NSRange range, BOOL * _Nonnull stop) { | |
if (link && NSIntersectionRange(range, targetRange).length > 0) { | |
[self setActiveLink:link range:range]; | |
*stop = YES; | |
} | |
}]; | |
if (! self.activeLink) [super touchesBegan:touches withEvent:event]; | |
} | |
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { | |
if (! self.activeLink) { | |
[super touchesMoved:touches withEvent:event]; | |
return; | |
} | |
CGPoint touchPoint = [touches.anyObject locationInView:self]; | |
CGFloat minSquaredDistance = DBL_MAX; | |
for (UITextSelectionRect* rect in self.activeLinkRects) minSquaredDistance = | |
fmin(minSquaredDistance, squaredDistanceFromPointToRect(touchPoint, rect.rect)); | |
if (minSquaredDistance < MaxMovementFromLink * MaxMovementFromLink) return; | |
[self setActiveLink:nil range:NSMakeRange(0, 0)]; | |
} | |
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { | |
if (! self.activeLink) { | |
[super touchesEnded:touches withEvent:event]; | |
return; | |
} | |
NSURL* link = self.activeLink; | |
[self setActiveLink:nil range:NSMakeRange(0, 0)]; | |
[UIApplication.sharedApplication openURL:link]; | |
} | |
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { | |
if (! self.activeLink) { | |
[super touchesCancelled:touches withEvent:event]; | |
return; | |
} | |
[self setActiveLink:nil range:NSMakeRange(0, 0)]; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment