Index: ios/chrome/browser/ui/util/label_link_controller.mm |
diff --git a/ios/chrome/browser/ui/util/label_link_controller.mm b/ios/chrome/browser/ui/util/label_link_controller.mm |
new file mode 100644 |
index 0000000000000000000000000000000000000000..7820ead3e6c3416786bfd0f6997bf849eb50c5e9 |
--- /dev/null |
+++ b/ios/chrome/browser/ui/util/label_link_controller.mm |
@@ -0,0 +1,451 @@ |
+// 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/ios/weak_nsobject.h" |
+#include "base/logging.h" |
+#include "base/mac/foundation_util.h" |
+#include "base/mac/scoped_block.h" |
+#import "base/mac/scoped_nsobject.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" |
+ |
+#pragma mark - LinkLayout |
+ |
+// Object encapsulating the range of a link and the frames corresponding with |
+// that range. |
+@interface LinkLayout : NSObject { |
+ // Backing objects for properties of same name. |
+ base::scoped_nsobject<NSArray> _frames; |
+} |
+ |
+// 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, retain) NSArray* frames; |
+ |
+@end |
+ |
+@implementation LinkLayout |
+ |
+@synthesize range = _range; |
+ |
+- (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; |
+} |
+ |
+#pragma mark - Accessors |
+ |
+- (void)setFrames:(NSArray*)frames { |
+ _frames.reset([frames retain]); |
+} |
+ |
+- (NSArray*)frames { |
+ return _frames.get(); |
+} |
+ |
+@end |
+ |
+#pragma mark - LabelLinkController |
+ |
+@interface LabelLinkController () |
+// Private property exposed publically in testing interface. |
+@property(nonatomic, assign) 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, readonly) NSAttributedString* originalLabelText; |
+ |
+// The array of TransparentLinkButtons inserted above the label. |
+@property(nonatomic, readonly) NSArray* 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. |
+ base::mac::ScopedBlock<ProceduralBlockWithURL> _action; |
+ base::WeakNSObject<UILabel> _label; // weak |
+ base::scoped_nsobject<UITapGestureRecognizer> _linkTapRecognizer; |
+ |
+ // Ivas backing properties. |
+ base::scoped_nsobject<UIColor> _linkColor; |
+ base::scoped_nsobject<UIFont> _linkFont; |
+ |
+ // Ivars that reset when label text changes. |
+ base::scoped_nsobject<NSMutableDictionary> _layoutsForURLs; |
+ base::scoped_nsobject<NSAttributedString> _originalLabelText; |
+ CGRect _lastLabelFrame; |
+ |
+ // Ivars that reset when text or bounds change. |
+ base::scoped_nsprotocol<id<TextRegionMapper>> _textMapper; |
+ |
+ // Internal tracking. |
+ BOOL _justUpdatedStyles; |
+ base::scoped_nsobject<NSMutableArray> _linkButtons; |
+} |
+ |
+@synthesize showTapAreas = _showTapAreas; |
+@synthesize textMapperClass = _textMapperClass; |
+@synthesize linkUnderlineStyle = _linkUnderlineStyle; |
+ |
+- (instancetype)initWithLabel:(UILabel*)label |
+ action:(ProceduralBlockWithURL)action { |
+ if ((self = [super init])) { |
+ DCHECK(label); |
+ _label.reset(label); |
+ _action.reset(action, base::scoped_policy::RETAIN); |
+ _linkUnderlineStyle = NSUnderlineStyleNone; |
+ [self reset]; |
+ |
+ [self addLabelObserverActions]; |
+ |
+ self.textMapperClass = [CoreTextRegionMapper class]; |
+ _linkButtons.reset([[NSMutableArray alloc] init]); |
+ } |
+ return self; |
+} |
+ |
+- (NSAttributedString*)originalLabelText { |
+ return _originalLabelText.get(); |
+} |
+ |
+- (NSArray*)linkButtons { |
+ return _linkButtons.get(); |
+} |
+ |
+- (void)addLabelObserverActions { |
+ LabelObserver* observer = [LabelObserver observerForLabel:_label]; |
+ base::WeakNSObject<LabelLinkController> weakSelf(self); |
+ [observer addStyleChangedAction:^(UILabel* label) { |
+ // One of the style properties has been changed, which will silently |
+ // update the label's attributedText. |
+ if (!weakSelf) |
+ return; |
+ base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
+ [strongSelf labelStyleInvalidated]; |
+ }]; |
+ [observer addTextChangedAction:^(UILabel* label) { |
+ if (!weakSelf) |
+ return; |
+ base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
+ 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]; |
+ } |
+ }]; |
+ [observer addLayoutChangedAction:^(UILabel* label) { |
+ if (!weakSelf) |
+ return; |
+ base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
+ [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]; |
+ [super dealloc]; |
+} |
+ |
+- (void)addLinkWithRange:(NSRange)range url:(GURL)url { |
+ DCHECK(url.is_valid()); |
+ if (!_layoutsForURLs) |
+ _layoutsForURLs.reset([[NSMutableDictionary alloc] init]); |
+ NSURL* key = net::NSURLWithGURL(url); |
+ base::scoped_nsobject<LinkLayout> layout( |
+ [[LinkLayout alloc] initWithRange:range]); |
+ [_layoutsForURLs setObject:layout forKey:key]; |
+ [self updateStyles]; |
+} |
+ |
+- (UIColor*)linkColor { |
+ return _linkColor.get(); |
+} |
+ |
+- (void)setLinkColor:(UIColor*)linkColor { |
+ _linkColor.reset([linkColor copy]); |
+ [self updateStyles]; |
+} |
+ |
+- (void)setLinkUnderlineStyle:(NSUnderlineStyle)underlineStyle { |
+ _linkUnderlineStyle = underlineStyle; |
+ [self updateStyles]; |
+} |
+ |
+- (UIFont*)linkFont { |
+ return _linkFont.get(); |
+} |
+ |
+- (void)setLinkFont:(UIFont*)linkFont { |
+ _linkFont.reset([linkFont retain]); |
+ [self updateStyles]; |
+} |
+ |
+- (void)setShowTapAreas:(BOOL)showTapAreas { |
+#ifndef NDEBUG |
+ for (TransparentLinkButton* button in _linkButtons.get()) { |
+ button.debug = showTapAreas; |
+ } |
+#endif // NDEBUG |
+ _showTapAreas = showTapAreas; |
+} |
+ |
+#pragma mark - internal methods |
+ |
+- (void)reset { |
+ _originalLabelText.reset([[_label attributedText] copy]); |
+ _textMapper.reset(); |
+ _lastLabelFrame = CGRectZero; |
+ _layoutsForURLs.reset(); |
+} |
+ |
+- (void)labelLayoutInvalidated { |
+ _textMapper.reset(); |
+ [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.reset([[_label attributedText] copy]); |
+ [self updateStyles]; |
+ } |
+ _lastLabelFrame = CGRectZero; |
+ [self labelLayoutInvalidated]; |
+} |
+ |
+- (void)updateStyles { |
+ if (![_layoutsForURLs count]) |
+ return; |
+ |
+ __block base::scoped_nsobject<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.reset(); |
+} |
+ |
+- (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]) { |
+ base::scoped_nsobject<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.reset([[self.textMapperClass alloc] |
+ initWithAttributedString:[_label attributedText] |
+ bounds:[_label bounds]]); |
+} |
+ |
+- (void)clearTapButtons { |
+ for (TransparentLinkButton* button in _linkButtons.get()) { |
+ [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.get()) { |
+ 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.get()(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.get()(net::GURLWithNSURL(key)); |
+ *stop = YES; |
+ break; |
+ } |
+ } |
+ }]; |
+} |
+ |
+@end |