Created
June 10, 2020 10:59
-
-
Save andreyvit/8f1f66db594543eeac59750dd4c0eef3 to your computer and use it in GitHub Desktop.
UIKit checkbox control that supports embedded links (for ToS acceptance checkbox)
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
@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 |
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
#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 |
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
/*********************************************************************************** | |
* | |
* 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 |
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
/*********************************************************************************** | |
* | |
* 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