diff --git a/Source/ASDisplayNode+Beta.h b/Source/ASDisplayNode+Beta.h index 882042a97..051f8e1d8 100644 --- a/Source/ASDisplayNode+Beta.h +++ b/Source/ASDisplayNode+Beta.h @@ -166,6 +166,11 @@ AS_CATEGORY_IMPLEMENTABLE */ - (void)enableSubtreeRasterization; +/** + * @abstract Top, left, bottom, right padding values for the node. Only used in yoga + */ +@property (readonly) UIEdgeInsets paddings; + @end NS_ASSUME_NONNULL_END diff --git a/Source/ASDisplayNode+Yoga.h b/Source/ASDisplayNode+Yoga.h index 000bb90e0..5437bedfb 100644 --- a/Source/ASDisplayNode+Yoga.h +++ b/Source/ASDisplayNode+Yoga.h @@ -19,12 +19,14 @@ ASDK_EXTERN void ASDisplayNodePerformBlockOnEveryYogaChild(ASDisplayNode * _Null @interface ASDisplayNode (Yoga) @property (copy) NSArray *yogaChildren; +@property (readonly, weak) ASDisplayNode *yogaParent; - (void)addYogaChild:(ASDisplayNode *)child; - (void)removeYogaChild:(ASDisplayNode *)child; - (void)insertYogaChild:(ASDisplayNode *)child atIndex:(NSUInteger)index; - (void)semanticContentAttributeDidChange:(UISemanticContentAttribute)attribute; +- (UIUserInterfaceLayoutDirection)yogaLayoutDirection; @property BOOL yogaLayoutInProgress; // TODO: Make this atomic (lock). diff --git a/Source/ASDisplayNode+Yoga.mm b/Source/ASDisplayNode+Yoga.mm index 619f5a780..216b2fcab 100644 --- a/Source/ASDisplayNode+Yoga.mm +++ b/Source/ASDisplayNode+Yoga.mm @@ -22,11 +22,14 @@ #import #import #import - #import +#import #define YOGA_LAYOUT_LOGGING 0 +// Access style property directly or use the getter to create one +#define _LOCKED_ACCESS_STYLE() (_style ?: [self _locked_style]) + #pragma mark - ASDisplayNode+Yoga @interface ASDisplayNode (YogaPrivate) @@ -131,6 +134,14 @@ - (void)semanticContentAttributeDidChange:(UISemanticContentAttribute)attribute ? YGDirectionLTR : YGDirectionRTL); } +- (UIUserInterfaceLayoutDirection)yogaLayoutDirection +{ + AS::Yoga2::AssertEnabled(self); + return _LOCKED_ACCESS_STYLE().direction == YGDirectionRTL + ? UIUserInterfaceLayoutDirectionRightToLeft + : UIUserInterfaceLayoutDirectionLeftToRight; +} + - (void)setYogaParent:(ASDisplayNode *)yogaParent { ASLockScopeSelf(); diff --git a/Source/ASDisplayNode+Yoga2.h b/Source/ASDisplayNode+Yoga2.h new file mode 100644 index 000000000..5dd9f8729 --- /dev/null +++ b/Source/ASDisplayNode+Yoga2.h @@ -0,0 +1,47 @@ +// +// ASDisplayNode+Yoga2.h +// AsyncDisplayKit +// +// Created by Adlai Holler on 3/8/19. +// Copyright © 2019 Pinterest. All rights reserved. +// + +#if defined(__cplusplus) + +#import +#import +#import + +#if YOGA +#import YOGA_HEADER_PATH +#endif + +NS_ASSUME_NONNULL_BEGIN + +namespace AS { +namespace Yoga2 { + +/** + * Returns whether Yoga2 is enabled for this node. + */ +bool GetEnabled(ASDisplayNode *node); + +inline void AssertEnabled() { + ASDisplayNodeCAssert(false, @"Expected Yoga2 to be enabled."); +} + +inline void AssertEnabled(ASDisplayNode *node) { + ASDisplayNodeCAssert(GetEnabled(node), @"Expected Yoga2 to be enabled."); +} + +inline void AssertDisabled(ASDisplayNode *node) { + ASDisplayNodeCAssert(!GetEnabled(node), @"Expected Yoga2 to be disabled."); +} + + +} // namespace Yoga2 +} // namespace AS + +NS_ASSUME_NONNULL_END + +#endif // defined(__cplusplus) diff --git a/Source/ASDisplayNode+Yoga2.mm b/Source/ASDisplayNode+Yoga2.mm new file mode 100644 index 000000000..6f0ca4ea0 --- /dev/null +++ b/Source/ASDisplayNode+Yoga2.mm @@ -0,0 +1,46 @@ +// +// ASDisplayNode+Yoga2.mm +// AsyncDisplayKit +// +// Created by Adlai Holler on 3/8/19. +// Copyright © 2019 Pinterest. All rights reserved. +// + +#import +#import +#import + +#if YOGA + +#import +#import +#import +#import +#import + +#import YOGA_HEADER_PATH + +namespace AS { +namespace Yoga2 { + +bool GetEnabled(ASDisplayNode *node) { + if (node) { + MutexLocker l(node->__instanceLock__); + return node->_flags.yoga; + } else { + return false; + } +} + +#else // !YOGA + +namespace AS { +namespace Yoga2 { + +bool GetEnabled(ASDisplayNode *node) { return false; } + +#endif // YOGA + +} // namespace Yoga2 +} // namespace AS + diff --git a/Source/ASDisplayNode.h b/Source/ASDisplayNode.h index eff930ccf..f5ee1e694 100644 --- a/Source/ASDisplayNode.h +++ b/Source/ASDisplayNode.h @@ -46,6 +46,11 @@ typedef UIViewController * _Nonnull(^ASDisplayNodeViewControllerBlock)(void); */ typedef CALayer * _Nonnull(^ASDisplayNodeLayerBlock)(void); +/** + * Accessibility elements creation block. Used to specify accessibility elements of the node. + */ +typedef NSArray *_Nullable (^ASDisplayNodeAccessibilityElementsBlock)(void); + /** * ASDisplayNode loaded callback block. This block is called BEFORE the -didLoad method and is always called on the main thread. */ @@ -817,6 +822,18 @@ ASDK_EXTERN NSInteger const ASDefaultDrawingPriority; @end +@interface ASDisplayNode (CustomAccessibilityBehavior) + +/** + * Set the block that should be used to determining the accessibility elements of the node. + * When set, the accessibility-related logic (e.g. label aggregation) will not be triggered. + * + * @param block The block that returns the accessibility elements of the node. + */ +- (void)setAccessibilityElementsBlock:(ASDisplayNodeAccessibilityElementsBlock)block; + +@end + @interface ASDisplayNode (ASLayoutElement) /** diff --git a/Source/ASDisplayNode.mm b/Source/ASDisplayNode.mm index 09f112102..1286f36b5 100644 --- a/Source/ASDisplayNode.mm +++ b/Source/ASDisplayNode.mm @@ -936,6 +936,22 @@ - (void)__setNodeController:(ASNodeController *)controller } } +- (UIEdgeInsets)paddings { + MutexLocker l(__instanceLock__); +#if YOGA + if (_flags.yoga) { + YGNodeRef yogaNode = _style.yogaNode; + CGFloat top = YGNodeLayoutGetPadding(yogaNode, YGEdgeTop); + CGFloat left = YGNodeLayoutGetPadding(yogaNode, YGEdgeLeft); + CGFloat bottom = YGNodeLayoutGetPadding(yogaNode, YGEdgeBottom); + CGFloat right = YGNodeLayoutGetPadding(yogaNode, YGEdgeRight); + return UIEdgeInsetsMake(top, left, bottom, right); + } +#endif // YOGA + return UIEdgeInsetsZero; + +} + - (void)checkResponderCompatibility { #if ASDISPLAYNODE_ASSERTIONS_ENABLED diff --git a/Source/ASExperimentalFeatures.h b/Source/ASExperimentalFeatures.h index a8b655ce9..637d383b9 100644 --- a/Source/ASExperimentalFeatures.h +++ b/Source/ASExperimentalFeatures.h @@ -31,6 +31,9 @@ typedef NS_OPTIONS(NSUInteger, ASExperimentalFeatures) { ASExperimentalDisableGlobalTextkitLock = 1 << 10, // exp_disable_global_textkit_lock ASExperimentalMainThreadOnlyDataController = 1 << 11, // exp_main_thread_only_data_controller ASExperimentalRangeUpdateOnChangesetUpdate = 1 << 12, // exp_range_update_on_changeset_update + ASExperimentalDoNotCacheAccessibilityElements = 1 << 23, // exp_do_not_cache_accessibility_elements + ASExperimentalEnableNodeIsHiddenFromAcessibility = 1 << 26, // exp_enable_node_is_hidden_from_accessibility + ASExperimentalEnableAcessibilityElementsReturnNil = 1 << 27, // exp_enable_accessibility_elements_return_nil ASExperimentalFeatureAll = 0xFFFFFFFF }; diff --git a/Source/ASExperimentalFeatures.mm b/Source/ASExperimentalFeatures.mm index 6113dc405..8f35fd3fa 100644 --- a/Source/ASExperimentalFeatures.mm +++ b/Source/ASExperimentalFeatures.mm @@ -24,7 +24,10 @@ @"exp_optimize_data_controller_pipeline", @"exp_disable_global_textkit_lock", @"exp_main_thread_only_data_controller", - @"exp_range_update_on_changeset_update"])); + @"exp_range_update_on_changeset_update", + @"exp_do_not_cache_accessibility_elements", + @"exp_enable_node_is_hidden_from_accessibility", + @"exp_enable_accessibility_elements_return_nil"])); if (flags == ASExperimentalFeatureAll) { return allNames; } diff --git a/Source/ASTextNode2.h b/Source/ASTextNode2.h index 5848adc37..86ad7b5ea 100644 --- a/Source/ASTextNode2.h +++ b/Source/ASTextNode2.h @@ -14,6 +14,20 @@ NS_ASSUME_NONNULL_BEGIN +/** + * Get and Set ASTextNode to use: + * a) Intrinsic size fix for NSAtrributedStrings with no paragraph styles and + * b) Yoga direction to determine alignment of NSTextAlignmentNatural text nodes. + */ +BOOL ASGetEnableTextNode2ImprovedRTL(void); +void ASSetEnableTextNode2ImprovedRTL(BOOL enable); + +/** + * Get and Set ASTextLayout and ASTextNode2 to enable to calculation of visible text range. + */ +BOOL ASGetEnableTextTruncationVisibleRange(void); +void ASSetEnableTextTruncationVisibleRange(BOOL enable); + /** @abstract Draws interactive rich text. @discussion Backed by the code in TextExperiment folder, on top of CoreText. @@ -171,7 +185,7 @@ NS_ASSUME_NONNULL_BEGIN @param point The point, in the receiver's coordinate system. @param attributeNameOut The name of the attribute at the point. Can be NULL. @param rangeOut The ultimate range of the found text. Can be NULL. - @result YES if an entity exists at `point`; NO otherwise. + @result The entity if it exists at `point`; nil otherwise. */ - (nullable id)linkAttributeValueAtPoint:(CGPoint)point attributeName:(out NSString * _Nullable * _Nullable)attributeNameOut range:(out NSRange * _Nullable)rangeOut AS_WARN_UNUSED_RESULT; @@ -212,7 +226,14 @@ NS_ASSUME_NONNULL_BEGIN @discussion If you still want to handle tap truncation action when passthroughNonlinkTouches is YES, you should set the alwaysHandleTruncationTokenTap to YES. */ -@property (nonatomic) BOOL passthroughNonlinkTouches; +@property BOOL passthroughNonlinkTouches; + +/** + @abstract Whether additionalTruncationMessage is interactive. + @discussion This affects whether touches on additionalTruncationMessage will be intercepted when + passthroughNonlinkTouches is YES. + */ +@property BOOL additionalTruncationMessageIsInteractive; /** @abstract Always handle tap truncationAction, even the passthroughNonlinkTouches is YES. Default is NO. diff --git a/Source/ASTextNode2.mm b/Source/ASTextNode2.mm index e268eca2d..c292616c5 100644 --- a/Source/ASTextNode2.mm +++ b/Source/ASTextNode2.mm @@ -12,18 +12,90 @@ #import #import -#import -#import #import #import -#import +#import #import +#import +#import +#import +#import +#import #import #import +#import +#import #import +using namespace AS; + +typedef void (^TextAttachmentUpdateBlock)(ASImageNode *imageNode); +void UpdateTextAttachmentForText(NSAttributedString *attributedString, + TextAttachmentUpdateBlock updateBlock); + +BOOL kTextNode2ImprovedRTL = false; +BOOL ASGetEnableTextNode2ImprovedRTL(void) { return kTextNode2ImprovedRTL; } +void ASSetEnableTextNode2ImprovedRTL(BOOL enable) { kTextNode2ImprovedRTL = enable; } + +BOOL kTextTruncationVisibleRange = false; +BOOL ASGetEnableTextTruncationVisibleRange(void) { return kTextTruncationVisibleRange; } +void ASSetEnableTextTruncationVisibleRange(BOOL enable) { kTextTruncationVisibleRange = enable; } + +// Provide a way for an ASAccessibilityElement to dispatch to the ASTextNode for its +// accessibilityFrame +@interface ASTextNode2 () + +- (CGRect)accessibilityFrameForAccessibilityElement:(ASAccessibilityElement *)accessibilityElement; + +@end + +/** + * Calculates the accessibility frame for a given ASAccessibilityElement, ASTextLayout and + * ASDisplayNode in the screen cooordinates space. This can be used for setting or + * providing an accessibility frame for the given ASAccessibilityElement. + */ +static CGRect ASTextNodeAccessiblityElementFrame(ASAccessibilityElement *element, + ASTextLayout *layout, + ASDisplayNode *containerNode) { + // This needs to be in the first non layer nodes coordinates space + containerNode = + containerNode ?: ASFindClosestViewOfLayer(element.node.layer).asyncdisplaykit_node; + NSCAssert(containerNode != nil, @"No container node found"); + CGRect textLayoutFrame = CGRectZero; + NSRange accessibilityRange = element.accessibilityRange; + if (accessibilityRange.location == NSNotFound) { + // If no accessibilityRange was specified (as is done for the text element), just use the + // label's range and clamp to the visible range otherwise the returned rect would be invalid. + NSRange range = NSMakeRange(0, element.accessibilityLabel.length); + range = NSIntersectionRange(range, layout.visibleRange); + textLayoutFrame = [layout rectForRange:[ASTextRange rangeWithRange:range]]; + } else { + textLayoutFrame = [layout rectForRange:[ASTextRange rangeWithRange:accessibilityRange]]; + } + CGRect accessibilityFrame = [element.node convertRect:textLayoutFrame toNode:containerNode]; + return UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, containerNode.view); +} + +@interface ASTextNodeFrameProvider : NSObject +@end + +@implementation ASTextNodeFrameProvider + +- (CGRect)accessibilityFrameForAccessibilityElement:(ASAccessibilityElement *)accessibilityElement { + ASTextNode2 *textNode = ASDynamicCast(accessibilityElement.node, ASTextNode2); + if (textNode == nil) { + NSCAssert(NO, @"Only accessibility elements from ASTextNode are allowed."); + return CGRectZero; + } + + // Ask the passed in text node for the accessibilityFrame + return [textNode accessibilityFrameForAccessibilityElement:accessibilityElement]; +} + +@end + @interface ASTextCacheValue : NSObject { @package AS::Mutex _m; @@ -43,7 +115,8 @@ @implementation ASTextCacheValue #define AS_TEXTNODE2_RECORD_ATTRIBUTED_STRINGS 0 /** - * If it can't find a compatible layout, this method creates one. + * Wraps a cache around a call to [ASTextLayout layoutWithContainer:text:]. + * [ASTextLayout layoutWithContainer:text:] creates a layout if it was not found in the cache. * * NOTE: Be careful to copy `text` if needed. */ @@ -59,11 +132,16 @@ @implementation ASTextCacheValue layoutCacheLock->lock(); ASTextCacheValue *cacheValue = [textLayoutCache objectForKey:text]; + + // Disable the cache if the text has attachments, since caching attachment content is expensive. + BOOL shouldCacheLayout = YES; if (cacheValue == nil) { + shouldCacheLayout = ![text as_hasAttribute:ASTextAttachmentAttributeName]; cacheValue = [[ASTextCacheValue alloc] init]; - [textLayoutCache setObject:cacheValue forKey:[text copy]]; + if (shouldCacheLayout) { + [textLayoutCache setObject:cacheValue forKey:[text copy]]; + } } - // Lock the cache item for the rest of the method. Only after acquiring can we release the NSCache. AS::MutexLocker lock(cacheValue->_m); layoutCacheLock->unlock(); @@ -116,15 +194,19 @@ @implementation ASTextCacheValue } // Cache Miss. Compute the text layout. + ASSignpostStart(MeasureText, cacheValue, "%@", [text.string substringToIndex:MIN(text.length, 10)]); ASTextLayout *layout = [ASTextLayout layoutWithContainer:container text:text]; + ASSignpostEnd(MeasureText, cacheValue, ""); // Store the result in the cache. { - // This is a critical section. However we also must hold the lock until this point, in case - // another thread requests this cache item while a layout is being calculated, so they don't race. - cacheValue->_layouts.push_front(std::make_tuple(container.size, layout)); - if (cacheValue->_layouts.size() > 3) { - cacheValue->_layouts.pop_back(); + if (shouldCacheLayout) { + // This is a critical section. However we also must hold the lock until this point, in case + // another thread requests this cache item while a layout is being calculated, so they don't race. + cacheValue->_layouts.push_front(std::make_tuple(container.size, layout)); + if (cacheValue->_layouts.size() > 3) { + cacheValue->_layouts.pop_back(); + } } } @@ -171,7 +253,9 @@ @implementation AS_TN2_CLASSNAME { ASTextNodeHighlightStyle _highlightStyle; BOOL _longPressCancelsTouches; BOOL _passthroughNonlinkTouches; - BOOL _alwaysHandleTruncationTokenTap; + BOOL _additionalTruncationMessageIsInteractive; + + NSMutableDictionary *_drawParameters; } @dynamic placeholderEnabled; @@ -197,7 +281,8 @@ - (instancetype)init // Disable user interaction for text node by default. self.userInteractionEnabled = NO; self.needsDisplayOnBoundsChange = YES; - + + _truncationMode = NSLineBreakByTruncatingTail; _textContainer.truncationType = ASTextTruncationTypeEnd; // The common case is for a text node to be non-opaque and blended over some background. @@ -207,7 +292,7 @@ - (instancetype)init self.linkAttributeNames = DefaultLinkAttributeNames(); // Accessibility - self.isAccessibilityElement = YES; + self.isAccessibilityElement = NO; self.accessibilityTraits = self.defaultAccessibilityTraits; // Placeholders @@ -223,6 +308,14 @@ - (instancetype)init - (void)dealloc { CGColorRelease(_shadowColor); + if (!ASDisplayNodeThreadIsMain()) { + if ([_attributedText as_hasAttribute:ASTextAttachmentAttributeName]) { + ASPerformMainThreadDeallocation(&_attributedText); + } + if ([_truncationAttributedText as_hasAttribute:ASTextAttachmentAttributeName]) { + ASPerformMainThreadDeallocation(&_truncationAttributedText); + } + } } #pragma mark - Description @@ -279,7 +372,7 @@ - (BOOL)supportsLayerBacking return NO; } - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); // If the text contains any links, return NO. NSAttributedString *attributedText = _attributedText; NSRange range = NSMakeRange(0, attributedText.length); @@ -301,7 +394,7 @@ - (BOOL)supportsLayerBacking - (NSString *)defaultAccessibilityLabel { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _attributedText.string; } @@ -310,11 +403,123 @@ - (UIAccessibilityTraits)defaultAccessibilityTraits return UIAccessibilityTraitStaticText; } +- (BOOL)isAccessibilityElement +{ + // If the ASTextNode2 should act as an UIAccessibilityContainer it has to return + // NO for isAccessibilityElement + return NO; +} + +- (NSInteger)accessibilityElementCount +{ + return self.accessibilityElements.count; +} + +// Returns the default ASTextNodeFrameProvider to be used as frame provider of text node's +// accessibility elements. +static ASTextNodeFrameProvider *ASTextNode2ASTextNodeFrameProviderDefault() { + static ASTextNodeFrameProvider *frameProvider = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + frameProvider = [[ASTextNodeFrameProvider alloc] init]; + }); + return frameProvider; +} + +- (NSArray *)accessibilityElements +{ + NSInteger attributedTextLength = _attributedText.length; + if (attributedTextLength == 0) { + return @[]; + } + + NSMutableArray *accessibilityElements = [[NSMutableArray alloc] init]; + + // Search the first node that is not layer backed + ASDisplayNode *containerNode = ASFindClosestViewOfLayer(self.layer).asyncdisplaykit_node; + NSCAssert(containerNode != nil, @"No container node found"); + + // Create an accessibility element to represent the label's text. It's not necessary to specify + // a accessibilityRange here, as the entirety of the text is being represented. + ASAccessibilityElement *accessibilityElement = + [[ASAccessibilityElement alloc] initWithAccessibilityContainer:containerNode.view]; + accessibilityElement.node = self; + accessibilityElement.accessibilityRange = NSMakeRange(NSNotFound, 0); + accessibilityElement.accessibilityIdentifier = self.accessibilityIdentifier; + accessibilityElement.accessibilityLabel = self.accessibilityLabel; + accessibilityElement.accessibilityValue = self.accessibilityValue; + accessibilityElement.accessibilityTraits = self.accessibilityTraits; + if (AS_AVAILABLE_IOS_TVOS(11, 11)) { + accessibilityElement.accessibilityAttributedLabel = self.accessibilityAttributedLabel; + accessibilityElement.accessibilityAttributedHint = self.accessibilityAttributedHint; + accessibilityElement.accessibilityAttributedValue = self.accessibilityAttributedValue; + } + accessibilityElement.frameProvider = ASTextNode2ASTextNodeFrameProviderDefault(); + [accessibilityElements addObject:accessibilityElement]; + + // Collect all links as accessiblity items + for (NSString *linkAttributeName in _linkAttributeNames) { + [_attributedText enumerateAttribute:linkAttributeName inRange:NSMakeRange(0, attributedTextLength) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) { + if (value == nil) { + return; + } + ASAccessibilityElement *accessibilityElement = + [[ASAccessibilityElement alloc] initWithAccessibilityContainer:self]; + accessibilityElement.node = self; + accessibilityElement.accessibilityTraits = UIAccessibilityTraitLink; + accessibilityElement.accessibilityLabel = [_attributedText.string substringWithRange:range]; + accessibilityElement.accessibilityRange = range; + if (AS_AVAILABLE_IOS_TVOS(11, 11)) { + accessibilityElement.accessibilityAttributedLabel = + [_attributedText attributedSubstringFromRange:range]; + } + accessibilityElement.frameProvider = ASTextNode2ASTextNodeFrameProviderDefault(); + [accessibilityElements addObject:accessibilityElement]; + }]; + } + return accessibilityElements; +} + +- (CGRect)accessibilityFrameForAccessibilityElement:(ASAccessibilityElement *)accessibilityElement { + // Go up the tree to the top container node that contains the ASTextNode + // this is especially necessary if the text node is layer backed + ASDisplayNode *containerNode = ASFindClosestViewOfLayer(_layer).asyncdisplaykit_node; + NSCAssert(containerNode != nil, @"No container node found"); + ASTextLayout *layout = + ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, _attributedText); + return ASTextNodeAccessiblityElementFrame(accessibilityElement, layout, containerNode); +} + +- (BOOL)performAccessibilityCustomActionLink:(UIAccessibilityCustomAction *)action +{ + NSCAssert(0 != (action.accessibilityTraits & UIAccessibilityTraitLink), @"Action needs to have UIAccessibilityTraitLink trait set"); + NSCAssert([action isKindOfClass:[ASAccessibilityCustomAction class]], @"Action needs to be of kind ASAccessibilityCustomAction"); + ASAccessibilityCustomAction *customAction = (ASAccessibilityCustomAction *)action; + + // In TextNode2 forward the link custom action to textNode:tappedLinkAttribute:value:atPoint:textRange: + // the default method that is available for link handling within ASTextNodeDelegate + if ([self.delegate respondsToSelector:@selector(textNode:tappedLinkAttribute:value:atPoint:textRange:)]) { + // Convert from screen coordinates to the node coordinate space + CGPoint centerAccessibilityFrame = CGPointMake(CGRectGetMidX(customAction.accessibilityFrame), CGRectGetMidY(customAction.accessibilityFrame)); + CGPoint center = [self.supernode convertPoint:centerAccessibilityFrame fromNode:nil]; + [self.delegate textNode:(ASTextNode *)self tappedLinkAttribute:NSLinkAttributeName value:customAction.value atPoint:center textRange:customAction.textRange]; + return YES; + } + return NO; +} + #pragma mark - Layout and Sizing +- (void)layoutDidFinish { + [super layoutDidFinish]; + if (CGRectIsEmpty(self.bounds) && self.layer.needsDisplay) { + self.layer.contents = nil; + } +} + - (void)setTextContainerInset:(UIEdgeInsets)textContainerInset { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCustom(_textContainer.insets, textContainerInset, UIEdgeInsetsEqualToEdgeInsets)) { [self setNeedsLayout]; } @@ -333,7 +538,7 @@ - (void)setTextContainerLinePositionModifier:(id)mod - (id)textContainerLinePositionModifier { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _textContainer.linePositionModifier; } @@ -342,16 +547,25 @@ - (CGSize)calculateSizeThatFits:(CGSize)constrainedSize ASDisplayNodeAssert(constrainedSize.width >= 0, @"Constrained width for text (%f) is too narrow", constrainedSize.width); ASDisplayNodeAssert(constrainedSize.height >= 0, @"Constrained height for text (%f) is too short", constrainedSize.height); - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); _textContainer.size = constrainedSize; [self _ensureTruncationText]; - + BOOL isCalculatingIntrinsicSize = NO; +#if YOGA + // In Yoga nodes, we cannot count on constrainedSize being infinite as we make AT_MOST measurements which receive fixed at-most values. + // However note the effect of this setting is to always left-align the text and report its actual used-width instead of the + // position of the far edge relative to the left: Which is behavior we always want under Yoga anyway. + if (_flags.yoga) { + isCalculatingIntrinsicSize = YES; + } +#endif // If the constrained size has a max/inf value on the text's forward direction, the text node is calculating its intrinsic size. // Need to consider both width and height when determining if it is calculating instrinsic size. Even the constrained width is provided, the height can be inf // it may provide a text that is longer than the width and require a wordWrapping line break mode and looking for the height to be calculated. - BOOL isCalculatingIntrinsicSize = (_textContainer.size.width >= ASTextContainerMaxSize.width) || (_textContainer.size.height >= ASTextContainerMaxSize.height); - + if (!isCalculatingIntrinsicSize) { + isCalculatingIntrinsicSize = (_textContainer.size.width >= ASTextContainerMaxSize.width) || (_textContainer.size.height >= ASTextContainerMaxSize.height); + } NSMutableAttributedString *mutableText = [_attributedText mutableCopy]; [self prepareAttributedString:mutableText isForIntrinsicSize:isCalculatingIntrinsicSize]; ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, mutableText); @@ -362,6 +576,12 @@ - (CGSize)calculateSizeThatFits:(CGSize)constrainedSize return layout.textBoundingSize; } +#if YOGA +- (float)yogaBaselineWithSize:(CGSize)size { + return ASTextGetBaseline(size.height, self.yogaParent, self.attributedText); +} +#endif + #pragma mark - Modifying User Text // Returns the ascender of the first character in attributedString by also including the line height if specified in paragraph style. @@ -381,21 +601,21 @@ + (CGFloat)ascenderWithAttributedString:(NSAttributedString *)attributedString - (NSAttributedString *)attributedText { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _attributedText; } - (void)setAttributedText:(NSAttributedString *)attributedText { - if (attributedText == nil) { - attributedText = [[NSAttributedString alloc] initWithString:@"" attributes:nil]; + // Avoid copy / create for nil or zero-length arg. Treat them both as singleton zero. + if (attributedText.length == 0) { + attributedText = ASGetZeroAttributedString(); } // Many accessors in this method will acquire the lock (including ASDisplayNode methods). // Holding it for the duration of the method is more efficient in this case. - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); - NSAttributedString *oldAttributedText = _attributedText; if (!ASCompareAssignCopy(_attributedText, attributedText)) { return; } @@ -403,12 +623,14 @@ - (void)setAttributedText:(NSAttributedString *)attributedText // Since truncation text matches style of attributedText, invalidate it now. [self _locked_invalidateTruncationText]; +#if !YOGA NSUInteger length = attributedText.length; if (length > 0) { ASLayoutElementStyle *style = [self _locked_style]; style.ascender = [[self class] ascenderWithAttributedString:attributedText]; style.descender = [[attributedText attribute:NSFontAttributeName atIndex:attributedText.length - 1 effectiveRange:NULL] descender]; } +#endif // Tell the display node superclasses that the cached layout is incorrect now [self setNeedsLayout]; @@ -419,22 +641,23 @@ - (void)setAttributedText:(NSAttributedString *)attributedText // Accessiblity self.accessibilityLabel = self.defaultAccessibilityLabel; - // We update the isAccessibilityElement setting if this node is not switching between strings. - if (oldAttributedText.length == 0 || length == 0) { - // We're an accessibility element by default if there is a string. - self.isAccessibilityElement = (length != 0); - } - #if AS_TEXTNODE2_RECORD_ATTRIBUTED_STRINGS [ASTextNode _registerAttributedText:_attributedText]; #endif + + if (self.isNodeLoaded) { + // Invalidate the accessibility elements for self as well as for the first accessibility + // container to requery the accessibility items for it + [self invalidateAccessibilityElements]; + [self invalidateFirstAccessibilityContainerOrNonLayerBackedNode]; + } } #pragma mark - Text Layout - (void)setExclusionPaths:(NSArray *)exclusionPaths { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); _textContainer.exclusionPaths = exclusionPaths; [self setNeedsLayout]; @@ -443,14 +666,18 @@ - (void)setExclusionPaths:(NSArray *)exclusionPaths - (NSArray *)exclusionPaths { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _textContainer.exclusionPaths; } - (void)prepareAttributedString:(NSMutableAttributedString *)attributedString isForIntrinsicSize:(BOOL)isForIntrinsicSize { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); NSLineBreakMode innerMode; + // `innerMode` is the truncation mode used by CoreText, within ASTextLayout. Since we are + // supplying our own truncation logic, we replace any actually-truncating modes with + // `NSLineBreakByWordWrapping` which just breaks down the lines for us. We still have the original + // truncationMode available to us from the ASTextContainer when we need it. switch (_truncationMode) { case NSLineBreakByWordWrapping: case NSLineBreakByCharWrapping: @@ -461,38 +688,75 @@ - (void)prepareAttributedString:(NSMutableAttributedString *)attributedString is innerMode = NSLineBreakByWordWrapping; } - // Apply/Fix paragraph style if needed - [attributedString enumerateAttribute:NSParagraphStyleAttributeName inRange:NSMakeRange(0, attributedString.length) options:kNilOptions usingBlock:^(NSParagraphStyle *style, NSRange range, BOOL * _Nonnull stop) { - - BOOL applyTruncationMode = YES; - NSMutableParagraphStyle *paragraphStyle = nil; - // Only "left" and "justified" alignments are supported while calculating intrinsic size. - // Other alignments like "right", "center" and "natural" cause the size to be bigger than needed and thus should be ignored/overridden. - const BOOL forceLeftAlignment = (style != nil - && isForIntrinsicSize - && style.alignment != NSTextAlignmentLeft - && style.alignment != NSTextAlignmentJustified); - if (style != nil) { - if (innerMode == style.lineBreakMode) { - applyTruncationMode = NO; - } - paragraphStyle = [style mutableCopy]; + // NOTE if there are no attributes, we still get called once with a `nil` style! + [attributedString enumerateAttribute:NSParagraphStyleAttributeName inRange:NSMakeRange(0, attributedString.length) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(NSParagraphStyle *style, NSRange range, BOOL * _Nonnull stop) { + const NSLineBreakMode previousMode = style ? style.lineBreakMode : NSLineBreakByWordWrapping; + const BOOL applyTruncationMode = innerMode != previousMode; + const BOOL useNaturalAlignment = (!style || style.alignment == NSTextAlignmentNatural); + BOOL forceLeftAlignment = NO; + // This experiment launched so long ago but relies on semanticContentAttribute + if (kTextNode2ImprovedRTL) { + // To calculate intrinsic size, we generally always use NSTextAlignmentLeft (perhaps NSTextAlignmentJustified). + forceLeftAlignment = (isForIntrinsicSize + && (!style + || (style.alignment != NSTextAlignmentLeft + && style.alignment != NSTextAlignmentJustified))); } else { - if (innerMode == NSLineBreakByWordWrapping) { - applyTruncationMode = NO; - } - paragraphStyle = [NSMutableParagraphStyle new]; + forceLeftAlignment = (style != nil + && isForIntrinsicSize + && style.alignment != NSTextAlignmentLeft + && style.alignment != NSTextAlignmentJustified); } - if (!applyTruncationMode && !forceLeftAlignment) { + + if (!applyTruncationMode && !forceLeftAlignment && !useNaturalAlignment) { return; } + NSMutableParagraphStyle *paragraphStyle = + [style mutableCopy] ?: [[NSMutableParagraphStyle alloc] init]; paragraphStyle.lineBreakMode = innerMode; - if (applyTruncationMode) { - paragraphStyle.lineBreakMode = _truncationMode; - } if (forceLeftAlignment) { paragraphStyle.alignment = NSTextAlignmentLeft; + } else if (useNaturalAlignment) { +#if YOGA + if (!kTextNode2ImprovedRTL) { +#endif + if (AS_AVAILABLE_IOS(10)) { + switch (self.primitiveTraitCollection.layoutDirection) { + case UITraitEnvironmentLayoutDirectionLeftToRight: + paragraphStyle.alignment = NSTextAlignmentLeft; + break; + case UITraitEnvironmentLayoutDirectionRightToLeft: + paragraphStyle.alignment = NSTextAlignmentRight; + break; + case UITraitEnvironmentLayoutDirectionUnspecified: + break; + } + } else { + NSNumber *layoutDirection = ASApplicationUserInterfaceLayoutDirection(); + if (layoutDirection) { + switch (static_cast([layoutDirection integerValue])) { + case UIUserInterfaceLayoutDirectionLeftToRight: + paragraphStyle.alignment = NSTextAlignmentLeft; + break; + case UIUserInterfaceLayoutDirectionRightToLeft: + paragraphStyle.alignment = NSTextAlignmentRight; + break; + } + } + } +#if YOGA + } else { + switch ([self yogaLayoutDirection]) { + case UIUserInterfaceLayoutDirectionLeftToRight: + paragraphStyle.alignment = NSTextAlignmentLeft; + break; + case UIUserInterfaceLayoutDirectionRightToLeft: + paragraphStyle.alignment = NSTextAlignmentRight; + break; + } + } +#endif } [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; }]; @@ -517,34 +781,28 @@ - (void)prepareAttributedString:(NSMutableAttributedString *)attributedString is - (NSObject *)drawParametersForAsyncLayer:(_ASDisplayLayer *)layer { - ASTextContainer *copiedContainer; - NSMutableAttributedString *mutableText; - BOOL needsTintColor; - id bgColor; - { - // Wrapping all the other access here, because we can't lock while accessing tintColor. - ASLockScopeSelf(); - [self _ensureTruncationText]; + MutexLocker l(__instanceLock__); + [self _ensureTruncationText]; - // Unlike layout, here we must copy the container since drawing is asynchronous. - copiedContainer = [_textContainer copy]; - copiedContainer.size = self.bounds.size; - [copiedContainer makeImmutable]; - mutableText = [_attributedText mutableCopy] ?: [[NSMutableAttributedString alloc] init]; + // Unlike layout, here we must copy the container since drawing is asynchronous. + ASTextContainer *copiedContainer = [_textContainer copy]; - [self prepareAttributedString:mutableText isForIntrinsicSize:NO]; - needsTintColor = self.textColorFollowsTintColor && mutableText.length > 0; - bgColor = self.backgroundColor ?: [NSNull null]; + // Some unit tests set insets directly, so don't override them with zero padding + if (!UIEdgeInsetsEqualToEdgeInsets(self.paddings, UIEdgeInsetsZero)) { + copiedContainer.insets = self.paddings; } - + copiedContainer.size = self.bounds.size; + [copiedContainer makeImmutable]; + NSMutableAttributedString *mutableText = [_attributedText mutableCopy] ?: [[NSMutableAttributedString alloc] init]; + + [self prepareAttributedString:mutableText isForIntrinsicSize:NO]; + // After all other attributes are set, apply tint color if needed and foreground color is not already specified - if (needsTintColor) { + if (self.textColorFollowsTintColor && mutableText.length > 0) { // Apply tint color if specified and if foreground color is undefined for attributedString NSRange limit = NSMakeRange(0, mutableText.length); // Look for previous attributes that define foreground color UIColor *attributeValue = (UIColor *)[mutableText attribute:NSForegroundColorAttributeName atIndex:limit.location effectiveRange:NULL]; - - // we need to unlock before accessing tintColor UIColor *tintColor = self.tintColor; if (attributeValue == nil && tintColor) { // None are found, apply tint color if available. Fallback to "black" text color @@ -552,11 +810,16 @@ - (NSObject *)drawParametersForAsyncLayer:(_ASDisplayLayer *)layer } } - return @{ - @"container": copiedContainer, - @"text": mutableText, - @"bgColor": bgColor - }; + ASTextLayout *layout = + ASTextNodeCompatibleLayoutWithContainerAndText(copiedContainer, mutableText); + if (!_drawParameters) { + _drawParameters = [[NSMutableDictionary alloc] init]; + } + _drawParameters[@"container"] = copiedContainer; + _drawParameters[@"text"] = mutableText; + _drawParameters[@"bgColor"] = self.backgroundColor ?: [NSNull null]; + _drawParameters[@"layout"] = layout ?: [NSNull null]; + return [_drawParameters copy]; } + (void)drawRect:(CGRect)bounds withParameters:(NSDictionary *)layoutDict isCancelled:(NS_NOESCAPE asdisplaynode_iscancelled_block_t)isCancelledBlock isRasterizing:(BOOL)isRasterizing @@ -639,14 +902,19 @@ - (id)_linkAttributeValueAtPoint:(CGPoint)point inAdditionalTruncationMessage:(out BOOL *)inAdditionalTruncationMessageOut forHighlighting:(BOOL)highlighting { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); // TODO: The copy and application of size shouldn't be required, but it is currently. // See discussion in https://github.com/TextureGroup/Texture/pull/396 ASTextContainer *containerCopy = [_textContainer copy]; containerCopy.size = self.calculatedSize; - ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(containerCopy, _attributedText); + NSMutableAttributedString *mutableText = [_attributedText mutableCopy] ?: [[NSMutableAttributedString alloc] init]; + [self prepareAttributedString:mutableText isForIntrinsicSize:NO]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(containerCopy, mutableText); + // Start by checking whether there is any "direct" hit of additionalTruncationMessage. This + // ensures that additionalTruncationMessage still receives the touch if any link's expanded touch + // area overlaps it. if ([self _locked_pointInsideAdditionalTruncationMessage:point withLayout:layout]) { if (inAdditionalTruncationMessageOut != NULL) { *inAdditionalTruncationMessageOut = YES; @@ -655,28 +923,60 @@ - (id)_linkAttributeValueAtPoint:(CGPoint)point } NSRange visibleRange = layout.visibleRange; + BOOL enableImprovedTextTruncationVisibleRange = ASGetEnableImprovedTextTruncationVisibleRange(); + BOOL enableTextTruncationVisibleRange = ASGetEnableTextTruncationVisibleRange(); + + if (_truncationAttributedText != nil && [self isTruncated]) { + // Make sure the touch area doesn't include the area of the truncated tokens when the text + // is truncated. + if (enableImprovedTextTruncationVisibleRange) { + // The link attribute should only be fetched if the point is inside the uncovered part of + // the text. This is to make sure tapping on the truncation token will not fire any link + // attributes. + if (layout.truncatedLineBeforeTruncationToken && + CGRectContainsPoint(layout.truncatedLine.bounds, point) && + !CGRectContainsPoint(layout.truncatedLineBeforeTruncationToken.bounds, point)) { + return nil; + } + if (layout.truncatedLineBeforeTruncationTokenRange.location != NSNotFound) { + // The index before the truncated tokens. + NSUInteger truncatedLineBeforeTruncationTokenEnd = + layout.truncatedLineBeforeTruncationTokenRange.location + + layout.truncatedLineBeforeTruncationTokenRange.length; + visibleRange = NSMakeRange(visibleRange.location, + truncatedLineBeforeTruncationTokenEnd - visibleRange.location); + } + } else if (enableTextTruncationVisibleRange) { + visibleRange.length -= _truncationAttributedText.length; + } + } NSRange clampedRange = NSIntersectionRange(visibleRange, NSMakeRange(0, _attributedText.length)); - // Search the 9 points of a 44x44 square around the touch until we find a link. + // Search the 17 points of a 44x44 square around the touch until we find a link. // Start from center, then do sides, then do top/bottom, then do corners. - static constexpr CGSize kRectOffsets[9] = { + static constexpr CGSize kRectOffsets[] = { { 0, 0 }, + { -11, 0 }, { 11, 0 }, { -22, 0 }, { 22, 0 }, + { 0, -11 }, { 0, 11 }, { 0, -22 }, { 0, 22 }, + { -11, -11 }, { -11, 11 }, { -22, -22 }, { -22, 22 }, + { 11, -11 }, { 11, 11 }, { 22, -22 }, { 22, 22 } }; for (const CGSize &offset : kRectOffsets) { const CGPoint testPoint = CGPointMake(point.x + offset.width, point.y + offset.height); - ASTextPosition *pos = [layout closestPositionToPoint:testPoint]; - if (!pos || !NSLocationInRange(pos.offset, clampedRange)) { + ASTextRange *range = [layout textRangeAtPoint:testPoint]; + if (!range || !NSLocationInRange(range.start.offset, clampedRange)) { continue; } + for (NSString *attributeName in _linkAttributeNames) { NSRange effectiveRange = NSMakeRange(0, 0); - id value = [_attributedText attribute:attributeName atIndex:pos.offset + id value = [_attributedText attribute:attributeName atIndex:range.start.offset longestEffectiveRange:&effectiveRange inRange:clampedRange]; if (value == nil) { // Didn't find any links specified with this attribute. @@ -691,7 +991,9 @@ - (id)_linkAttributeValueAtPoint:(CGPoint)point continue; } - *rangeOut = NSIntersectionRange(visibleRange, effectiveRange); + if (rangeOut != NULL) { + *rangeOut = NSIntersectionRange(visibleRange, effectiveRange); + } if (attributeNameOut != NULL) { *attributeNameOut = attributeName; @@ -701,6 +1003,20 @@ - (id)_linkAttributeValueAtPoint:(CGPoint)point } } + // If there is no link, we can safely check whether the touch lands in the expanded touch + // area of additionalTruncationMessage. + if (_additionalTruncationMessage) { + for (const CGSize &offset : kRectOffsets) { + const CGPoint testPoint = CGPointMake(point.x + offset.width, point.y + offset.height); + if ([self _locked_pointInsideAdditionalTruncationMessage:testPoint withLayout:layout]) { + if (inAdditionalTruncationMessageOut != NULL) { + *inAdditionalTruncationMessageOut = YES; + } + return nil; + } + } + } + return nil; } @@ -710,21 +1026,22 @@ - (BOOL)_locked_pointInsideAdditionalTruncationMessage:(CGPoint)point withLayout BOOL inAdditionalTruncationMessage = NO; CTLineRef truncatedCTLine = layout.truncatedLine.CTLine; - if (truncatedCTLine != NULL && _additionalTruncationMessage != nil) { + if (truncatedCTLine != NULL && _additionalTruncationMessage != nil && + CGRectContainsPoint(layout.truncatedLine.bounds, point)) { CFIndex stringIndexForPosition = CTLineGetStringIndexForPosition(truncatedCTLine, point); if (stringIndexForPosition != kCFNotFound) { CFIndex truncatedCTLineGlyphCount = CTLineGetGlyphCount(truncatedCTLine); CTLineRef truncationTokenLine = CTLineCreateWithAttributedString((CFAttributedStringRef)_truncationAttributedText); CFIndex truncationTokenLineGlyphCount = truncationTokenLine ? CTLineGetGlyphCount(truncationTokenLine) : 0; - + if (truncationTokenLine) { CFRelease(truncationTokenLine); } CTLineRef additionalTruncationTokenLine = CTLineCreateWithAttributedString((CFAttributedStringRef)_additionalTruncationMessage); CFIndex additionalTruncationTokenLineGlyphCount = additionalTruncationTokenLine ? CTLineGetGlyphCount(additionalTruncationTokenLine) : 0; - + if (additionalTruncationTokenLine) { CFRelease(additionalTruncationTokenLine); } @@ -744,14 +1061,14 @@ - (BOOL)_locked_pointInsideAdditionalTruncationMessage:(CGPoint)point withLayout if ((firstTruncatedTokenIndex + truncationTokenLineGlyphCount) < stringIndexForPosition && stringIndexForPosition < (firstTruncatedTokenIndex + composedTruncationTextLineGlyphCount)) { inAdditionalTruncationMessage = YES; - } + } break; } case ASTextTruncationTypeEnd: { if (stringIndexForPosition > (truncatedCTLineGlyphCount - additionalTruncationTokenLineGlyphCount)) { inAdditionalTruncationMessage = YES; } - break; + break; } default: // For now, assume that a tap inside this text, but outside the text range is a tap on the @@ -763,7 +1080,7 @@ - (BOOL)_locked_pointInsideAdditionalTruncationMessage:(CGPoint)point withLayout } } } - + return inAdditionalTruncationMessage; } @@ -772,7 +1089,7 @@ - (BOOL)_locked_pointInsideAdditionalTruncationMessage:(CGPoint)point withLayout - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { ASDisplayNodeAssertMainThread(); - ASLockScopeSelf(); // Protect usage of _highlight* ivars. + MutexLocker l(__instanceLock__); // Protect usage of _highlight* ivars. if (gestureRecognizer == _longPressGestureRecognizer) { // Don't allow long press on truncation message @@ -806,21 +1123,21 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer - (ASTextNodeHighlightStyle)highlightStyle { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _highlightStyle; } - (void)setHighlightStyle:(ASTextNodeHighlightStyle)highlightStyle { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); _highlightStyle = highlightStyle; } - (NSRange)highlightRange { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return _highlightRange; } @@ -838,7 +1155,7 @@ - (void)setHighlightRange:(NSRange)highlightRange animated:(BOOL)animated - (void)_setHighlightRange:(NSRange)highlightRange forAttributeName:(NSString *)highlightedAttributeName value:(id)highlightedAttributeValue animated:(BOOL)animated { ASDisplayNodeAssertMainThread(); - ASLockScopeSelf(); // Protect usage of _highlight* ivars. + MutexLocker l(__instanceLock__); // Protect usage of _highlight* ivars. // Set these so that link tapping works. _highlightedLinkAttributeName = highlightedAttributeName; @@ -906,7 +1223,7 @@ - (void)_setHighlightRange:(NSRange)highlightRange forAttributeName:(NSString *) ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(textContainerCopy, _attributedText); NSArray *highlightRects = [layout selectionRectsWithoutStartAndEndForRange:[ASTextRange rangeWithRange:highlightRange]]; - NSMutableArray *converted = [NSMutableArray arrayWithCapacity:highlightRects.count]; + NSMutableArray *converted = [[NSMutableArray alloc] initWithCapacity:highlightRects.count]; CALayer *layer = self.layer; UIEdgeInsets shadowPadding = self.shadowPadding; @@ -924,7 +1241,10 @@ - (void)_setHighlightRange:(NSRange)highlightRange forAttributeName:(NSString *) ASHighlightOverlayLayer *overlayLayer = [[ASHighlightOverlayLayer alloc] initWithRects:converted]; overlayLayer.highlightColor = [[self class] _highlightColorForStyle:self.highlightStyle]; - overlayLayer.frame = highlightTargetLayer.bounds; + CGRect frame = highlightTargetLayer.bounds; + frame.origin.x += self.paddings.left; + frame.origin.y += self.paddings.top; + overlayLayer.frame = frame; overlayLayer.masksToBounds = NO; overlayLayer.opacity = [[self class] _highlightOpacityForStyle:self.highlightStyle]; [highlightTargetLayer addSublayer:overlayLayer]; @@ -1007,7 +1327,7 @@ - (UIColor *)placeholderColor - (void)setPlaceholderColor:(UIColor *)placeholderColor { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCopy(_placeholderColor, placeholderColor)) { self.placeholderEnabled = CGColorGetAlpha(placeholderColor.CGColor) > 0; } @@ -1024,15 +1344,11 @@ - (UIImage *)placeholderImage - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { ASDisplayNodeAssertMainThread(); - ASLockScopeSelf(); // Protect usage of _passthroughNonlinkTouches and _alwaysHandleTruncationTokenTap ivars. + MutexLocker l(__instanceLock__); // Protect usage of ivars. if (!_passthroughNonlinkTouches) { return [super pointInside:point withEvent:event]; } - - if (_alwaysHandleTruncationTokenTap) { - return YES; - } NSRange range = NSMakeRange(0, 0); NSString *linkAttributeName = nil; @@ -1043,7 +1359,10 @@ - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event range:&range inAdditionalTruncationMessage:&inAdditionalTruncationMessage forHighlighting:YES]; - + if (_additionalTruncationMessageIsInteractive && inAdditionalTruncationMessage) { + return YES; + } + NSUInteger lastCharIndex = NSIntegerMax; BOOL linkCrossesVisibleRange = (lastCharIndex > range.location) && (lastCharIndex < NSMaxRange(range) - 1); @@ -1078,7 +1397,7 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event if (inAdditionalTruncationMessage) { NSRange visibleRange = NSMakeRange(0, 0); { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); // TODO: The copy and application of size shouldn't be required, but it is currently. // See discussion in https://github.com/TextureGroup/Texture/pull/396 ASTextContainer *containerCopy = [_textContainer copy]; @@ -1109,7 +1428,7 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event ASDisplayNodeAssertMainThread(); [super touchesEnded:touches withEvent:event]; - ASLockScopeSelf(); // Protect usage of _highlight* ivars. + MutexLocker l(__instanceLock__); // Protect usage of _highlight* ivars. id delegate = self.delegate; if ([self _pendingLinkTap] && [delegate respondsToSelector:@selector(textNode:tappedLinkAttribute:value:atPoint:textRange:)]) { CGPoint point = [[touches anyObject] locationInView:self.view]; @@ -1130,7 +1449,7 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event ASDisplayNodeAssertMainThread(); [super touchesMoved:touches withEvent:event]; - ASLockScopeSelf(); // Protect usage of _highlight* ivars. + MutexLocker l(__instanceLock__); // Protect usage of _highlight* ivars. UITouch *touch = [touches anyObject]; CGPoint locationInView = [touch locationInView:self.view]; // on 3D Touch enabled phones, this gets fired with changes in force, and usually will get fired immediately after touchesBegan:withEvent: @@ -1160,7 +1479,7 @@ - (void)_handleLongPress:(UILongPressGestureRecognizer *)longPressRecognizer if (longPressRecognizer.state == UIGestureRecognizerStateBegan) { id delegate = self.delegate; if ([delegate respondsToSelector:@selector(textNode:longPressedLinkAttribute:value:atPoint:textRange:)]) { - ASLockScopeSelf(); // Protect usage of _highlight* ivars. + MutexLocker l(__instanceLock__); // Protect usage of _highlight* ivars. CGPoint touchPoint = [_longPressGestureRecognizer locationInView:self.view]; [delegate textNode:(ASTextNode *)self longPressedLinkAttribute:_highlightedLinkAttributeName value:_highlightedLinkAttributeValue atPoint:touchPoint textRange:_highlightRange]; } @@ -1169,7 +1488,7 @@ - (void)_handleLongPress:(UILongPressGestureRecognizer *)longPressRecognizer - (BOOL)_pendingLinkTap { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); return (_highlightedLinkAttributeValue != nil && ![self _pendingTruncationTap]) && self.delegate != nil; } @@ -1179,18 +1498,26 @@ - (BOOL)_pendingTruncationTap return [ASLockedSelf(_highlightedLinkAttributeName) isEqualToString:ASTextNodeTruncationTokenAttributeName]; } -- (BOOL)alwaysHandleTruncationTokenTap -{ - ASLockScopeSelf(); - return _alwaysHandleTruncationTokenTap; +- (BOOL)passthroughNonlinkTouches { + MutexLocker l(__instanceLock__); + return _passthroughNonlinkTouches; } -- (void)setAlwaysHandleTruncationTokenTap:(BOOL)alwaysHandleTruncationTokenTap -{ - ASLockScopeSelf(); - _alwaysHandleTruncationTokenTap = alwaysHandleTruncationTokenTap; +- (void)setPassthroughNonlinkTouches:(BOOL)passthroughNonlinkTouches { + MutexLocker l(__instanceLock__); + _passthroughNonlinkTouches = passthroughNonlinkTouches; } - + +- (BOOL)additionalTruncationMessageIsInteractive { + MutexLocker l(__instanceLock__); + return _additionalTruncationMessageIsInteractive; +} + +- (void)setAdditionalTruncationMessageIsInteractive:(BOOL)additionalTruncationMessageIsInteractive { + MutexLocker l(__instanceLock__); + _additionalTruncationMessageIsInteractive = additionalTruncationMessageIsInteractive; +} + #pragma mark - Shadow Properties /** @@ -1206,7 +1533,7 @@ - (CGColorRef)shadowColor - (void)setShadowColor:(CGColorRef)shadowColor { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (_shadowColor != shadowColor && CGColorEqualToColor(shadowColor, _shadowColor) == NO) { CGColorRelease(_shadowColor); _shadowColor = CGColorRetain(shadowColor); @@ -1221,7 +1548,7 @@ - (CGSize)shadowOffset - (void)setShadowOffset:(CGSize)shadowOffset { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCustom(_shadowOffset, shadowOffset, CGSizeEqualToSize)) { [self setNeedsDisplay]; } @@ -1234,7 +1561,7 @@ - (CGFloat)shadowOpacity - (void)setShadowOpacity:(CGFloat)shadowOpacity { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssign(_shadowOpacity, shadowOpacity)) { [self setNeedsDisplay]; } @@ -1247,7 +1574,7 @@ - (CGFloat)shadowRadius - (void)setShadowRadius:(CGFloat)shadowRadius { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssign(_shadowRadius, shadowRadius)) { [self setNeedsDisplay]; } @@ -1262,7 +1589,7 @@ - (UIEdgeInsets)shadowPadding - (void)setPointSizeScaleFactors:(NSArray *)scaleFactors { AS_TEXT_ALERT_UNIMPLEMENTED_FEATURE(); - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCopy(_pointSizeScaleFactors, scaleFactors)) { [self setNeedsLayout]; } @@ -1275,19 +1602,9 @@ - (void)setPointSizeScaleFactors:(NSArray *)scaleFactors #pragma mark - Truncation Message -static NSAttributedString *DefaultTruncationAttributedString() -{ - static NSAttributedString *defaultTruncationAttributedString; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - defaultTruncationAttributedString = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"\u2026", @"Default truncation string")]; - }); - return defaultTruncationAttributedString; -} - - (void)_ensureTruncationText { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (_textContainer.truncationToken == nil) { _textContainer.truncationToken = [self _locked_composedTruncationText]; } @@ -1300,7 +1617,7 @@ - (NSAttributedString *)truncationAttributedText - (void)setTruncationAttributedText:(NSAttributedString *)truncationAttributedText { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCopy(_truncationAttributedText, truncationAttributedText)) { [self _invalidateTruncationText]; } @@ -1313,7 +1630,7 @@ - (NSAttributedString *)additionalTruncationMessage - (void)setAdditionalTruncationMessage:(NSAttributedString *)additionalTruncationMessage { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssignCopy(_additionalTruncationMessage, additionalTruncationMessage)) { [self _invalidateTruncationText]; } @@ -1326,7 +1643,7 @@ - (NSLineBreakMode)truncationMode - (void)setTruncationMode:(NSLineBreakMode)truncationMode { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssign(_truncationMode, truncationMode)) { ASTextTruncationType truncationType; switch (truncationMode) { @@ -1351,19 +1668,31 @@ - (void)setTruncationMode:(NSLineBreakMode)truncationMode - (BOOL)isTruncated { - return ASLockedSelf([self locked_textLayoutForSize:[self _locked_threadSafeBounds].size].truncatedLine != nil); + AS::MutexLocker l(__instanceLock__); + ASTextLayout *layout = [self locked_textLayoutForSize:[self _locked_threadSafeBounds].size]; + return !NSEqualRanges(layout.visibleRange, layout.range); } - (BOOL)shouldTruncateForConstrainedSize:(ASSizeRange)constrainedSize { - return ASLockedSelf([self locked_textLayoutForSize:constrainedSize.max].truncatedLine != nil); + AS::MutexLocker l(__instanceLock__); + ASTextLayout *layout = [self locked_textLayoutForSize:constrainedSize.max]; + return !NSEqualRanges(layout.visibleRange, layout.range); } - (ASTextLayout *)locked_textLayoutForSize:(CGSize)size { - ASTextContainer *container = [_textContainer copy]; - container.size = size; - return ASTextNodeCompatibleLayoutWithContainerAndText(container, _attributedText); + ASTextContainer *container; + if (!CGSizeEqualToSize(_textContainer.size, size)) { + container = [_textContainer copy]; + container.size = size; + [container makeImmutable]; + } else { + container = _textContainer; + } + NSMutableAttributedString *mutableText = [_attributedText mutableCopy]; + [self prepareAttributedString:mutableText isForIntrinsicSize:NO]; + return ASTextNodeCompatibleLayoutWithContainerAndText(container, mutableText); } - (NSUInteger)maximumNumberOfLines @@ -1374,7 +1703,7 @@ - (NSUInteger)maximumNumberOfLines - (void)setMaximumNumberOfLines:(NSUInteger)maximumNumberOfLines { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); if (ASCompareAssign(_textContainer.maximumNumberOfRows, maximumNumberOfLines)) { [self setNeedsDisplay]; } @@ -1382,16 +1711,15 @@ - (void)setMaximumNumberOfLines:(NSUInteger)maximumNumberOfLines - (NSUInteger)lineCount { - ASLockScopeSelf(); - AS_TEXT_ALERT_UNIMPLEMENTED_FEATURE(); - return 0; + MutexLocker l(__instanceLock__); + return ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, _attributedText).rowCount; } #pragma mark - Truncation Message - (void)_invalidateTruncationText { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); [self _locked_invalidateTruncationText]; [self setNeedsDisplay]; } @@ -1407,7 +1735,7 @@ - (void)_locked_invalidateTruncationText */ - (NSRange)_additionalTruncationMessageRangeWithVisibleRange:(NSRange)visibleRange { - ASLockScopeSelf(); + MutexLocker l(__instanceLock__); // Check if we even have an additional truncation message. if (!_additionalTruncationMessage) { @@ -1441,36 +1769,8 @@ - (NSAttributedString *)_locked_composedTruncationText composedTruncationText = _truncationAttributedText; } else if (_additionalTruncationMessage != nil) { composedTruncationText = _additionalTruncationMessage; - } else { - composedTruncationText = DefaultTruncationAttributedString(); } - return [self _locked_prepareTruncationStringForDrawing:composedTruncationText]; -} - -/** - * - cleanses it of core text attributes so TextKit doesn't crash - * - Adds whole-string attributes so the truncation message matches the styling - * of the body text - */ -- (NSAttributedString *)_locked_prepareTruncationStringForDrawing:(NSAttributedString *)truncationString -{ - DISABLED_ASAssertLocked(__instanceLock__); - NSMutableAttributedString *truncationMutableString = [truncationString mutableCopy]; - // Grab the attributes from the full string - if (_attributedText.length > 0) { - NSAttributedString *originalString = _attributedText; - NSInteger originalStringLength = _attributedText.length; - // Add any of the original string's attributes to the truncation string, - // but don't overwrite any of the truncation string's attributes - NSDictionary *originalStringAttributes = [originalString attributesAtIndex:originalStringLength-1 effectiveRange:NULL]; - [truncationString enumerateAttributesInRange:NSMakeRange(0, truncationString.length) options:0 usingBlock: - ^(NSDictionary *attributes, NSRange range, BOOL *stop) { - NSMutableDictionary *futureTruncationAttributes = [originalStringAttributes mutableCopy]; - [futureTruncationAttributes addEntriesFromDictionary:attributes]; - [truncationMutableString setAttributes:futureTruncationAttributes range:range]; - }]; - } - return truncationMutableString; + return composedTruncationText; } #if AS_TEXTNODE2_RECORD_ATTRIBUTED_STRINGS @@ -1509,4 +1809,26 @@ - (BOOL)usingExperiment return YES; } +- (void)interfaceStateDidChange:(ASInterfaceState)newState fromState:(ASInterfaceState)oldState { + UpdateTextAttachmentForText(self.attributedText, ^(ASImageNode *imageNode) { + [imageNode exitInterfaceState:oldState]; + [imageNode enterInterfaceState:newState]; + }); + [super interfaceStateDidChange:newState fromState:oldState]; +} + @end + +void UpdateTextAttachmentForText(NSAttributedString *attributedString, + TextAttachmentUpdateBlock updateBlock) { + [attributedString + enumerateAttribute:ASTextAttachmentAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(ASTextAttachment *asTextAttachment, NSRange range, BOOL *_Nonnull stop) { + if (ASImageNode *node = ASDynamicCast(asTextAttachment.content, ASImageNode)) { + updateBlock(node); + } + }]; +} + diff --git a/Source/Details/_ASDisplayView.mm b/Source/Details/_ASDisplayView.mm index 7741e4447..6a8237f76 100644 --- a/Source/Details/_ASDisplayView.mm +++ b/Source/Details/_ASDisplayView.mm @@ -41,6 +41,7 @@ @implementation _ASDisplayView NSArray *_accessibilityElements; CGRect _lastAccessibilityElementsFrame; + BOOL _inIsAccessibilityElement; } #pragma mark - Class diff --git a/Source/Details/_ASDisplayViewAccessiblity.h b/Source/Details/_ASDisplayViewAccessiblity.h index 7f159d1d0..1b1e3155b 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.h +++ b/Source/Details/_ASDisplayViewAccessiblity.h @@ -7,7 +7,10 @@ // Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 // -#import +#import + +NS_ASSUME_NONNULL_BEGIN + // WARNING: When dealing with accessibility elements, please use the `accessibilityElements` // property instead of the older methods e.g. `accessibilityElementCount()`. While the older methods @@ -15,6 +18,51 @@ // their correctness. For details, see // https://developer.apple.com/documentation/objectivec/nsobject/1615147-accessibilityelements +@class ASDisplayNode; +@class ASAccessibilityElement; + +/** + * The methods adopted by the object to provide frame information for a given + * ASAccessibilityElement + */ +@protocol ASAccessibilityElementFrameProviding + +/** + * Returns the accessibilityFrame for the given ASAccessibilityElement + */ +- (CGRect)accessibilityFrameForAccessibilityElement:(ASAccessibilityElement *)accessibilityElement; + +@end + +/** + * Encapsulates Texture related information about an item that should be + * accessible to users with disabilities, but that isn’t accessible by default. + */ +@interface ASAccessibilityElement : UIAccessibilityElement + +@property (nonatomic) ASDisplayNode *node; +@property (nonatomic) NSRange accessibilityRange; + +/** + * If a frameProvider is set on the ASAccessibilityElement it will be asked to + * return the frame for the corresponding UIAccessibilityElement within + * accessibilityElement. + * + * @note: If a frameProvider is set any accessibilityFrame set on the + * UIAccessibilityElement explicitly will be ignored + */ +@property (nonatomic) id frameProvider; + +@end + +@interface ASAccessibilityCustomAction : UIAccessibilityCustomAction + +@property (nonatomic, readonly) ASDisplayNode *node; +@property (nonatomic, nullable, readonly) id value; +@property (nonatomic, readonly) NSRange textRange; + +@end + // After recusively collecting all of the accessibility elements of a node, they get sorted. This sort determines // the order that a screen reader will traverse the elements. By default, we sort these elements based on their // origin: lower y origin comes first, then lower x origin. If 2 nodes have an equal origin, the node with the smaller @@ -28,3 +76,5 @@ typedef NSComparisonResult (^ASSortAccessibilityElementsComparator)(NSObject *, // Use this method to supply your own custom sort comparator used to determine the order of the accessibility elements void setUserDefinedAccessibilitySortComparator(ASSortAccessibilityElementsComparator userDefinedComparator); + +NS_ASSUME_NONNULL_END diff --git a/Source/Details/_ASDisplayViewAccessiblity.mm b/Source/Details/_ASDisplayViewAccessiblity.mm index 8f3e12991..fad6712dc 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.mm +++ b/Source/Details/_ASDisplayViewAccessiblity.mm @@ -9,19 +9,76 @@ #ifndef ASDK_ACCESSIBILITY_DISABLE -#import #import +#import #import #import #import #import #import #import +#import +#import #import +/// Returns if the passed in node is considered a leaf node +NS_INLINE BOOL ASIsLeafNode(__unsafe_unretained ASDisplayNode *node) { + return node.subnodes.count == 0; +} + +/// Returns an NSString trimmed of whitespaces and newlines at the beginning the end. +static NSString *ASTrimmedAccessibilityLabel(NSString *accessibilityLabel) { + return [accessibilityLabel + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +/// Returns a NSAttributedString trimmed of whitespaces and newlines at the beginning and the end. +static NSAttributedString *ASTrimmedAttributedAccessibilityLabel( + NSAttributedString *attributedString) { + // Create a cached inverted character set from whitespaceAndNewlineCharacterSet + // [NSCharacterSet whitespaceAndNewlineCharacterSet] is cached, but the invertedSet is not. + static NSCharacterSet *invertedWhiteSpaceAndNewLineCharacterSet; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + invertedWhiteSpaceAndNewLineCharacterSet = + [NSCharacterSet whitespaceAndNewlineCharacterSet].invertedSet; + }); + NSString *string = attributedString.string; + + NSRange range = [string rangeOfCharacterFromSet:invertedWhiteSpaceAndNewLineCharacterSet]; + NSUInteger location = (range.length > 0) ? range.location : 0; + + range = [string rangeOfCharacterFromSet:invertedWhiteSpaceAndNewLineCharacterSet + options:NSBackwardsSearch]; + NSUInteger length = (range.length > 0) ? NSMaxRange(range) - location : string.length - location; + + if (location == 0 && length == string.length) { + return attributedString; + } + + return [attributedString attributedSubstringFromRange:NSMakeRange(location, length)]; +} + +/// Returns NO when implicit custom action synthesis should not be enabled for the node. Returns YES +/// when implicit custom action synthesis is OK for the node, assuming it contains an non-empty +/// accessibility label. +static BOOL ASMayImplicitlySynthesizeAccessibilityCustomAction(ASDisplayNode *node, + ASDisplayNode *rootContainerNode) { + if (node == rootContainerNode) { + return NO; + } + return node.accessibilityTraits & ASInteractiveAccessibilityTraitsMask(); +} + #pragma mark - UIAccessibilityElement +@protocol ASAccessibilityElementPositioning + +@property (nonatomic, readonly) CGRect accessibilityFrame; + +@end + static ASSortAccessibilityElementsComparator currentAccessibilityComparator = nil; static ASSortAccessibilityElementsComparator defaultAccessibilityComparator = nil; @@ -70,14 +127,56 @@ static CGRect ASAccessibilityFrameForNode(ASDisplayNode *node) { return [layer convertRect:node.bounds toLayer:ASFindWindowOfLayer(layer).layer]; } -@interface ASAccessibilityElement : UIAccessibilityElement +@interface _ASDisplayViewAccessibilityFrameProvider : NSObject +@end -@property (nonatomic) ASDisplayNode *node; +@implementation _ASDisplayViewAccessibilityFrameProvider + +- (CGRect)accessibilityFrameForAccessibilityElement:(ASAccessibilityElement *)accessibilityElement { + return ASAccessibilityFrameForNode(accessibilityElement.node); +} + +@end + +@interface ASAccessibilityElement () + (ASAccessibilityElement *)accessibilityElementWithContainer:(UIView *)container node:(ASDisplayNode *)node; @end +// Returns the default _ASDisplayViewAccessibilityFrameProvider to be used as frame provider +// of accessibility elements within ASDisplayViewAccessibility. +static _ASDisplayViewAccessibilityFrameProvider *_ASDisplayViewAccessibilityFrameProviderDefault() { + static _ASDisplayViewAccessibilityFrameProvider *frameProvider = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + frameProvider = [[_ASDisplayViewAccessibilityFrameProvider alloc] init]; + }); + return frameProvider; +} + +// Create an ASAccessibilityElement for a given UIView and ASDisplayNode for usage +// within _ASDisplayViewAccessibility +static ASAccessibilityElement *_ASDisplayViewAccessibilityCreateASAccessibilityElement( + UIView *containerView, ASDisplayNode *node) { + ASAccessibilityElement *accessibilityElement = + [[ASAccessibilityElement alloc] initWithAccessibilityContainer:containerView]; + accessibilityElement.accessibilityIdentifier = node.accessibilityIdentifier; + accessibilityElement.accessibilityLabel = node.accessibilityLabel; + accessibilityElement.accessibilityHint = node.accessibilityHint; + accessibilityElement.accessibilityValue = node.accessibilityValue; + accessibilityElement.accessibilityTraits = node.accessibilityTraits; + if (AS_AVAILABLE_IOS_TVOS(11, 11)) { + accessibilityElement.accessibilityAttributedLabel = node.accessibilityAttributedLabel; + accessibilityElement.accessibilityAttributedHint = node.accessibilityAttributedHint; + accessibilityElement.accessibilityAttributedValue = node.accessibilityAttributedValue; + } + accessibilityElement.node = node; + accessibilityElement.frameProvider = _ASDisplayViewAccessibilityFrameProviderDefault(); + + return accessibilityElement; +} + @implementation ASAccessibilityElement + (ASAccessibilityElement *)accessibilityElementWithContainer:(UIView *)container node:(ASDisplayNode *)node @@ -100,19 +199,34 @@ + (ASAccessibilityElement *)accessibilityElementWithContainer:(UIView *)containe - (CGRect)accessibilityFrame { - return ASAccessibilityFrameForNode(self.node); + if (_frameProvider) { + return [_frameProvider accessibilityFrameForAccessibilityElement:self]; + } + + return [super accessibilityFrame]; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"<%@ %p, %@, %@>", NSStringFromClass([self class]), self, + self.accessibilityLabel, + NSStringFromCGRect(self.accessibilityFrame)]; } @end #pragma mark - _ASDisplayView / UIAccessibilityContainer -@interface ASAccessibilityCustomAction : UIAccessibilityCustomAction +@interface ASAccessibilityCustomAction () @property (nonatomic) ASDisplayNode *node; +@property (nonatomic, nullable) id value; +@property (nonatomic) NSRange textRange; @end +@interface ASAccessibilityCustomAction() +@end + @implementation ASAccessibilityCustomAction - (CGRect)accessibilityFrame @@ -122,141 +236,221 @@ - (CGRect)accessibilityFrame @end -/// Collect all subnodes for the given node by walking down the subnode tree and calculates the screen coordinates based on the containerNode and container -static void CollectUIAccessibilityElementsForNode(ASDisplayNode *node, ASDisplayNode *containerNode, id container, NSMutableArray *elements) +#pragma mark - Collecting Accessibility with ASTextNode Links Handling + +/// Collect all subnodes for the given node by walking down the subnode tree and calculates the +/// screen coordinates based on the containerNode and container. This is necessary for layer backed +/// nodes or rasterrized subtrees as no UIView instance for this node exists. +static void CollectAccessibilityElementsForLayerBackedOrRasterizedNode(ASDisplayNode *node, ASDisplayNode *containerNode, id container, NSMutableArray *elements) { ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray"); - + + // Iterate any node in the tree and either collect nodes that are accessibility elements + // or leaf nodes that are accessibility containers ASDisplayNodePerformBlockOnEveryNodeBFS(node, ^(ASDisplayNode * _Nonnull currentNode) { - // For every subnode that is layer backed or it's supernode has subtree rasterization enabled - // we have to create a UIAccessibilityElement as no view for this node exists - if (currentNode != containerNode && currentNode.isAccessibilityElement) { - UIAccessibilityElement *accessibilityElement = [ASAccessibilityElement accessibilityElementWithContainer:container node:currentNode]; - [elements addObject:accessibilityElement]; + if (currentNode != containerNode) { + if (currentNode.isAccessibilityElement) { + // For every subnode that is an accessibility element and is layer backed + // or an ancestor has subtree rasterization enabled, create a + // UIAccessibilityElement as no view for this node exists + UIAccessibilityElement *accessibilityElement = + _ASDisplayViewAccessibilityCreateASAccessibilityElement(container, currentNode); + [elements addObject:accessibilityElement]; + } else if (ASIsLeafNode(currentNode) && currentNode.accessibilityElementCount > 0) { + // In leaf nodes that are layer backed and acting as UIAccessibilityContainer + // (isAccessibilityElement == NO we call through to the + // accessibilityElements to collect all accessibility elements of this node + [elements addObjectsFromArray:currentNode.accessibilityElements]; + } } }); } -static void CollectAccessibilityElementsForContainer(ASDisplayNode *container, UIView *view, - NSMutableArray *elements) { - ASDisplayNodeCAssertNotNil(view, @"Passed in view should not be nil"); - if (view == nil) { +/// Called from CollectAccessibilityElements for nodes that are returning YES for +/// isAccessibilityContainer to collect all subnodes accessibility labels as well as custom actions +/// for nodes that have interactive accessibility traits enabled. Furthermore for ASTextNode's it +/// also aggregates all links within the attributedString as custom action +static void AggregateSubtreeAccessibilityLabelsAndCustomActions(ASDisplayNode *rootContainer, + ASDisplayNode *containerNode, + UIView *containerView, + NSMutableArray *elements) { + ASDisplayNodeCAssertNotNil(containerView, @"Passed in view should not be nil"); + if (containerView == nil) { return; } UIAccessibilityElement *accessiblityElement = - [ASAccessibilityElement accessibilityElementWithContainer:view - node:container]; - + _ASDisplayViewAccessibilityCreateASAccessibilityElement(containerView, containerNode); + NSMutableArray *labeledNodes = [[NSMutableArray alloc] init]; NSMutableArray *actions = [[NSMutableArray alloc] init]; std::queue queue; - queue.push(container); - + queue.push(containerNode); + // If the container does not have an accessibility label set, or if the label is meant for custom - // actions only, then aggregate its subnodes' labels. Otherwise, treat the label as an overriden + // actions only, then aggregate its subnodes' labels. Otherwise, treat the label as an overridden // value and do not perform the aggregation. BOOL shouldAggregateSubnodeLabels = - (container.accessibilityLabel.length == 0) || - (container.accessibilityTraits & ASInteractiveAccessibilityTraitsMask()); - + (ASTrimmedAccessibilityLabel(containerNode.accessibilityLabel).length == 0) || + ASMayImplicitlySynthesizeAccessibilityCustomAction(containerNode, rootContainer); + + // Iterate through the whole subnode tree and aggregate ASDisplayNode *node = nil; while (!queue.empty()) { node = queue.front(); queue.pop(); - - if (node != container && node.isAccessibilityContainer) { - UIView *containerView = node.isLayerBacked ? view : node.view; - CollectAccessibilityElementsForContainer(node, containerView, elements); + + // If the node is an accessibility container go further down for collecting all the nodes information. + if (node != containerNode && node.isAccessibilityContainer) { + UIView *view = containerNode.isLayerBacked ? containerView : containerNode.view; + AggregateSubtreeAccessibilityLabelsAndCustomActions(node, node, view, elements); continue; } - - if (node.accessibilityLabel.length > 0) { - if (node.accessibilityTraits & ASInteractiveAccessibilityTraitsMask()) { - ASAccessibilityCustomAction *action = [[ASAccessibilityCustomAction alloc] initWithName:node.accessibilityLabel target:node selector:@selector(performAccessibilityCustomAction:)]; + + + // Aggregate either custom actions for specific accessibility traits or the accessibility labels + // of the node. + NSString *trimmedNodeAccessibilityLabel = ASTrimmedAccessibilityLabel(node.accessibilityLabel); + if (trimmedNodeAccessibilityLabel.length > 0) { + if (ASMayImplicitlySynthesizeAccessibilityCustomAction(node, rootContainer)) { + ASAccessibilityCustomAction *action = [[ASAccessibilityCustomAction alloc] + initWithName:trimmedNodeAccessibilityLabel + target:node + selector:@selector(performAccessibilityCustomAction:)]; action.node = node; [actions addObject:action]; - + + // Connect the node with the custom action which representing it. node.accessibilityCustomAction = action; - } else if (node == container || shouldAggregateSubnodeLabels) { - ASAccessibilityElement *nonInteractiveElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:node]; + } else if (node == containerNode || shouldAggregateSubnodeLabels) { + ASAccessibilityElement *nonInteractiveElement = + _ASDisplayViewAccessibilityCreateASAccessibilityElement(containerView, node); [labeledNodes addObject:nonInteractiveElement]; + + // For ASTextNode accessibility container besides aggregating all of the of the subnodes + // we are also collecting all of the link as custom actions. + NSAttributedString *attributedText = nil; + if ([node respondsToSelector:@selector(attributedText)]) { + attributedText = ((ASTextNode *)node).attributedText; + } + NSArray *linkAttributeNames = nil; + if ([node respondsToSelector:@selector(linkAttributeNames)]) { + linkAttributeNames = ((ASTextNode *)node).linkAttributeNames; + } + linkAttributeNames = linkAttributeNames ?: @[]; + + for (NSString *linkAttributeName in linkAttributeNames) { + [attributedText enumerateAttribute:linkAttributeName inRange:NSMakeRange(0, attributedText.length) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) { + if (value == nil) { + return; + } + ASAccessibilityCustomAction *action = [[ASAccessibilityCustomAction alloc] initWithName:[attributedText.string substringWithRange:range] target:node selector:@selector(performAccessibilityCustomActionLink:)]; + action.accessibilityTraits = UIAccessibilityTraitLink; + action.node = node; + action.value = value; + action.textRange = range; + [actions addObject:action]; + }]; + } } } - + for (ASDisplayNode *subnode in node.subnodes) { queue.push(subnode); } } - + SortAccessibilityElements(labeledNodes); - + if (AS_AVAILABLE_IOS_TVOS(11, 11)) { - NSArray *attributedLabels = [labeledNodes valueForKey:@"accessibilityAttributedLabel"]; - NSMutableAttributedString *attributedLabel = [NSMutableAttributedString new]; - [attributedLabels enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (idx != 0) { - [attributedLabel appendAttributedString:[[NSAttributedString alloc] initWithString:@", "]]; + NSAttributedString *attributedAccessbilityLabelsDivider = + [[NSAttributedString alloc] initWithString:@", "]; + NSMutableAttributedString *attributedAccessibilityLabel = + [[NSMutableAttributedString alloc] init]; + [labeledNodes enumerateObjectsUsingBlock:^(ASAccessibilityElement *_Nonnull element, + NSUInteger idx, BOOL *_Nonnull stop) { + NSAttributedString *trimmedAttributedLabel = + ASTrimmedAttributedAccessibilityLabel(element.accessibilityAttributedLabel); + if (trimmedAttributedLabel.length == 0) { + return; + } + if (idx != 0 && attributedAccessibilityLabel.length != 0) { + [attributedAccessibilityLabel appendAttributedString:attributedAccessbilityLabelsDivider]; } - [attributedLabel appendAttributedString:(NSAttributedString *)obj]; + [attributedAccessibilityLabel appendAttributedString:trimmedAttributedLabel]; }]; - accessiblityElement.accessibilityAttributedLabel = attributedLabel; + accessiblityElement.accessibilityAttributedLabel = attributedAccessibilityLabel; } else { - NSArray *labels = [labeledNodes valueForKey:@"accessibilityLabel"]; - accessiblityElement.accessibilityLabel = [labels componentsJoinedByString:@", "]; + NSMutableString *accessibilityLabel = [[NSMutableString alloc] init]; + [labeledNodes enumerateObjectsUsingBlock:^(ASAccessibilityElement *_Nonnull element, + NSUInteger idx, BOOL *_Nonnull stop) { + NSString *trimmedAccessibilityLabel = ASTrimmedAccessibilityLabel(element.accessibilityLabel); + if (trimmedAccessibilityLabel.length == 0) { + return; + } + if (idx != 0 && accessibilityLabel.length != 0) { + [accessibilityLabel appendString:@", "]; + } + [accessibilityLabel appendString:trimmedAccessibilityLabel]; + }]; + accessiblityElement.accessibilityLabel = accessibilityLabel; } - + SortAccessibilityElements(actions); accessiblityElement.accessibilityCustomActions = actions; - + [elements addObject:accessiblityElement]; } /// Check if a view is a subviews of an UIScrollView. This is used to determine whether to enforce that /// accessibility elements must be on screen static BOOL recusivelyCheckSuperviewsForScrollView(UIView *view) { - if (!view) { - return NO; - } else if ([view isKindOfClass:[UIScrollView class]]) { - return YES; - } - return recusivelyCheckSuperviewsForScrollView(view.superview); + if (!view) { + return NO; + } else if ([view isKindOfClass:[UIScrollView class]]) { + return YES; + } + return recusivelyCheckSuperviewsForScrollView(view.superview); } /// returns YES if this node should be considered "hidden" from the screen reader. static BOOL nodeIsHiddenFromAcessibility(ASDisplayNode *node) { - return node.isHidden || node.alpha == 0.0 || node.accessibilityElementsHidden; + if (ASActivateExperimentalFeature(ASExperimentalEnableNodeIsHiddenFromAcessibility)) { + return node.isHidden || node.alpha == 0.0 || node.accessibilityElementsHidden; + } + return NO; } /// Collect all accessibliity elements for a given view and view node -static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *elements) +static void CollectAccessibilityElementsWithTextNodeLinkHandling(ASDisplayNode *node, NSMutableArray *elements) { ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray"); ASDisplayNodeCAssertFalse(node.isLayerBacked); if (node.isLayerBacked) { return; } - + BOOL anySubNodeIsCollection = (nil != ASDisplayNodeFindFirstNode(node, - ^BOOL(ASDisplayNode *nodeToCheck) { + ^BOOL(ASDisplayNode *nodeToCheck) { return ASDynamicCast(nodeToCheck, ASCollectionNode) != nil || - ASDynamicCast(nodeToCheck, ASTableNode) != nil; + ASDynamicCast(nodeToCheck, ASTableNode) != nil; })); - + UIView *view = node.view; // If we don't have a window, let's just bail out if (!view.window) { return; } - + + // Handle an accessibility container (collects accessibility labels or custom actions) if (node.isAccessibilityContainer && !anySubNodeIsCollection) { - CollectAccessibilityElementsForContainer(node, view, elements); + AggregateSubtreeAccessibilityLabelsAndCustomActions(node, node, node.view, elements); return; } - - // Handle rasterize case + + // Handle a node which tree is rasterized to collect all accessibility elements if (node.rasterizesSubtree) { - CollectUIAccessibilityElementsForNode(node, node, view, elements); + CollectAccessibilityElementsForLayerBackedOrRasterizedNode(node, node, node.view, elements); return; } @@ -287,82 +481,193 @@ static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *el // If a subnode is outside of the view's window, exclude it UNLESS it is a subview of an UIScrollView. // In this case UIKit will return the element even if it is outside of the window or the scrollView's visible rect (contentOffset + contentSize) CGRect nodeInWindowCoords = [node convertRect:subnode.frame toNode:nil]; - if (!CGRectIntersectsRect(view.window.frame, nodeInWindowCoords) && !recusivelyCheckSuperviewsForScrollView(view)) { + if (!CGRectIntersectsRect(view.window.frame, nodeInWindowCoords) && !recusivelyCheckSuperviewsForScrollView(view) && ASActivateExperimentalFeature(ASExperimentalEnableAcessibilityElementsReturnNil)) { continue; } if (subnode.isAccessibilityElement) { // An accessiblityElement can either be a UIView or a UIAccessibilityElement if (subnode.isLayerBacked) { - // No view for layer backed nodes exist. It's necessary to create a UIAccessibilityElement that represents this node - UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:subnode]; + // No view for layer backed nodes exist. It's necessary to create a UIAccessibilityElement + // that represents this node + UIAccessibilityElement *accessiblityElement = + _ASDisplayViewAccessibilityCreateASAccessibilityElement(node.view, subnode); [elements addObject:accessiblityElement]; } else { - // Accessiblity element is not layer backed just add the view as accessibility element + // Accessiblity element is not layer backed, add the view to the elements as _ASDisplayView + // is itself a UIAccessibilityContainer [elements addObject:subnode.view]; } } else if (subnode.isLayerBacked) { - // Go down the hierarchy of the layer backed subnode and collect all of the UIAccessibilityElement - CollectUIAccessibilityElementsForNode(subnode, node, view, elements); + // Go down the hierarchy for layer backed subnodes which are also UIAccessibilityContainer's + // and collect all of the UIAccessibilityElement + CollectAccessibilityElementsForLayerBackedOrRasterizedNode(subnode, node, node.view, elements); } else if (subnode.accessibilityElementCount > 0) { - // UIView is itself a UIAccessibilityContainer just add it + // _ASDisplayView is itself a UIAccessibilityContainer just add it, UIKit will call the + // accessiblity methods of the nodes _ASDisplayView [elements addObject:subnode.view]; } } } +#pragma mark - _ASDisplayView + +@interface _ASDisplayView () { + NSArray *_accessibilityElements; + BOOL _inIsAccessibilityElement; +} + +@end + @implementation _ASDisplayView (UIAccessibilityContainer) -#pragma mark - UIAccessibility +#pragma mark UIAccessibility + +- (BOOL)isAccessibilityElement +{ + ASDisplayNodeAssertMainThread(); + if (_inIsAccessibilityElement) { + return [super isAccessibilityElement]; + } + _inIsAccessibilityElement = YES; + BOOL isAccessibilityElement = [self.asyncdisplaykit_node isAccessibilityElement]; + _inIsAccessibilityElement = NO; + return isAccessibilityElement; +} - (void)setAccessibilityElements:(nullable NSArray *)accessibilityElements { - // this is a no-op. You should not be setting accessibilityElements directly on _ASDisplayView. - // if you wish to set accessibilityElements, do so in your node. UIKit will call _ASDisplayView's - // accessibilityElements which will in turn ask its node for its elements. + ASDisplayNodeAssertMainThread(); + _accessibilityElements = accessibilityElements; } - (nullable NSArray *)accessibilityElements { ASDisplayNodeAssertMainThread(); - + ASDisplayNode *viewNode = self.asyncdisplaykit_node; if (viewNode == nil) { return @[]; } - + // we no longer cache accessibilityElements. When caching, in order to provide correct element when items become hidden/visible // we had to manually clear _accessibilityElements. This seemed like a heavy burden to place on a user, and one that is also // not immediately obvious. While recomputing accessibilityElements may be expensive, this will only affect users that have // voice over enabled (we checked to ensure performance did not suffer by not caching for an overall user base). For those // users with voice over on, being correct is almost certainly more important than being performant. - return [viewNode accessibilityElements]; + if (_accessibilityElements == nil || ASActivateExperimentalFeature(ASExperimentalDoNotCacheAccessibilityElements)) { + _accessibilityElements = [viewNode accessibilityElements]; + } + return _accessibilityElements; +} + +@end + +@implementation ASDisplayNode (CustomAccessibilityBehavior) + +- (void)setAccessibilityElementsBlock:(ASDisplayNodeAccessibilityElementsBlock)block { + AS::MutexLocker l(__instanceLock__); + _accessibilityElementsBlock = block; } @end @implementation ASDisplayNode (AccessibilityInternal) -- (nullable NSArray *)accessibilityElements +- (BOOL)isAccessibilityElement +{ + if (!self.isNodeLoaded) { + ASDisplayNodeFailAssert(@"Cannot access isAccessibilityElement since node is not loaded"); + return [super isAccessibilityElement]; + } + + return [_view isAccessibilityElement]; +} + +- (NSInteger)accessibilityElementCount +{ + if (!self.isNodeLoaded) { + ASDisplayNodeFailAssert(@"Cannot access accessibilityElementCount since node is not loaded"); + return 0; + } + + // Please Note! + // If accessibility is not enabled on a device or the Accessibility Inspector was not started + // once yet on a Mac this method will always return 0! UIKit will dynamically link in + // specific accessibility implementation methods in this cases. + return [_view accessibilityElementCount]; +} + +- (NSArray *)accessibilityElements { - // NSObject implements the informal accessibility protocol. This means that all ASDisplayNodes already have an accessibilityElements - // property. If an ASDisplayNode subclass has explicitly set the property, let's use that instead of traversing the node tree to try - // to create the elements automatically - NSArray *elements = [super accessibilityElements]; - if (elements.count) { - return elements; + if (ASActivateExperimentalFeature(ASExperimentalEnableAcessibilityElementsReturnNil)) { + // NSObject implements the informal accessibility protocol. This means that all ASDisplayNodes already have an accessibilityElements + // property. If an ASDisplayNode subclass has explicitly set the property, let's use that instead of traversing the node tree to try + // to create the elements automatically + NSArray *elements = [super accessibilityElements]; + if (elements.count) { + return elements; + } } if (!self.isNodeLoaded) { ASDisplayNodeFailAssert(@"Cannot access accessibilityElements since node is not loaded"); - return nil; + return ASActivateExperimentalFeature(ASExperimentalEnableAcessibilityElementsReturnNil) ? nil : @[]; + } + if (_accessibilityElementsBlock) { + return _accessibilityElementsBlock(); } + NSMutableArray *accessibilityElements = [[NSMutableArray alloc] init]; - CollectAccessibilityElements(self, accessibilityElements); + CollectAccessibilityElementsWithTextNodeLinkHandling(self, accessibilityElements); + SortAccessibilityElements(accessibilityElements); // If we did not find any accessibility elements, return nil instead of empty array. This allows a WKWebView within the node // to participate in accessibility. - return accessibilityElements.count == 0 ? nil : accessibilityElements; + if (ASActivateExperimentalFeature(ASExperimentalEnableAcessibilityElementsReturnNil)) { + return accessibilityElements.count == 0 ? nil : accessibilityElements; + } else { + return accessibilityElements; + } +} + +- (void)invalidateFirstAccessibilityContainerOrNonLayerBackedNode { + if (!ASAccessibilityIsEnabled()) { + return; + } + ASDisplayNode *firstNonLayerbackedNode = nil; + BOOL containerInvalidated = [self invalidateUpToContainer:&firstNonLayerbackedNode]; + if (!self.isLayerBacked) { + return; + } + if (!containerInvalidated) { + [firstNonLayerbackedNode invalidateAccessibilityElements]; + } +} + +// Walks up the tree and until the first node that returns YES for isAccessibilityContainer is found +// and invalidates it's accessibility elements and YES will be returned. +// In case no node that returns YES for isAccessibilityContainer the first non layer backed node +// will be returned with the firstNonLayerbackedNode pointer and NO will be returned. +- (BOOL)invalidateUpToContainer:(ASDisplayNode **)firstNonLayerbackedNode { + ASDisplayNode *supernode = self.supernode; + if (supernode.isAccessibilityContainer) { + if (supernode.isNodeLoaded) { + [supernode invalidateAccessibilityElements]; + return YES; + } + } + if (*firstNonLayerbackedNode == nil && !self.isLayerBacked) { + *firstNonLayerbackedNode = self; + } + if (!supernode) { + return NO; + } + return [self.supernode invalidateUpToContainer:firstNonLayerbackedNode]; +} + +- (void)invalidateAccessibilityElements { + self.accessibilityElements = nil; } @end diff --git a/Source/Layout/ASYogaUtilities.h b/Source/Layout/ASYogaUtilities.h index d5529fc45..2db89ec0b 100644 --- a/Source/Layout/ASYogaUtilities.h +++ b/Source/Layout/ASYogaUtilities.h @@ -17,6 +17,10 @@ // Should pass a string literal, not an NSString as the first argument to ASYogaLog #define ASYogaLog(x, ...) as_log_verbose(ASLayoutLog(), x, ##__VA_ARGS__); +/** Helper function for Yoga baseline measurement. */ +ASDK_EXTERN CGFloat ASTextGetBaseline(CGFloat height, ASDisplayNode *_Nullable yogaParent, + NSAttributedString *str); + @interface ASDisplayNode (YogaHelpers) + (ASDisplayNode *)yogaNode; diff --git a/Source/Layout/ASYogaUtilities.mm b/Source/Layout/ASYogaUtilities.mm index 68beede1a..40b4e3c85 100644 --- a/Source/Layout/ASYogaUtilities.mm +++ b/Source/Layout/ASYogaUtilities.mm @@ -10,6 +10,17 @@ #import #if YOGA /* YOGA */ +CGFloat ASTextGetBaseline(CGFloat height, ASDisplayNode *yogaParent, NSAttributedString *str) { + if (!yogaParent) return height; + NSUInteger len = str.length; + if (!len) return height; + BOOL isLast = (yogaParent.style.alignItems == ASStackLayoutAlignItemsBaselineLast); + UIFont *font = [str attribute:NSFontAttributeName + atIndex:(isLast ? len - 1 : 0) + effectiveRange:NULL]; + return isLast ? height + font.descender : font.ascender; +} + @implementation ASDisplayNode (YogaHelpers) + (ASDisplayNode *)yogaNode diff --git a/Source/Private/ASDisplayNode+FrameworkPrivate.h b/Source/Private/ASDisplayNode+FrameworkPrivate.h index b7e8b1538..cf525a084 100644 --- a/Source/Private/ASDisplayNode+FrameworkPrivate.h +++ b/Source/Private/ASDisplayNode+FrameworkPrivate.h @@ -320,8 +320,47 @@ NS_INLINE UIAccessibilityTraits ASInteractiveAccessibilityTraitsMask() { return UIAccessibilityTraitLink | UIAccessibilityTraitKeyboardKey | UIAccessibilityTraitButton; } +// dispatch_once variables must live outside of static inline function or else will be copied +// for each separate invocation. We want them shared across all invocations. +static BOOL shouldEnableAccessibilityForTesting; +static dispatch_once_t kShouldEnableAccessibilityForTestingOnceToken; +NS_INLINE BOOL ASAccessibilityIsEnabled() { +#if DEBUG + return true; +#else + if (UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning()) { + return true; + } + // In some ui test environment where DEBUG is not defined. + dispatch_once(&kShouldEnableAccessibilityForTestingOnceToken, ^{ + shouldEnableAccessibilityForTesting = [[[NSProcessInfo processInfo] arguments] + containsObject:@"AS_FORCE_ACCESSIBILITY_FOR_TESTING"]; + }); + return shouldEnableAccessibilityForTesting; +#endif +} + @interface ASDisplayNode (AccessibilityInternal) + +/** + * @discussion An array of the accessibility elements from the node. + */ - (nullable NSArray *)accessibilityElements; + +/** + * @discussion Invalidates the cached accessibility elements for the node + */ +- (void)invalidateAccessibilityElements; + +/** + * @discussion Invalidates the accessibility elements for the first accessibility container or + * the first non layer backed node by walking up the tree starting by self. + * + * @note Call this when a layer backed node changed (added/removed/updated) or + * a view in an accessibility container changed. + */ +- (void)invalidateFirstAccessibilityContainerOrNonLayerBackedNode; + @end; @interface UIView (ASDisplayNodeInternal) diff --git a/Source/Private/ASDisplayNodeInternal.h b/Source/Private/ASDisplayNodeInternal.h index b9f5f40d4..9016d1d73 100644 --- a/Source/Private/ASDisplayNodeInternal.h +++ b/Source/Private/ASDisplayNodeInternal.h @@ -122,6 +122,13 @@ static constexpr CACornerMask kASCACornerAllCorners = unsigned isDeallocating:1; #if YOGA + unsigned yoga:1; + unsigned shouldSuppressYogaCustomMeasure:1; + unsigned yogaIsApplyingLayout:1; + unsigned yogaRequestedNestedLayout:1; + + // NOTE: This has been replaced in the large YouTube merge. I can't remove it + // completely in this PR as there are still many places that use it. unsigned willApplyNextYogaCalculatedLayout:1; #endif // Automatically manages subnodes @@ -253,6 +260,7 @@ static constexpr CACornerMask kASCACornerAllCorners = CGPoint _accessibilityActivationPoint; UIBezierPath *_accessibilityPath; + ASDisplayNodeAccessibilityElementsBlock _accessibilityElementsBlock; // Safe Area support // These properties are used on iOS 10 and lower, where safe area is not supported by UIKit. diff --git a/Source/Private/ASInternalHelpers.h b/Source/Private/ASInternalHelpers.h index ed1c55218..bd4fbd526 100644 --- a/Source/Private/ASInternalHelpers.h +++ b/Source/Private/ASInternalHelpers.h @@ -17,12 +17,22 @@ NS_ASSUME_NONNULL_BEGIN +@interface NSAttributedString (ASTextAttachment) + +- (BOOL)as_hasAttribute:(NSAttributedStringKey)attributeKey; + +@end + ASDK_EXTERN void ASInitializeFrameworkMainThreadOnConstructor(void); ASDK_EXTERN void ASInitializeFrameworkMainThreadOnDestructor(void); ASDK_EXTERN BOOL ASDefaultAllowsGroupOpacity(void); ASDK_EXTERN BOOL ASDefaultAllowsEdgeAntialiasing(void); +/// ASTraitCollection is probably a better place to look on iOS >= 10 +/// This _may not be set_ if AS_INITIALIZE_FRAMEWORK_MANUALLY is not set or we are used by an extension +ASDK_EXTERN NSNumber *ASApplicationUserInterfaceLayoutDirection(void); + ASDK_EXTERN BOOL ASSubclassOverridesSelector(Class superclass, Class subclass, SEL selector); ASDK_EXTERN BOOL ASSubclassOverridesClassSelector(Class superclass, Class subclass, SEL selector); @@ -120,6 +130,8 @@ ASDISPLAYNODE_INLINE AS_WARN_UNUSED_RESULT ASImageDownloaderPriority ASImageDown */ ASDK_EXTERN NSMutableSet *ASCreatePointerBasedMutableSet(void); +ASDK_EXTERN NSAttributedString *ASGetZeroAttributedString(void); + NS_ASSUME_NONNULL_END #ifndef AS_INITIALIZE_FRAMEWORK_MANUALLY diff --git a/Source/Private/ASInternalHelpers.mm b/Source/Private/ASInternalHelpers.mm index c815abd5b..c992dc136 100644 --- a/Source/Private/ASInternalHelpers.mm +++ b/Source/Private/ASInternalHelpers.mm @@ -16,6 +16,24 @@ static NSNumber *allowsGroupOpacityFromUIKitOrNil; static NSNumber *allowsEdgeAntialiasingFromUIKitOrNil; +static NSNumber *applicationUserInterfaceLayoutDirection = nil; + +@implementation NSAttributedString (ASTextAttachment) + +- (BOOL)as_hasAttribute:(NSAttributedStringKey)attributeKey { + NSUInteger length = self.length; + if (length == 0) { + return NO; + } + NSRange range; + id result = [self attribute:attributeKey + atIndex:0 + longestEffectiveRange:&range + inRange:NSMakeRange(0, length)]; + return result || range.length != length; +} + +@end BOOL ASDefaultAllowsGroupOpacity() { @@ -39,6 +57,10 @@ BOOL ASDefaultAllowsEdgeAntialiasing() return edgeAntialiasing; } +NSNumber *ASApplicationUserInterfaceLayoutDirection() { + return applicationUserInterfaceLayoutDirection; +} + #if AS_SIGNPOST_ENABLE void _ASInitializeSignpostObservers(void) { @@ -78,6 +100,7 @@ void ASInitializeFrameworkMainThreadOnDestructor(void) static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ ASDisplayNodeCAssertMainThread(); + applicationUserInterfaceLayoutDirection = @([UIView userInterfaceLayoutDirectionForSemanticContentAttribute:UISemanticContentAttributeUnspecified]); // Ensure these values are cached on the main thread before needed in the background. if (ASActivateExperimentalFeature(ASExperimentalLayerDefaults)) { // Nop. We will gather default values on-demand in ASDefaultAllowsGroupOpacity and ASDefaultAllowsEdgeAntialiasing @@ -267,3 +290,12 @@ - (NSComparisonResult)asdk_inverseCompare:(NSIndexPath *)otherIndexPath }); return (__bridge_transfer NSMutableSet *)CFSetCreateMutable(NULL, 0, &callbacks); } + +NSAttributedString *ASGetZeroAttributedString(void) { + static NSAttributedString *str; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + str = [[NSAttributedString alloc] init]; + }); + return str; +} diff --git a/Source/TextExperiment/Component/ASTextDebugOption.mm b/Source/TextExperiment/Component/ASTextDebugOption.mm index 2565b903c..9887e1ad7 100644 --- a/Source/TextExperiment/Component/ASTextDebugOption.mm +++ b/Source/TextExperiment/Component/ASTextDebugOption.mm @@ -6,7 +6,7 @@ // Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 // -#import "ASTextDebugOption.h" +#import #import static pthread_mutex_t _sharedDebugLock; diff --git a/Source/TextExperiment/Component/ASTextLayout.h b/Source/TextExperiment/Component/ASTextLayout.h index 55ac417f1..58d70c632 100644 --- a/Source/TextExperiment/Component/ASTextLayout.h +++ b/Source/TextExperiment/Component/ASTextLayout.h @@ -10,9 +10,9 @@ #import #import -#import "ASTextDebugOption.h" -#import "ASTextLine.h" -#import "ASTextInput.h" +#import +#import +#import @protocol ASTextLinePositionModifier; @@ -23,6 +23,19 @@ NS_ASSUME_NONNULL_BEGIN */ ASDK_EXTERN const CGSize ASTextContainerMaxSize; +/** + * Get and Set ASTextNode2 to enable the better calculation of visible text range. + */ +BOOL ASGetEnableImprovedTextTruncationVisibleRange(void); +void ASSetEnableImprovedTextTruncationVisibleRange(BOOL enable); + +/** + * Get and Set ASTextLayout to fix the clickable area on truncation token when the last visible line + * is untruncated (e.g. the last visible line is an empty line). + */ +BOOL ASGetEnableImprovedTextTruncationVisibleRangeLastLineFix(void); +void ASSetEnableImprovedTextTruncationVisibleRangeLastLineFix(BOOL enable); + /** The ASTextContainer class defines a region in which text is laid out. ASTextLayout class uses one or more ASTextContainer objects to generate layouts. @@ -82,9 +95,6 @@ ASDK_EXTERN const CGSize ASTextContainerMaxSize; /// Default is YES; @property (getter=isPathFillEvenOdd) BOOL pathFillEvenOdd; -/// Whether the text is vertical form (may used for CJK text layout). Default is NO. -@property (getter=isVerticalForm) BOOL verticalForm; - /// Maximum number of rows, 0 means no limit. Default is 0. @property NSUInteger maximumNumberOfRows; @@ -224,8 +234,13 @@ ASDK_EXTERN const CGSize ASTextContainerMaxSize; @property (nonatomic, readonly) CTFrameRef frame; ///< Array of `ASTextLine`, no truncated @property (nonatomic, readonly) NSArray *lines; -///< ASTextLine with truncated token, or nil +///< ASTextLine with truncated token, or nil. Note that this may be nil if no truncation token was specified. +///< To check if the entire string was drawn, use NSEqualRanges(visibleRange, range). @property (nullable, nonatomic, readonly) ASTextLine *truncatedLine; +///< Range of the truncated line before the truncation tokens +@property(nonatomic, readonly) NSRange truncatedLineBeforeTruncationTokenRange; +///< The part of truncatedLine before the truncation token. +@property(nullable, nonatomic, readonly) ASTextLine *truncatedLineBeforeTruncationToken; ///< Array of `ASTextAttachment` @property (nullable, nonatomic, readonly) NSArray *attachments; ///< Array of NSRange(wrapped by NSValue) in text diff --git a/Source/TextExperiment/Component/ASTextLayout.mm b/Source/TextExperiment/Component/ASTextLayout.mm index f01c25908..e3bed8d28 100644 --- a/Source/TextExperiment/Component/ASTextLayout.mm +++ b/Source/TextExperiment/Component/ASTextLayout.mm @@ -9,15 +9,21 @@ #import -#import #import -#import +#import +#import +#import +#import +#import +#import #import +#import #import -#import const CGSize ASTextContainerMaxSize = (CGSize){0x100000, 0x100000}; +NSAttributedString *fillBaseAttributes(NSAttributedString *str, NSDictionary *attrs); + typedef struct { CGFloat head; CGFloat foot; @@ -55,22 +61,29 @@ static CGColorRef ASTextGetCGColor(CGColorRef color) { return color; } +BOOL kTextNode2ImprovedTextTruncationVisibleRange = false; +BOOL ASGetEnableImprovedTextTruncationVisibleRange(void) { + return kTextNode2ImprovedTextTruncationVisibleRange; +} +void ASSetEnableImprovedTextTruncationVisibleRange(BOOL enable) { + kTextNode2ImprovedTextTruncationVisibleRange = enable; +} + +BOOL kTextNode2ImprovedTextTruncationVisibleRangeLastLineFix = false; +BOOL ASGetEnableImprovedTextTruncationVisibleRangeLastLineFix(void) { + return kTextNode2ImprovedTextTruncationVisibleRangeLastLineFix; +} +void ASSetEnableImprovedTextTruncationVisibleRangeLastLineFix(BOOL enable) { + kTextNode2ImprovedTextTruncationVisibleRangeLastLineFix = enable; +} + @implementation ASTextLinePositionSimpleModifier - (void)modifyLines:(NSArray *)lines fromText:(NSAttributedString *)text inContainer:(ASTextContainer *)container { - if (container.verticalForm) { - for (NSUInteger i = 0, max = lines.count; i < max; i++) { - ASTextLine *line = lines[i]; - CGPoint pos = line.position; - pos.x = container.size.width - container.insets.right - line.row * _fixedLineHeight - _fixedLineHeight * 0.9; - line.position = pos; - } - } else { - for (NSUInteger i = 0, max = lines.count; i < max; i++) { - ASTextLine *line = lines[i]; - CGPoint pos = line.position; - pos.y = line.row * _fixedLineHeight + _fixedLineHeight * 0.9 + container.insets.top; - line.position = pos; - } + for (NSUInteger i = 0, max = lines.count; i < max; i++) { + ASTextLine *line = lines[i]; + CGPoint pos = line.position; + pos.y = line.row * _fixedLineHeight + _fixedLineHeight * 0.9 + container.insets.top; + line.position = pos; } } @@ -286,14 +299,6 @@ - (void)setPathLineWidth:(CGFloat)pathLineWidth { Setter(_pathLineWidth = pathLineWidth); } -- (BOOL)isVerticalForm { - Getter(BOOL v = _verticalForm) return v; -} - -- (void)setVerticalForm:(BOOL)verticalForm { - Setter(_verticalForm = verticalForm); -} - - (NSUInteger)maximumNumberOfRows { Getter(NSUInteger num = _maximumNumberOfRows) return num; } @@ -342,6 +347,8 @@ @interface ASTextLayout () @property (nonatomic) CTFrameRef frame; @property (nonatomic) NSArray *lines; @property (nonatomic) ASTextLine *truncatedLine; +@property(nonatomic) NSRange truncatedLineBeforeTruncationTokenRange; +@property(nonatomic) ASTextLine *truncatedLineBeforeTruncationToken; @property (nonatomic) NSArray *attachments; @property (nonatomic) NSArray *attachmentRanges; @property (nonatomic) NSArray *attachmentRects; @@ -395,13 +402,12 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri return [self layoutWithContainer:container text:text range:NSMakeRange(0, text.length)]; } -+ (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttributedString *)text range:(NSRange)range { ++ (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container + text:(NSAttributedString *)text range:(NSRange)range { ASTextLayout *layout = NULL; CGPathRef cgPath = nil; CGRect cgPathBox = {0}; - BOOL isVerticalForm = NO; BOOL rowMaySeparated = NO; - NSMutableDictionary *frameAttrs = nil; CTFramesetterRef ctSetter = NULL; CTFrameRef ctFrame = NULL; CFArrayRef ctLines = nil; @@ -414,7 +420,10 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri NSMutableSet *attachmentContentsSet = nil; BOOL needTruncation = NO; NSAttributedString *truncationToken = nil; + NSMutableAttributedString *lastLineText = nil; ASTextLine *truncatedLine = nil; + NSRange truncatedLineBeforeTruncationTokenRange; + ASTextLine *truncatedLineBeforeTruncationToken; ASRowEdge *lineRowsEdge = NULL; NSUInteger *lineRowsIndex = NULL; NSRange visibleRange; @@ -435,30 +444,32 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri if (range.location + range.length > text.length) return nil; [container makeImmutable]; maximumNumberOfRows = container.maximumNumberOfRows; - - // It may use larger constraint size when create CTFrame with - // CTFramesetterCreateFrame in iOS 10. + + // Amazingly, nobody ever figured out _what_ the supposed layoutSizeBug on iOS 10 was, or as + // follows from that, whether it actually ever got fixed in, say, iOS 11. + // However, there is a lot of functionality now that assumes this is YES (and presumably is broken + // on iOS 9-). Thus, this flag should probably just be removed to simplify this code. BOOL needFixLayoutSizeBug = AS_AT_LEAST_IOS10; layout = [[ASTextLayout alloc] _init]; layout.text = text; layout.container = container; layout.range = range; - isVerticalForm = container.verticalForm; - - // set cgPath and cgPathBox + + // `exclusionPaths` are like `verticalForm` in that their behavior hasn't been verified in a long + // time, though I think they have a much better chance of still working. + // TODO Verify or remove ASTextLayout exclusionPaths if (container.path == nil && container.exclusionPaths.count == 0) { if (container.size.width <= 0 || container.size.height <= 0) FAIL_AND_RETURN CGRect rect = (CGRect) {CGPointZero, container.size }; + // Note from above, `needFixLayoutSizeBug` should really always be YES if (needFixLayoutSizeBug) { + // Thus, `contraintSizeIsExtended` is always YES. And thus we always set the main-axis + // constraint to "infinity"/maxSize: constraintSizeIsExtended = YES; constraintRectBeforeExtended = UIEdgeInsetsInsetRect(rect, container.insets); constraintRectBeforeExtended = CGRectStandardize(constraintRectBeforeExtended); - if (container.isVerticalForm) { - rect.size.width = ASTextContainerMaxSize.width; - } else { - rect.size.height = ASTextContainerMaxSize.height; - } + rect.size.height = ASTextContainerMaxSize.height; } rect = UIEdgeInsetsInsetRect(rect, container.insets); rect = CGRectStandardize(rect); @@ -486,7 +497,7 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri [layout.container.exclusionPaths enumerateObjectsUsingBlock: ^(UIBezierPath *onePath, NSUInteger idx, BOOL *stop) { CGPathAddPath(path, NULL, onePath.CGPath); }]; - + cgPathBox = CGPathGetPathBoundingBox(path); CGAffineTransform trans = CGAffineTransformMakeScale(1, -1); CGMutablePathRef transPath = CGPathCreateMutableCopyByTransformingPath(path, &trans); @@ -496,19 +507,31 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri cgPath = path; } if (!cgPath) FAIL_AND_RETURN - + + // With no exclusion or container paths, we got a rectangular "path" `cgPath` from above. As + // noted, we are probably about to do a whole bunch of wrong things if we did not. + + // BEGIN CTFramesetter cache code. There are two KEY LINEs in the below section, the rest of it is + // code to support caching of CTFramesetters, which are very expensive to create. + // frame setter config - frameAttrs = [[NSMutableDictionary alloc] init]; - if (container.isPathFillEvenOdd == NO) { - frameAttrs[(id)kCTFramePathFillRuleAttributeName] = @(kCTFramePathFillWindingNumber); - } - if (container.pathLineWidth > 0) { - frameAttrs[(id)kCTFramePathWidthAttributeName] = @(container.pathLineWidth); - } - if (container.isVerticalForm == YES) { - frameAttrs[(id)kCTFrameProgressionAttributeName] = @(kCTFrameProgressionRightToLeft); - } - + NSDictionary *frameAttrs = ({ + static constexpr NSUInteger kMaxAttrCount = 3; + NSUInteger count = 0; + id keys[kMaxAttrCount]; + id objects[kMaxAttrCount]; + if (container.isPathFillEvenOdd == NO) { + keys[count] = (id)kCTFramePathFillRuleAttributeName; + objects[count++] = @(kCTFramePathFillWindingNumber); + } + if (container.pathLineWidth > 0) { + keys[count] = (id)kCTFramePathWidthAttributeName; + objects[count++] = @(container.pathLineWidth); + } + // If you add new attributes, make sure to update kMaxAttrCount above. + count ? [[NSDictionary alloc] initWithObjects:objects forKeys:keys count:count] : nil; + }); + /* * Framesetter cache. * Framesetters can only be used by one thread at a time. @@ -555,10 +578,12 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri // Create a framesetter if needed. if (!ctSetter) { + // KEY LINE #1 (See above) ctSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)text); } if (!ctSetter) FAIL_AND_RETURN + // KEY LINE #2 (See above) ctFrame = CTFramesetterCreateFrame(ctSetter, ASTextCFRangeFromNSRange(range), cgPath, (CFDictionaryRef)frameAttrs); // Return to cache. @@ -575,6 +600,11 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri } if (!ctFrame) FAIL_AND_RETURN + + // END CTFramesetterCache code + + // Now (Step 2?) we figure out where the individual lines are: CoreText already did this, we are + // just extracting them from the ctFrame reference. lines = [NSMutableArray new]; ctLines = CTFrameGetLines(ctFrame); lineCount = CFArrayGetCount(ctLines); @@ -583,17 +613,13 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri if (lineOrigins == NULL) FAIL_AND_RETURN CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, lineCount), lineOrigins); } - + CGRect textBoundingRect = CGRectZero; CGSize textBoundingSize = CGSizeZero; NSInteger rowIdx = -1; NSUInteger rowCount = 0; CGRect lastRect = CGRectMake(0, -FLT_MAX, 0, 0); CGPoint lastPosition = CGPointMake(0, -FLT_MAX); - if (isVerticalForm) { - lastRect = CGRectMake(FLT_MAX, 0, 0, 0); - lastPosition = CGPointMake(FLT_MAX, 0); - } // calculate line frame NSUInteger lineCurrentIdx = 0; @@ -602,7 +628,7 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri CTLineRef ctLine = (CTLineRef)CFArrayGetValueAtIndex(ctLines, i); CFArrayRef ctRuns = CTLineGetGlyphRuns(ctLine); if (!ctRuns || CFArrayGetCount(ctRuns) == 0) continue; - + // CoreText coordinate system CGPoint ctLineOrigin = lineOrigins[i]; @@ -610,8 +636,8 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri CGPoint position; position.x = cgPathBox.origin.x + ctLineOrigin.x; position.y = cgPathBox.size.height + cgPathBox.origin.y - ctLineOrigin.y; - - ASTextLine *line = [ASTextLine lineWithCTLine:ctLine position:position vertical:isVerticalForm]; + + ASTextLine *line = [ASTextLine lineWithCTLine:ctLine position:position vertical:NO]; [lines addObject:line]; } @@ -619,46 +645,35 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri // Give user a chance to modify the line's position. [container.linePositionModifier modifyLines:lines fromText:text inContainer:container]; + // We treat the first line differently: BOOL first = YES; for (ASTextLine *line in lines) { - CGPoint position = line.position; - CGRect rect = line.bounds; + CGPoint linePosition = line.position; + CGRect lineBounds = line.bounds; if (constraintSizeIsExtended) { - if (isVerticalForm) { - if (rect.origin.x + rect.size.width > - constraintRectBeforeExtended.origin.x + - constraintRectBeforeExtended.size.width) { - measuringBeyondConstraints = YES; - } - } else { - if (rect.origin.y + rect.size.height > - constraintRectBeforeExtended.origin.y + - constraintRectBeforeExtended.size.height) { - measuringBeyondConstraints = YES; - } + if (lineBounds.origin.y + lineBounds.size.height > + constraintRectBeforeExtended.origin.y + + constraintRectBeforeExtended.size.height) { + // To support truncation, we will sometimes keep laying out rows even though we have gone + // past the (vertical) constraints. + measuringBeyondConstraints = YES; } } + // rowIdx (and rowCount) ONLY ADVANCE for rows that are within constraints. BOOL newRow = !measuringBeyondConstraints; - if (newRow && rowMaySeparated && position.x != lastPosition.x) { - if (isVerticalForm) { - if (rect.size.width > lastRect.size.width) { - if (rect.origin.x > lastPosition.x && lastPosition.x > rect.origin.x - rect.size.width) newRow = NO; - } else { - if (lastRect.origin.x > position.x && position.x > lastRect.origin.x - lastRect.size.width) newRow = NO; - } + if (newRow && rowMaySeparated && linePosition.x != lastPosition.x) { + if (lineBounds.size.height > lastRect.size.height) { + // Are this line's bounds entirely above the last line's? If so, it's not a new row + if (lineBounds.origin.y < lastPosition.y && lastPosition.y < lineBounds.origin.y + lineBounds.size.height) newRow = NO; } else { - if (rect.size.height > lastRect.size.height) { - if (rect.origin.y < lastPosition.y && lastPosition.y < rect.origin.y + rect.size.height) newRow = NO; - } else { - if (lastRect.origin.y < position.y && position.y < lastRect.origin.y + lastRect.size.height) newRow = NO; - } + if (lastRect.origin.y < linePosition.y && linePosition.y < lastRect.origin.y + lastRect.size.height) newRow = NO; } } if (newRow) rowIdx++; - lastRect = rect; - lastPosition = position; + lastRect = lineBounds; + lastPosition = linePosition; line.index = lineCurrentIdx; line.row = rowIdx; @@ -668,16 +683,26 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri if (first) { first = NO; - textBoundingRect = rect; - } else if (!measuringBeyondConstraints) { + textBoundingRect = lineBounds; + } + // We do a couple of checks: If we are within our given constraints and maximumNumberOfRows, + // we will expand the overall `textBoundingRect`. + else if (!measuringBeyondConstraints) { if (maximumNumberOfRows == 0 || rowIdx < maximumNumberOfRows) { - textBoundingRect = CGRectUnion(textBoundingRect, rect); + textBoundingRect = CGRectUnion(textBoundingRect, lineBounds); } } } + // BEGIN Truncation code. + { + // We expect to remove lines if we truncate, but we will want them later. NSMutableArray *removedLines = [NSMutableArray new]; + + // There are two main reasons to truncate: We exceed the given bounds, or we exceed the given + // `maximumNumberOfRows`. The first one we mostly figured out above. Here we now check for the + // `maximumNumberOfRows` limit: if (rowCount > 0) { if (maximumNumberOfRows > 0) { if (rowCount > maximumNumberOfRows) { @@ -692,8 +717,12 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri } while (1); } } - ASTextLine *lastLine = rowCount < lines.count ? lines[rowCount - 1] : lines.lastObject; + + // `rowCount` is currently set to how many rows fit our constraints. Our last line might be + // either the last line, or just the last one that fit (< rowCount). + ASTextLine *lastLine = rowCount < lines.count ? lines[rowCount - 1] : lines.lastObject; if (!needTruncation && lastLine.range.location + lastLine.range.length < text.length) { + // lastLine doesn't go to the end of the text: ie it has been truncated. needTruncation = YES; while (lines.count > rowCount) { ASTextLine *line = lines.lastObject; @@ -718,21 +747,11 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri } lastRowIdx = line.row; lineRowsIndex[lastRowIdx] = i; - if (isVerticalForm) { - lastHead = rect.origin.x + rect.size.width; - lastFoot = lastHead - rect.size.width; - } else { - lastHead = rect.origin.y; - lastFoot = lastHead + rect.size.height; - } + lastHead = rect.origin.y; + lastFoot = lastHead + rect.size.height; } else { - if (isVerticalForm) { - lastHead = MAX(lastHead, rect.origin.x + rect.size.width); - lastFoot = MIN(lastFoot, rect.origin.x); - } else { - lastHead = MIN(lastHead, rect.origin.y); - lastFoot = MAX(lastFoot, rect.origin.y + rect.size.height); - } + lastHead = MIN(lastHead, rect.origin.y); + lastFoot = MAX(lastFoot, rect.origin.y + rect.size.height); } } lineRowsEdge[lastRowIdx] = (ASRowEdge) {.head = lastHead, .foot = lastFoot}; @@ -744,94 +763,76 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri } } - { // calculate bounding size - CGRect rect = textBoundingRect; - if (container.path) { - if (container.pathLineWidth > 0) { - CGFloat inset = container.pathLineWidth / 2; - rect = CGRectInset(rect, -inset, -inset); - } - } else { - rect = UIEdgeInsetsInsetRect(rect, ASTextUIEdgeInsetsInvert(container.insets)); - } - rect = CGRectStandardize(rect); - CGSize size = rect.size; - if (container.verticalForm) { - size.width += container.size.width - (rect.origin.x + rect.size.width); - } else { - size.width += rect.origin.x; - } - size.height += rect.origin.y; - if (size.width < 0) size.width = 0; - if (size.height < 0) size.height = 0; - size.width = ceil(size.width); - size.height = ceil(size.height); - textBoundingSize = size; - } - visibleRange = ASTextNSRangeFromCFRange(CTFrameGetVisibleStringRange(ctFrame)); if (needTruncation) { ASTextLine *lastLine = lines.lastObject; NSRange lastRange = lastLine.range; - visibleRange.length = lastRange.location + lastRange.length - visibleRange.location; + visibleRange.length = NSMaxRange(lastRange) - visibleRange.location; // create truncated line if (container.truncationType != ASTextTruncationTypeNone) { + CTLineTruncationType type = kCTLineTruncationEnd; + if (container.truncationType == ASTextTruncationTypeStart) { + type = kCTLineTruncationStart; + } else if (container.truncationType == ASTextTruncationTypeMiddle) { + type = kCTLineTruncationMiddle; + } + CTLineRef truncationTokenLine = NULL; - if (container.truncationToken) { - truncationToken = container.truncationToken; - truncationTokenLine = CTLineCreateWithAttributedString((CFAttributedStringRef) truncationToken); + truncationToken = container.truncationToken; + // For Tail truncation, if the truncation token is empty or only whitespace, we simply take + // whatever the Framesetter already generated and go with that (e.g. last word is omitted.) + static dispatch_once_t nonWhitespaceCharacterSetOnce; + static NSCharacterSet *nonWhitespaceCharacterSet; + dispatch_once(&nonWhitespaceCharacterSetOnce, ^{ + nonWhitespaceCharacterSet = NSCharacterSet.whitespaceCharacterSet.invertedSet; + }); + // Truncation token is all whitespace, or just an empty string. + if (truncationToken && type == kCTLineTruncationEnd && NSNotFound == [truncationToken.string rangeOfCharacterFromSet:nonWhitespaceCharacterSet].location) { + // Don't do anything. Reset truncationToken to nil, leave truncationTokenLine as NULL. + truncationToken = nil; } else { + // Create a CTLine from truncationToken. By default, apply the attributes from the end of + // the last line to the new truncation token. CFArrayRef runs = CTLineGetGlyphRuns(lastLine.CTLine); NSUInteger runCount = CFArrayGetCount(runs); + NSMutableAttributedString *string = + [[NSMutableAttributedString alloc] initWithString:ASTextTruncationToken]; NSMutableDictionary *attrs = nil; if (runCount > 0) { - CTRunRef run = (CTRunRef) CFArrayGetValueAtIndex(runs, runCount - 1); - attrs = (id) CTRunGetAttributes(run); - attrs = attrs ? attrs.mutableCopy : [NSMutableArray new]; + + // Get last line run + CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runs, runCount - 1); + + // Attributes from last run + attrs = (id)CTRunGetAttributes(run); + attrs = attrs ? [attrs mutableCopy] : [[NSMutableDictionary alloc] init]; [attrs removeObjectsForKeys:[NSMutableAttributedString as_allDiscontinuousAttributeKeys]]; - CTFontRef font = (__bridge CTFontRef) attrs[(id) kCTFontAttributeName]; - CGFloat fontSize = font ? CTFontGetSize(font) : 12.0; - UIFont *uiFont = [UIFont systemFontOfSize:fontSize * 0.9]; - if (uiFont) { - font = CTFontCreateWithName((__bridge CFStringRef) uiFont.fontName, uiFont.pointSize, NULL); - } else { - font = NULL; - } - if (font) { - attrs[(id) kCTFontAttributeName] = (__bridge id) (font); - uiFont = nil; - CFRelease(font); + + if (container.truncationToken) { + string = [container.truncationToken mutableCopy]; } - CGColorRef color = (__bridge CGColorRef) (attrs[(id) kCTForegroundColorAttributeName]); + + // Ignore clear color + CGColorRef color = (__bridge CGColorRef)attrs[(id)kCTForegroundColorAttributeName]; if (color && CFGetTypeID(color) == CGColorGetTypeID() && CGColorGetAlpha(color) == 0) { - // ignore clear color - [attrs removeObjectForKey:(id) kCTForegroundColorAttributeName]; + [attrs removeObjectForKey:(id)kCTForegroundColorAttributeName]; } - if (!attrs) attrs = [NSMutableDictionary new]; } - truncationToken = [[NSAttributedString alloc] initWithString:ASTextTruncationToken attributes:attrs]; - truncationTokenLine = CTLineCreateWithAttributedString((CFAttributedStringRef) truncationToken); + truncationToken = fillBaseAttributes(string, attrs); + truncationTokenLine = CTLineCreateWithAttributedString((CFAttributedStringRef)truncationToken); } if (truncationTokenLine) { - CTLineTruncationType type = kCTLineTruncationEnd; - if (container.truncationType == ASTextTruncationTypeStart) { - type = kCTLineTruncationStart; - } else if (container.truncationType == ASTextTruncationTypeMiddle) { - type = kCTLineTruncationMiddle; - } - NSMutableAttributedString *lastLineText = [text attributedSubstringFromRange:lastLine.range].mutableCopy; + // TODO: Avoid creating this unless we actually modify the last line text. + lastLineText = [[text attributedSubstringFromRange:lastLine.range] mutableCopy]; CGFloat truncatedWidth = lastLine.width; CGFloat atLeastOneLine = lastLine.width; CGRect cgPathRect = CGRectZero; if (CGPathIsRect(cgPath, &cgPathRect)) { - if (isVerticalForm) { - truncatedWidth = cgPathRect.size.height; - } else { - truncatedWidth = cgPathRect.size.width; - } + truncatedWidth = cgPathRect.size.width; } int i = 0; + int limit = (int) removedLines.count; if (type != kCTLineTruncationStart) { // Middle or End/Tail wants to collect some text (at least one line's // worth) preceding the truncated content, with which to construct a "truncated line". i = (int)removedLines.count - 1; @@ -843,13 +844,19 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri [lastLineText appendAttributedString:[text attributedSubstringFromRange:removedLines[i].range]]; atLeastOneLine += removedLines[i--].width; } - [lastLineText appendAttributedString:truncationToken]; + if (type == kCTLineTruncationEnd) { + [lastLineText appendAttributedString:truncationToken]; + } + if (type == kCTLineTruncationMiddle) { // If we are truncating Middle, we do not want + // to collect the same text into the truncated line more than once. + limit = i+1; + } } if (type != kCTLineTruncationEnd && removedLines.count > 0) { // Middle or Start/Head wants to collect some // text following the truncated content. i = 0; atLeastOneLine = removedLines[i].width; - while (atLeastOneLine < truncatedWidth && i < removedLines.count) { + while (atLeastOneLine < truncatedWidth && i < limit) { atLeastOneLine += removedLines[i++].width; } for (i--; i >= 0; i--) { @@ -867,91 +874,134 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri CTLineRef ctLastLineExtend = CTLineCreateWithAttributedString((CFAttributedStringRef) lastLineText); if (ctLastLineExtend) { - CTLineRef ctTruncatedLine = CTLineCreateTruncatedLine(ctLastLineExtend, truncatedWidth, type, truncationTokenLine); + // CTLineCreateTruncatedLine only reorders its CTRuns and doesn't change the range. + // After the reorder, some CTRuns in ctTruncatedLine are visible to the users, but we + // don't know which ones since this is completely handled by Core Text. + CTLineRef ctTruncatedLine = CTLineCreateTruncatedLine(ctLastLineExtend, truncatedWidth, + type, truncationTokenLine); + if (kTextNode2ImprovedTextTruncationVisibleRange) { + CFArrayRef truncatedLineRuns = CTLineGetGlyphRuns(ctTruncatedLine); + // Calculate the range of the truncated line before the truncation tokens + if (truncatedLineRuns) { + CFIndex truncatedLineRunCount = CFArrayGetCount(truncatedLineRuns); + + CGFloat truncationTokenWidth = + CTLineGetTypographicBounds(truncationTokenLine, 0, 0, 0); + CGFloat ctLastLineExtendWidth = + CTLineGetTypographicBounds(ctLastLineExtend, 0, 0, 0); + + // If the last line is not truncated, the truncation token is appended directly to + // the text without deletion. + if (kTextNode2ImprovedTextTruncationVisibleRangeLastLineFix && + (truncatedWidth > ctLastLineExtendWidth)) { + NSAttributedString *lastLineSubString = [lastLineText + attributedSubstringFromRange:NSMakeRange(0, + MAX(0, lastLineText.length - + truncationToken.length))]; + truncatedLineBeforeTruncationToken = + [ASTextLine lineWithCTLine:CTLineCreateWithAttributedString( + (CFAttributedStringRef)lastLineSubString) + position:lastLine.position + vertical:NO]; + + } else { + // Since Core Text hides the information that which CTRun is the last visible one, + // iterate through the runs to estimate the last visible run. + for (int i = 0; i < truncatedLineRunCount; i++) { + CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(truncatedLineRuns, + truncatedLineRunCount - 1 - i); + CFRange runRange = CTRunGetStringRange(run); + // Make sure that lastLineSubString has a valid range. + NSUInteger truncatedLineBeforeTruncationTokenRangeEnd = + MAX(MIN(lastLineText.length, runRange.location + runRange.length), 0); + truncatedLineBeforeTruncationTokenRange = NSMakeRange( + lastLine.range.location, truncatedLineBeforeTruncationTokenRangeEnd); + + NSAttributedString *lastLineSubString = [lastLineText + attributedSubstringFromRange: + NSMakeRange(0, truncatedLineBeforeTruncationTokenRangeEnd)]; + CGFloat lastLineSubStringWidth = CTLineGetTypographicBounds( + CTLineCreateWithAttributedString((CFAttributedStringRef)lastLineSubString), + 0, 0, 0); + // If lastLineSubString and truncationToken can "almost" fit in truncatedWidth, + // assume the current run is the last visible CTRun, and lastLineSubString is + // the visible part in the last line. Adding 2 here for error tolerance since + // truncationTokenWidth + lastLineSubStringWidth might be slightly longer than + // truncatedWidth. + if (truncationTokenWidth + lastLineSubStringWidth < truncatedWidth + 2) { + truncatedLineBeforeTruncationToken = + [ASTextLine lineWithCTLine:CTLineCreateWithAttributedString( + (CFAttributedStringRef)lastLineSubString) + position:lastLine.position + vertical:NO]; + break; + } + } + } + } + } + CFRelease(ctLastLineExtend); if (ctTruncatedLine) { - truncatedLine = [ASTextLine lineWithCTLine:ctTruncatedLine position:lastLine.position vertical:isVerticalForm]; - truncatedLine.index = lastLine.index; - truncatedLine.row = lastLine.row; + truncatedLine = [ASTextLine lineWithCTLine:ctTruncatedLine position:lastLine.position vertical:NO]; + // 1) If truncation mode is middle or start, and the end of the string contains taller text (or taller attachments), then truncating the line may make it taller + // (By pulling up the tall text that was previously in a later, clipped line, into the truncation line). + // 1b) There are edge cases where truncating the line makes it taller, thus it exceeds the bounds, and we in fact needed to truncate at an earlier line. + // Accommodating these cases in a robust manner would require multiple passes. (TODO_NOTREALLY) + // 2) In all cases, truncating the line may make it shorter. (Of course) + // 3) If text is not left-aligned, and truncating changed the width of the last line, it also needs to change its position. + BOOL adjusted = NO; + CGPoint adjustedPosition = truncatedLine.position; + if (truncatedLine.bounds.size.height > lastLine.bounds.size.height) { + adjusted = YES; + adjustedPosition = {adjustedPosition.x, lastLine.position.y + (truncatedLine.bounds.size.height - lastLine.bounds.size.height)/2}; + } + if ([lastLineText as_alignment] == NSTextAlignmentRight) { // (TODO: same for center-aligned) + adjusted = YES; + adjustedPosition = {lastLine.position.x - (truncatedLine.bounds.size.width - lastLine.bounds.size.width), adjustedPosition.y}; + } + if (adjusted) { + truncatedLine = [ASTextLine lineWithCTLine:ctTruncatedLine position:adjustedPosition vertical:NO]; + } CFRelease(ctTruncatedLine); } + textBoundingRect = CGRectUnion(textBoundingRect, truncatedLine.bounds); + truncatedLine.index = lastLine.index; + truncatedLine.row = lastLine.row; } CFRelease(truncationTokenLine); } } } - } - - if (isVerticalForm) { - NSCharacterSet *rotateCharset = ASTextVerticalFormRotateCharacterSet(); - NSCharacterSet *rotateMoveCharset = ASTextVerticalFormRotateAndMoveCharacterSet(); - void (^lineBlock)(ASTextLine *) = ^(ASTextLine *line){ - CFArrayRef runs = CTLineGetGlyphRuns(line.CTLine); - if (!runs) return; - NSUInteger runCount = CFArrayGetCount(runs); - if (runCount == 0) return; - NSMutableArray *lineRunRanges = [NSMutableArray new]; - line.verticalRotateRange = lineRunRanges; - for (NSUInteger r = 0; r < runCount; r++) { - CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runs, r); - NSMutableArray *runRanges = [NSMutableArray new]; - [lineRunRanges addObject:runRanges]; - NSUInteger glyphCount = CTRunGetGlyphCount(run); - if (glyphCount == 0) continue; - - CFIndex runStrIdx[glyphCount + 1]; - CTRunGetStringIndices(run, CFRangeMake(0, 0), runStrIdx); - CFRange runStrRange = CTRunGetStringRange(run); - runStrIdx[glyphCount] = runStrRange.location + runStrRange.length; - CFDictionaryRef runAttrs = CTRunGetAttributes(run); - CTFontRef font = (CTFontRef)CFDictionaryGetValue(runAttrs, kCTFontAttributeName); - BOOL isColorGlyph = ASTextCTFontContainsColorBitmapGlyphs(font); - - NSUInteger prevIdx = 0; - ASTextRunGlyphDrawMode prevMode = ASTextRunGlyphDrawModeHorizontal; - NSString *layoutStr = layout.text.string; - for (NSUInteger g = 0; g < glyphCount; g++) { - BOOL glyphRotate = 0, glyphRotateMove = NO; - CFIndex runStrLen = runStrIdx[g + 1] - runStrIdx[g]; - if (isColorGlyph) { - glyphRotate = YES; - } else if (runStrLen == 1) { - unichar c = [layoutStr characterAtIndex:runStrIdx[g]]; - glyphRotate = [rotateCharset characterIsMember:c]; - if (glyphRotate) glyphRotateMove = [rotateMoveCharset characterIsMember:c]; - } else if (runStrLen > 1){ - NSString *glyphStr = [layoutStr substringWithRange:NSMakeRange(runStrIdx[g], runStrLen)]; - BOOL glyphRotate = [glyphStr rangeOfCharacterFromSet:rotateCharset].location != NSNotFound; - if (glyphRotate) glyphRotateMove = [glyphStr rangeOfCharacterFromSet:rotateMoveCharset].location != NSNotFound; - } - - ASTextRunGlyphDrawMode mode = glyphRotateMove ? ASTextRunGlyphDrawModeVerticalRotateMove : (glyphRotate ? ASTextRunGlyphDrawModeVerticalRotate : ASTextRunGlyphDrawModeHorizontal); - if (g == 0) { - prevMode = mode; - } else if (mode != prevMode) { - ASTextRunGlyphRange *aRange = [ASTextRunGlyphRange rangeWithRange:NSMakeRange(prevIdx, g - prevIdx) drawMode:prevMode]; - [runRanges addObject:aRange]; - prevIdx = g; - prevMode = mode; - } - } - if (prevIdx < glyphCount) { - ASTextRunGlyphRange *aRange = [ASTextRunGlyphRange rangeWithRange:NSMakeRange(prevIdx, glyphCount - prevIdx) drawMode:prevMode]; - [runRanges addObject:aRange]; + { // calculate bounding size + CGRect rect = textBoundingRect; + if (container.path) { + if (container.pathLineWidth > 0) { + CGFloat inset = container.pathLineWidth / 2; + rect = CGRectInset(rect, -inset, -inset); } - + } else { + rect = UIEdgeInsetsInsetRect(rect, ASTextUIEdgeInsetsInvert(container.insets)); } - }; - for (ASTextLine *line in lines) { - lineBlock(line); + rect = CGRectStandardize(rect); + CGSize size = rect.size; + size.width += rect.origin.x; + size.height += rect.origin.y; + if (size.width < 0) size.width = 0; + if (size.height < 0) size.height = 0; + size.width = ceil(size.width); + size.height = ceil(size.height); + textBoundingSize = size; } - if (truncatedLine) lineBlock(truncatedLine); } - + + // We store some hints we will look up when its time to draw. First thing, do we need to draw at all? if (visibleRange.length > 0) { layout.needDrawText = YES; - + + // ... and some finer details: We go through the NSAttrbutedString attributes looking for things + // that would require the following drawing behaviors, and set flags for them if so: void (^block)(NSDictionary *attrs, NSRange range, BOOL *stop) = ^(NSDictionary *attrs, NSRange range, BOOL *stop) { if (attrs[ASTextHighlightAttributeName]) layout.containsHighlight = YES; if (attrs[ASTextBlockBorderAttributeName]) layout.needDrawBlockBorder = YES; @@ -963,12 +1013,18 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri if (attrs[ASTextStrikethroughAttributeName]) layout.needDrawStrikethrough = YES; if (attrs[ASTextBorderAttributeName]) layout.needDrawBorder = YES; }; - + [layout.text enumerateAttributesInRange:visibleRange options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:block]; if (truncatedLine) { + if (container.truncationType == ASTextTruncationTypeStart || container.truncationType == ASTextTruncationTypeMiddle) { + // If truncation mode is middle or start, there is another visible range not expressed in visibleRange. + [lastLineText enumerateAttributesInRange:NSMakeRange(0, lastLineText.length) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:block]; + } [truncationToken enumerateAttributesInRange:NSMakeRange(0, truncationToken.length) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:block]; } } + + // Also, set up attachments to use for rendering later. for (NSUInteger i = 0, max = lines.count; i < max; i++) { ASTextLine *line = lines[i]; if (truncatedLine && line.index == truncatedLine.index) line = truncatedLine; @@ -993,6 +1049,8 @@ + (ASTextLayout *)layoutWithContainer:(ASTextContainer *)container text:(NSAttri layout.frame = ctFrame; layout.lines = lines; layout.truncatedLine = truncatedLine; + layout.truncatedLineBeforeTruncationTokenRange = truncatedLineBeforeTruncationTokenRange; + layout.truncatedLineBeforeTruncationToken = truncatedLineBeforeTruncationToken; layout.attachments = attachments; layout.attachmentRanges = attachmentRanges; layout.attachmentRects = attachmentRects; @@ -1067,19 +1125,16 @@ - (id)copyWithZone:(NSZone *)zone { */ - (NSUInteger)_rowIndexForEdge:(CGFloat)edge { if (_rowCount == 0) return NSNotFound; - BOOL isVertical = _container.verticalForm; NSUInteger lo = 0, hi = _rowCount - 1, mid = 0; NSUInteger rowIdx = NSNotFound; while (lo <= hi) { mid = (lo + hi) / 2; ASRowEdge oneEdge = _lineRowsEdge[mid]; - if (isVertical ? - (oneEdge.foot <= edge && edge <= oneEdge.head) : - (oneEdge.head <= edge && edge <= oneEdge.foot)) { + if (oneEdge.head <= edge && edge <= oneEdge.foot) { rowIdx = mid; break; } - if ((isVertical ? (edge > oneEdge.head) : (edge < oneEdge.head))) { + if (edge < oneEdge.head) { if (mid == 0) break; hi = mid - 1; } else { @@ -1101,18 +1156,10 @@ - (NSUInteger)_closestRowIndexForEdge:(CGFloat)edge { if (_rowCount == 0) return NSNotFound; NSUInteger rowIdx = [self _rowIndexForEdge:edge]; if (rowIdx == NSNotFound) { - if (_container.verticalForm) { - if (edge > _lineRowsEdge[0].head) { - rowIdx = 0; - } else if (edge < _lineRowsEdge[_rowCount - 1].foot) { - rowIdx = _rowCount - 1; - } - } else { - if (edge < _lineRowsEdge[0].head) { - rowIdx = 0; - } else if (edge > _lineRowsEdge[_rowCount - 1].foot) { - rowIdx = _rowCount - 1; - } + if (edge < _lineRowsEdge[0].head) { + rowIdx = 0; + } else if (edge > _lineRowsEdge[_rowCount - 1].foot) { + rowIdx = _rowCount - 1; } } return rowIdx; @@ -1247,22 +1294,12 @@ - (BOOL)_isRightToLeftInLine:(ASTextLine *)line atPoint:(CGPoint)point { CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runs, r); CGPoint glyphPosition; CTRunGetPositions(run, CFRangeMake(0, 1), &glyphPosition); - if (_container.verticalForm) { - CGFloat runX = glyphPosition.x; - runX += line.position.y; - CGFloat runWidth = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); - if (runX <= point.y && point.y <= runX + runWidth) { - if (CTRunGetStatus(run) & kCTRunStatusRightToLeft) RTL = YES; - break; - } - } else { - CGFloat runX = glyphPosition.x; - runX += line.position.x; - CGFloat runWidth = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); - if (runX <= point.x && point.x <= runX + runWidth) { - if (CTRunGetStatus(run) & kCTRunStatusRightToLeft) RTL = YES; - break; - } + CGFloat runX = glyphPosition.x; + runX += line.position.x; + CGFloat runWidth = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); + if (runX <= point.x && point.x <= runX + runWidth) { + if (CTRunGetStatus(run) & kCTRunStatusRightToLeft) RTL = YES; + break; } } return RTL; @@ -1311,7 +1348,7 @@ - (NSUInteger)rowIndexForLine:(NSUInteger)line { - (NSUInteger)lineIndexForPoint:(CGPoint)point { if (_lines.count == 0 || _rowCount == 0) return NSNotFound; - NSUInteger rowIdx = [self _rowIndexForEdge:_container.verticalForm ? point.x : point.y]; + NSUInteger rowIdx = [self _rowIndexForEdge: point.y]; if (rowIdx == NSNotFound) return NSNotFound; NSUInteger lineIdx0 = _lineRowsIndex[rowIdx]; @@ -1325,9 +1362,8 @@ - (NSUInteger)lineIndexForPoint:(CGPoint)point { } - (NSUInteger)closestLineIndexForPoint:(CGPoint)point { - BOOL isVertical = _container.verticalForm; if (_lines.count == 0 || _rowCount == 0) return NSNotFound; - NSUInteger rowIdx = [self _closestRowIndexForEdge:isVertical ? point.x : point.y]; + NSUInteger rowIdx = [self _closestRowIndexForEdge: point.y]; if (rowIdx == NSNotFound) return NSNotFound; NSUInteger lineIdx0 = _lineRowsIndex[rowIdx]; @@ -1338,30 +1374,16 @@ - (NSUInteger)closestLineIndexForPoint:(CGPoint)point { NSUInteger minIndex = lineIdx0; for (NSUInteger i = lineIdx0; i <= lineIdx1; i++) { CGRect bounds = ((ASTextLine *)_lines[i]).bounds; - if (isVertical) { - if (bounds.origin.y <= point.y && point.y <= bounds.origin.y + bounds.size.height) return i; - CGFloat distance; - if (point.y < bounds.origin.y) { - distance = bounds.origin.y - point.y; - } else { - distance = point.y - (bounds.origin.y + bounds.size.height); - } - if (distance < minDistance) { - minDistance = distance; - minIndex = i; - } + if (bounds.origin.x <= point.x && point.x <= bounds.origin.x + bounds.size.width) return i; + CGFloat distance; + if (point.x < bounds.origin.x) { + distance = bounds.origin.x - point.x; } else { - if (bounds.origin.x <= point.x && point.x <= bounds.origin.x + bounds.size.width) return i; - CGFloat distance; - if (point.x < bounds.origin.x) { - distance = bounds.origin.x - point.x; - } else { - distance = point.x - (bounds.origin.x + bounds.size.width); - } - if (distance < minDistance) { - minDistance = distance; - minIndex = i; - } + distance = point.x - (bounds.origin.x + bounds.size.width); + } + if (distance < minDistance) { + minDistance = distance; + minIndex = i; } } return minIndex; @@ -1374,19 +1396,14 @@ - (CGFloat)offsetForTextPosition:(NSUInteger)position lineIndex:(NSUInteger)line if (position < range.location || position > range.location + range.length) return CGFLOAT_MAX; CGFloat offset = CTLineGetOffsetForStringIndex(line.CTLine, position, NULL); - return _container.verticalForm ? (offset + line.position.y) : (offset + line.position.x); + return offset + line.position.x; } - (NSUInteger)textPositionForPoint:(CGPoint)point lineIndex:(NSUInteger)lineIndex { if (lineIndex >= _lines.count) return NSNotFound; ASTextLine *line = _lines[lineIndex]; - if (_container.verticalForm) { - point.x = point.y - line.position.y; - point.y = 0; - } else { - point.x -= line.position.x; - point.y = 0; - } + point.x -= line.position.x; + point.y = 0; CFIndex idx = CTLineGetStringIndexForPosition(line.CTLine, point); if (idx == kCFNotFound) return NSNotFound; @@ -1442,12 +1459,10 @@ If the emoji contains one or more variant form (such as ☔️ "\u2614\uFE0F") } - (ASTextPosition *)closestPositionToPoint:(CGPoint)point { - BOOL isVertical = _container.verticalForm; // When call CTLineGetStringIndexForPosition() on ligature such as 'fi', // and the point `hit` the glyph's left edge, it may get the ligature inside offset. // I don't know why, maybe it's a bug of CoreText. Try to avoid it. - if (isVertical) point.y += 0.00001234; - else point.x += 0.00001234; + point.x += 0.00001234; NSUInteger lineIndex = [self closestLineIndexForPoint:point]; if (lineIndex == NSNotFound) return nil; @@ -1473,22 +1488,12 @@ - (ASTextPosition *)closestPositionToPoint:(CGPoint)point { CGFloat left = [self offsetForTextPosition:bindingRange.location lineIndex:lineIndex]; CGFloat right = [self offsetForTextPosition:bindingRange.location + bindingRange.length lineIndex:lineIndex]; if (left != CGFLOAT_MAX && right != CGFLOAT_MAX) { - if (_container.isVerticalForm) { - if (fabs(point.y - left) < fabs(point.y - right)) { - position = bindingRange.location; - finalAffinity = ASTextAffinityForward; - } else { - position = bindingRange.location + bindingRange.length; - finalAffinity = ASTextAffinityBackward; - } + if (fabs(point.x - left) < fabs(point.x - right)) { + position = bindingRange.location; + finalAffinity = ASTextAffinityForward; } else { - if (fabs(point.x - left) < fabs(point.x - right)) { - position = bindingRange.location; - finalAffinity = ASTextAffinityForward; - } else { - position = bindingRange.location + bindingRange.length; - finalAffinity = ASTextAffinityBackward; - } + position = bindingRange.location + bindingRange.length; + finalAffinity = ASTextAffinityBackward; } } else if (left != CGFLOAT_MAX) { position = left; @@ -1560,13 +1565,13 @@ - (ASTextPosition *)closestPositionToPoint:(CGPoint)point { } // above whole text frame - if (lineIndex == 0 && (isVertical ? (point.x > line.right) : (point.y < line.top))) { + if (lineIndex == 0 && (point.y < line.top)) { position = 0; finalAffinity = ASTextAffinityForward; finalAffinityDetected = YES; } // below whole text frame - if (lineIndex == _lines.count - 1 && (isVertical ? (point.x < line.left) : (point.y > line.bottom))) { + if (lineIndex == _lines.count - 1 && (point.y > line.bottom)) { position = line.range.location + line.range.length; finalAffinity = ASTextAffinityBackward; finalAffinityDetected = YES; @@ -1596,19 +1601,11 @@ - (ASTextPosition *)closestPositionToPoint:(CGPoint)point { } [self _insideComposedCharacterSequences:line position:position block: ^(CGFloat left, CGFloat right, NSUInteger prev, NSUInteger next) { - if (isVertical) { - position = fabs(left - point.y) < fabs(right - point.y) < (right ? prev : next); - } else { - position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next); - } + position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next); }]; [self _insideEmoji:line position:position block: ^(CGFloat left, CGFloat right, NSUInteger prev, NSUInteger next) { - if (isVertical) { - position = fabs(left - point.y) < fabs(right - point.y) < (right ? prev : next); - } else { - position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next); - } + position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next); }]; if (position < _visibleRange.location) position = _visibleRange.location; @@ -1623,7 +1620,7 @@ - (ASTextPosition *)closestPositionToPoint:(CGPoint)point { } else if (position <= line.range.location) { finalAffinity = RTL ? ASTextAffinityBackward : ASTextAffinityForward; } else { - finalAffinity = (ofs < (isVertical ? point.y : point.x) && !RTL) ? ASTextAffinityForward : ASTextAffinityBackward; + finalAffinity = (ofs < point.x && !RTL) ? ASTextAffinityForward : ASTextAffinityBackward; } } } @@ -1647,35 +1644,19 @@ - (ASTextPosition *)positionForPoint:(CGPoint)point if (lineIndex == NSNotFound) return oldPosition; ASTextLine *line = _lines[lineIndex]; ASRowEdge vertical = _lineRowsEdge[line.row]; - if (_container.verticalForm) { - point.x = (vertical.head + vertical.foot) * 0.5; - } else { - point.y = (vertical.head + vertical.foot) * 0.5; - } + point.y = (vertical.head + vertical.foot) * 0.5; newPos = [self closestPositionToPoint:point]; if ([newPos compare:otherPosition] == [oldPosition compare:otherPosition] && newPos.offset != otherPosition.offset) { return newPos; } - - if (_container.isVerticalForm) { - if ([oldPosition compare:otherPosition] == NSOrderedAscending) { // search backward - ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionUp offset:1]; - if (range) return range.start; - } else { // search forward - ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionDown offset:1]; - if (range) return range.end; - } - } else { - if ([oldPosition compare:otherPosition] == NSOrderedAscending) { // search backward - ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionLeft offset:1]; - if (range) return range.start; - } else { // search forward - ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionRight offset:1]; - if (range) return range.end; - } + if ([oldPosition compare:otherPosition] == NSOrderedAscending) { // search backward + ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionLeft offset:1]; + if (range) return range.start; + } else { // search forward + ASTextRange *range = [self textRangeByExtendingPosition:otherPosition inDirection:UITextLayoutDirectionRight offset:1]; + if (range) return range.end; } - return oldPosition; } @@ -1691,14 +1672,8 @@ - (ASTextRange *)textRangeAtPoint:(CGPoint)point { BOOL RTL = [self _isRightToLeftInLine:_lines[lineIndex] atPoint:point]; CGRect rect = [self caretRectForPosition:pos]; if (CGRectIsNull(rect)) return nil; - - if (_container.verticalForm) { - ASTextRange *range = [self textRangeByExtendingPosition:pos inDirection:(rect.origin.y >= point.y && !RTL) ? UITextLayoutDirectionUp:UITextLayoutDirectionDown offset:1]; - return range; - } else { - ASTextRange *range = [self textRangeByExtendingPosition:pos inDirection:(rect.origin.x >= point.x && !RTL) ? UITextLayoutDirectionLeft:UITextLayoutDirectionRight offset:1]; - return range; - } + ASTextRange *range = [self textRangeByExtendingPosition:pos inDirection:(rect.origin.x >= point.x && !RTL) ? UITextLayoutDirectionLeft:UITextLayoutDirectionRight offset:1]; + return range; } - (ASTextRange *)closestTextRangeAtPoint:(CGPoint)point { @@ -1714,22 +1689,18 @@ - (ASTextRange *)closestTextRangeAtPoint:(CGPoint)point { UITextLayoutDirection direction = UITextLayoutDirectionRight; if (pos.offset >= line.range.location + line.range.length) { if (direction != RTL) { - direction = _container.verticalForm ? UITextLayoutDirectionUp : UITextLayoutDirectionLeft; + direction = UITextLayoutDirectionLeft; } else { - direction = _container.verticalForm ? UITextLayoutDirectionDown : UITextLayoutDirectionRight; + direction = UITextLayoutDirectionRight; } } else if (pos.offset <= line.range.location) { if (direction != RTL) { - direction = _container.verticalForm ? UITextLayoutDirectionDown : UITextLayoutDirectionRight; + direction = UITextLayoutDirectionRight; } else { - direction = _container.verticalForm ? UITextLayoutDirectionUp : UITextLayoutDirectionLeft; + direction = UITextLayoutDirectionLeft; } } else { - if (_container.verticalForm) { - direction = (rect.origin.y >= point.y && !RTL) ? UITextLayoutDirectionUp:UITextLayoutDirectionDown; - } else { - direction = (rect.origin.x >= point.x && !RTL) ? UITextLayoutDirectionLeft:UITextLayoutDirectionRight; - } + direction = (rect.origin.x >= point.x && !RTL) ? UITextLayoutDirectionLeft:UITextLayoutDirectionRight; } ASTextRange *range = [self textRangeByExtendingPosition:pos inDirection:direction offset:1]; @@ -1807,17 +1778,9 @@ - (ASTextRange *)textRangeByExtendingPosition:(ASTextPosition *)position if (!position) return nil; if (position.offset < visibleStart || position.offset > visibleEnd) return nil; if (offset == 0) return [self textRangeByExtendingPosition:position]; - - BOOL isVerticalForm = _container.verticalForm; - BOOL verticalMove, forwardMove; - - if (isVerticalForm) { - verticalMove = direction == UITextLayoutDirectionLeft || direction == UITextLayoutDirectionRight; - forwardMove = direction == UITextLayoutDirectionLeft || direction == UITextLayoutDirectionDown; - } else { - verticalMove = direction == UITextLayoutDirectionUp || direction == UITextLayoutDirectionDown; - forwardMove = direction == UITextLayoutDirectionDown || direction == UITextLayoutDirectionRight; - } + + BOOL verticalMove = direction == UITextLayoutDirectionUp || direction == UITextLayoutDirectionDown; + BOOL forwardMove = direction == UITextLayoutDirectionDown || direction == UITextLayoutDirectionRight; if (offset < 0) { forwardMove = !forwardMove; @@ -1858,32 +1821,17 @@ - (ASTextRange *)textRangeByExtendingPosition:(ASTextPosition *)position for (NSUInteger i = 0; i < moveToLineCount; i++) { NSUInteger lineIndex = moveToLineFirstIndex + i; ASTextLine *line = _lines[lineIndex]; - if (isVerticalForm) { - if (line.top <= ofs && ofs <= line.bottom) { - insideIndex = line.index; - break; - } - if (line.top < mostLeft) { - mostLeft = line.top; - mostLeftLine = line; - } - if (line.bottom > mostRight) { - mostRight = line.bottom; - mostRightLine = line; - } - } else { - if (line.left <= ofs && ofs <= line.right) { - insideIndex = line.index; - break; - } - if (line.left < mostLeft) { - mostLeft = line.left; - mostLeftLine = line; - } - if (line.right > mostRight) { - mostRight = line.right; - mostRightLine = line; - } + if (line.left <= ofs && ofs <= line.right) { + insideIndex = line.index; + break; + } + if (line.left < mostLeft) { + mostLeft = line.left; + mostLeftLine = line; + } + if (line.right > mostRight) { + mostRight = line.right; + mostRightLine = line; } } BOOL afinityEdge = NO; @@ -1896,12 +1844,7 @@ - (ASTextRange *)textRangeByExtendingPosition:(ASTextPosition *)position afinityEdge = YES; } ASTextLine *insideLine = _lines[insideIndex]; - NSUInteger pos; - if (isVerticalForm) { - pos = [self textPositionForPoint:CGPointMake(insideLine.position.x, ofs) lineIndex:insideIndex]; - } else { - pos = [self textPositionForPoint:CGPointMake(ofs, insideLine.position.y) lineIndex:insideIndex]; - } + NSUInteger pos = [self textPositionForPoint:CGPointMake(ofs, insideLine.position.y) lineIndex:insideIndex]; if (pos == NSNotFound) return nil; ASTextPosition *extPos; if (afinityEdge) { @@ -1980,11 +1923,7 @@ - (CGPoint)linePositionForPosition:(ASTextPosition *)position { ASTextLine *line = _lines[lineIndex]; CGFloat offset = [self offsetForTextPosition:position.offset lineIndex:lineIndex]; if (offset == CGFLOAT_MAX) return CGPointZero; - if (_container.verticalForm) { - return CGPointMake(line.position.x, offset); - } else { - return CGPointMake(offset, line.position.y); - } + return CGPointMake(offset, line.position.y); } - (CGRect)caretRectForPosition:(ASTextPosition *)position { @@ -1993,11 +1932,7 @@ - (CGRect)caretRectForPosition:(ASTextPosition *)position { ASTextLine *line = _lines[lineIndex]; CGFloat offset = [self offsetForTextPosition:position.offset lineIndex:lineIndex]; if (offset == CGFLOAT_MAX) return CGRectNull; - if (_container.verticalForm) { - return CGRectMake(line.bounds.origin.x, offset, line.bounds.size.width, 0); - } else { - return CGRectMake(offset, line.bounds.origin.y, 0, line.bounds.size.height); - } + return CGRectMake(offset, line.bounds.origin.y, 0, line.bounds.size.height); } - (CGRect)firstRectForRange:(ASTextRange *)range { @@ -2015,54 +1950,28 @@ - (CGRect)firstRectForRange:(ASTextRange *)range { if (line.row != startLine.row) break; [lines addObject:line]; } - if (_container.verticalForm) { - if (lines.count == 1) { - CGFloat top = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; - CGFloat bottom; - if (startLine == endLine) { - bottom = [self offsetForTextPosition:range.end.offset lineIndex:startLineIndex]; - } else { - bottom = startLine.bottom; - } - if (top == CGFLOAT_MAX || bottom == CGFLOAT_MAX) return CGRectNull; - if (top > bottom) ASTEXT_SWAP(top, bottom); - return CGRectMake(startLine.left, top, startLine.width, bottom - top); + if (lines.count == 1) { + CGFloat left = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; + CGFloat right; + if (startLine == endLine) { + right = [self offsetForTextPosition:range.end.offset lineIndex:startLineIndex]; } else { - CGFloat top = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; - CGFloat bottom = startLine.bottom; - if (top == CGFLOAT_MAX || bottom == CGFLOAT_MAX) return CGRectNull; - if (top > bottom) ASTEXT_SWAP(top, bottom); - CGRect rect = CGRectMake(startLine.left, top, startLine.width, bottom - top); - for (NSUInteger i = 1; i < lines.count; i++) { - ASTextLine *line = lines[i]; - rect = CGRectUnion(rect, line.bounds); - } - return rect; + right = startLine.right; } + if (left == CGFLOAT_MAX || right == CGFLOAT_MAX) return CGRectNull; + if (left > right) ASTEXT_SWAP(left, right); + return CGRectMake(left, startLine.top, right - left, startLine.height); } else { - if (lines.count == 1) { - CGFloat left = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; - CGFloat right; - if (startLine == endLine) { - right = [self offsetForTextPosition:range.end.offset lineIndex:startLineIndex]; - } else { - right = startLine.right; - } - if (left == CGFLOAT_MAX || right == CGFLOAT_MAX) return CGRectNull; - if (left > right) ASTEXT_SWAP(left, right); - return CGRectMake(left, startLine.top, right - left, startLine.height); - } else { - CGFloat left = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; - CGFloat right = startLine.right; - if (left == CGFLOAT_MAX || right == CGFLOAT_MAX) return CGRectNull; - if (left > right) ASTEXT_SWAP(left, right); - CGRect rect = CGRectMake(left, startLine.top, right - left, startLine.height); - for (NSUInteger i = 1; i < lines.count; i++) { - ASTextLine *line = lines[i]; - rect = CGRectUnion(rect, line.bounds); - } - return rect; + CGFloat left = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; + CGFloat right = startLine.right; + if (left == CGFLOAT_MAX || right == CGFLOAT_MAX) return CGRectNull; + if (left > right) ASTEXT_SWAP(left, right); + CGRect rect = CGRectMake(left, startLine.top, right - left, startLine.height); + for (NSUInteger i = 1; i < lines.count; i++) { + ASTextLine *line = lines[i]; + rect = CGRectUnion(rect, line.bounds); } + return rect; } } @@ -2080,7 +1989,6 @@ - (CGRect)rectForRange:(ASTextRange *)range { - (NSArray *)selectionRectsForRange:(ASTextRange *)range { range = [self _correctedRangeWithEdge:range]; - BOOL isVertical = _container.verticalForm; NSMutableArray *rects = [[NSMutableArray alloc] init]; if (!range) return rects; @@ -2094,81 +2002,52 @@ - (NSArray *)selectionRectsForRange:(ASTextRange *)range { CGFloat offsetEnd = [self offsetForTextPosition:range.end.offset lineIndex:endLineIndex]; ASTextSelectionRect *start = [ASTextSelectionRect new]; - if (isVertical) { - start.rect = CGRectMake(startLine.left, offsetStart, startLine.width, 0); - } else { - start.rect = CGRectMake(offsetStart, startLine.top, 0, startLine.height); - } + start.rect = CGRectMake(offsetStart, startLine.top, 0, startLine.height); start.containsStart = YES; - start.isVertical = isVertical; + start.isVertical = NO; [rects addObject:start]; ASTextSelectionRect *end = [ASTextSelectionRect new]; - if (isVertical) { - end.rect = CGRectMake(endLine.left, offsetEnd, endLine.width, 0); - } else { - end.rect = CGRectMake(offsetEnd, endLine.top, 0, endLine.height); - } + end.rect = CGRectMake(offsetEnd, endLine.top, 0, endLine.height); end.containsEnd = YES; - end.isVertical = isVertical; + end.isVertical = NO; [rects addObject:end]; if (startLine.row == endLine.row) { // same row if (offsetStart > offsetEnd) ASTEXT_SWAP(offsetStart, offsetEnd); ASTextSelectionRect *rect = [ASTextSelectionRect new]; - if (isVertical) { - rect.rect = CGRectMake(startLine.bounds.origin.x, offsetStart, MAX(startLine.width, endLine.width), offsetEnd - offsetStart); - } else { - rect.rect = CGRectMake(offsetStart, startLine.bounds.origin.y, offsetEnd - offsetStart, MAX(startLine.height, endLine.height)); - } - rect.isVertical = isVertical; + rect.rect = CGRectMake(offsetStart, startLine.bounds.origin.y, offsetEnd - offsetStart, MAX(startLine.height, endLine.height)); + rect.isVertical = NO; [rects addObject:rect]; } else { // more than one row // start line select rect ASTextSelectionRect *topRect = [ASTextSelectionRect new]; - topRect.isVertical = isVertical; + topRect.isVertical = NO; CGFloat topOffset = [self offsetForTextPosition:range.start.offset lineIndex:startLineIndex]; CTRunRef topRun = [self _runForLine:startLine position:range.start]; if (topRun && (CTRunGetStatus(topRun) & kCTRunStatusRightToLeft)) { - if (isVertical) { - topRect.rect = CGRectMake(startLine.left, _container.path ? startLine.top : _container.insets.top, startLine.width, topOffset - startLine.top); - } else { - topRect.rect = CGRectMake(_container.path ? startLine.left : _container.insets.left, startLine.top, topOffset - startLine.left, startLine.height); - } + topRect.rect = CGRectMake(_container.path ? startLine.left : _container.insets.left, startLine.top, topOffset - startLine.left, startLine.height); topRect.writingDirection = UITextWritingDirectionRightToLeft; } else { - if (isVertical) { - topRect.rect = CGRectMake(startLine.left, topOffset, startLine.width, (_container.path ? startLine.bottom : _container.size.height - _container.insets.bottom) - topOffset); - } else { - // TODO: Fixes highlighting first row only to the end of the text and not highlight - // the while line to the end. Needs to brought over to multiline support - topRect.rect = CGRectMake(topOffset, startLine.top, (_container.path ? startLine.right : _container.size.width - _container.insets.right) - topOffset - (_container.size.width - _container.insets.right - startLine.right), startLine.height); - } + // TODO: Fixes highlighting first row only to the end of the text and not highlight + // the while line to the end. Needs to brought over to multiline support + topRect.rect = CGRectMake(topOffset, startLine.top, (_container.path ? startLine.right : _container.size.width - _container.insets.right) - topOffset - (_container.size.width - _container.insets.right - startLine.right), startLine.height); } [rects addObject:topRect]; // end line select rect ASTextSelectionRect *bottomRect = [ASTextSelectionRect new]; - bottomRect.isVertical = isVertical; + bottomRect.isVertical = NO; CGFloat bottomOffset = [self offsetForTextPosition:range.end.offset lineIndex:endLineIndex]; CTRunRef bottomRun = [self _runForLine:endLine position:range.end]; if (bottomRun && (CTRunGetStatus(bottomRun) & kCTRunStatusRightToLeft)) { - if (isVertical) { - bottomRect.rect = CGRectMake(endLine.left, bottomOffset, endLine.width, (_container.path ? endLine.bottom : _container.size.height - _container.insets.bottom) - bottomOffset); - } else { - bottomRect.rect = CGRectMake(bottomOffset, endLine.top, (_container.path ? endLine.right : _container.size.width - _container.insets.right) - bottomOffset, endLine.height); - } + bottomRect.rect = CGRectMake(bottomOffset, endLine.top, (_container.path ? endLine.right : _container.size.width - _container.insets.right) - bottomOffset, endLine.height); bottomRect.writingDirection = UITextWritingDirectionRightToLeft; } else { - if (isVertical) { - CGFloat top = _container.path ? endLine.top : _container.insets.top; - bottomRect.rect = CGRectMake(endLine.left, top, endLine.width, bottomOffset - top); - } else { - CGFloat left = _container.path ? endLine.left : _container.insets.left; - bottomRect.rect = CGRectMake(left, endLine.top, bottomOffset - left, endLine.height); - } + CGFloat left = _container.path ? endLine.left : _container.insets.left; + bottomRect.rect = CGRectMake(left, endLine.top, bottomOffset - left, endLine.height); } [rects addObject:bottomRect]; @@ -2186,49 +2065,28 @@ - (NSArray *)selectionRectsForRange:(ASTextRange *)range { } } if (startLineDetected) { - if (isVertical) { - if (!_container.path) { - r.origin.y = _container.insets.top; - r.size.height = _container.size.height - _container.insets.bottom - _container.insets.top; - } - r.size.width = CGRectGetMinX(topRect.rect) - CGRectGetMaxX(bottomRect.rect); - r.origin.x = CGRectGetMaxX(bottomRect.rect); - } else { - if (!_container.path) { - r.origin.x = _container.insets.left; - r.size.width = _container.size.width - _container.insets.right - _container.insets.left; - } - r.origin.y = CGRectGetMaxY(topRect.rect); - r.size.height = bottomRect.rect.origin.y - r.origin.y; + if (!_container.path) { + r.origin.x = _container.insets.left; + r.size.width = _container.size.width - _container.insets.right - _container.insets.left; } + r.origin.y = CGRectGetMaxY(topRect.rect); + r.size.height = bottomRect.rect.origin.y - r.origin.y; ASTextSelectionRect *rect = [ASTextSelectionRect new]; rect.rect = r; - rect.isVertical = isVertical; + rect.isVertical = NO; [rects addObject:rect]; } } else { - if (isVertical) { - CGRect r0 = bottomRect.rect; - CGRect r1 = topRect.rect; - CGFloat mid = (CGRectGetMaxX(r0) + CGRectGetMinX(r1)) * 0.5; - r0.size.width = mid - r0.origin.x; - CGFloat r1ofs = r1.origin.x - mid; - r1.origin.x -= r1ofs; - r1.size.width += r1ofs; - topRect.rect = r1; - bottomRect.rect = r0; - } else { - CGRect r0 = topRect.rect; - CGRect r1 = bottomRect.rect; - CGFloat mid = (CGRectGetMaxY(r0) + CGRectGetMinY(r1)) * 0.5; - r0.size.height = mid - r0.origin.y; - CGFloat r1ofs = r1.origin.y - mid; - r1.origin.y -= r1ofs; - r1.size.height += r1ofs; - topRect.rect = r0; - bottomRect.rect = r1; - } + CGRect r0 = topRect.rect; + CGRect r1 = bottomRect.rect; + CGFloat mid = (CGRectGetMaxY(r0) + CGRectGetMinY(r1)) * 0.5; + r0.size.height = mid - r0.origin.y; + CGFloat r1ofs = r1.origin.y - mid; + r1.origin.y -= r1ofs; + r1.size.height += r1ofs; + topRect.rect = r0; + bottomRect.rect = r1; } } return rects; @@ -2274,18 +2132,11 @@ typedef NS_OPTIONS(NSUInteger, ASTextBorderType) { ASTextBorderTypeNormal = 1 << 1, }; -static CGRect ASTextMergeRectInSameLine(CGRect rect1, CGRect rect2, BOOL isVertical) { - if (isVertical) { - CGFloat top = MIN(rect1.origin.y, rect2.origin.y); - CGFloat bottom = MAX(rect1.origin.y + rect1.size.height, rect2.origin.y + rect2.size.height); - CGFloat width = MAX(rect1.size.width, rect2.size.width); - return CGRectMake(rect1.origin.x, top, width, bottom - top); - } else { - CGFloat left = MIN(rect1.origin.x, rect2.origin.x); - CGFloat right = MAX(rect1.origin.x + rect1.size.width, rect2.origin.x + rect2.size.width); - CGFloat height = MAX(rect1.size.height, rect2.size.height); - return CGRectMake(left, rect1.origin.y, right - left, height); - } +static CGRect ASTextMergeRectInSameLine(CGRect rect1, CGRect rect2) { + CGFloat left = MIN(rect1.origin.x, rect2.origin.x); + CGFloat right = MAX(rect1.origin.x + rect1.size.width, rect2.origin.x + rect2.size.width); + CGFloat height = MAX(rect1.size.height, rect2.size.height); + return CGRectMake(left, rect1.origin.y, right - left, height); } static void ASTextGetRunsMaxMetric(CFArrayRef runs, CGFloat *xHeight, CGFloat *underlinePosition, CGFloat *lineThickness) { @@ -2312,13 +2163,13 @@ static void ASTextGetRunsMaxMetric(CFArrayRef runs, CGFloat *xHeight, CGFloat *u if (lineThickness) *lineThickness = maxLineThickness; } -static void ASTextDrawRun(ASTextLine *line, CTRunRef run, CGContextRef context, CGSize size, BOOL isVertical, NSArray *runRanges, CGFloat verticalOffset) { +static void ASTextDrawRun(ASTextLine *line, CTRunRef run, CGContextRef context, CGSize size, NSArray *runRanges, CGFloat verticalOffset) { CGAffineTransform runTextMatrix = CTRunGetTextMatrix(run); BOOL runTextMatrixIsID = CGAffineTransformIsIdentity(runTextMatrix); CFDictionaryRef runAttrs = CTRunGetAttributes(run); NSValue *glyphTransformValue = (NSValue *)CFDictionaryGetValue(runAttrs, (__bridge const void *)(ASTextGlyphTransformAttributeName)); - if (!isVertical && !glyphTransformValue) { // draw run + if (!glyphTransformValue) { // draw run if (!runTextMatrixIsID) { CGContextSaveGState(context); CGAffineTransform trans = CGContextGetTextMatrix(context); @@ -2358,102 +2209,46 @@ static void ASTextDrawRun(ASTextLine *line, CTRunRef run, CGContextRef context, CGContextSetTextDrawingMode(context, kCGTextFillStroke); } } - - if (isVertical) { + if (glyphTransformValue) { CFIndex runStrIdx[glyphCount + 1]; CTRunGetStringIndices(run, CFRangeMake(0, 0), runStrIdx); CFRange runStrRange = CTRunGetStringRange(run); runStrIdx[glyphCount] = runStrRange.location + runStrRange.length; CGSize glyphAdvances[glyphCount]; CTRunGetAdvances(run, CFRangeMake(0, 0), glyphAdvances); - CGFloat ascent = CTFontGetAscent(runFont); - CGFloat descent = CTFontGetDescent(runFont); CGAffineTransform glyphTransform = glyphTransformValue.CGAffineTransformValue; CGPoint zeroPoint = CGPointZero; - - for (ASTextRunGlyphRange *oneRange in runRanges) { - NSRange range = oneRange.glyphRangeInRun; - NSUInteger rangeMax = range.location + range.length; - ASTextRunGlyphDrawMode mode = oneRange.drawMode; - - for (NSUInteger g = range.location; g < rangeMax; g++) { - CGContextSaveGState(context); { - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - if (glyphTransformValue) { - CGContextSetTextMatrix(context, glyphTransform); - } - if (mode) { // CJK glyph, need rotated - CGFloat ofs = (ascent - descent) * 0.5; - CGFloat w = glyphAdvances[g].width * 0.5; - CGFloat x = line.position.x + verticalOffset + glyphPositions[g].y + (ofs - w); - CGFloat y = -line.position.y + size.height - glyphPositions[g].x - (ofs + w); - if (mode == ASTextRunGlyphDrawModeVerticalRotateMove) { - x += w; - y += w; - } - CGContextSetTextPosition(context, x, y); - } else { - CGContextRotateCTM(context, -M_PI_2); - CGContextSetTextPosition(context, - line.position.y - size.height + glyphPositions[g].x, - line.position.x + verticalOffset + glyphPositions[g].y); - } - - if (ASTextCTFontContainsColorBitmapGlyphs(runFont)) { - CTFontDrawGlyphs(runFont, glyphs + g, &zeroPoint, 1, context); - } else { - CGFontRef cgFont = CTFontCopyGraphicsFont(runFont, NULL); - CGContextSetFont(context, cgFont); - CGContextSetFontSize(context, CTFontGetSize(runFont)); - CGContextShowGlyphsAtPositions(context, glyphs + g, &zeroPoint, 1); - CGFontRelease(cgFont); - } - } CGContextRestoreGState(context); - } + + for (NSUInteger g = 0; g < glyphCount; g++) { + CGContextSaveGState(context); { + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextSetTextMatrix(context, glyphTransform); + CGContextSetTextPosition(context, + line.position.x + glyphPositions[g].x, + size.height - (line.position.y + glyphPositions[g].y)); + + if (ASTextCTFontContainsColorBitmapGlyphs(runFont)) { + CTFontDrawGlyphs(runFont, glyphs + g, &zeroPoint, 1, context); + } else { + CGFontRef cgFont = CTFontCopyGraphicsFont(runFont, NULL); + CGContextSetFont(context, cgFont); + CGContextSetFontSize(context, CTFontGetSize(runFont)); + CGContextShowGlyphsAtPositions(context, glyphs + g, &zeroPoint, 1); + CGFontRelease(cgFont); + } + } CGContextRestoreGState(context); } - } else { // not vertical - if (glyphTransformValue) { - CFIndex runStrIdx[glyphCount + 1]; - CTRunGetStringIndices(run, CFRangeMake(0, 0), runStrIdx); - CFRange runStrRange = CTRunGetStringRange(run); - runStrIdx[glyphCount] = runStrRange.location + runStrRange.length; - CGSize glyphAdvances[glyphCount]; - CTRunGetAdvances(run, CFRangeMake(0, 0), glyphAdvances); - CGAffineTransform glyphTransform = glyphTransformValue.CGAffineTransformValue; - CGPoint zeroPoint = CGPointZero; - - for (NSUInteger g = 0; g < glyphCount; g++) { - CGContextSaveGState(context); { - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextSetTextMatrix(context, glyphTransform); - CGContextSetTextPosition(context, - line.position.x + glyphPositions[g].x, - size.height - (line.position.y + glyphPositions[g].y)); - - if (ASTextCTFontContainsColorBitmapGlyphs(runFont)) { - CTFontDrawGlyphs(runFont, glyphs + g, &zeroPoint, 1, context); - } else { - CGFontRef cgFont = CTFontCopyGraphicsFont(runFont, NULL); - CGContextSetFont(context, cgFont); - CGContextSetFontSize(context, CTFontGetSize(runFont)); - CGContextShowGlyphsAtPositions(context, glyphs + g, &zeroPoint, 1); - CGFontRelease(cgFont); - } - } CGContextRestoreGState(context); - } + } else { + if (ASTextCTFontContainsColorBitmapGlyphs(runFont)) { + CTFontDrawGlyphs(runFont, glyphs, glyphPositions, glyphCount, context); } else { - if (ASTextCTFontContainsColorBitmapGlyphs(runFont)) { - CTFontDrawGlyphs(runFont, glyphs, glyphPositions, glyphCount, context); - } else { - CGFontRef cgFont = CTFontCopyGraphicsFont(runFont, NULL); - CGContextSetFont(context, cgFont); - CGContextSetFontSize(context, CTFontGetSize(runFont)); - CGContextShowGlyphsAtPositions(context, glyphs, glyphPositions, glyphCount); - CGFontRelease(cgFont); - } + CGFontRef cgFont = CTFontCopyGraphicsFont(runFont, NULL); + CGContextSetFont(context, cgFont); + CGContextSetFontSize(context, CTFontGetSize(runFont)); + CGContextShowGlyphsAtPositions(context, glyphs, glyphPositions, glyphCount); + CGFontRelease(cgFont); } } - } CGContextRestoreGState(context); } } @@ -2488,7 +2283,7 @@ static void ASTextSetLinePatternInContext(ASTextLineStyle style, CGFloat width, } -static void ASTextDrawBorderRects(CGContextRef context, CGSize size, ASTextBorder *border, NSArray *rects, BOOL isVertical) { +static void ASTextDrawBorderRects(CGContextRef context, CGSize size, ASTextBorder *border, NSArray *rects) { if (rects.count == 0) return; ASTextShadow *shadow = border.shadow; @@ -2501,11 +2296,7 @@ static void ASTextDrawBorderRects(CGContextRef context, CGSize size, ASTextBorde NSMutableArray *paths = [NSMutableArray new]; for (NSValue *value in rects) { CGRect rect = value.CGRectValue; - if (isVertical) { - rect = UIEdgeInsetsInsetRect(rect, UIEdgeInsetRotateVertical(border.insets)); - } else { - rect = UIEdgeInsetsInsetRect(rect, border.insets); - } + rect = UIEdgeInsetsInsetRect(rect, border.insets); rect = ASTextCGRectPixelRound(rect); UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:border.cornerRadius]; [path closePath]; @@ -2547,11 +2338,7 @@ static void ASTextDrawBorderRects(CGContextRef context, CGSize size, ASTextBorde CGContextSetLineJoin(context, border.lineJoin); for (NSValue *value in rects) { CGRect rect = value.CGRectValue; - if (isVertical) { - rect = UIEdgeInsetsInsetRect(rect, UIEdgeInsetRotateVertical(border.insets)); - } else { - rect = UIEdgeInsetsInsetRect(rect, border.insets); - } + rect = UIEdgeInsetsInsetRect(rect, border.insets); rect = CGRectInset(rect, inset, inset); UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:border.cornerRadius + radiusDelta]; [path closePath]; @@ -2604,85 +2391,46 @@ static void ASTextDrawBorderRects(CGContextRef context, CGSize size, ASTextBorde } } -static void ASTextDrawLineStyle(CGContextRef context, CGFloat length, CGFloat lineWidth, ASTextLineStyle style, CGPoint position, CGColorRef color, BOOL isVertical) { +static void ASTextDrawLineStyle(CGContextRef context, CGFloat length, CGFloat lineWidth, ASTextLineStyle style, CGPoint position, CGColorRef color) { NSUInteger styleBase = style & 0xFF; if (styleBase == 0) return; CGContextSaveGState(context); { - if (isVertical) { - CGFloat x, y1, y2, w; - y1 = ASRoundPixelValue(position.y); - y2 = ASRoundPixelValue(position.y + length); - w = (styleBase == ASTextLineStyleThick ? lineWidth * 2 : lineWidth); - - CGFloat linePixel = ASTextCGFloatToPixel(w); - if (fabs(linePixel - floor(linePixel)) < 0.1) { - int iPixel = linePixel; - if (iPixel == 0 || (iPixel % 2)) { // odd line pixel - x = ASTextCGFloatPixelHalf(position.x); - } else { - x = ASFloorPixelValue(position.x); - } + CGFloat x1, x2, y, w; + x1 = ASRoundPixelValue(position.x); + x2 = ASRoundPixelValue(position.x + length); + w = (styleBase == ASTextLineStyleThick ? lineWidth * 2 : lineWidth); + + CGFloat linePixel = ASTextCGFloatToPixel(w); + if (fabs(linePixel - floor(linePixel)) < 0.1) { + int iPixel = linePixel; + if (iPixel == 0 || (iPixel % 2)) { // odd line pixel + y = ASTextCGFloatPixelHalf(position.y); } else { - x = position.x; - } - - CGContextSetStrokeColorWithColor(context, color); - ASTextSetLinePatternInContext(style, lineWidth, position.y, context); - CGContextSetLineWidth(context, w); - if (styleBase == ASTextLineStyleSingle) { - CGContextMoveToPoint(context, x, y1); - CGContextAddLineToPoint(context, x, y2); - CGContextStrokePath(context); - } else if (styleBase == ASTextLineStyleThick) { - CGContextMoveToPoint(context, x, y1); - CGContextAddLineToPoint(context, x, y2); - CGContextStrokePath(context); - } else if (styleBase == ASTextLineStyleDouble) { - CGContextMoveToPoint(context, x - w, y1); - CGContextAddLineToPoint(context, x - w, y2); - CGContextStrokePath(context); - CGContextMoveToPoint(context, x + w, y1); - CGContextAddLineToPoint(context, x + w, y2); - CGContextStrokePath(context); + y = ASFloorPixelValue(position.y); } } else { - CGFloat x1, x2, y, w; - x1 = ASRoundPixelValue(position.x); - x2 = ASRoundPixelValue(position.x + length); - w = (styleBase == ASTextLineStyleThick ? lineWidth * 2 : lineWidth); - - CGFloat linePixel = ASTextCGFloatToPixel(w); - if (fabs(linePixel - floor(linePixel)) < 0.1) { - int iPixel = linePixel; - if (iPixel == 0 || (iPixel % 2)) { // odd line pixel - y = ASTextCGFloatPixelHalf(position.y); - } else { - y = ASFloorPixelValue(position.y); - } - } else { - y = position.y; - } - - CGContextSetStrokeColorWithColor(context, color); - ASTextSetLinePatternInContext(style, lineWidth, position.x, context); - CGContextSetLineWidth(context, w); - if (styleBase == ASTextLineStyleSingle) { - CGContextMoveToPoint(context, x1, y); - CGContextAddLineToPoint(context, x2, y); - CGContextStrokePath(context); - } else if (styleBase == ASTextLineStyleThick) { - CGContextMoveToPoint(context, x1, y); - CGContextAddLineToPoint(context, x2, y); - CGContextStrokePath(context); - } else if (styleBase == ASTextLineStyleDouble) { - CGContextMoveToPoint(context, x1, y - w); - CGContextAddLineToPoint(context, x2, y - w); - CGContextStrokePath(context); - CGContextMoveToPoint(context, x1, y + w); - CGContextAddLineToPoint(context, x2, y + w); - CGContextStrokePath(context); - } + y = position.y; + } + + CGContextSetStrokeColorWithColor(context, color); + ASTextSetLinePatternInContext(style, lineWidth, position.x, context); + CGContextSetLineWidth(context, w); + if (styleBase == ASTextLineStyleSingle) { + CGContextMoveToPoint(context, x1, y); + CGContextAddLineToPoint(context, x2, y); + CGContextStrokePath(context); + } else if (styleBase == ASTextLineStyleThick) { + CGContextMoveToPoint(context, x1, y); + CGContextAddLineToPoint(context, x2, y); + CGContextStrokePath(context); + } else if (styleBase == ASTextLineStyleDouble) { + CGContextMoveToPoint(context, x1, y - w); + CGContextAddLineToPoint(context, x2, y - w); + CGContextStrokePath(context); + CGContextMoveToPoint(context, x1, y + w); + CGContextAddLineToPoint(context, x2, y + w); + CGContextStrokePath(context); } } CGContextRestoreGState(context); } @@ -2694,8 +2442,7 @@ static void ASTextDrawText(ASTextLayout *layout, CGContextRef context, CGSize si CGContextTranslateCTM(context, 0, size.height); CGContextScaleCTM(context, 1, -1); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; NSArray *lines = layout.lines; for (NSUInteger l = 0, lMax = lines.count; l < lMax; l++) { @@ -2709,7 +2456,7 @@ static void ASTextDrawText(ASTextLayout *layout, CGContextRef context, CGSize si CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runs, r); CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextSetTextPosition(context, posX, posY); - ASTextDrawRun(line, run, context, size, isVertical, lineRunRanges[r], verticalOffset); + ASTextDrawRun(line, run, context, size, lineRunRanges[r], verticalOffset); } if (cancel && cancel()) break; } @@ -2725,8 +2472,7 @@ static void ASTextDrawBlockBorder(ASTextLayout *layout, CGContextRef context, CG CGContextSaveGState(context); CGContextTranslateCTM(context, point.x, point.y); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; NSArray *lines = layout.lines; for (NSInteger l = 0, lMax = lines.count; l < lMax; l++) { @@ -2772,18 +2518,13 @@ static void ASTextDrawBlockBorder(ASTextLayout *layout, CGContextRef context, CG } lineContinueIndex++; } while (true); - - if (isVertical) { - UIEdgeInsets insets = layout.container.insets; - unionRect.origin.y = insets.top; - unionRect.size.height = layout.container.size.height -insets.top - insets.bottom; - } else { - UIEdgeInsets insets = layout.container.insets; - unionRect.origin.x = insets.left; - unionRect.size.width = layout.container.size.width -insets.left - insets.right; - } + + UIEdgeInsets insets = layout.container.insets; + unionRect.origin.x = insets.left; + unionRect.size.width = layout.container.size.width -insets.left - insets.right; + unionRect.origin.x += verticalOffset; - ASTextDrawBorderRects(context, size, border, @[[NSValue valueWithCGRect:unionRect]], isVertical); + ASTextDrawBorderRects(context, size, border, @[[NSValue valueWithCGRect:unionRect]]); l = lineContinueIndex; break; @@ -2798,8 +2539,7 @@ static void ASTextDrawBorder(ASTextLayout *layout, CGContextRef context, CGSize CGContextSaveGState(context); CGContextTranslateCTM(context, point.x, point.y); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; NSArray *lines = layout.lines; NSString *borderKey = (type == ASTextBorderTypeNormal ? ASTextBorderAttributeName : ASTextBackgroundBorderAttributeName); @@ -2857,25 +2597,15 @@ static void ASTextDrawBorder(ASTextLayout *layout, CGContextRef context, CGSize CTRunGetPositions(iRun, CFRangeMake(0, 1), &iRunPosition); CGFloat ascent, descent; CGFloat iRunWidth = CTRunGetTypographicBounds(iRun, CFRangeMake(0, 0), &ascent, &descent, NULL); - - if (isVertical) { - ASTEXT_SWAP(iRunPosition.x, iRunPosition.y); - iRunPosition.y += iLine.position.y; - CGRect iRect = CGRectMake(verticalOffset + line.position.x - descent, iRunPosition.y, ascent + descent, iRunWidth); - if (CGRectIsNull(extLineRect)) { - extLineRect = iRect; - } else { - extLineRect = CGRectUnion(extLineRect, iRect); - } + + iRunPosition.x += iLine.position.x; + CGRect iRect = CGRectMake(iRunPosition.x, iLine.position.y - ascent, iRunWidth, ascent + descent); + if (CGRectIsNull(extLineRect)) { + extLineRect = iRect; } else { - iRunPosition.x += iLine.position.x; - CGRect iRect = CGRectMake(iRunPosition.x, iLine.position.y - ascent, iRunWidth, ascent + descent); - if (CGRectIsNull(extLineRect)) { - extLineRect = iRect; - } else { - extLineRect = CGRectUnion(extLineRect, iRect); - } + extLineRect = CGRectUnion(extLineRect, iRect); } + } if (!CGRectIsNull(extLineRect)) { @@ -2887,27 +2617,18 @@ static void ASTextDrawBorder(ASTextLayout *layout, CGContextRef context, CGSize CGRect curRect= ((NSValue *)[runRects firstObject]).CGRectValue; for (NSInteger re = 0, reMax = runRects.count; re < reMax; re++) { CGRect rect = ((NSValue *)runRects[re]).CGRectValue; - if (isVertical) { - if (fabs(rect.origin.x - curRect.origin.x) < 1) { - curRect = ASTextMergeRectInSameLine(rect, curRect, isVertical); - } else { - [drawRects addObject:[NSValue valueWithCGRect:curRect]]; - curRect = rect; - } + if (fabs(rect.origin.y - curRect.origin.y) < 1) { + curRect = ASTextMergeRectInSameLine(rect, curRect); } else { - if (fabs(rect.origin.y - curRect.origin.y) < 1) { - curRect = ASTextMergeRectInSameLine(rect, curRect, isVertical); - } else { - [drawRects addObject:[NSValue valueWithCGRect:curRect]]; - curRect = rect; - } + [drawRects addObject:[NSValue valueWithCGRect:curRect]]; + curRect = rect; } } if (!CGRectEqualToRect(curRect, CGRectZero)) { [drawRects addObject:[NSValue valueWithCGRect:curRect]]; } - ASTextDrawBorderRects(context, size, border, drawRects, isVertical); + ASTextDrawBorderRects(context, size, border, drawRects); if (l == endLineIndex) { r = endRunIndex; @@ -2930,8 +2651,7 @@ static void ASTextDrawDecoration(ASTextLayout *layout, CGContextRef context, CGS CGContextSaveGState(context); CGContextTranslateCTM(context, point.x, point.y); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; CGContextTranslateCTM(context, verticalOffset, 0); for (NSUInteger l = 0, lMax = layout.lines.count; l < lMax; l++) { @@ -2969,26 +2689,15 @@ static void ASTextDrawDecoration(ASTextLayout *layout, CGContextRef context, CGS CGPoint underlineStart, strikethroughStart; CGFloat length; - - if (isVertical) { - underlineStart.x = line.position.x + underlinePosition; - strikethroughStart.x = line.position.x + xHeight / 2; - - CGPoint runPosition = CGPointZero; - CTRunGetPositions(run, CFRangeMake(0, 1), &runPosition); - underlineStart.y = strikethroughStart.y = runPosition.x + line.position.y; - length = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); - - } else { - underlineStart.y = line.position.y - underlinePosition; - strikethroughStart.y = line.position.y - xHeight / 2; - - CGPoint runPosition = CGPointZero; - CTRunGetPositions(run, CFRangeMake(0, 1), &runPosition); - underlineStart.x = strikethroughStart.x = runPosition.x + line.position.x; - length = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); - } - + + underlineStart.y = line.position.y - underlinePosition; + strikethroughStart.y = line.position.y - xHeight / 2; + + CGPoint runPosition = CGPointZero; + CTRunGetPositions(run, CFRangeMake(0, 1), &runPosition); + underlineStart.x = strikethroughStart.x = runPosition.x + line.position.x; + length = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), NULL, NULL, NULL); + if (needDrawUnderline) { CGColorRef color = underline.color.CGColor; if (!color) { @@ -3010,12 +2719,12 @@ static void ASTextDrawDecoration(ASTextLayout *layout, CGContextRef context, CGS CGContextSetShadowWithColor(context, offset, shadow.radius, shadow.color.CGColor); CGContextSetBlendMode(context, shadow.blendMode); CGContextTranslateCTM(context, offsetAlterX, 0); - ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color, isVertical); + ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color); } CGContextRestoreGState(context); } CGContextRestoreGState(context); shadow = shadow.subShadow; } - ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color, isVertical); + ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color); } if (needDrawStrikethrough) { @@ -3039,12 +2748,12 @@ static void ASTextDrawDecoration(ASTextLayout *layout, CGContextRef context, CGS CGContextSetShadowWithColor(context, offset, shadow.radius, shadow.color.CGColor); CGContextSetBlendMode(context, shadow.blendMode); CGContextTranslateCTM(context, offsetAlterX, 0); - ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color, isVertical); + ASTextDrawLineStyle(context, length, thickness, underline.style, underlineStart, color); } CGContextRestoreGState(context); } CGContextRestoreGState(context); shadow = shadow.subShadow; } - ASTextDrawLineStyle(context, length, thickness, strikethrough.style, strikethroughStart, color, isVertical); + ASTextDrawLineStyle(context, length, thickness, strikethrough.style, strikethroughStart, color); } } } @@ -3053,8 +2762,7 @@ static void ASTextDrawDecoration(ASTextLayout *layout, CGContextRef context, CGS static void ASTextDrawAttachment(ASTextLayout *layout, CGContextRef context, CGSize size, CGPoint point, UIView *targetView, CALayer *targetLayer, BOOL (^cancel)(void)) { - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; for (NSUInteger i = 0, max = layout.attachments.count; i < max; i++) { ASTextAttachment *a = layout.attachments[i]; @@ -3063,26 +2771,34 @@ static void ASTextDrawAttachment(ASTextLayout *layout, CGContextRef context, CGS UIImage *image = nil; UIView *view = nil; CALayer *layer = nil; + ASDisplayNode *node = nil; if ([a.content isKindOfClass:[UIImage class]]) { image = a.content; } else if ([a.content isKindOfClass:[UIView class]]) { view = a.content; } else if ([a.content isKindOfClass:[CALayer class]]) { layer = a.content; + } else if ([a.content isKindOfClass:[ASDisplayNode class]]) { + node = a.content; } - if (!image && !view && !layer) continue; - if (image && !context) continue; + if (!image && !view && !layer && !node) continue; + if ((image || node) && !context) continue; if (view && !targetView) continue; if (layer && !targetLayer) continue; if (cancel && cancel()) break; - + if (!image && node) { + ASNetworkImageNode *networkImage = ASDynamicCast(node, ASNetworkImageNode); + if ([networkImage animatedImage]) { + // Need to check first because [networkImage defaultImage] can return coverImage for + // animated image. + image = [[UIImage alloc] initWithCGImage:(CGImageRef)node.contents]; + } else { + image = [networkImage image] ?: [networkImage defaultImage]; + } + } CGSize asize = image ? image.size : view ? view.frame.size : layer.frame.size; CGRect rect = ((NSValue *)layout.attachmentRects[i]).CGRectValue; - if (isVertical) { - rect = UIEdgeInsetsInsetRect(rect, UIEdgeInsetRotateVertical(a.contentInsets)); - } else { - rect = UIEdgeInsetsInsetRect(rect, a.contentInsets); - } + rect = UIEdgeInsetsInsetRect(rect, a.contentInsets); rect = ASTextCGRectFitWithContentMode(rect, asize, a.contentMode); rect = ASTextCGRectPixelRound(rect); rect = CGRectStandardize(rect); @@ -3111,8 +2827,7 @@ static void ASTextDrawShadow(ASTextLayout *layout, CGContextRef context, CGSize //move out of context. (0xFFFF is just a random large number) CGFloat offsetAlterX = size.width + 0xFFFF; - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; CGContextSaveGState(context); { CGContextTranslateCTM(context, point.x, point.y); @@ -3149,7 +2864,7 @@ static void ASTextDrawShadow(ASTextLayout *layout, CGContextRef context, CGSize CGContextSetShadowWithColor(context, offset, shadow.radius, shadow.color.CGColor); CGContextSetBlendMode(context, shadow.blendMode); CGContextTranslateCTM(context, offsetAlterX, 0); - ASTextDrawRun(line, run, context, size, isVertical, lineRunRanges[r], verticalOffset); + ASTextDrawRun(line, run, context, size, lineRunRanges[r], verticalOffset); } CGContextRestoreGState(context); shadow = shadow.subShadow; } @@ -3165,8 +2880,7 @@ static void ASTextDrawInnerShadow(ASTextLayout *layout, CGContextRef context, CG CGContextScaleCTM(context, 1, -1); CGContextSetTextMatrix(context, CGAffineTransformIdentity); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; NSArray *lines = layout.lines; for (NSUInteger l = 0, lMax = lines.count; l < lMax; l++) { @@ -3217,7 +2931,7 @@ static void ASTextDrawInnerShadow(ASTextLayout *layout, CGContextRef context, CG CGContextFillRect(context, runImageBounds); CGContextSetBlendMode(context, kCGBlendModeDestinationIn); CGContextBeginTransparencyLayer(context, NULL); { - ASTextDrawRun(line, run, context, size, isVertical, lineRunRanges[r], verticalOffset); + ASTextDrawRun(line, run, context, size, lineRunRanges[r], verticalOffset); } CGContextEndTransparencyLayer(context); } CGContextEndTransparencyLayer(context); } CGContextEndTransparencyLayer(context); @@ -3239,8 +2953,7 @@ static void ASTextDrawDebug(ASTextLayout *layout, CGContextRef context, CGSize s CGContextSetLineJoin(context, kCGLineJoinMiter); CGContextSetLineCap(context, kCGLineCapButt); - BOOL isVertical = layout.container.verticalForm; - CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0; + CGFloat verticalOffset = 0; CGContextTranslateCTM(context, verticalOffset, 0); if (op.CTFrameBorderColor || op.CTFrameFillColor) { @@ -3316,28 +3029,19 @@ static void ASTextDrawDebug(ASTextLayout *layout, CGContextRef context, CGSize s } if (op.baselineColor) { [op.baselineColor setStroke]; - if (isVertical) { - CGFloat x = ASTextCGFloatPixelHalf(line.position.x); - CGFloat y1 = ASTextCGFloatPixelHalf(line.top); - CGFloat y2 = ASTextCGFloatPixelHalf(line.bottom); - CGContextMoveToPoint(context, x, y1); - CGContextAddLineToPoint(context, x, y2); - CGContextStrokePath(context); - } else { - CGFloat x1 = ASTextCGFloatPixelHalf(lineBounds.origin.x); - CGFloat x2 = ASTextCGFloatPixelHalf(lineBounds.origin.x + lineBounds.size.width); - CGFloat y = ASTextCGFloatPixelHalf(line.position.y); - CGContextMoveToPoint(context, x1, y); - CGContextAddLineToPoint(context, x2, y); - CGContextStrokePath(context); - } + CGFloat x1 = ASTextCGFloatPixelHalf(lineBounds.origin.x); + CGFloat x2 = ASTextCGFloatPixelHalf(lineBounds.origin.x + lineBounds.size.width); + CGFloat y = ASTextCGFloatPixelHalf(line.position.y); + CGContextMoveToPoint(context, x1, y); + CGContextAddLineToPoint(context, x2, y); + CGContextStrokePath(context); } if (op.CTLineNumberColor) { [op.CTLineNumberColor set]; NSMutableAttributedString *num = [[NSMutableAttributedString alloc] initWithString:@(l).description]; num.as_color = op.CTLineNumberColor; num.as_font = [UIFont systemFontOfSize:6]; - [num drawAtPoint:CGPointMake(line.position.x, line.position.y - (isVertical ? 1 : 6))]; + [num drawAtPoint:CGPointMake(line.position.x, line.position.y - 6)]; } if (op.CTRunFillColor || op.CTRunBorderColor || op.CTRunNumberColor || op.CGGlyphFillColor || op.CGGlyphBorderColor) { CFArrayRef runs = CTLineGetGlyphRuns(line.CTLine); @@ -3353,24 +3057,14 @@ static void ASTextDrawDebug(ASTextLayout *layout, CGContextRef context, CGSize s CTRunGetAdvances(run, CFRangeMake(0, glyphCount), glyphAdvances); CGPoint runPosition = glyphPositions[0]; - if (isVertical) { - ASTEXT_SWAP(runPosition.x, runPosition.y); - runPosition.x = line.position.x; - runPosition.y += line.position.y; - } else { - runPosition.x += line.position.x; - runPosition.y = line.position.y - runPosition.y; - } + runPosition.x += line.position.x; + runPosition.y = line.position.y - runPosition.y; CGFloat ascent, descent, leading; CGFloat width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, &leading); CGRect runTypoBounds; - if (isVertical) { - runTypoBounds = CGRectMake(runPosition.x - descent, runPosition.y, ascent + descent, width); - } else { - runTypoBounds = CGRectMake(runPosition.x, line.position.y - ascent, width, ascent + descent); - } - + runTypoBounds = CGRectMake(runPosition.x, line.position.y - ascent, width, ascent + descent); + if (op.CTRunFillColor) { [op.CTRunFillColor setFill]; CGContextAddRect(context, ASTextCGRectPixelRound(runTypoBounds)); @@ -3393,16 +3087,10 @@ static void ASTextDrawDebug(ASTextLayout *layout, CGContextRef context, CGSize s CGPoint pos = glyphPositions[g]; CGSize adv = glyphAdvances[g]; CGRect rect; - if (isVertical) { - ASTEXT_SWAP(pos.x, pos.y); - pos.x = runPosition.x; - pos.y += line.position.y; - rect = CGRectMake(pos.x - descent, pos.y, runTypoBounds.size.width, adv.width); - } else { - pos.x += line.position.x; - pos.y = runPosition.y; - rect = CGRectMake(pos.x, pos.y - ascent, adv.width, runTypoBounds.size.height); - } + pos.x += line.position.x; + pos.y = runPosition.y; + rect = CGRectMake(pos.x, pos.y - ascent, adv.width, runTypoBounds.size.height); + if (op.CGGlyphFillColor) { [op.CGGlyphFillColor setFill]; CGContextAddRect(context, ASTextCGRectPixelRound(rect)); @@ -3481,3 +3169,15 @@ - (void)drawInContext:(CGContextRef)context } @end + +NSAttributedString *fillBaseAttributes(NSAttributedString *str, NSDictionary *attrs) { + NSUInteger len = str.length; + if (!len) return str; + NSMutableAttributedString *m_result; // Do not create unless needed. + for (NSString *name in attrs) { + if ([str as_hasAttribute:name]) continue; + if (!m_result) m_result = [str mutableCopy]; + [m_result addAttribute:name value:attrs[name] range:NSMakeRange(0, len)]; + } + return m_result ?: str; +} diff --git a/Source/TextExperiment/String/ASTextAttribute.h b/Source/TextExperiment/String/ASTextAttribute.h index 571334ed3..e142bee00 100644 --- a/Source/TextExperiment/String/ASTextAttribute.h +++ b/Source/TextExperiment/String/ASTextAttribute.h @@ -48,9 +48,10 @@ typedef NS_OPTIONS (NSInteger, ASTextLineStyle) { Text vertical alignment. */ typedef NS_ENUM(NSInteger, ASTextVerticalAlignment) { - ASTextVerticalAlignmentTop = 0, ///< Top alignment. - ASTextVerticalAlignmentCenter = 1, ///< Center alignment. - ASTextVerticalAlignmentBottom = 2, ///< Bottom alignment. + ASTextVerticalAlignmentTop = 0, ///< Top alignment. + ASTextVerticalAlignmentCenter = 1, ///< Center alignment. + ASTextVerticalAlignmentBottom = 2, ///< Bottom alignment. + ASTextVerticalAlignmentBaseline = 3, ///< Baseline alignment. }; /** @@ -270,7 +271,9 @@ typedef void(^ASTextAction)(UIView *containerView, NSAttributedString *text, NSR */ @interface ASTextAttachment : NSObject + (instancetype)attachmentWithContent:(nullable id)content NS_RETURNS_RETAINED; -@property (nullable, nonatomic) id content; ///< Supported type: UIImage, UIView, CALayer + +@property(nullable, nonatomic) + id content; ///< Supported type: UIImage, UIView, CALayer, ASDisplayNode @property (nonatomic) UIViewContentMode contentMode; ///< Content display mode. @property (nonatomic) UIEdgeInsets contentInsets; ///< The insets when drawing content. @property (nullable, nonatomic) NSDictionary *userInfo; ///< The user information dictionary. diff --git a/Source/TextExperiment/String/ASTextAttribute.mm b/Source/TextExperiment/String/ASTextAttribute.mm index 8af432271..516bff9d6 100644 --- a/Source/TextExperiment/String/ASTextAttribute.mm +++ b/Source/TextExperiment/String/ASTextAttribute.mm @@ -9,6 +9,8 @@ #import "ASTextAttribute.h" #import +#import +#import #import NSString *const ASTextBackedStringAttributeName = @"ASTextBackedString"; @@ -365,6 +367,10 @@ - (id)copyWithZone:(NSZone *)zone { return one; } +- (void)dealloc { + ASPerformMainThreadDeallocation(&_userInfo); +} + @end diff --git a/Source/TextExperiment/Utility/NSAttributedString+ASText.h b/Source/TextExperiment/Utility/NSAttributedString+ASText.h index ef44fb4f3..f0d2adddc 100644 --- a/Source/TextExperiment/Utility/NSAttributedString+ASText.h +++ b/Source/TextExperiment/Utility/NSAttributedString+ASText.h @@ -29,6 +29,11 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nullable, nonatomic, copy, readonly) NSDictionary *as_attributes; +/** + Returns the attributes at the first character as Core Text attributes if NS attributes. + */ +@property (nullable, nonatomic, copy, readonly) NSDictionary *as_ctAttributes; + /** Returns the attributes for the character at a given index. @@ -597,10 +602,10 @@ NS_ASSUME_NONNULL_BEGIN /** Creates and returns an attachment. - - + + Example: ContentMode:bottom Alignment:Top. - + The text The attachment holder ↓ ↓ ─────────┌──────────────────────┐─────── @@ -613,21 +618,25 @@ NS_ASSUME_NONNULL_BEGIN │ ██████████████ ←───────────────── The attachment content │ ██████████████ │ └──────────────────────┘ - + @param content The attachment (UIImage/UIView/CALayer). @param contentMode The attachment's content mode in attachment holder @param attachmentSize The attachment holder's size in text layout. @param font The attachment will align to this font. @param alignment The attachment holder's alignment to text line. - + @param contentInset The attachment's contentInset. + @param userInfo The infomation associated with the attachment. + @return An attributed string, or nil if an error occurs. @since ASText:6.0 */ -+ (NSMutableAttributedString *)as_attachmentStringWithContent:(nullable id)content ++ (NSMutableAttributedString *)as_attachmentStringWithContent:(id)content contentMode:(UIViewContentMode)contentMode attachmentSize:(CGSize)attachmentSize alignToFont:(UIFont *)font - alignment:(ASTextVerticalAlignment)alignment; + alignment:(ASTextVerticalAlignment)alignment + contentInsets:(UIEdgeInsets)contentInsets + userInfo:(NSDictionary *)userInfo; /** Creates and returns an attahment from a fourquare image as if it was an emoji. diff --git a/Source/TextExperiment/Utility/NSAttributedString+ASText.mm b/Source/TextExperiment/Utility/NSAttributedString+ASText.mm index 39f628151..01f8b4699 100644 --- a/Source/TextExperiment/Utility/NSAttributedString+ASText.mm +++ b/Source/TextExperiment/Utility/NSAttributedString+ASText.mm @@ -37,6 +37,39 @@ - (NSDictionary *)as_attributes { return [self as_attributesAtIndex:0]; } +- (NSDictionary *)as_ctAttributes { + NSDictionary *attributes = self.as_attributes; + if (attributes == nil) { + return nil; + } + + NSMutableDictionary *mutableCTAttributes = [[NSMutableDictionary alloc] initWithCapacity:attributes.count]; + + // Map for NS attributes that are not mapping cleanly to CT attributes + static NSDictionary *NSToCTAttributeNamesMap = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSToCTAttributeNamesMap = @{ + NSFontAttributeName: (NSString *)kCTFontAttributeName, + NSBackgroundColorAttributeName: (NSString *)kCTBackgroundColorAttributeName, + NSForegroundColorAttributeName: (NSString *)kCTForegroundColorAttributeName, + NSUnderlineColorAttributeName: (NSString *)kCTUnderlineColorAttributeName, + NSUnderlineStyleAttributeName: (NSString *)kCTUnderlineStyleAttributeName, + NSStrokeWidthAttributeName: (NSString *)kCTStrokeWidthAttributeName, + NSStrokeColorAttributeName: (NSString *)kCTStrokeColorAttributeName, + NSKernAttributeName: (NSString *)kCTKernAttributeName, + NSLigatureAttributeName: (NSString *)kCTLigatureAttributeName + }; + }); + + [attributes enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { + key = NSToCTAttributeNamesMap[key] ?: key; + [mutableCTAttributes setObject:value forKey:key]; + }]; + + return [mutableCTAttributes copy]; +} + - (UIFont *)as_font { return [self as_fontAtIndex:0]; } @@ -470,14 +503,18 @@ + (NSMutableAttributedString *)as_attachmentStringWithContent:(id)content contentMode:(UIViewContentMode)contentMode attachmentSize:(CGSize)attachmentSize alignToFont:(UIFont *)font - alignment:(ASTextVerticalAlignment)alignment { + alignment:(ASTextVerticalAlignment)alignment + contentInsets:(UIEdgeInsets)contentInsets + userInfo:(NSDictionary *)userInfo { NSMutableAttributedString *atr = [[NSMutableAttributedString alloc] initWithString:ASTextAttachmentToken]; - + ASTextAttachment *attach = [ASTextAttachment new]; attach.content = content; attach.contentMode = contentMode; + attach.userInfo = userInfo; + attach.contentInsets = contentInsets; [atr as_setTextAttachment:attach range:NSMakeRange(0, atr.length)]; - + ASTextRunDelegate *delegate = [ASTextRunDelegate new]; delegate.width = attachmentSize.width; switch (alignment) { @@ -507,16 +544,17 @@ + (NSMutableAttributedString *)as_attachmentStringWithContent:(id)content delegate.descent = attachmentSize.height; } } break; + case ASTextVerticalAlignmentBaseline: default: { delegate.ascent = attachmentSize.height; delegate.descent = 0; - } break; + } } - + CTRunDelegateRef delegateRef = delegate.CTRunDelegate; [atr as_setRunDelegate:delegateRef range:NSMakeRange(0, atr.length)]; if (delegate) CFRelease(delegateRef); - + return atr; } @@ -953,29 +991,30 @@ - (void)as_setParagraphStyle:(NSParagraphStyle *)paragraphStyle range:(NSRange)r [self as_setAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; } -#define ParagraphStyleSet(_attr_) \ -[self enumerateAttribute:NSParagraphStyleAttributeName \ -inRange:range \ -options:kNilOptions \ -usingBlock: ^(NSParagraphStyle *value, NSRange subRange, BOOL *stop) { \ -NSMutableParagraphStyle *style = nil; \ -if (value) { \ -if (CFGetTypeID((__bridge CFTypeRef)(value)) == CTParagraphStyleGetTypeID()) { \ -value = [NSParagraphStyle as_styleWithCTStyle:(__bridge CTParagraphStyleRef)(value)]; \ -} \ -if (value. _attr_ == _attr_) return; \ -if ([value isKindOfClass:[NSMutableParagraphStyle class]]) { \ -style = (id)value; \ -} else { \ -style = value.mutableCopy; \ -} \ -} else { \ -if ([NSParagraphStyle defaultParagraphStyle]. _attr_ == _attr_) return; \ -style = [NSParagraphStyle defaultParagraphStyle].mutableCopy; \ -} \ -style. _attr_ = _attr_; \ -[self as_setParagraphStyle:style range:subRange]; \ -}]; +#define ParagraphStyleSet(_attr_) \ + [self enumerateAttribute:NSParagraphStyleAttributeName \ + inRange:range \ + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired \ + usingBlock:^(NSParagraphStyle * value, NSRange subRange, BOOL * stop) { \ + NSMutableParagraphStyle *style = nil; \ + if (value) { \ + if (CFGetTypeID((__bridge CFTypeRef)(value)) == CTParagraphStyleGetTypeID()) { \ + value = [NSParagraphStyle \ + as_styleWithCTStyle:(__bridge CTParagraphStyleRef)(value)]; \ + } \ + if (value._attr_ == _attr_) return; \ + if ([value isKindOfClass:[NSMutableParagraphStyle class]]) { \ + style = (id)value; \ + } else { \ + style = [value mutableCopy]; \ + } \ + } else { \ + if ([NSParagraphStyle defaultParagraphStyle]._attr_ == _attr_) return; \ + style = [[NSMutableParagraphStyle alloc] init]; \ + } \ + style._attr_ = _attr_; \ + [self as_setParagraphStyle:style range:subRange]; \ + }]; - (void)as_setAlignment:(NSTextAlignment)alignment range:(NSRange)range { ParagraphStyleSet(alignment); diff --git a/Source/TextExperiment/Utility/NSParagraphStyle+ASText.mm b/Source/TextExperiment/Utility/NSParagraphStyle+ASText.mm index bc19fd234..220bd38a7 100644 --- a/Source/TextExperiment/Utility/NSParagraphStyle+ASText.mm +++ b/Source/TextExperiment/Utility/NSParagraphStyle+ASText.mm @@ -20,7 +20,7 @@ @implementation NSParagraphStyle (ASText) + (NSParagraphStyle *)as_styleWithCTStyle:(CTParagraphStyleRef)CTStyle { if (CTStyle == NULL) return nil; - NSMutableParagraphStyle *style = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; #if TARGET_OS_IOS #pragma clang diagnostic push diff --git a/Tests/ASDisplayViewAccessibilityTests.mm b/Tests/ASDisplayViewAccessibilityTests.mm index d511e98fe..8562999d2 100644 --- a/Tests/ASDisplayViewAccessibilityTests.mm +++ b/Tests/ASDisplayViewAccessibilityTests.mm @@ -29,10 +29,25 @@ extern void SortAccessibilityElements(NSMutableArray *elements); @interface ASDisplayViewAccessibilityTests : XCTestCase +@property (nonatomic) ASConfiguration *experimentalConfiguration; @end @implementation ASDisplayViewAccessibilityTests +- (void)setUp +{ + [super setUp]; + self.experimentalConfiguration = [ASConfiguration new]; + self.experimentalConfiguration.experimentalFeatures = ASExperimentalEnableNodeIsHiddenFromAcessibility | ASExperimentalEnableAcessibilityElementsReturnNil |ASExperimentalDoNotCacheAccessibilityElements; + [ASConfigurationManager test_resetWithConfiguration:self.experimentalConfiguration]; +} + +- (void)tearDown +{ + [super tearDown]; + [ASConfigurationManager test_resetWithConfiguration:nil]; +} + - (void)testAccessibilityElementsAccessors { // Setup nodes with accessibility info diff --git a/Tests/ASTextNode2Tests.mm b/Tests/ASTextNode2Tests.mm index 70d25cb26..56ca71932 100644 --- a/Tests/ASTextNode2Tests.mm +++ b/Tests/ASTextNode2Tests.mm @@ -74,7 +74,8 @@ - (void)testTruncation - (void)testAccessibility { - XCTAssertTrue(_textNode.isAccessibilityElement, @"Should be an accessibility element"); + XCTAssertFalse(_textNode.isAccessibilityElement, @"Should not be an accessibility element. It is a container"); + XCTAssertTrue(_textNode.accessibilityElements.count > 0, @"Should have some accessibility elements"); XCTAssertTrue(_textNode.accessibilityTraits == UIAccessibilityTraitStaticText, @"Should have static text accessibility trait, instead has %llu", _textNode.accessibilityTraits); diff --git a/Tests/ReferenceImages_64/ASTextNode2SnapshotTests/testTextContainerInsetIsIncludedWithSmallerConstrainedSize_ASTextNode2@2x.png b/Tests/ReferenceImages_64/ASTextNode2SnapshotTests/testTextContainerInsetIsIncludedWithSmallerConstrainedSize_ASTextNode2@2x.png index 84ef8a2fc..e0ef83dbd 100644 Binary files a/Tests/ReferenceImages_64/ASTextNode2SnapshotTests/testTextContainerInsetIsIncludedWithSmallerConstrainedSize_ASTextNode2@2x.png and b/Tests/ReferenceImages_64/ASTextNode2SnapshotTests/testTextContainerInsetIsIncludedWithSmallerConstrainedSize_ASTextNode2@2x.png differ