| // Copyright 2015 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "ios/chrome/browser/ui/util/label_link_controller.h" |
| |
| #include <map> |
| #include <vector> |
| |
| #include "base/ios/ios_util.h" |
| #include "base/logging.h" |
| #include "base/mac/foundation_util.h" |
| #import "base/strings/sys_string_conversions.h" |
| #include "ios/chrome/browser/ui/ui_util.h" |
| #import "ios/chrome/browser/ui/util/label_observer.h" |
| #import "ios/chrome/browser/ui/util/text_region_mapper.h" |
| #import "ios/chrome/browser/ui/util/transparent_link_button.h" |
| #import "net/base/mac/url_conversions.h" |
| #include "url/gurl.h" |
| |
| #if !defined(__has_feature) || !__has_feature(objc_arc) |
| #error "This file requires ARC support." |
| #endif |
| |
| #pragma mark - LinkLayout |
| |
| // Object encapsulating the range of a link and the frames corresponding with |
| // that range. |
| @interface LinkLayout : NSObject |
| |
| // Designated initializer. |
| - (instancetype)initWithRange:(NSRange)range NS_DESIGNATED_INITIALIZER; |
| - (instancetype)init NS_UNAVAILABLE; |
| |
| // The range passed on initialization. |
| @property(nonatomic, readonly) NSRange range; |
| |
| // The frames calculated for |_range|. |
| @property(nonatomic, strong) NSArray* frames; |
| |
| @end |
| |
| @implementation LinkLayout |
| |
| @synthesize range = _range; |
| @synthesize frames = _frames; |
| |
| - (instancetype)initWithRange:(NSRange)range { |
| if ((self = [super init])) { |
| DCHECK_NE(range.location, static_cast<NSUInteger>(NSNotFound)); |
| DCHECK_NE(range.length, 0U); |
| _range = range; |
| } |
| return self; |
| } |
| |
| @end |
| |
| #pragma mark - LabelLinkController |
| |
| @interface LabelLinkController () |
| // Private property exposed publically in testing interface. |
| @property(nonatomic, unsafe_unretained) Class textMapperClass; |
| |
| // The original attributed text set on the label. This may be different from |
| // the label's |attributedText| property, as additional style attributes may be |
| // introduced for links. |
| @property(nonatomic, strong, readonly) NSAttributedString* originalLabelText; |
| |
| // The array of TransparentLinkButtons inserted above the label. |
| @property(nonatomic, strong, readonly) NSMutableArray* linkButtons; |
| |
| // Adds LabelObserverActions to the LabelObserver corresponding to |_label|. |
| - (void)addLabelObserverActions; |
| |
| // Clears all defined links and any data associated with them. Update the |
| // original attributed text from the controlled label. |
| - (void)reset; |
| |
| // Handle a change to the label that changes the positioning of glyphs but not |
| // any styling of those glyphs. Forces a recomputation of the tap regions, and |
| // recreates any tap buttons. |
| - (void)labelLayoutInvalidated; |
| |
| // Handle a change to the label that changes the glyph style. This forces all of |
| // the link-specific styling applied by this class to be regenerated (which |
| // itself will again re-trigger this method), and because any kind of style |
| // change may alter the position of glyphs, this forces a layout invalidation. |
| - (void)labelStyleInvalidated; |
| |
| // Updates the attributed string content of the controlled label to |
| // have the designated link colors and styles. |
| // No-op if no links are defined. |
| - (void)updateStyles; |
| |
| // If the controlled label's bounds have changed from the last time tap rects |
| // were updated, determine which regions in the label should be tappable. |
| - (void)updateTapRects; |
| |
| // Creates a new text mapper instance with the current label bounds and |
| // attributed text. |
| - (void)resetTextMapper; |
| |
| // Clear any tap buttons that have been created, removing them from their |
| // superview if necessary. |
| - (void)clearTapButtons; |
| |
| // Updates the tap buttons as detailed below. This method is called every time |
| // tap rects are updated, as well as every time |_label|'s superview changes. |
| // If there are no tap buttons defined, but there are known tap rects, and |
| // |_label| has a superview, then tap buttons are created and added to that |
| // view. |
| // If there are tap buttons, and |_label| has no superview, then the tap buttons |
| // are cleared. |
| // If there are tap buttons, but they are not subviews of |_label|'s superview |
| // (if _label's superview has changed since the buttons were created), then |
| // the tap buttons are migrated into the new superview. |
| - (void)updateTapButtons; |
| |
| @end |
| |
| @implementation LabelLinkController { |
| // Ivars immutable for the lifetime of the object. |
| ProceduralBlockWithURL _action; |
| UILabel* _label; |
| UITapGestureRecognizer* _linkTapRecognizer; |
| |
| // Ivars that reset when label text changes. |
| NSMutableDictionary* _layoutsForURLs; |
| CGRect _lastLabelFrame; |
| |
| // Ivars that reset when text or bounds change. |
| id<TextRegionMapper> _textMapper; |
| |
| // Internal tracking. |
| BOOL _justUpdatedStyles; |
| LabelObserver* _labelObserver; |
| } |
| |
| @synthesize showTapAreas = _showTapAreas; |
| @synthesize textMapperClass = _textMapperClass; |
| @synthesize linkUnderlineStyle = _linkUnderlineStyle; |
| @synthesize linkButtons = _linkButtons; |
| @synthesize originalLabelText = _originalLabelText; |
| @synthesize linkFont = _linkFont; |
| @synthesize linkColor = _linkColor; |
| |
| - (instancetype)initWithLabel:(UILabel*)label |
| action:(ProceduralBlockWithURL)action { |
| if ((self = [super init])) { |
| DCHECK(label); |
| _label = label; |
| _action = [action copy]; |
| _linkUnderlineStyle = NSUnderlineStyleNone; |
| [self reset]; |
| |
| _labelObserver = [LabelObserver observerForLabel:_label]; |
| [_labelObserver startObserving]; |
| [self addLabelObserverActions]; |
| |
| self.textMapperClass = [CoreTextRegionMapper class]; |
| _linkButtons = [[NSMutableArray alloc] init]; |
| } |
| return self; |
| } |
| |
| - (void)addLabelObserverActions { |
| __weak LabelLinkController* weakSelf = self; |
| [_labelObserver addStyleChangedAction:^(UILabel* label) { |
| // One of the style properties has been changed, which will silently |
| // update the label's attributedText. |
| if (!weakSelf) |
| return; |
| LabelLinkController* strongSelf = weakSelf; |
| [strongSelf labelStyleInvalidated]; |
| }]; |
| [_labelObserver addTextChangedAction:^(UILabel* label) { |
| if (!weakSelf) |
| return; |
| LabelLinkController* strongSelf = weakSelf; |
| NSString* originalText = [[strongSelf originalLabelText] string]; |
| if ([label.text isEqualToString:originalText]) { |
| // The actual text of the label didn't change, so this was a change to |
| // the string attributes only. |
| [strongSelf labelStyleInvalidated]; |
| } else { |
| // The label text has changed, so start everything from scratch. |
| [strongSelf reset]; |
| } |
| }]; |
| [_labelObserver addLayoutChangedAction:^(UILabel* label) { |
| if (!weakSelf) |
| return; |
| LabelLinkController* strongSelf = weakSelf; |
| [strongSelf labelLayoutInvalidated]; |
| NSArray* linkButtons = [strongSelf linkButtons]; |
| // If this layout change corresponds to |label|'s moving to a new superview, |
| // update the tap buttons so that they are inserted above |label| in the new |
| // hierarchy. |
| if (linkButtons.count && label.superview != [linkButtons[0] superview]) |
| [strongSelf updateTapButtons]; |
| }]; |
| } |
| |
| - (void)dealloc { |
| [self clearTapButtons]; |
| [_labelObserver stopObserving]; |
| } |
| |
| - (void)addLinkWithRange:(NSRange)range url:(GURL)url { |
| DCHECK(url.is_valid()); |
| if (!_layoutsForURLs) |
| _layoutsForURLs = [[NSMutableDictionary alloc] init]; |
| NSURL* key = net::NSURLWithGURL(url); |
| LinkLayout* layout = [[LinkLayout alloc] initWithRange:range]; |
| [_layoutsForURLs setObject:layout forKey:key]; |
| [self updateStyles]; |
| } |
| |
| - (void)setLinkColor:(UIColor*)linkColor { |
| _linkColor = [linkColor copy]; |
| [self updateStyles]; |
| } |
| |
| - (void)setLinkUnderlineStyle:(NSUnderlineStyle)underlineStyle { |
| _linkUnderlineStyle = underlineStyle; |
| [self updateStyles]; |
| } |
| |
| - (void)setLinkFont:(UIFont*)linkFont { |
| _linkFont = linkFont; |
| [self updateStyles]; |
| } |
| |
| - (void)setShowTapAreas:(BOOL)showTapAreas { |
| #ifndef NDEBUG |
| for (TransparentLinkButton* button in _linkButtons) { |
| button.debug = showTapAreas; |
| } |
| #endif // NDEBUG |
| _showTapAreas = showTapAreas; |
| } |
| |
| #pragma mark - internal methods |
| |
| - (void)reset { |
| _originalLabelText = [[_label attributedText] copy]; |
| _textMapper = nil; |
| _lastLabelFrame = CGRectZero; |
| _layoutsForURLs = nil; |
| } |
| |
| - (void)labelLayoutInvalidated { |
| _textMapper = nil; |
| [self updateTapRects]; |
| } |
| |
| - (void)labelStyleInvalidated { |
| // If the style invalidation was triggered by this class updating link styles, |
| // then the original label text is still correct, but the tap rects still need |
| // to be updated. Otherwise, update the original label text, and then update |
| // styles. This will set |_justUpdatedStyles| and trigger another call to |
| // this method via KVO. |
| if (_justUpdatedStyles) { |
| // TODO(crbug.com/664648): Remove _justUpdatedStyles due to bug that |
| // prevents proper style updates after successive label format changes. |
| _justUpdatedStyles = NO; |
| } else if (![_originalLabelText isEqual:[_label attributedText]]) { |
| _originalLabelText = [[_label attributedText] copy]; |
| [self updateStyles]; |
| } |
| _lastLabelFrame = CGRectZero; |
| [self labelLayoutInvalidated]; |
| } |
| |
| - (void)updateStyles { |
| if (![_layoutsForURLs count]) |
| return; |
| |
| __block NSMutableAttributedString* labelText = |
| [_originalLabelText mutableCopy]; |
| [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| NSURL* key, LinkLayout* layout, BOOL* stop) { |
| if (_linkColor) { |
| [labelText addAttribute:NSForegroundColorAttributeName |
| value:_linkColor |
| range:layout.range]; |
| } |
| if (_linkUnderlineStyle != NSUnderlineStyleNone) { |
| [labelText addAttribute:NSUnderlineStyleAttributeName |
| value:@(_linkUnderlineStyle) |
| range:layout.range]; |
| } |
| if (_linkFont) { |
| [labelText addAttribute:NSFontAttributeName |
| value:_linkFont |
| range:layout.range]; |
| } |
| }]; |
| _justUpdatedStyles = YES; |
| [_label setAttributedText:labelText]; |
| _textMapper = nil; |
| } |
| |
| - (void)updateTapRects { |
| // Don't update if the label hasn't changed size or position. |
| if (CGRectEqualToRect([_label frame], _lastLabelFrame)) |
| return; |
| // Don't update if there are no links. |
| if (![_layoutsForURLs count]) |
| return; |
| |
| _lastLabelFrame = [_label frame]; |
| [self clearTapButtons]; |
| |
| // If the label bounds are zero in either dimension, no rects are possible. |
| if (0.0 == _lastLabelFrame.size.width || 0.0 == _lastLabelFrame.size.height) |
| return; |
| |
| if (!_textMapper) |
| [self resetTextMapper]; |
| |
| for (LinkLayout* layout in [_layoutsForURLs allValues]) { |
| NSMutableArray* frames = [[NSMutableArray alloc] init]; |
| NSArray* rects = [_textMapper rectsForRange:layout.range]; |
| for (NSUInteger rectIdx = 0; rectIdx < [rects count]; ++rectIdx) { |
| CGRect frame = [rects[rectIdx] CGRectValue]; |
| frame = [[_label superview] convertRect:frame fromView:_label]; |
| [frames addObject:[NSValue valueWithCGRect:frame]]; |
| } |
| layout.frames = frames; |
| } |
| [self updateTapButtons]; |
| } |
| |
| - (void)resetTextMapper { |
| DCHECK([self.textMapperClass conformsToProtocol:@protocol(TextRegionMapper)]); |
| _textMapper = [[self.textMapperClass alloc] |
| initWithAttributedString:[_label attributedText] |
| bounds:[_label bounds]]; |
| } |
| |
| - (void)clearTapButtons { |
| for (TransparentLinkButton* button in _linkButtons) { |
| [button removeFromSuperview]; |
| } |
| [_linkButtons removeAllObjects]; |
| } |
| |
| - (void)updateTapButtons { |
| // If the label has no superview, clear any existing buttons. |
| if (![_label superview]) { |
| [self clearTapButtons]; |
| return; |
| } else if ([_linkButtons count]) { |
| // If the buttons are currently in some view other than the label's |
| // superview, repatriate them. |
| if (base::mac::ObjCCast<TransparentLinkButton>(_linkButtons[0]).superview != |
| [_label superview]) { |
| for (TransparentLinkButton* button in _linkButtons) { |
| CGRect newFrame = |
| [[_label superview] convertRect:button.frame fromView:button]; |
| [[_label superview] insertSubview:button aboveSubview:_label]; |
| button.frame = newFrame; |
| } |
| } |
| } |
| // If there are no buttons, make some and put them in the label's superview. |
| if (![_linkButtons count] && _layoutsForURLs) { |
| [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| NSURL* key, LinkLayout* layout, BOOL* stop) { |
| GURL URL = net::GURLWithNSURL(key); |
| NSString* accessibilityLabel = |
| [[_label text] substringWithRange:layout.range]; |
| NSArray* buttons = |
| [TransparentLinkButton buttonsForLinkFrames:layout.frames |
| URL:URL |
| accessibilityLabel:accessibilityLabel]; |
| for (TransparentLinkButton* button in buttons) { |
| #ifndef NDEBUG |
| button.debug = self.showTapAreas; |
| #endif // NDEBUG |
| [button addTarget:self |
| action:@selector(linkButtonTapped:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| [[_label superview] insertSubview:button aboveSubview:_label]; |
| [_linkButtons addObject:button]; |
| } |
| }]; |
| } |
| } |
| |
| #pragma mark - Tap Handlers |
| |
| - (void)linkButtonTapped:(id)sender { |
| TransparentLinkButton* button = |
| base::mac::ObjCCast<TransparentLinkButton>(sender); |
| _action(button.URL); |
| } |
| |
| #pragma mark - Test facilitators |
| |
| - (NSArray*)tapRectsForURL:(GURL)url { |
| NSURL* key = net::NSURLWithGURL(url); |
| LinkLayout* layout = [_layoutsForURLs objectForKey:key]; |
| return layout.frames; |
| } |
| |
| - (void)tapLabelAtPoint:(CGPoint)point { |
| [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| NSURL* key, LinkLayout* layout, BOOL* stop) { |
| for (NSValue* frameValue in layout.frames) { |
| CGRect frame = [frameValue CGRectValue]; |
| if (CGRectContainsPoint(frame, point)) { |
| _action(net::GURLWithNSURL(key)); |
| *stop = YES; |
| break; |
| } |
| } |
| }]; |
| } |
| |
| @end |