ZSWTappableLabel.m 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. // This file is based on third party code, see below for the original author
  2. // and original license.
  3. // Modifications are (c) by Threema GmbH and licensed under the AGPLv3.
  4. //
  5. // ZSWTappableLabel.m
  6. // ZSWTappableLabel
  7. //
  8. // Copyright (c) 2019 Zachary West. All rights reserved.
  9. //
  10. // MIT License
  11. // https://github.com/zacwest/ZSWTappableLabel
  12. //
  13. #import "ZSWTappableLabel.h"
  14. #import "Private/ZSWTappableLabelTappableRegionInfoImpl.h"
  15. #import "Private/ZSWTappableLabelAccessibilityActionLongPress.h"
  16. #import "Private/ZSWTappableLabelTouchHandling.h"
  17. #pragma mark -
  18. NSAttributedStringKey const ZSWTappableLabelHighlightedBackgroundAttributeName = @"ZSWTappableLabelHighlightedBackgroundAttributeName";
  19. NSAttributedStringKey const ZSWTappableLabelTappableRegionAttributeName = @"ZSWTappableLabelTappableRegionAttributeName";
  20. NSAttributedStringKey const ZSWTappableLabelHighlightedForegroundAttributeName = @"ZSWTappableLabelHighlightedForegroundAttributeName";
  21. typedef NS_ENUM(NSInteger, ZSWTappableLabelNotifyType) {
  22. ZSWTappableLabelNotifyTypeTap = 1,
  23. ZSWTappableLabelNotifyTypeLongPress,
  24. };
  25. #pragma mark -
  26. @interface ZSWTappableLabel() <UIGestureRecognizerDelegate>
  27. @property (nonatomic) NSArray<UIAccessibilityElement *> *accessibleElements;
  28. @property (nonatomic) CGRect lastAccessibleElementsBounds;
  29. @property (nonatomic) ZSWTappableLabelTouchHandling *touchHandling;
  30. @property (nonatomic) BOOL needsToWatchTouches;
  31. @property (nonatomic) UILongPressGestureRecognizer *longPressGR;
  32. @property (nonatomic) BOOL hasCurrentEvent;
  33. @end
  34. @implementation ZSWTappableLabel
  35. - (instancetype)initWithFrame:(CGRect)frame {
  36. self = [super initWithFrame:frame];
  37. if (self) {
  38. [self tappableLabelCommonInit];
  39. }
  40. return self;
  41. }
  42. - (id)initWithCoder:(NSCoder *)aDecoder {
  43. self = [super initWithCoder:aDecoder];
  44. if (self) {
  45. [self tappableLabelCommonInit];
  46. // Text was assigned by IB, possibly, so we need to make sure we're running if there's anything useful.
  47. [self checkForTappableRegions];
  48. }
  49. return self;
  50. }
  51. - (void)tappableLabelCommonInit {
  52. self.userInteractionEnabled = YES;
  53. self.numberOfLines = 0;
  54. self.lineBreakMode = NSLineBreakByWordWrapping;
  55. self.longPressDuration = 0.5;
  56. self.longPressAccessibilityActionName = nil; // reset value
  57. self.longPressGR = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)];
  58. self.longPressGR.delegate = self;
  59. [self addGestureRecognizer:self.longPressGR];
  60. }
  61. - (void)setLongPressDuration:(NSTimeInterval)longPressDuration {
  62. _longPressDuration = longPressDuration;
  63. self.longPressGR.minimumPressDuration = longPressDuration;
  64. }
  65. - (void)setLongPressDelegate:(id<ZSWTappableLabelLongPressDelegate>)longPressDelegate {
  66. _longPressDelegate = longPressDelegate;
  67. _accessibleElements = nil;
  68. }
  69. - (void)setLongPressAccessibilityActionName:(NSString *)longPressAccessibilityActionName {
  70. _longPressAccessibilityActionName = longPressAccessibilityActionName ?: NSLocalizedString(@"Open Menu", nil);
  71. _accessibleElements = nil;
  72. }
  73. - (void)setAccessibilityDelegate:(id<ZSWTappableLabelAccessibilityDelegate>)accessibilityDelegate {
  74. _accessibilityDelegate = accessibilityDelegate;
  75. _accessibleElements = nil;
  76. }
  77. - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
  78. [super traitCollectionDidChange:previousTraitCollection];
  79. if (self.adjustsFontForContentSizeCategory) {
  80. UIContentSizeCategory previousCategory = previousTraitCollection.preferredContentSizeCategory;
  81. UIContentSizeCategory currentCategory = self.traitCollection.preferredContentSizeCategory;
  82. if (![previousCategory isEqual:currentCategory] || previousCategory != currentCategory) {
  83. self.touchHandling = nil;
  84. }
  85. }
  86. }
  87. - (ZSWTappableLabelTouchHandling *)createTouchHandlingIfNeeded {
  88. ZSWTappableLabelTouchHandling *existingTouchHandling = self.touchHandling;
  89. if (existingTouchHandling) {
  90. if (CGRectEqualToRect(existingTouchHandling.bounds, self.bounds)) {
  91. // we can continue to use the existing touch handling
  92. return existingTouchHandling;
  93. } else {
  94. // we need to create a new touch handling. additionally, we need to reset from the last one.
  95. // if the view is resizing while we were handling a touch, we need to cancel out the attributed
  96. // changes that we performed from the previous touch handling.
  97. [super setAttributedText:existingTouchHandling.unmodifiedAttributedString];
  98. }
  99. }
  100. // If the user doesn't specify a font, UILabel is going to render with the current
  101. // one it wants, so we need to fill in the blanks
  102. NSAttributedString *attributedText = [super attributedText];
  103. NSMutableAttributedString *mutableText = [attributedText mutableCopy];
  104. UIFont *font = self.font;
  105. [attributedText enumerateAttribute:NSFontAttributeName
  106. inRange:NSMakeRange(0, attributedText.length)
  107. options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
  108. usingBlock:^(id value, NSRange range, BOOL *stop) {
  109. if (!value) {
  110. [mutableText addAttribute:NSFontAttributeName
  111. value:font
  112. range:range];
  113. }
  114. }];
  115. if (self.textAlignment != NSTextAlignmentLeft) {
  116. NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
  117. style.alignment = self.textAlignment;
  118. [attributedText enumerateAttribute:NSParagraphStyleAttributeName
  119. inRange:NSMakeRange(0, attributedText.length)
  120. options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
  121. usingBlock:^(id value, NSRange range, BOOL *stop) {
  122. if (!value) {
  123. [mutableText addAttribute:NSParagraphStyleAttributeName
  124. value:style
  125. range:range];
  126. }
  127. }];
  128. }
  129. NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:mutableText];
  130. NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
  131. NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:^{
  132. CGSize size = self.bounds.size;
  133. // On iOS 10, NSLayoutManager will think it doesn't have enough space to fit text
  134. // compared to UILabel which will render more text in the same given space. I can't seem to find
  135. // any reason, and it's a 1-2pt difference.
  136. size.height = CGFLOAT_MAX;
  137. return size;
  138. }()];
  139. textContainer.lineBreakMode = self.lineBreakMode;
  140. textContainer.maximumNumberOfLines = self.numberOfLines;
  141. textContainer.lineFragmentPadding = 0;
  142. [layoutManager addTextContainer:textContainer];
  143. [textStorage addLayoutManager:layoutManager];
  144. // UILabel vertically centers if it doesn't fill the whole bounds, so compensate for that.
  145. CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer];
  146. CGPoint pointOffset = CGPointMake(0, (CGRectGetHeight(self.bounds) - CGRectGetHeight(usedRect))/2.0);
  147. ZSWTappableLabelTouchHandling *touchHandling = [[ZSWTappableLabelTouchHandling alloc] initWithTextStorage:textStorage pointOffset:pointOffset bounds:self.bounds];
  148. self.touchHandling = touchHandling;
  149. return touchHandling;
  150. }
  151. - (void)performWithTouchHandling:(void(^)(ZSWTappableLabelTouchHandling *th))block {
  152. ZSWTappableLabelTouchHandling *touchHandling = [self createTouchHandlingIfNeeded];
  153. block(touchHandling);
  154. }
  155. #pragma mark - Overloading
  156. - (void)setText:(NSString *)text {
  157. [super setText:text];
  158. self.touchHandling = nil;
  159. [self checkForTappableRegions];
  160. }
  161. - (void)setAttributedText:(NSAttributedString *)attributedText {
  162. [super setAttributedText:attributedText];
  163. self.touchHandling = nil;
  164. [self checkForTappableRegions];
  165. }
  166. - (void)checkForTappableRegions {
  167. NSAttributedString *attributedText = self.attributedText;
  168. __block BOOL containsTappableRegion = NO;
  169. [attributedText enumerateAttribute:ZSWTappableLabelTappableRegionAttributeName
  170. inRange:NSMakeRange(0, attributedText.length)
  171. options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
  172. usingBlock:^(id value, NSRange range, BOOL *stop) {
  173. if ([value boolValue]) {
  174. *stop = YES;
  175. containsTappableRegion = YES;
  176. }
  177. }];
  178. self.needsToWatchTouches = containsTappableRegion;
  179. self.longPressGR.enabled = containsTappableRegion;
  180. }
  181. #pragma mark - UIGestureRecognizerDelegate
  182. - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
  183. if (gestureRecognizer == self.longPressGR && !self.longPressDelegate) {
  184. // We wait until the last moment to decide if a long press should occur because keeping track of the
  185. // GR's enabled state when the delegate changes is a bit more state management than seems appropriate.
  186. return NO;
  187. }
  188. __block BOOL shouldReceive = NO;
  189. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  190. shouldReceive = [th isTappableRegionAtPoint:[touch locationInView:self]];
  191. }];
  192. return shouldReceive;
  193. }
  194. #pragma mark - Touch handling
  195. - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
  196. if (!self.needsToWatchTouches) {
  197. [super touchesBegan:touches withEvent:event];
  198. return;
  199. }
  200. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  201. CGPoint point = [touches.anyObject locationInView:self];
  202. NSUInteger characterIdx = [th characterIndexAtPoint:point];
  203. if ([th isTappableRegionAtCharacterIndex:characterIdx]) {
  204. // Touching in a tappable region, we're good to start controlling these touches.
  205. self.hasCurrentEvent = YES;
  206. [self applyHighlightAtIndex:characterIdx];
  207. } else {
  208. // Touching is outside of a tappable region, we should forward the touches onward.
  209. // This forwarding allows e.g. a UICollectionViewCell we're contained in to highlight, select, etc.
  210. self.hasCurrentEvent = NO;
  211. [super touchesBegan:touches withEvent:event];
  212. }
  213. }];
  214. }
  215. - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
  216. if (!self.needsToWatchTouches || !self.hasCurrentEvent) {
  217. [super touchesMoved:touches withEvent:event];
  218. return;
  219. }
  220. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  221. NSUInteger characterIdx = [th characterIndexAtPoint:[touches.anyObject locationInView:self]];
  222. [self applyHighlightAtIndex:characterIdx];
  223. }];
  224. }
  225. - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
  226. if (!self.needsToWatchTouches || !self.hasCurrentEvent) {
  227. [super touchesEnded:touches withEvent:event];
  228. return;
  229. }
  230. self.hasCurrentEvent = NO;
  231. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  232. NSUInteger characterIdx = [th characterIndexAtPoint:[touches.anyObject locationInView:self]];
  233. [self notifyForCharacterIndex:characterIdx type:ZSWTappableLabelNotifyTypeTap];
  234. [self removeHighlight];
  235. }];
  236. }
  237. - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
  238. if (!self.needsToWatchTouches || !self.hasCurrentEvent) {
  239. [super touchesCancelled:touches withEvent:event];
  240. return;
  241. }
  242. self.hasCurrentEvent = NO;
  243. [self removeHighlight];
  244. }
  245. - (void)applyHighlightAtIndex:(NSUInteger)characterIndex {
  246. if (characterIndex == NSNotFound) {
  247. [self removeHighlight];
  248. return;
  249. }
  250. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  251. NSMutableAttributedString *attributedString = [th.unmodifiedAttributedString mutableCopy];
  252. NSRange highlightEffectiveRange = NSMakeRange(0, 0), foregroundEffectiveRange = NSMakeRange(0, 0);
  253. UIColor *highlightColor = [attributedString attribute:ZSWTappableLabelHighlightedBackgroundAttributeName
  254. atIndex:characterIndex
  255. longestEffectiveRange:&highlightEffectiveRange
  256. inRange:NSMakeRange(0, attributedString.length)];
  257. UIColor *foregroundColor = [attributedString attribute:ZSWTappableLabelHighlightedForegroundAttributeName
  258. atIndex:characterIndex
  259. longestEffectiveRange:&foregroundEffectiveRange
  260. inRange:NSMakeRange(0, attributedString.length)];
  261. if (highlightColor || foregroundColor) {
  262. if (highlightColor) {
  263. [attributedString addAttribute:NSBackgroundColorAttributeName
  264. value:highlightColor
  265. range:highlightEffectiveRange];
  266. }
  267. if (foregroundColor) {
  268. [attributedString addAttribute:NSForegroundColorAttributeName
  269. value:foregroundColor
  270. range:foregroundEffectiveRange];
  271. }
  272. [super setAttributedText:attributedString];
  273. } else {
  274. [self removeHighlight];
  275. }
  276. }];
  277. }
  278. - (void)removeHighlight {
  279. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  280. [super setAttributedText:th.unmodifiedAttributedString];
  281. }];
  282. }
  283. - (void)notifyForCharacterIndex:(NSUInteger)characterIndex type:(ZSWTappableLabelNotifyType)notifyType {
  284. if (characterIndex == NSNotFound) {
  285. return;
  286. }
  287. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  288. NSDictionary *attributes = [th.unmodifiedAttributedString attributesAtIndex:characterIndex effectiveRange:NULL] ?: @{};
  289. switch (notifyType) {
  290. case ZSWTappableLabelNotifyTypeTap:
  291. [self.tapDelegate tappableLabel:self
  292. tappedAtIndex:characterIndex
  293. withAttributes:attributes];
  294. break;
  295. case ZSWTappableLabelNotifyTypeLongPress:
  296. [self.longPressDelegate tappableLabel:self
  297. longPressedAtIndex:characterIndex
  298. withAttributes:attributes];
  299. break;
  300. }
  301. }];
  302. }
  303. - (BOOL)longPressForAccessibilityAction:(ZSWTappableLabelAccessibilityActionLongPress *)action {
  304. [self notifyForCharacterIndex:action.characterIndex type:ZSWTappableLabelNotifyTypeLongPress];
  305. return YES;
  306. }
  307. - (void)longPress:(UILongPressGestureRecognizer *)longPressGR {
  308. if (longPressGR.state != UIGestureRecognizerStateBegan) {
  309. // We only care about began because that is when we notify our delegate. Everything else can be ignored.
  310. return;
  311. }
  312. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  313. NSUInteger characterIndex = [th characterIndexAtPoint:[longPressGR locationInView:self]];
  314. [self notifyForCharacterIndex:characterIndex type:ZSWTappableLabelNotifyTypeLongPress];
  315. }];
  316. }
  317. - (NSDictionary *)checkIsPointAction:(CGPoint)longPressPoint {
  318. ZSWTappableLabelTouchHandling *touchHandling = [self createTouchHandlingIfNeeded];
  319. if ([touchHandling isTappableRegionAtPoint:longPressPoint]) {
  320. NSUInteger characterIndex = [touchHandling characterIndexAtPoint:longPressPoint];
  321. return [touchHandling.unmodifiedAttributedString attributesAtIndex:characterIndex effectiveRange:NULL] ?: nil;
  322. } else {
  323. return nil;
  324. }
  325. }
  326. #pragma mark - Public attribute getting
  327. - (nullable id<ZSWTappableLabelTappableRegionInfo>)tappableRegionInfoAtPoint:(CGPoint)point {
  328. __block ZSWTappableLabelTappableRegionInfoImpl *regionInfo;
  329. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  330. NSUInteger characterIndex = [th characterIndexAtPoint:point];
  331. if (characterIndex == NSNotFound) {
  332. return;
  333. }
  334. NSRange effectiveRange;
  335. NSNumber *attribute = [th.unmodifiedAttributedString attribute:ZSWTappableLabelTappableRegionAttributeName
  336. atIndex:characterIndex
  337. effectiveRange:&effectiveRange];
  338. if (![attribute boolValue]) {
  339. return;
  340. }
  341. CGRect frame = [th frameForCharacterRange:effectiveRange];
  342. NSDictionary<NSAttributedStringKey, id> *attributes = [th.unmodifiedAttributedString attributesAtIndex:characterIndex effectiveRange:NULL];
  343. regionInfo = [[ZSWTappableLabelTappableRegionInfoImpl alloc] initWithFrame:frame
  344. attributes:attributes
  345. containerView:self];
  346. }];
  347. return regionInfo;
  348. }
  349. - (nullable id<ZSWTappableLabelTappableRegionInfo>)tappableRegionInfoForPreviewingContext:(id<UIViewControllerPreviewing>)previewingContext location:(CGPoint)location {
  350. return [self tappableRegionInfoAtPoint:[previewingContext.sourceView convertPoint:location toView:self]];
  351. }
  352. #pragma mark - Accessibility
  353. - (BOOL)isAccessibilityElement {
  354. return NO; // because we're a container
  355. }
  356. - (NSArray *)accessibleElements {
  357. if (_accessibleElements && CGRectEqualToRect(self.bounds, self.lastAccessibleElementsBounds)) {
  358. // As long as our content and bounds don't change, our elements won't need updating, because
  359. // their frame is based on our container space.
  360. return _accessibleElements;
  361. }
  362. NSMutableArray<UIAccessibilityElement *> *accessibleElements = [NSMutableArray array];
  363. NSAttributedString *unmodifiedAttributedString = self.attributedText;
  364. id<ZSWTappableLabelAccessibilityDelegate> accessibilityDelegate = self.accessibilityDelegate;
  365. id<ZSWTappableLabelLongPressDelegate> longPressDelegate = self.longPressDelegate;
  366. NSString *longPressAccessibilityActionName = self.longPressAccessibilityActionName;
  367. [self performWithTouchHandling:^(ZSWTappableLabelTouchHandling *th) {
  368. if (!unmodifiedAttributedString.length) {
  369. return;
  370. }
  371. // Our general strategy is to break apart the string into multiple elements, where the boundary for each
  372. // element is the tappable region start/stop locations. This produces something like:
  373. //
  374. // [This is an] [example: link] [sentence with a link in the middle.]
  375. //
  376. // This matches Safari's behavior when it encounters links in the page. Remember that a VoiceOver user can
  377. // always enumerate and read the entire contents using the two-finger up/down gesture, and this is behavior
  378. // they are likely used to.
  379. void (^enumerationBlock)(id, NSRange, BOOL *) = ^(id value, NSRange range, BOOL *stop) {
  380. /***** BEGIN THREEMA MODIFICATION: only add accessibility elements for links, not for static text parts *********/
  381. if (![value boolValue])
  382. return;
  383. /***** END THREEMA MODIFICATION: only add accessibility elements for links, not for static text parts *********/
  384. UIAccessibilityElement *element = [[UIAccessibilityElement alloc] initWithAccessibilityContainer:self];
  385. element.accessibilityLabel = [unmodifiedAttributedString.string substringWithRange:range];
  386. element.accessibilityFrameInContainerSpace = [th frameForCharacterRange:range];
  387. if ([value boolValue]) {
  388. element.accessibilityTraits = UIAccessibilityTraitLink | UIAccessibilityTraitStaticText;
  389. } else {
  390. element.accessibilityTraits = UIAccessibilityTraitStaticText;
  391. }
  392. NSMutableArray<UIAccessibilityCustomAction *> *customActions = [NSMutableArray array];
  393. if (longPressDelegate) {
  394. ZSWTappableLabelAccessibilityActionLongPress *action = [[ZSWTappableLabelAccessibilityActionLongPress alloc] initWithName:longPressAccessibilityActionName target:self selector:@selector(longPressForAccessibilityAction:)];
  395. action.characterIndex = range.location;
  396. [customActions addObject:action];
  397. }
  398. if (accessibilityDelegate) {
  399. NSDictionary<NSAttributedStringKey, id> *attributesAtStart = [unmodifiedAttributedString attributesAtIndex:range.location effectiveRange:NULL];
  400. [customActions addObjectsFromArray:[accessibilityDelegate tappableLabel:self
  401. accessibilityCustomActionsForCharacterRange:range
  402. withAttributesAtStart:attributesAtStart]];
  403. }
  404. if (customActions.count > 0) {
  405. element.accessibilityCustomActions = customActions;
  406. }
  407. [accessibleElements addObject:element];
  408. };
  409. [unmodifiedAttributedString enumerateAttribute:ZSWTappableLabelTappableRegionAttributeName
  410. inRange:NSMakeRange(0, unmodifiedAttributedString.length)
  411. options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
  412. usingBlock:enumerationBlock];
  413. }];
  414. _accessibleElements = [accessibleElements copy];
  415. self.lastAccessibleElementsBounds = self.bounds;
  416. return _accessibleElements;
  417. }
  418. - (NSInteger)accessibilityElementCount {
  419. return [self accessibleElements].count;
  420. }
  421. - (id)accessibilityElementAtIndex:(NSInteger)idx {
  422. return [self accessibleElements][idx];
  423. }
  424. - (NSInteger)indexOfAccessibilityElement:(id)element {
  425. return [[self accessibleElements] indexOfObject:element];
  426. }
  427. @end