OLD | NEW |
(Empty) | |
| 1 // Copyright 2015 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. |
| 4 |
| 5 #import "ios/chrome/browser/ui/util/label_link_controller.h" |
| 6 |
| 7 #include <map> |
| 8 #include <vector> |
| 9 |
| 10 #include "base/ios/ios_util.h" |
| 11 #include "base/ios/weak_nsobject.h" |
| 12 #include "base/logging.h" |
| 13 #include "base/mac/foundation_util.h" |
| 14 #include "base/mac/scoped_block.h" |
| 15 #import "base/mac/scoped_nsobject.h" |
| 16 #import "base/strings/sys_string_conversions.h" |
| 17 #include "ios/chrome/browser/ui/ui_util.h" |
| 18 #import "ios/chrome/browser/ui/util/label_observer.h" |
| 19 #import "ios/chrome/browser/ui/util/text_region_mapper.h" |
| 20 #import "ios/chrome/browser/ui/util/transparent_link_button.h" |
| 21 #import "net/base/mac/url_conversions.h" |
| 22 #include "url/gurl.h" |
| 23 |
| 24 #pragma mark - LinkLayout |
| 25 |
| 26 // Object encapsulating the range of a link and the frames corresponding with |
| 27 // that range. |
| 28 @interface LinkLayout : NSObject { |
| 29 // Backing objects for properties of same name. |
| 30 base::scoped_nsobject<NSArray> _frames; |
| 31 } |
| 32 |
| 33 // Designated initializer. |
| 34 - (instancetype)initWithRange:(NSRange)range NS_DESIGNATED_INITIALIZER; |
| 35 - (instancetype)init NS_UNAVAILABLE; |
| 36 |
| 37 // The range passed on initialization. |
| 38 @property(nonatomic, readonly) NSRange range; |
| 39 |
| 40 // The frames calculated for |_range|. |
| 41 @property(nonatomic, retain) NSArray* frames; |
| 42 |
| 43 @end |
| 44 |
| 45 @implementation LinkLayout |
| 46 |
| 47 @synthesize range = _range; |
| 48 |
| 49 - (instancetype)initWithRange:(NSRange)range { |
| 50 if ((self = [super init])) { |
| 51 DCHECK_NE(range.location, static_cast<NSUInteger>(NSNotFound)); |
| 52 DCHECK_NE(range.length, 0U); |
| 53 _range = range; |
| 54 } |
| 55 return self; |
| 56 } |
| 57 |
| 58 #pragma mark - Accessors |
| 59 |
| 60 - (void)setFrames:(NSArray*)frames { |
| 61 _frames.reset([frames retain]); |
| 62 } |
| 63 |
| 64 - (NSArray*)frames { |
| 65 return _frames.get(); |
| 66 } |
| 67 |
| 68 @end |
| 69 |
| 70 #pragma mark - LabelLinkController |
| 71 |
| 72 @interface LabelLinkController () |
| 73 // Private property exposed publically in testing interface. |
| 74 @property(nonatomic, assign) Class textMapperClass; |
| 75 |
| 76 // The original attributed text set on the label. This may be different from |
| 77 // the label's |attributedText| property, as additional style attributes may be |
| 78 // introduced for links. |
| 79 @property(nonatomic, readonly) NSAttributedString* originalLabelText; |
| 80 |
| 81 // The array of TransparentLinkButtons inserted above the label. |
| 82 @property(nonatomic, readonly) NSArray* linkButtons; |
| 83 |
| 84 // Adds LabelObserverActions to the LabelObserver corresponding to |_label|. |
| 85 - (void)addLabelObserverActions; |
| 86 |
| 87 // Clears all defined links and any data associated with them. Update the |
| 88 // original attributed text from the controlled label. |
| 89 - (void)reset; |
| 90 |
| 91 // Handle a change to the label that changes the positioning of glyphs but not |
| 92 // any styling of those glyphs. Forces a recomputation of the tap regions, and |
| 93 // recreates any tap buttons. |
| 94 - (void)labelLayoutInvalidated; |
| 95 |
| 96 // Handle a change to the label that changes the glyph style. This forces all of |
| 97 // the link-specific styling applied by this class to be regenerated (which |
| 98 // itself will again re-trigger this method), and because any kind of style |
| 99 // change may alter the position of glyphs, this forces a layout invalidation. |
| 100 - (void)labelStyleInvalidated; |
| 101 |
| 102 // Updates the attributed string content of the controlled label to |
| 103 // have the designated link colors and styles. |
| 104 // No-op if no links are defined. |
| 105 - (void)updateStyles; |
| 106 |
| 107 // If the controlled label's bounds have changed from the last time tap rects |
| 108 // were updated, determine which regions in the label should be tappable. |
| 109 - (void)updateTapRects; |
| 110 |
| 111 // Creates a new text mapper instance with the current label bounds and |
| 112 // attributed text. |
| 113 - (void)resetTextMapper; |
| 114 |
| 115 // Clear any tap buttons that have been created, removing them from their |
| 116 // superview if necessary. |
| 117 - (void)clearTapButtons; |
| 118 |
| 119 // Updates the tap buttons as detailed below. This method is called every time |
| 120 // tap rects are updated, as well as every time |_label|'s superview changes. |
| 121 // If there are no tap buttons defined, but there are known tap rects, and |
| 122 // |_label| has a superview, then tap buttons are created and added to that |
| 123 // view. |
| 124 // If there are tap buttons, and |_label| has no superview, then the tap buttons |
| 125 // are cleared. |
| 126 // If there are tap buttons, but they are not subviews of |_label|'s superview |
| 127 // (if _label's superview has changed since the buttons were created), then |
| 128 // the tap buttons are migrated into the new superview. |
| 129 - (void)updateTapButtons; |
| 130 |
| 131 @end |
| 132 |
| 133 @implementation LabelLinkController { |
| 134 // Ivars immutable for the lifetime of the object. |
| 135 base::mac::ScopedBlock<ProceduralBlockWithURL> _action; |
| 136 base::WeakNSObject<UILabel> _label; // weak |
| 137 base::scoped_nsobject<UITapGestureRecognizer> _linkTapRecognizer; |
| 138 |
| 139 // Ivas backing properties. |
| 140 base::scoped_nsobject<UIColor> _linkColor; |
| 141 base::scoped_nsobject<UIFont> _linkFont; |
| 142 |
| 143 // Ivars that reset when label text changes. |
| 144 base::scoped_nsobject<NSMutableDictionary> _layoutsForURLs; |
| 145 base::scoped_nsobject<NSAttributedString> _originalLabelText; |
| 146 CGRect _lastLabelFrame; |
| 147 |
| 148 // Ivars that reset when text or bounds change. |
| 149 base::scoped_nsprotocol<id<TextRegionMapper>> _textMapper; |
| 150 |
| 151 // Internal tracking. |
| 152 BOOL _justUpdatedStyles; |
| 153 base::scoped_nsobject<NSMutableArray> _linkButtons; |
| 154 } |
| 155 |
| 156 @synthesize showTapAreas = _showTapAreas; |
| 157 @synthesize textMapperClass = _textMapperClass; |
| 158 @synthesize linkUnderlineStyle = _linkUnderlineStyle; |
| 159 |
| 160 - (instancetype)initWithLabel:(UILabel*)label |
| 161 action:(ProceduralBlockWithURL)action { |
| 162 if ((self = [super init])) { |
| 163 DCHECK(label); |
| 164 _label.reset(label); |
| 165 _action.reset(action, base::scoped_policy::RETAIN); |
| 166 _linkUnderlineStyle = NSUnderlineStyleNone; |
| 167 [self reset]; |
| 168 |
| 169 [self addLabelObserverActions]; |
| 170 |
| 171 self.textMapperClass = [CoreTextRegionMapper class]; |
| 172 _linkButtons.reset([[NSMutableArray alloc] init]); |
| 173 } |
| 174 return self; |
| 175 } |
| 176 |
| 177 - (NSAttributedString*)originalLabelText { |
| 178 return _originalLabelText.get(); |
| 179 } |
| 180 |
| 181 - (NSArray*)linkButtons { |
| 182 return _linkButtons.get(); |
| 183 } |
| 184 |
| 185 - (void)addLabelObserverActions { |
| 186 LabelObserver* observer = [LabelObserver observerForLabel:_label]; |
| 187 base::WeakNSObject<LabelLinkController> weakSelf(self); |
| 188 [observer addStyleChangedAction:^(UILabel* label) { |
| 189 // One of the style properties has been changed, which will silently |
| 190 // update the label's attributedText. |
| 191 if (!weakSelf) |
| 192 return; |
| 193 base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
| 194 [strongSelf labelStyleInvalidated]; |
| 195 }]; |
| 196 [observer addTextChangedAction:^(UILabel* label) { |
| 197 if (!weakSelf) |
| 198 return; |
| 199 base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
| 200 NSString* originalText = [[strongSelf originalLabelText] string]; |
| 201 if ([label.text isEqualToString:originalText]) { |
| 202 // The actual text of the label didn't change, so this was a change to |
| 203 // the string attributes only. |
| 204 [strongSelf labelStyleInvalidated]; |
| 205 } else { |
| 206 // The label text has changed, so start everything from scratch. |
| 207 [strongSelf reset]; |
| 208 } |
| 209 }]; |
| 210 [observer addLayoutChangedAction:^(UILabel* label) { |
| 211 if (!weakSelf) |
| 212 return; |
| 213 base::scoped_nsobject<LabelLinkController> strongSelf([weakSelf retain]); |
| 214 [strongSelf labelLayoutInvalidated]; |
| 215 NSArray* linkButtons = [strongSelf linkButtons]; |
| 216 // If this layout change corresponds to |label|'s moving to a new superview, |
| 217 // update the tap buttons so that they are inserted above |label| in the new |
| 218 // hierarchy. |
| 219 if (linkButtons.count && label.superview != [linkButtons[0] superview]) |
| 220 [strongSelf updateTapButtons]; |
| 221 }]; |
| 222 } |
| 223 |
| 224 - (void)dealloc { |
| 225 [self clearTapButtons]; |
| 226 [super dealloc]; |
| 227 } |
| 228 |
| 229 - (void)addLinkWithRange:(NSRange)range url:(GURL)url { |
| 230 DCHECK(url.is_valid()); |
| 231 if (!_layoutsForURLs) |
| 232 _layoutsForURLs.reset([[NSMutableDictionary alloc] init]); |
| 233 NSURL* key = net::NSURLWithGURL(url); |
| 234 base::scoped_nsobject<LinkLayout> layout( |
| 235 [[LinkLayout alloc] initWithRange:range]); |
| 236 [_layoutsForURLs setObject:layout forKey:key]; |
| 237 [self updateStyles]; |
| 238 } |
| 239 |
| 240 - (UIColor*)linkColor { |
| 241 return _linkColor.get(); |
| 242 } |
| 243 |
| 244 - (void)setLinkColor:(UIColor*)linkColor { |
| 245 _linkColor.reset([linkColor copy]); |
| 246 [self updateStyles]; |
| 247 } |
| 248 |
| 249 - (void)setLinkUnderlineStyle:(NSUnderlineStyle)underlineStyle { |
| 250 _linkUnderlineStyle = underlineStyle; |
| 251 [self updateStyles]; |
| 252 } |
| 253 |
| 254 - (UIFont*)linkFont { |
| 255 return _linkFont.get(); |
| 256 } |
| 257 |
| 258 - (void)setLinkFont:(UIFont*)linkFont { |
| 259 _linkFont.reset([linkFont retain]); |
| 260 [self updateStyles]; |
| 261 } |
| 262 |
| 263 - (void)setShowTapAreas:(BOOL)showTapAreas { |
| 264 #ifndef NDEBUG |
| 265 for (TransparentLinkButton* button in _linkButtons.get()) { |
| 266 button.debug = showTapAreas; |
| 267 } |
| 268 #endif // NDEBUG |
| 269 _showTapAreas = showTapAreas; |
| 270 } |
| 271 |
| 272 #pragma mark - internal methods |
| 273 |
| 274 - (void)reset { |
| 275 _originalLabelText.reset([[_label attributedText] copy]); |
| 276 _textMapper.reset(); |
| 277 _lastLabelFrame = CGRectZero; |
| 278 _layoutsForURLs.reset(); |
| 279 } |
| 280 |
| 281 - (void)labelLayoutInvalidated { |
| 282 _textMapper.reset(); |
| 283 [self updateTapRects]; |
| 284 } |
| 285 |
| 286 - (void)labelStyleInvalidated { |
| 287 // If the style invalidation was triggered by this class updating link styles, |
| 288 // then the original label text is still correct, but the tap rects still need |
| 289 // to be updated. Otherwise, update the original label text, and then update |
| 290 // styles. This will set |_justUpdatedStyles| and trigger another call to |
| 291 // this method via KVO. |
| 292 if (_justUpdatedStyles) { |
| 293 // TODO(crbug.com/664648): Remove _justUpdatedStyles due to bug that |
| 294 // prevents proper style updates after successive label format changes. |
| 295 _justUpdatedStyles = NO; |
| 296 } else if (![_originalLabelText isEqual:[_label attributedText]]) { |
| 297 _originalLabelText.reset([[_label attributedText] copy]); |
| 298 [self updateStyles]; |
| 299 } |
| 300 _lastLabelFrame = CGRectZero; |
| 301 [self labelLayoutInvalidated]; |
| 302 } |
| 303 |
| 304 - (void)updateStyles { |
| 305 if (![_layoutsForURLs count]) |
| 306 return; |
| 307 |
| 308 __block base::scoped_nsobject<NSMutableAttributedString> labelText( |
| 309 [_originalLabelText mutableCopy]); |
| 310 [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| 311 NSURL* key, LinkLayout* layout, BOOL* stop) { |
| 312 if (_linkColor) { |
| 313 [labelText addAttribute:NSForegroundColorAttributeName |
| 314 value:_linkColor |
| 315 range:layout.range]; |
| 316 } |
| 317 if (_linkUnderlineStyle != NSUnderlineStyleNone) { |
| 318 [labelText addAttribute:NSUnderlineStyleAttributeName |
| 319 value:@(_linkUnderlineStyle) |
| 320 range:layout.range]; |
| 321 } |
| 322 if (_linkFont) { |
| 323 [labelText addAttribute:NSFontAttributeName |
| 324 value:_linkFont |
| 325 range:layout.range]; |
| 326 } |
| 327 }]; |
| 328 _justUpdatedStyles = YES; |
| 329 [_label setAttributedText:labelText]; |
| 330 _textMapper.reset(); |
| 331 } |
| 332 |
| 333 - (void)updateTapRects { |
| 334 // Don't update if the label hasn't changed size or position. |
| 335 if (CGRectEqualToRect([_label frame], _lastLabelFrame)) |
| 336 return; |
| 337 // Don't update if there are no links. |
| 338 if (![_layoutsForURLs count]) |
| 339 return; |
| 340 |
| 341 _lastLabelFrame = [_label frame]; |
| 342 [self clearTapButtons]; |
| 343 |
| 344 // If the label bounds are zero in either dimension, no rects are possible. |
| 345 if (0.0 == _lastLabelFrame.size.width || 0.0 == _lastLabelFrame.size.height) |
| 346 return; |
| 347 |
| 348 if (!_textMapper) |
| 349 [self resetTextMapper]; |
| 350 |
| 351 for (LinkLayout* layout in [_layoutsForURLs allValues]) { |
| 352 base::scoped_nsobject<NSMutableArray> frames([[NSMutableArray alloc] init]); |
| 353 NSArray* rects = [_textMapper rectsForRange:layout.range]; |
| 354 for (NSUInteger rectIdx = 0; rectIdx < [rects count]; ++rectIdx) { |
| 355 CGRect frame = [rects[rectIdx] CGRectValue]; |
| 356 frame = [[_label superview] convertRect:frame fromView:_label]; |
| 357 [frames addObject:[NSValue valueWithCGRect:frame]]; |
| 358 } |
| 359 layout.frames = frames; |
| 360 } |
| 361 [self updateTapButtons]; |
| 362 } |
| 363 |
| 364 - (void)resetTextMapper { |
| 365 DCHECK([self.textMapperClass conformsToProtocol:@protocol(TextRegionMapper)]); |
| 366 _textMapper.reset([[self.textMapperClass alloc] |
| 367 initWithAttributedString:[_label attributedText] |
| 368 bounds:[_label bounds]]); |
| 369 } |
| 370 |
| 371 - (void)clearTapButtons { |
| 372 for (TransparentLinkButton* button in _linkButtons.get()) { |
| 373 [button removeFromSuperview]; |
| 374 } |
| 375 [_linkButtons removeAllObjects]; |
| 376 } |
| 377 |
| 378 - (void)updateTapButtons { |
| 379 // If the label has no superview, clear any existing buttons. |
| 380 if (![_label superview]) { |
| 381 [self clearTapButtons]; |
| 382 return; |
| 383 } else if ([_linkButtons count]) { |
| 384 // If the buttons are currently in some view other than the label's |
| 385 // superview, repatriate them. |
| 386 if (base::mac::ObjCCast<TransparentLinkButton>(_linkButtons[0]).superview != |
| 387 [_label superview]) { |
| 388 for (TransparentLinkButton* button in _linkButtons.get()) { |
| 389 CGRect newFrame = |
| 390 [[_label superview] convertRect:button.frame fromView:button]; |
| 391 [[_label superview] insertSubview:button aboveSubview:_label]; |
| 392 button.frame = newFrame; |
| 393 } |
| 394 } |
| 395 } |
| 396 // If there are no buttons, make some and put them in the label's superview. |
| 397 if (![_linkButtons count] && _layoutsForURLs) { |
| 398 [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| 399 NSURL* key, LinkLayout* layout, BOOL* stop) { |
| 400 GURL URL = net::GURLWithNSURL(key); |
| 401 NSString* accessibilityLabel = |
| 402 [[_label text] substringWithRange:layout.range]; |
| 403 NSArray* buttons = |
| 404 [TransparentLinkButton buttonsForLinkFrames:layout.frames |
| 405 URL:URL |
| 406 accessibilityLabel:accessibilityLabel]; |
| 407 for (TransparentLinkButton* button in buttons) { |
| 408 #ifndef NDEBUG |
| 409 button.debug = self.showTapAreas; |
| 410 #endif // NDEBUG |
| 411 [button addTarget:self |
| 412 action:@selector(linkButtonTapped:) |
| 413 forControlEvents:UIControlEventTouchUpInside]; |
| 414 [[_label superview] insertSubview:button aboveSubview:_label]; |
| 415 [_linkButtons addObject:button]; |
| 416 } |
| 417 }]; |
| 418 } |
| 419 } |
| 420 |
| 421 #pragma mark - Tap Handlers |
| 422 |
| 423 - (void)linkButtonTapped:(id)sender { |
| 424 TransparentLinkButton* button = |
| 425 base::mac::ObjCCast<TransparentLinkButton>(sender); |
| 426 _action.get()(button.URL); |
| 427 } |
| 428 |
| 429 #pragma mark - Test facilitators |
| 430 |
| 431 - (NSArray*)tapRectsForURL:(GURL)url { |
| 432 NSURL* key = net::NSURLWithGURL(url); |
| 433 LinkLayout* layout = [_layoutsForURLs objectForKey:key]; |
| 434 return layout.frames; |
| 435 } |
| 436 |
| 437 - (void)tapLabelAtPoint:(CGPoint)point { |
| 438 [_layoutsForURLs enumerateKeysAndObjectsUsingBlock:^( |
| 439 NSURL* key, LinkLayout* layout, BOOL* stop) { |
| 440 for (NSValue* frameValue in layout.frames) { |
| 441 CGRect frame = [frameValue CGRectValue]; |
| 442 if (CGRectContainsPoint(frame, point)) { |
| 443 _action.get()(net::GURLWithNSURL(key)); |
| 444 *stop = YES; |
| 445 break; |
| 446 } |
| 447 } |
| 448 }]; |
| 449 } |
| 450 |
| 451 @end |
OLD | NEW |