/*********************************************************************************** * * 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 "KILabel.h" NSString * const KILabelLinkTypeKey = @"linkType"; NSString * const KILabelRangeKey = @"range"; NSString * const KILabelLinkKey = @"link"; #pragma mark - Private Interface @interface 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; // During a touch, range of text that is displayed as selected @property (nonatomic, assign) NSRange selectedRange; @end #pragma mark - Implementation @implementation KILabel { NSMutableDictionary *_linkTypeAttributes; } #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 _textContainer = [[NSTextContainer alloc] init]; _textContainer.lineFragmentPadding = 0; _textContainer.maximumNumberOfLines = self.numberOfLines; _textContainer.lineBreakMode = self.lineBreakMode; _textContainer.size = self.frame.size; // Create a layout manager for rendering _layoutManager = [[NSLayoutManager alloc] init]; _layoutManager.delegate = self; [_layoutManager addTextContainer:_textContainer]; // Attach the layou manager to the container and storage [_textContainer setLayoutManager:_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 = KILinkTypeOptionAll; // Link Type Attributes. Default is empty (no attributes). _linkTypeAttributes = [NSMutableDictionary dictionary]; // Don't underline URL links by default. _systemURLStyle = NO; // By default we hilight the selected link during a touch to give feedback that we are // responding to touch. _selectedLinkBackgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0]; // Establish the text store with our current text [self updateTextStoreWithText]; } #pragma mark - Text and Style management - (void)setAutomaticLinkDetectionEnabled:(BOOL)decorating { _automaticLinkDetectionEnabled = decorating; // Make sure the text is updated properly [self updateTextStoreWithText]; } - (void)setLinkDetectionTypes:(KILinkTypeOption)linkDetectionTypes { _linkDetectionTypes = linkDetectionTypes; // Make sure the text is updated properly [self updateTextStoreWithText]; } - (NSDictionary *)linkAtPoint:(CGPoint)location { // Do nothing if we have no text if (_textStorage.string.length == 0) { return nil; } // Work out the offset of the text in the view CGPoint textOffset = [self calcGlyphsPositionInView]; // Get the touch location and use text offset to convert to text cotainer coords location.x -= textOffset.x; location.y -= textOffset.y; NSUInteger touchedChar = [_layoutManager glyphIndexForPoint:location inTextContainer:_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 = [_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:KILabelRangeKey] rangeValue]; if ((touchedChar >= range.location) && touchedChar < (range.location + range.length)) { return dictionary; } } return nil; } // Applies background color 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)) { [_textStorage removeAttribute:NSBackgroundColorAttributeName range:self.selectedRange]; } // Apply the new selection to the text if (range.length && _selectedLinkBackgroundColor != nil) { [_textStorage addAttribute:NSBackgroundColorAttributeName value:_selectedLinkBackgroundColor range:range]; } // Save the new range _selectedRange = range; [self setNeedsDisplay]; } - (void)setNumberOfLines:(NSInteger)numberOfLines { [super setNumberOfLines:numberOfLines]; _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. if (!text) { text = @""; } 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]; } - (void)setSystemURLStyle:(BOOL)systemURLStyle { _systemURLStyle = systemURLStyle; // Force refresh self.text = self.text; } - (NSDictionary*)attributesForLinkType:(KILinkType)linkType { NSDictionary *attributes = _linkTypeAttributes[@(linkType)]; if (!attributes) { attributes = @{NSForegroundColorAttributeName : self.tintColor}; } return attributes; } - (void)setAttributes:(NSDictionary*)attributes forLinkType:(KILinkType)linkType { if (attributes) { _linkTypeAttributes[@(linkType)] = attributes; } else { [_linkTypeAttributes removeObjectForKey:@(linkType)]; } // Force refresh text self.text = self.text; } #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 = [KILabel sanitizeAttributedString:attributedString]; } if (self.isAutomaticLinkDetectionEnabled && (attributedString.length != 0)) { self.linkRanges = [self getRangesForLinks:attributedString]; attributedString = [self addLinkAttributesToAttributedString:attributedString linkRanges:self.linkRanges]; } else { self.linkRanges = nil; } if (_textStorage) { // Set the string on the storage [_textStorage setAttributedString:attributedString]; } else { // Create a new text storage and attach it correctly to the layout manager _textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; [_textStorage addLayoutManager:_layoutManager]; [_layoutManager setTextStorage:_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 color attributes UIColor *color = self.textColor; if (!self.isEnabled) { color = [UIColor lightGrayColor]; } else if (self.isHighlighted) { color = self.highlightedTextColor; } // Setup paragraph attributes NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; paragraph.alignment = self.textAlignment; // Create the dictionary NSDictionary *attributes = @{NSFontAttributeName : self.font, NSForegroundColorAttributeName : color, NSShadowAttributeName : shadow, NSParagraphStyleAttributeName : paragraph, }; return attributes; } /** * Returns array of ranges for all special words, user handles, hashtags and urls in the specfied * text. * * @param text Text to parse for links * * @return Array of dictionaries describing the links. */ - (NSArray *)getRangesForLinks:(NSAttributedString *)text { NSMutableArray *rangesForLinks = [[NSMutableArray alloc] init]; if (self.linkDetectionTypes & KILinkTypeOptionUserHandle) { [rangesForLinks addObjectsFromArray:[self getRangesForUserHandles:text.string]]; } if (self.linkDetectionTypes & KILinkTypeOptionHashtag) { [rangesForLinks addObjectsFromArray:[self getRangesForHashtags:text.string]]; } if (self.linkDetectionTypes & KILinkTypeOptionURL) { [rangesForLinks addObjectsFromArray:[self getRangesForURLs:self.attributedText]]; } return rangesForLinks; } - (NSArray *)getRangesForUserHandles:(NSString *)text { NSMutableArray *rangesForUserHandles = [[NSMutableArray alloc] init]; // Setup a regular expression for user handles and hashtags static NSRegularExpression *regex = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSError *error = nil; regex = [[NSRegularExpression alloc] initWithPattern:@"(? 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