Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@andreyvit
Created June 10, 2020 10:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andreyvit/8f1f66db594543eeac59750dd4c0eef3 to your computer and use it in GitHub Desktop.
Save andreyvit/8f1f66db594543eeac59750dd4c0eef3 to your computer and use it in GitHub Desktop.
UIKit checkbox control that supports embedded links (for ToS acceptance checkbox)
@import UIKit;
typedef void (^RDLCheckboxURLHandler)(NSURL *url);
IB_DESIGNABLE
@interface ATRichTextCheckbox : UIControl
@property (nonatomic, readonly) UIButton *checkbox;
@property (nonatomic, readonly) UILabel *label;
@property (nonatomic, getter=isSelected) IBInspectable BOOL selected;
@property (nonatomic, getter=isCheckboxVisible) IBInspectable BOOL showsCheckbox;
@property (nonatomic, copy) IBInspectable NSString *htmlText;
@property (nonatomic, copy) IBInspectable NSString *markdownText;
@property (nonatomic) IBInspectable UIFont *font;
@property (nonatomic) IBInspectable CGFloat fontSize;
@property (nonatomic) IBInspectable UIColor *textColor;
@property (nonatomic) IBInspectable CGFloat checkboxToLabelSpacing;
@property (nonatomic) IBInspectable CGFloat overhang;
@property (nonatomic, copy) RDLCheckboxURLHandler urlTapHandler;
@end
#import "ATRichTextCheckbox.h"
#import "ATRichTextCheckbox_KILabel.h"
static UIColor *UIColor_colorWithRGBHex(UInt32 hex) {
int r = (hex >> 16) & 0xFF;
int g = (hex >> 8) & 0xFF;
int b = (hex) & 0xFF;
return [UIColor colorWithRed:(r / 255.0f) green:(g / 255.0f) blue:(b / 255.0f) alpha:1.0f];
}
@interface ATRichTextCheckbox ()
@end
@implementation ATRichTextCheckbox {
BOOL _selected;
ATRichTextCheckbox_KILabel *_label;
UIButton *_checkbox;
NSArray *_constraints;
}
@synthesize selected=_selected;
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self _at_preinitialize];
[self _at_initialize];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
[self _at_preinitialize];
self = [super initWithCoder:coder];
if (self) {
[self _at_initialize];
}
return self;
}
- (void)_at_preinitialize {
_overhang = 10; // 24 + 10 + 10 = 44
_textColor = [UIColor blackColor];
_checkboxToLabelSpacing = 8;
_font = [UIFont systemFontOfSize:15]; // default font size for UIButton
_showsCheckbox = YES;
}
- (void)_at_initialize {
}
+ (BOOL)requiresConstraintBasedLayout {
return YES;
}
#pragma mark - Children
- (ATRichTextCheckbox_KILabel *)label {
if (!_label) {
_label = [ATRichTextCheckbox_KILabel new];
_label.translatesAutoresizingMaskIntoConstraints = NO;
_label.numberOfLines = 0;
_label.lineBreakMode = NSLineBreakByWordWrapping;
_label.automaticLinkDetectionEnabled = NO;
__weak ATRichTextCheckbox *weakSelf = self;
_label.linkTapHandler = ^(KILinkType linkType, NSString *string, NSRange range) {
ATRichTextCheckbox *self = weakSelf;
NSURL *url = [NSURL URLWithString:string];
if (self->_urlTapHandler != nil) {
self->_urlTapHandler(url);
} else {
NSLog(@"%@ detected tap on %@", NSStringFromClass([self class]), string);
}
};
[self _renderText];
}
return _label;
}
- (UIButton *)checkbox {
if (!_checkbox) {
_checkbox = [UIButton buttonWithType:UIButtonTypeCustom];
_checkbox.translatesAutoresizingMaskIntoConstraints = NO;
_checkbox.adjustsImageWhenHighlighted = YES; // TODO: proper
#if TARGET_INTERFACE_BUILDER
[_checkbox setTitle:@"☐" forState:UIControlStateNormal];
[_checkbox setTitle:@"☑︎" forState:UIControlStateSelected];
#endif
_checkbox.selected = _selected;
[_checkbox addTarget:self action:@selector(checkboxTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return _checkbox;
}
#pragma mark - Properties
- (void)setFont:(UIFont *)font {
if (_font != font) {
_font = font;
[self _renderText];
}
}
- (CGFloat)fontSize {
return self.font.pointSize;
}
- (void)setFontSize:(CGFloat)fontSize {
self.font = [UIFont systemFontOfSize:fontSize];
}
- (void)setTextColor:(UIColor *)textColor {
if (_textColor != textColor) {
_textColor = textColor;
[self _renderText];
}
}
- (void)setHtmlText:(NSString *)htmlText {
if (_htmlText != htmlText) {
_htmlText = [htmlText copy];
[self _renderText];
}
}
- (void)setSelected:(BOOL)selected {
if (_selected != selected) {
_selected = selected;
_checkbox.selected = _selected;
}
}
- (void)setShowsCheckbox:(BOOL)showsCheckbox {
if (_showsCheckbox != showsCheckbox) {
_showsCheckbox = showsCheckbox;
[self setNeedsUpdateConstraints];
}
}
- (void)setCheckboxToLabelSpacing:(CGFloat)checkboxToLabelSpacing {
if (_checkboxToLabelSpacing != checkboxToLabelSpacing) {
_checkboxToLabelSpacing = checkboxToLabelSpacing;
[self setNeedsUpdateConstraints];
}
}
#pragma mark - Actions
- (IBAction)checkboxTapped:(id)sender {
self.selected = !self.selected;
[self sendActionsForControlEvents:UIControlEventValueChanged];
}
#pragma mark - Layout
- (void)updateConstraints {
[super updateConstraints];
NSMutableArray *constraints = [NSMutableArray new];
if (_showsCheckbox) {
if (!self.checkbox.superview) {
[self addSubview:self.checkbox];
}
} else {
if (self.checkbox.superview) {
[self.checkbox removeFromSuperview];
}
}
if (!self.label.superview) {
[self addSubview:self.label];
self.label.tintColor = UIColor_colorWithRGBHex(0xe97e41);
}
NSDictionary *metrics = @{@"checkboxToLabel": @(_checkboxToLabelSpacing)};
NSDictionary *views = NSDictionaryOfVariableBindings(_checkbox, _label);
if (_showsCheckbox) {
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"|[_checkbox]-(checkboxToLabel)-[_label]|" options:NSLayoutFormatAlignAllTop metrics:metrics views:views]];
} else {
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"|[_label]|" options:NSLayoutFormatAlignAllTop metrics:metrics views:views]];
}
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[_label]|" options:0 metrics:nil views:views]];
if (_showsCheckbox) {
[constraints addObject:[NSLayoutConstraint constraintWithItem:_checkbox attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationLessThanOrEqual toItem:self attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0.0]];
[constraints addObject:[NSLayoutConstraint constraintWithItem:_checkbox attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:24]];
[constraints addObject:[NSLayoutConstraint constraintWithItem:_checkbox attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:23]];
}
if (_constraints) {
[self removeConstraints:_constraints];
}
[self addConstraints:constraints];
_constraints = [NSArray arrayWithArray:constraints];
}
- (void)layoutSubviews {
[super layoutSubviews];
_label.preferredMaxLayoutWidth = _label.frame.size.width;
[self layoutIfNeeded];
}
#pragma mark - Overhang
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if ([self _at_pointInsideCheckbox:point]) {
return _checkbox;
}
if ([_label pointInside:point withEvent:event]) {
if ([_label getLinkAtLocation:[self convertPoint:point toView:_label]]) {
return _label;
} else if (_showsCheckbox) {
return _checkbox;
}
}
return [super hitTest:point withEvent:event];
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
if ([self _at_pointInsideCheckbox:point]) {
return YES;
}
return [super pointInside:point withEvent:event];
}
- (BOOL)_at_pointInsideCheckbox:(CGPoint)point {
if (!_showsCheckbox) {
return NO;
}
CGRect checkboxRect = CGRectInset(_checkbox.frame, -_overhang, -_overhang);
return CGRectContainsPoint(checkboxRect, point);
}
#pragma mark - Text handling
- (void)_renderText {
#if TARGET_INTERFACE_BUILDER
NSMutableAttributedString *as = [[[NSAttributedString alloc] initWithString:_htmlText] mutableCopy];
#else
// too slow on IB
NSMutableAttributedString *as = [[[NSAttributedString alloc] initWithData:[_htmlText dataUsingEncoding:NSUTF8StringEncoding] options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding)} documentAttributes:nil error:NULL] mutableCopy];
#endif
[as addAttribute:NSForegroundColorAttributeName value:_textColor range:NSMakeRange(0, as.length)];
[as addAttribute:NSFontAttributeName value:_font range:NSMakeRange(0, as.length)];
[as enumerateAttributesInRange:NSMakeRange(0, as.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) {
// if (a)
}];
_label.attributedText = as;
}
@end
/***********************************************************************************
*
* Based on KILink by Matthew Styles, commit dc7e4aae84b6ee01f141a1f1faf190bc0e4ddf3c
* Modified to support custom URLs.
*
* The MIT License (MIT)
*
* Copyright (c) 2013 Matthew Styles
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
***********************************************************************************/
#import <UIKit/UIKit.h>
// Constants for identifying link types
typedef NS_ENUM(NSInteger, KILinkType)
{
KILinkTypeUserHandle,
KILinkTypeHashtag,
KILinkTypeURL
};
// Constants for identifying link types we can detect
typedef NS_OPTIONS(NSUInteger, KILinkDetectionTypes)
{
KILinkDetectionTypeUserHandle = (1 << 0),
KILinkDetectionTypeHashtag = (1 << 1),
KILinkDetectionTypeURL = (1 << 2),
// Convenient constants
KILinkDetectionTypeNone = 0,
KILinkDetectionTypeAll = NSUIntegerMax
};
// Block method that is called when an interactive word is touched
typedef void (^KILinkTapHandler)(KILinkType linkType, NSString *string, NSRange range);
@interface ATRichTextCheckbox_KILabel : UILabel <NSLayoutManagerDelegate>
// Automatic detection of links, hashtags and usernames. When this is enabled links
// are coloured using the views tintColor property.
@property (nonatomic, assign, getter = isAutomaticLinkDetectionEnabled) BOOL automaticLinkDetectionEnabled;
@property (nonatomic, assign) KILinkDetectionTypes linkDetectionTypes;
// Colour used to hilight selected link background
@property (nonatomic, copy) UIColor *selectedLinkBackgroundColour;
// Get or set a block that is called when a link is touched
@property (nonatomic, copy) KILinkTapHandler linkTapHandler;
// Returns a dictionary of data about the link that it at the location. Returns
// nil if there is no link. A link dictionary contains the following keys:
// @"linkType" a TDLinkType that identifies the type of link
// @"range" the range of the link within the label text
// @"link" the link text. This could be an URL, handle or hashtag depending on the linkType value
- (NSDictionary *)getLinkAtLocation:(CGPoint)location;
@end
/***********************************************************************************
*
* The MIT License (MIT)
*
* Copyright (c) 2013 Matthew Styles
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
***********************************************************************************/
#import "ATRichTextCheckbox_KILabel.h"
#pragma mark - Private Interface
@interface ATRichTextCheckbox_KILabel()
// Used to control layout of glyphs and rendering
@property (nonatomic, retain) NSLayoutManager *layoutManager;
// Specifies the space in which to render text
@property (nonatomic, retain) NSTextContainer *textContainer;
// Backing storage for text that is rendered by the layout manager
@property (nonatomic, retain) NSTextStorage *textStorage;
// Dictionary of detected links and their ranges in the text
@property (nonatomic, copy) NSArray *linkRanges;
// State used to trag if the user has dragged during a touch
@property (nonatomic, assign) BOOL isTouchMoved;
@property (nonatomic, assign) BOOL trackingTouch;
// During a touch, range of text that is displayed as selected
@property (nonatomic, assign) NSRange selectedRange;
@end
#pragma mark - Implementation
@implementation ATRichTextCheckbox_KILabel
#pragma mark - Construction
- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self)
{
[self setupTextSystem];
}
return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder
{
self = [super initWithCoder:aDecoder];
if (self)
{
[self setupTextSystem];
}
return self;
}
// Common initialisation. Must be done once during construction.
- (void)setupTextSystem
{
// Create a text container and set it up to match our label properties
self.textContainer = [[NSTextContainer alloc] init];
self.textContainer.lineFragmentPadding = 0;
self.textContainer.maximumNumberOfLines = self.numberOfLines;
self.textContainer.lineBreakMode = self.lineBreakMode;
self.textContainer.size = self.frame.size;
// Create a layout manager for rendering
self.layoutManager = [[NSLayoutManager alloc] init];
self.layoutManager.delegate = self;
[self.layoutManager addTextContainer:self.textContainer];
// Attach the layou manager to the container and storage
[self.textContainer setLayoutManager:self.layoutManager];
// Make sure user interaction is enabled so we can accept touches
self.userInteractionEnabled = YES;
// Don't go via public setter as this will have undesired side effect
_automaticLinkDetectionEnabled = YES;
// All links are detectable by default
_linkDetectionTypes = KILinkDetectionTypeAll;
// Default background colour looks good on a white background
self.selectedLinkBackgroundColour = [UIColor colorWithWhite:0.95 alpha:1.0];
// Establish the text store with our current text
[self updateTextStoreWithText];
// Attach a default detection handler to help with debugging
self.linkTapHandler = ^(KILinkType linkType, NSString *string, NSRange range) {
NSString *linkTypeName = nil;
switch (linkType)
{
case KILinkTypeUserHandle:
linkTypeName = @"KILinkTypeUserHandle";
break;
case KILinkTypeHashtag:
linkTypeName = @"KILinkTypeHashtag";
break;
case KILinkTypeURL:
linkTypeName = @"KILinkTypeURL";
break;
}
NSLog(@"Default handler for label: %@, %@, (%lu, %lu)", linkTypeName, string, (unsigned long)range.location, (unsigned long)range.length);
};
}
#pragma mark - Text and Style management
- (void)setAutomaticLinkDetectionEnabled:(BOOL)decorating
{
_automaticLinkDetectionEnabled = decorating;
// Make sure the text is updated properly
[self updateTextStoreWithText];
}
- (void)setLinkDetectionTypes:(KILinkDetectionTypes)linkDetectionTypes
{
_linkDetectionTypes = linkDetectionTypes;
// Make sure the text is updated properly
[self updateTextStoreWithText];
}
- (NSDictionary *)getLinkAtLocation:(CGPoint)location
{
// Do nothing if we have no text
if (self.textStorage.string.length == 0)
{
return nil;
}
// Work out the offset of the text in the view
CGPoint textOffset;
NSRange glyphRange = [self.layoutManager glyphRangeForTextContainer:self.textContainer];
textOffset = [self calcTextOffsetForGlyphRange:glyphRange];
// Get the touch location and use text offset to convert to text cotainer coords
location.x -= textOffset.x;
location.y -= textOffset.y;
NSUInteger touchedChar = [self.layoutManager glyphIndexForPoint:location inTextContainer:self.textContainer];
// If the touch is in white space after the last glyph on the line we don't
// count it as a hit on the text
NSRange lineRange;
CGRect lineRect = [self.layoutManager lineFragmentUsedRectForGlyphAtIndex:touchedChar effectiveRange:&lineRange];
if (CGRectContainsPoint(lineRect, location) == NO)
{
return nil;
}
// Find the word that was touched and call the detection block
for (NSDictionary *dictionary in self.linkRanges)
{
NSRange range = [[dictionary objectForKey:@"range"] rangeValue];
if ((touchedChar >= range.location) && touchedChar < (range.location + range.length))
{
return dictionary;
}
}
return nil;
}
// Applies background colour to selected range. Used to hilight touched links
- (void)setSelectedRange:(NSRange)range
{
// Remove the current selection if the selection is changing
if (self.selectedRange.length && !NSEqualRanges(self.selectedRange, range))
{
[self.textStorage removeAttribute:NSBackgroundColorAttributeName
range:self.selectedRange];
}
// Apply the new selection to the text
if (range.length)
{
[self.textStorage addAttribute:NSBackgroundColorAttributeName
value:self.selectedLinkBackgroundColour
range:range];
}
// Save the new range
_selectedRange = range;
[self setNeedsDisplay];
}
- (void)setNumberOfLines:(NSInteger)numberOfLines
{
[super setNumberOfLines:numberOfLines];
self.textContainer.maximumNumberOfLines = numberOfLines;
}
- (void)setText:(NSString *)text
{
// Pass the text to the super class first
[super setText:text];
// Update our text store with an attributed string based on the original
// label text properties.
NSAttributedString *attributedText = [[NSAttributedString alloc] initWithString:text
attributes:[self attributesFromProperties]];
[self updateTextStoreWithAttributedString:attributedText];
}
- (void)setAttributedText:(NSAttributedString *)attributedText
{
// Pass the text to the super class first
[super setAttributedText:attributedText];
[self updateTextStoreWithAttributedString:attributedText];
}
#pragma mark - Text Storage Management
- (void)updateTextStoreWithText
{
// Now update our storage from either the attributedString or the plain text
if (self.attributedText)
{
[self updateTextStoreWithAttributedString:self.attributedText];
}
else if (self.text)
{
[self updateTextStoreWithAttributedString:[[NSAttributedString alloc] initWithString:self.text attributes:[self attributesFromProperties]]];
}
else
{
[self updateTextStoreWithAttributedString:[[NSAttributedString alloc] initWithString:@"" attributes:[self attributesFromProperties]]];
}
[self setNeedsDisplay];
}
- (void)updateTextStoreWithAttributedString:(NSAttributedString *)attributedString
{
if (attributedString.length != 0)
{
attributedString = [ATRichTextCheckbox_KILabel sanitizeAttributedString:attributedString];
}
if (attributedString.length != 0)
{
self.linkRanges = [self getRangesForLinks:attributedString];
attributedString = [self addLinkAttributesToAttributedString:attributedString linkRanges:self.linkRanges];
}
else
{
self.linkRanges = nil;
}
if (self.textStorage)
{
// Set the string on the storage
[self.textStorage setAttributedString:attributedString];
}
else
{
// Create a new text storage and attach it correctly to the layout manager
self.textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];
[self.textStorage addLayoutManager:self.layoutManager];
[self.layoutManager setTextStorage:self.textStorage];
}
}
// Returns attributed string attributes based on the text properties set on the label.
// These are styles that are only applied when NOT using the attributedText directly.
- (NSDictionary *)attributesFromProperties
{
// Setup shadow attributes
NSShadow *shadow = shadow = [[NSShadow alloc] init];
if (self.shadowColor)
{
shadow.shadowColor = self.shadowColor;
shadow.shadowOffset = self.shadowOffset;
}
else
{
shadow.shadowOffset = CGSizeMake(0, -1);
shadow.shadowColor = nil;
}
// Setup colour attributes
UIColor *colour = self.textColor;
if (!self.isEnabled)
{
colour = [UIColor lightGrayColor];
}
else if (self.isHighlighted)
{
colour = self.highlightedTextColor;
}
// Setup paragraph attributes
NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
paragraph.alignment = self.textAlignment;
// Create the dictionary
NSDictionary *attributes = @{
NSFontAttributeName : self.font,
NSForegroundColorAttributeName : colour,
NSShadowAttributeName : shadow,
NSParagraphStyleAttributeName : paragraph };
return attributes;
}
// Returns array of ranges for all special words, user handles, hashtags and urls
- (NSArray *)getRangesForLinks:(NSAttributedString *)text
{
NSMutableArray *rangesForLinks = [[NSMutableArray alloc] init];
if (self.isAutomaticLinkDetectionEnabled) {
if (self.linkDetectionTypes & KILinkDetectionTypeUserHandle)
{
[rangesForLinks addObjectsFromArray:[self getRangesForUserHandles:text.string]];
}
if (self.linkDetectionTypes & KILinkDetectionTypeHashtag)
{
[rangesForLinks addObjectsFromArray:[self getRangesForHashtags:text.string]];
}
if (self.linkDetectionTypes & KILinkDetectionTypeURL)
{
[rangesForLinks addObjectsFromArray:[self getRangesForURLs:self.attributedText]];
}
}
[text enumerateAttribute:NSLinkAttributeName inRange:NSMakeRange(0, text.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
if ([value isKindOfClass:NSURL.class]) {
NSURL *url = value;
[rangesForLinks addObject:@{@"linkType" : @(KILinkTypeURL),
@"range" : [NSValue valueWithRange:range],
@"link" : url.absoluteString}];
}
}];
return rangesForLinks;
}
- (NSArray *)getRangesForUserHandles:(NSString *)text
{
NSMutableArray *rangesForUserHandles = [[NSMutableArray alloc] init];
// Setup a regular expression for user handles and hashtags
NSError *error = nil;
NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:@"(?<!\\w)@([\\w\\_]+)?"
options:0
error:&error];
// Run the expression and get matches
NSArray *matches = [regex matchesInString:text
options:0
range:NSMakeRange(0, text.length)];
// Add all our ranges to the result
for (NSTextCheckingResult *match in matches)
{
NSRange matchRange = [match range];
NSString *matchString = [text substringWithRange:matchRange];
[rangesForUserHandles addObject:@{
@"linkType" : @(KILinkTypeUserHandle),
@"range" : [NSValue valueWithRange:matchRange],
@"link" : matchString }];
}
return rangesForUserHandles;
}
- (NSArray *)getRangesForHashtags:(NSString *)text
{
NSMutableArray *rangesForHashtags = [[NSMutableArray alloc] init];
// Setup a regular expression for user handles and hashtags
NSError *error = nil;
NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:@"(?<!\\w)#([\\w\\_]+)?"
options:0
error:&error];
// Run the expression and get matches
NSArray *matches = [regex matchesInString:text
options:0
range:NSMakeRange(0, text.length)];
// Add all our ranges to the result
for (NSTextCheckingResult *match in matches)
{
NSRange matchRange = [match range];
NSString *matchString = [text substringWithRange:matchRange];
[rangesForHashtags addObject:@{
@"linkType" : @(KILinkTypeHashtag),
@"range" : [NSValue valueWithRange:matchRange],
@"link" : matchString }];
}
return rangesForHashtags;
}
- (NSArray *)getRangesForURLs:(NSAttributedString *)text
{
NSMutableArray *rangesForURLs = [[NSMutableArray alloc] init];;
// Use a data detector to find urls in the text
NSError *error = nil;
NSDataDetector *detector = [[NSDataDetector alloc] initWithTypes:NSTextCheckingTypeLink error:&error];
NSString *plainText = text.string;
NSArray *matches = [detector matchesInString:plainText
options:0
range:NSMakeRange(0, text.length)];
// Add a range entry for every url we found
for (NSTextCheckingResult *match in matches)
{
NSRange matchRange = [match range];
// If there's a link embedded in the attributes, use that instead of the raw text
NSString *realURL = [text attribute:NSLinkAttributeName
atIndex:matchRange.location
effectiveRange:nil];
if (realURL == nil)
{
realURL = [plainText substringWithRange:matchRange];
}
if ([match resultType] == NSTextCheckingTypeLink)
{
[rangesForURLs addObject:@{
@"linkType" : @(KILinkTypeURL),
@"range" : [NSValue valueWithRange:matchRange],
@"link" : realURL }];
}
}
return rangesForURLs;
}
- (NSAttributedString *)addLinkAttributesToAttributedString:(NSAttributedString *)string linkRanges:(NSArray *)linkRanges
{
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:string];
// Tint colour used to hilight non-url links
NSDictionary *attributes = @{NSForegroundColorAttributeName : self.tintColor};
for (NSDictionary *dictionary in linkRanges)
{
NSRange range = [[dictionary objectForKey:@"range"] rangeValue];
// Use our tint colour to hilight the link
[attributedString addAttributes:attributes range:range];
// Add an URL attribute if this is a URL
if ((KILinkType)[dictionary[@"linkType"] intValue] == KILinkTypeURL)
{
// Add a link attribute using the stored link
[attributedString addAttribute:NSLinkAttributeName
value:dictionary[@"link"]
range:range];
}
}
return attributedString;
}
#pragma mark - Layout and Rendering
- (CGRect)textRectForBounds:(CGRect)bounds limitedToNumberOfLines:(NSInteger)numberOfLines
{
// Use our text container to calculate the bounds required. First save our
// current text container setup
CGSize savedTextContainerSize = self.textContainer.size;
NSInteger savedTextContainerNumberOfLines = self.textContainer.maximumNumberOfLines;
// Apply the new potential bounds and number of lines
self.textContainer.size = bounds.size;
self.textContainer.maximumNumberOfLines = numberOfLines;
// Measure the text with the new state
CGRect textBounds;
@try
{
NSRange glyphRange = [self.layoutManager glyphRangeForTextContainer:self.textContainer];
textBounds = [self.layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:self.textContainer];
// Position the bounds and round up the size for good measure
textBounds.origin = bounds.origin;
textBounds.size.width = ceilf(textBounds.size.width);
textBounds.size.height = ceilf(textBounds.size.height);
}
@finally
{
// Restore the old container state before we exit under any circumstances
self.textContainer.size = savedTextContainerSize;
self.textContainer.maximumNumberOfLines = savedTextContainerNumberOfLines;
}
return textBounds;
}
- (void)drawTextInRect:(CGRect)rect
{
// Don't call super implementation. Might want to uncomment this out when
// debugging layout and rendering problems.
// [super drawTextInRect:rect];
// Calculate the offset of the text in the view
CGPoint textOffset;
NSRange glyphRange = [self.layoutManager glyphRangeForTextContainer:self.textContainer];
textOffset = [self calcTextOffsetForGlyphRange:glyphRange];
// Drawing code
[self.layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:textOffset];
[self.layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:textOffset];
}
// Returns the XY offset of the range of glyphs from the view's origin
- (CGPoint)calcTextOffsetForGlyphRange:(NSRange)glyphRange
{
CGPoint textOffset = CGPointZero;
CGRect textBounds = [self.layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:self.textContainer];
CGFloat paddingHeight = (self.bounds.size.height - textBounds.size.height) / 2.0f;
if (paddingHeight > 0)
{
textOffset.y = paddingHeight;
}
return textOffset;
}
- (void)setFrame:(CGRect)frame
{
[super setFrame:frame];
self.textContainer.size = self.bounds.size;
}
- (void)setBounds:(CGRect)bounds
{
[super setBounds:bounds];
self.textContainer.size = self.bounds.size;
}
- (void)layoutSubviews
{
[super layoutSubviews];
// Update our container size when the view frame changes
self.textContainer.size = self.bounds.size;
}
#pragma mark - Interactions
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
self.isTouchMoved = NO;
// Get the info for the touched link if there is one
NSDictionary *touchedLink;
CGPoint touchLocation = [[touches anyObject] locationInView:self];
touchedLink = [self getLinkAtLocation:touchLocation];
_trackingTouch = (touchedLink != nil);
if (touchedLink)
{
self.selectedRange = [[touchedLink objectForKey:@"range"] rangeValue];
}
else
{
[super touchesBegan:touches withEvent:event];
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
if (!_trackingTouch) {
[super touchesMoved:touches withEvent:event];
}
self.isTouchMoved = YES;
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
if (!_trackingTouch) {
[super touchesEnded:touches withEvent:event];
}
// If the user dragged their finger we ignore the touch
if (self.isTouchMoved)
{
self.selectedRange = NSMakeRange(0, 0);
return;
}
// Get the info for the touched link if there is one
NSDictionary *touchedLink;
CGPoint touchLocation = [[touches anyObject] locationInView:self];
touchedLink = [self getLinkAtLocation:touchLocation];
if (touchedLink)
{
NSRange range = [[touchedLink objectForKey:@"range"] rangeValue];
NSString *touchedSubstring = [touchedLink objectForKey:@"link"];
KILinkType linkType = (KILinkType)[[touchedLink objectForKey:@"linkType"] intValue];
if (self.linkTapHandler) {
self.linkTapHandler(linkType, touchedSubstring, range);
}
}
else
{
[super touchesBegan:touches withEvent:event];
}
self.selectedRange = NSMakeRange(0, 0);
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
if (!_trackingTouch) {
[super touchesCancelled:touches withEvent:event];
}
// Make sure we don't leave a selection when the touch is cancelled
self.selectedRange = NSMakeRange(0, 0);
}
#pragma mark - Layout manager delegate
-(BOOL)layoutManager:(NSLayoutManager *)layoutManager shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex
{
// Don't allow line breaks inside URLs
NSRange range;
NSURL *linkURL = [layoutManager.textStorage attribute:NSLinkAttributeName
atIndex:charIndex
effectiveRange:&range];
return !(linkURL && (charIndex > range.location) && (charIndex <= NSMaxRange(range)));
}
+ (NSAttributedString *)sanitizeAttributedString:(NSAttributedString *)attributedString
{
// Setup paragraph alignement properly. IB applies the line break style
// to the attributed string. The problem is that the text container then
// breaks at the first line of text. If we set the line break to wrapping
// then the text container defines the break mode and it works.
// NOTE: This is either an Apple bug or something I've misunderstood.
// Get the current paragraph style. IB only allows a single paragraph so
// getting the style of the first char is fine.
NSRange range;
NSParagraphStyle *paragraphStyle = [attributedString attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:&range];
if (paragraphStyle == nil)
{
return attributedString;
}
// Remove the line breaks
NSMutableParagraphStyle *mutableParagraphStyle = [paragraphStyle mutableCopy];
mutableParagraphStyle.lineBreakMode = NSLineBreakByWordWrapping;
// Apply new style
NSMutableAttributedString *restyled = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString];
[restyled addAttribute:NSParagraphStyleAttributeName value:mutableParagraphStyle range:NSMakeRange(0, restyled.length)];
return restyled;
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment