mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-17 11:00:07 +00:00
Introduce `ASTableView`, a UITableView subclass that uses `ASCellNode` instead of UITableViewCell. Add working range support via `ASRangeController`, which observes the visible range, maintains a working range, and handles most ASDK machinery. ASRangeController is loosely-enough coupled that it should be easily adapted to UICollectionView if that's desired in the future. Notable considerations in the ASRangeController architecture: * There's no sense rewriting UITableView -- the real win comes from using nodes instead of UITableViewCells (easily parallelisable computation, large number of cells vs. few table views, etc.). So, use a UITableView with empty cells, using UITableViewCell's contentView as a host for arbitrary node hierarchies. * Instead of lazy-loading cells the instant they're needed by UITableView, load them in advance. Preload a substantial number of nodes in the direction of scroll, as well as a small buffer in the other direction. * Maintain compatibility with UITableView's API, with one primary change -- consumer code yields configured ASCellNodes, not UITableViewCells. * Don't use -tableView:heightForRowAtIndexPath:. Nodes already compute their preferred sizes and cache results for use at layout-time, so ASTableView uses their calculatedSizes directly. * Corollary: ASTableView is only aware of nodes that have been sized. This means that, if a cell appears onscreen, it has layout data and can display a "realistic placeholder", e.g. by making its subnodes' background colour grey. Other improvements: * Remove dead references and update headers (fixes #7, #20). * Rename `-[ASDisplayNode sizeToFit:]` to `-measure:` and fix `constrainedSizeForCalulatedSize` typo (fixes #15). * Rename `-willAppear` and `-didDisappear` to `-willEnterHierarchy` and `-didExitHierarchy`. Remove `-willDisappear` -- it was redundant, and there was no counterpart `-didAppear`. * Rename `viewLoaded` to `nodeLoaded`.
942 lines
32 KiB
Plaintext
942 lines
32 KiB
Plaintext
/* Copyright (c) 2014-present, Facebook, Inc.
|
|
* All rights reserved.
|
|
*
|
|
* This source code is licensed under the BSD-style license found in the
|
|
* LICENSE file in the root directory of this source tree. An additional grant
|
|
* of patent rights can be found in the PATENTS file in the same directory.
|
|
*/
|
|
|
|
#import "ASTextNode.h"
|
|
|
|
#import <AsyncDisplayKit/_ASDisplayLayer.h>
|
|
#import <AsyncDisplayKit/ASAssert.h>
|
|
#import <AsyncDisplayKit/ASDisplayNode+Subclasses.h>
|
|
#import <AsyncDisplayKit/ASHighlightOverlayLayer.h>
|
|
#import <AsyncDisplayKit/ASTextNodeCoreTextAdditions.h>
|
|
#import <AsyncDisplayKit/ASTextNodeTextKitHelpers.h>
|
|
|
|
#import "ASTextNodeRenderer.h"
|
|
#import "ASTextNodeShadower.h"
|
|
|
|
static const NSTimeInterval ASTextNodeHighlightFadeOutDuration = 0.15;
|
|
static const NSTimeInterval ASTextNodeHighlightFadeInDuration = 0.1;
|
|
static const CGFloat ASTextNodeHighlightLightOpacity = 0.11;
|
|
static const CGFloat ASTextNodeHighlightDarkOpacity = 0.22;
|
|
static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncationAttribute";
|
|
|
|
@interface ASTextNodeDrawParameters : NSObject
|
|
|
|
- (instancetype)initWithRenderer:(ASTextNodeRenderer *)renderer
|
|
shadower:(ASTextNodeShadower *)shadower
|
|
textOrigin:(CGPoint)textOrigin
|
|
backgroundColor:(CGColorRef)backgroundColor;
|
|
|
|
@property (nonatomic, strong, readonly) ASTextNodeRenderer *renderer;
|
|
|
|
@property (nonatomic, strong, readonly) ASTextNodeShadower *shadower;
|
|
|
|
@property (nonatomic, assign, readonly) CGPoint textOrigin;
|
|
|
|
@property (nonatomic, assign, readonly) CGColorRef backgroundColor;
|
|
|
|
@end
|
|
|
|
@implementation ASTextNodeDrawParameters
|
|
|
|
- (instancetype)initWithRenderer:(ASTextNodeRenderer *)renderer
|
|
shadower:(ASTextNodeShadower *)shadower
|
|
textOrigin:(CGPoint)textOrigin
|
|
backgroundColor:(CGColorRef)backgroundColor
|
|
{
|
|
if (self = [super init]) {
|
|
_renderer = renderer;
|
|
_shadower = shadower;
|
|
_textOrigin = textOrigin;
|
|
_backgroundColor = CGColorRetain(backgroundColor);
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
CGColorRelease(_backgroundColor);
|
|
}
|
|
|
|
@end
|
|
|
|
ASDISPLAYNODE_INLINE CGFloat ceilPixelValueForScale(CGFloat f, CGFloat scale)
|
|
{
|
|
// Round up to device pixel (.5 on retina)
|
|
return ceilf(f * scale) / scale;
|
|
}
|
|
|
|
ASDISPLAYNODE_INLINE CGFloat ceilPixelValue(CGFloat f)
|
|
{
|
|
return ceilPixelValueForScale(f, [UIScreen mainScreen].scale);
|
|
}
|
|
|
|
|
|
@interface ASTextNode () <UIGestureRecognizerDelegate>
|
|
|
|
@end
|
|
|
|
@implementation ASTextNode {
|
|
CGSize _shadowOffset;
|
|
CGColorRef _shadowColor;
|
|
CGFloat _shadowOpacity;
|
|
CGFloat _shadowRadius;
|
|
|
|
NSAttributedString *_composedTruncationString;
|
|
|
|
NSString *_highlightedLinkAttributeName;
|
|
id _highlightedLinkAttributeValue;
|
|
NSRange _highlightRange;
|
|
ASHighlightOverlayLayer *_activeHighlightLayer;
|
|
|
|
ASDN::Mutex _rendererLock;
|
|
|
|
CGSize _constrainedSize;
|
|
|
|
ASTextNodeRenderer *_renderer;
|
|
ASTextNodeShadower *_shadower;
|
|
|
|
UILongPressGestureRecognizer *_longPressGestureRecognizer;
|
|
}
|
|
|
|
#pragma mark - NSObject
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super init]) {
|
|
// Load default values from superclass.
|
|
_shadowOffset = [super shadowOffset];
|
|
CGColorRef superColor = [super shadowColor];
|
|
if (superColor != NULL) {
|
|
_shadowColor = CGColorRetain(superColor);
|
|
}
|
|
_shadowOpacity = [super shadowOpacity];
|
|
_shadowRadius = [super shadowRadius];
|
|
|
|
// Disable user interaction for text node by default.
|
|
self.userInteractionEnabled = NO;
|
|
self.needsDisplayOnBoundsChange = YES;
|
|
|
|
_truncationMode = NSLineBreakByWordWrapping;
|
|
_truncationAttributedString = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"\u2026", @"Default truncation string")];
|
|
|
|
// The common case is for a text node to be non-opaque and blended over some background.
|
|
self.opaque = NO;
|
|
self.backgroundColor = [UIColor clearColor];
|
|
|
|
// Accessibility
|
|
self.isAccessibilityElement = YES;
|
|
self.accessibilityTraits = UIAccessibilityTraitStaticText;
|
|
|
|
_constrainedSize = CGSizeMake(-INFINITY, -INFINITY);
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
if (_shadowColor != NULL) {
|
|
CGColorRelease(_shadowColor);
|
|
}
|
|
|
|
if (_longPressGestureRecognizer) {
|
|
_longPressGestureRecognizer.delegate = nil;
|
|
[_longPressGestureRecognizer removeTarget:nil action:NULL];
|
|
[self.view removeGestureRecognizer:_longPressGestureRecognizer];
|
|
}
|
|
}
|
|
|
|
- (NSString *)description
|
|
{
|
|
NSString *plainString = [[_attributedString string] stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
|
NSString *truncationString = [_composedTruncationString string];
|
|
if (plainString.length > 50)
|
|
plainString = [[plainString substringToIndex:50] stringByAppendingString:@"\u2026"];
|
|
return [NSString stringWithFormat:@"<%@: %p; text = \"%@\"; truncation string = \"%@\"; frame = %@>", self.class, self, plainString, truncationString, self.nodeLoaded ? NSStringFromCGRect(self.layer.frame) : nil];
|
|
}
|
|
|
|
#pragma mark - ASDisplayNode
|
|
|
|
- (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);
|
|
// The supplied constrainedSize should include room for shadowPadding.
|
|
// Inset the constrainedSize by the shadow padding to get the size available for text.
|
|
UIEdgeInsets shadowPadding = [[self _shadower] shadowPadding];
|
|
// Invert the negative values of shadow padding to get a positive inset
|
|
UIEdgeInsets shadowPaddingOutset = ASDNEdgeInsetsInvert(shadowPadding);
|
|
|
|
// Inset the padded constrainedSize to get the remaining size available for text
|
|
CGRect constrainedRect = CGRect{CGPointZero, constrainedSize};
|
|
CGSize constrainedSizeForText = UIEdgeInsetsInsetRect(constrainedRect, shadowPaddingOutset).size;
|
|
ASDisplayNodeAssert(constrainedSizeForText.width >= 0, @"Constrained width for text (%f) after subtracting shadow padding (%@) is too narrow", constrainedSizeForText.width, NSStringFromUIEdgeInsets(shadowPadding));
|
|
ASDisplayNodeAssert(constrainedSizeForText.height >= 0, @"Constrained height for text (%f) after subtracting shadow padding (%@) is too short", constrainedSizeForText.height, NSStringFromUIEdgeInsets(shadowPadding));
|
|
|
|
_constrainedSize = constrainedSizeForText;
|
|
[self _invalidateRenderer];
|
|
[self setNeedsDisplay];
|
|
CGSize rendererSize = [[self _renderer] size];
|
|
|
|
// Add shadow padding back
|
|
CGSize renderSizePlusShadowPadding = UIEdgeInsetsInsetRect(CGRect{CGPointZero, rendererSize}, shadowPadding).size;
|
|
ASDisplayNodeAssert(renderSizePlusShadowPadding.width >= 0, @"Calculated width for text with shadow padding (%f) is too narrow", constrainedSizeForText.width);
|
|
ASDisplayNodeAssert(renderSizePlusShadowPadding.height >= 0, @"Calculated height for text with shadow padding (%f) is too short", constrainedSizeForText.height);
|
|
|
|
return CGSizeMake(fminf(ceilPixelValue(renderSizePlusShadowPadding.width), constrainedSize.width),
|
|
fminf(ceilPixelValue(renderSizePlusShadowPadding.height), constrainedSize.height));
|
|
}
|
|
|
|
- (void)willEnterHierarchy
|
|
{
|
|
CALayer *layer = self.layer;
|
|
if (!layer.contents) {
|
|
// This can happen on occasion that the layer will not display unless this
|
|
// set.
|
|
[layer setNeedsDisplay];
|
|
}
|
|
[super willEnterHierarchy];
|
|
}
|
|
|
|
- (void)displayDidFinish
|
|
{
|
|
[super displayDidFinish];
|
|
|
|
// We invalidate our renderer here to clear the very high memory cost of
|
|
// keeping this around. _invalidateRenderer will dealloc this onto a bg
|
|
// thread resulting in less stutters on the main thread than if it were
|
|
// to be deallocated in dealloc. This is also helpful in opportunistically
|
|
// reducing memory consumption and reducing the overall footprint of the app.
|
|
[self _invalidateRenderer];
|
|
}
|
|
|
|
- (void)didExitHierarchy
|
|
{
|
|
// We nil out the contents and kill our renderer to prevent the very large
|
|
// memory overhead of maintaining these for all text nodes. They can be
|
|
// regenerated when layout is necessary.
|
|
self.contents = nil;
|
|
|
|
[self _invalidateRenderer];
|
|
|
|
[super didExitHierarchy];
|
|
}
|
|
|
|
- (void)didLoad
|
|
{
|
|
[super didLoad];
|
|
|
|
// If we are view-backed, support gesture interaction.
|
|
if (!self.isLayerBacked) {
|
|
_longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(_handleLongPress:)];
|
|
_longPressGestureRecognizer.delegate = self;
|
|
[self.view addGestureRecognizer:_longPressGestureRecognizer];
|
|
}
|
|
}
|
|
|
|
#pragma mark - Renderer Management
|
|
|
|
- (ASTextNodeRenderer *)_renderer
|
|
{
|
|
ASDN::MutexLocker l(_rendererLock);
|
|
if (_renderer == nil) {
|
|
CGSize constrainedSize = _constrainedSize.width != -INFINITY ? _constrainedSize : self.bounds.size;
|
|
_renderer = [[ASTextNodeRenderer alloc] initWithAttributedString:_attributedString
|
|
truncationString:_composedTruncationString
|
|
truncationMode:_truncationMode
|
|
constrainedSize:constrainedSize];
|
|
}
|
|
return _renderer;
|
|
}
|
|
|
|
- (void)_invalidateRenderer
|
|
{
|
|
ASDN::MutexLocker l(_rendererLock);
|
|
if (_renderer) {
|
|
// Destruction of the layout managers/containers/text storage is quite
|
|
// expensive, and can take some time, so we dispatch onto a bg queue to
|
|
// actually dealloc.
|
|
__block ASTextNodeRenderer *renderer = _renderer;
|
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
renderer = nil;
|
|
});
|
|
}
|
|
_renderer = nil;
|
|
}
|
|
|
|
#pragma mark - Shadow Drawer Management
|
|
- (ASTextNodeShadower *)_shadower
|
|
{
|
|
if (_shadower == nil) {
|
|
_shadower = [[ASTextNodeShadower alloc] initWithShadowOffset:_shadowOffset
|
|
shadowColor:_shadowColor
|
|
shadowOpacity:_shadowOpacity
|
|
shadowRadius:_shadowRadius];
|
|
}
|
|
return _shadower;
|
|
}
|
|
|
|
- (void)_invalidateShadower
|
|
{
|
|
_shadower = nil;
|
|
}
|
|
|
|
#pragma mark - Modifying User Text
|
|
|
|
- (void)setAttributedString:(NSAttributedString *)attributedString {
|
|
if (attributedString == _attributedString) {
|
|
return;
|
|
}
|
|
|
|
if (attributedString == nil) {
|
|
attributedString = [[NSAttributedString alloc] initWithString:@"" attributes:nil];
|
|
}
|
|
|
|
_attributedString = ASCleanseAttributedStringOfCoreTextAttributes(attributedString);
|
|
|
|
// We need an entirely new renderer
|
|
[self _invalidateRenderer];
|
|
|
|
// Tell the display node superclasses that the cached sizes are incorrect now
|
|
[self invalidateCalculatedSize];
|
|
|
|
[self setNeedsDisplay];
|
|
|
|
self.accessibilityLabel = _attributedString.string;
|
|
|
|
if (_attributedString.length == 0) {
|
|
// We're not an accessibility element by default if there is no string.
|
|
self.isAccessibilityElement = NO;
|
|
} else {
|
|
self.isAccessibilityElement = YES;
|
|
}
|
|
}
|
|
|
|
#pragma mark - Drawing
|
|
|
|
+ (void)drawRect:(CGRect)bounds withParameters:(ASTextNodeDrawParameters *)parameters isCancelled:(asdisplaynode_iscancelled_block_t)isCancelledBlock isRasterizing:(BOOL)isRasterizing
|
|
{
|
|
CGContextRef context = UIGraphicsGetCurrentContext();
|
|
ASDisplayNodeAssert(context, @"This is no good without a context.");
|
|
|
|
CGContextSaveGState(context);
|
|
|
|
// Fill background
|
|
if (!isRasterizing) {
|
|
CGColorRef backgroundColor = parameters.backgroundColor;
|
|
if (backgroundColor) {
|
|
CGContextSetFillColorWithColor(context, backgroundColor);
|
|
CGContextSetBlendMode(context, kCGBlendModeCopy);
|
|
// outset the background fill to cover fractional errors when drawing at a
|
|
// small contentsScale.
|
|
CGContextFillRect(context, CGRectInset(bounds, -2, -2));
|
|
CGContextSetBlendMode(context, kCGBlendModeNormal);
|
|
}
|
|
}
|
|
|
|
// Draw shadow
|
|
[parameters.shadower setShadowInContext:context];
|
|
|
|
// Draw text
|
|
bounds.origin = parameters.textOrigin;
|
|
[parameters.renderer drawInRect:bounds isRasterizing:isRasterizing];
|
|
|
|
CGContextRestoreGState(context);
|
|
}
|
|
|
|
- (NSObject *)drawParametersForAsyncLayer:(_ASDisplayLayer *)layer
|
|
{
|
|
// Offset the text origin by any shadow padding
|
|
UIEdgeInsets shadowPadding = [self shadowPadding];
|
|
CGPoint textOrigin = CGPointMake(self.bounds.origin.x - shadowPadding.left, self.bounds.origin.y - shadowPadding.top);
|
|
return [[ASTextNodeDrawParameters alloc] initWithRenderer:[self _renderer]
|
|
shadower:[self _shadower]
|
|
textOrigin:textOrigin
|
|
backgroundColor:self.backgroundColor.CGColor];
|
|
}
|
|
|
|
#pragma mark - Attributes
|
|
|
|
- (id)linkAttributeValueAtPoint:(CGPoint)point
|
|
attributeName:(out NSString **)attributeNameOut
|
|
range:(out NSRange *)rangeOut
|
|
{
|
|
return [self _linkAttributeValueAtPoint:point
|
|
attributeName:attributeNameOut
|
|
range:rangeOut
|
|
inAdditionalTruncationMessage:NULL];
|
|
}
|
|
|
|
- (id)_linkAttributeValueAtPoint:(CGPoint)point
|
|
attributeName:(out NSString **)attributeNameOut
|
|
range:(out NSRange *)rangeOut
|
|
inAdditionalTruncationMessage:(out BOOL *)inAdditionalTruncationMessageOut
|
|
{
|
|
ASTextNodeRenderer *renderer = [self _renderer];
|
|
NSRange visibleRange = [renderer visibleRange];
|
|
NSAttributedString *attributedString = _attributedString;
|
|
|
|
// Check in a 9-point region around the actual touch point so we make sure
|
|
// we get the best attribute for the touch.
|
|
__block CGFloat minimumGlyphDistance = CGFLOAT_MAX;
|
|
|
|
// Final output vars
|
|
__block id linkAttributeValue = nil;
|
|
__block NSString *linkAttributeName = nil;
|
|
__block BOOL inTruncationMessage = NO;
|
|
|
|
[renderer enumerateTextIndexesAtPosition:point usingBlock:^(NSUInteger characterIndex, CGRect glyphBoundingRect, BOOL *stop) {
|
|
CGPoint glyphLocation = CGPointMake(CGRectGetMidX(glyphBoundingRect), CGRectGetMidY(glyphBoundingRect));
|
|
CGFloat currentDistance = sqrtf(powf(point.x - glyphLocation.x, 2.f) + powf(point.y - glyphLocation.y, 2.f));
|
|
if (currentDistance >= minimumGlyphDistance) {
|
|
// If the distance computed from the touch to the glyph location is
|
|
// not the minimum among the located link attributes, we can just skip
|
|
// to the next location.
|
|
return;
|
|
}
|
|
|
|
// Check if it's outside the visible range, if so, then we mark this touch
|
|
// as inside the truncation message, because in at least one of the touch
|
|
// points it was.
|
|
if (!(NSLocationInRange(characterIndex, visibleRange))) {
|
|
inTruncationMessage = YES;
|
|
}
|
|
|
|
if (inAdditionalTruncationMessageOut != NULL) {
|
|
*inAdditionalTruncationMessageOut = inTruncationMessage;
|
|
}
|
|
|
|
// Short circuit here if it's just in the truncation message. Since the
|
|
// truncation message may be beyond the scope of the actual input string,
|
|
// we have to make sure that we don't start asking for attributes on it.
|
|
if (inTruncationMessage) {
|
|
return;
|
|
}
|
|
|
|
for (NSString *attributeName in _linkAttributeNames) {
|
|
NSRange range;
|
|
id value = [attributedString attribute:attributeName atIndex:characterIndex longestEffectiveRange:&range inRange:visibleRange];
|
|
NSString *name = attributeName;
|
|
|
|
if (value == nil || name == nil) {
|
|
// Didn't find anything
|
|
continue;
|
|
}
|
|
|
|
// Check if delegate implements optional method, if not assume NO.
|
|
// Should the text be highlightable/touchable?
|
|
if (![_delegate respondsToSelector:@selector(richTextNode:shouldHighlightLinkAttribute:value:)] ||
|
|
![_delegate richTextNode:self shouldHighlightLinkAttribute:name value:value]) {
|
|
value = nil;
|
|
name = nil;
|
|
}
|
|
|
|
if (value != nil || name != nil) {
|
|
// We found a minimum glyph distance link attribute, so set the min
|
|
// distance, and the out params.
|
|
minimumGlyphDistance = currentDistance;
|
|
|
|
if (rangeOut != NULL && value != nil) {
|
|
*rangeOut = range;
|
|
// Limit to only the visible range, because the attributed string will
|
|
// return values outside the visible range.
|
|
if (NSMaxRange(*rangeOut) > NSMaxRange(visibleRange)) {
|
|
(*rangeOut).length = MAX(NSMaxRange(visibleRange) - (*rangeOut).location, 0);
|
|
}
|
|
}
|
|
|
|
if (attributeNameOut != NULL) {
|
|
*attributeNameOut = name;
|
|
}
|
|
|
|
// Set the values for the next iteration
|
|
linkAttributeValue = value;
|
|
linkAttributeName = name;
|
|
|
|
break;
|
|
}
|
|
}
|
|
}];
|
|
|
|
return linkAttributeValue;
|
|
}
|
|
|
|
#pragma mark - UIGestureRecognizerDelegate
|
|
|
|
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
|
{
|
|
if (gestureRecognizer == _longPressGestureRecognizer) {
|
|
// Don't allow long press on truncation message
|
|
if ([self _pendingTruncationTap]) {
|
|
return NO;
|
|
}
|
|
|
|
// Ask our delegate if a long-press on an attribute is relevant
|
|
if ([self.delegate respondsToSelector:@selector(richTextNode:shouldLongPressLinkAttribute:value:)]) {
|
|
return [self.delegate richTextNode:self shouldLongPressLinkAttribute:_highlightedLinkAttributeName value:_highlightedLinkAttributeValue];
|
|
}
|
|
|
|
// Otherwise we are good to go.
|
|
return YES;
|
|
}
|
|
|
|
if (([self _pendingLinkTap] || [self _pendingTruncationTap])
|
|
&& [gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]
|
|
&& CGRectContainsPoint(self.view.bounds, [gestureRecognizer locationInView:self.view])) {
|
|
return NO;
|
|
}
|
|
|
|
return [super gestureRecognizerShouldBegin:gestureRecognizer];
|
|
}
|
|
|
|
#pragma mark - Highlighting
|
|
|
|
- (NSRange)highlightRange
|
|
{
|
|
return _highlightRange;
|
|
}
|
|
|
|
- (void)setHighlightRange:(NSRange)highlightRange
|
|
{
|
|
[self setHighlightRange:highlightRange animated:NO];
|
|
}
|
|
|
|
- (void)setHighlightRange:(NSRange)highlightRange animated:(BOOL)animated
|
|
{
|
|
[self _setHighlightRange:highlightRange forAttributeName:nil value:nil animated:animated];
|
|
}
|
|
|
|
- (void)_setHighlightRange:(NSRange)highlightRange forAttributeName:(NSString *)highlightedAttributeName value:(id)highlightedAttributeValue animated:(BOOL)animated
|
|
{
|
|
ASDisplayNodeAssertMainThread();
|
|
|
|
_highlightedLinkAttributeName = highlightedAttributeName;
|
|
_highlightedLinkAttributeValue = highlightedAttributeValue;
|
|
|
|
if (!NSEqualRanges(highlightRange, _highlightRange) && ((0 != highlightRange.length) || (0 != _highlightRange.length))) {
|
|
|
|
_highlightRange = highlightRange;
|
|
|
|
if (_activeHighlightLayer) {
|
|
if (animated) {
|
|
__unsafe_unretained CALayer *weakHighlightLayer = _activeHighlightLayer;
|
|
_activeHighlightLayer = nil;
|
|
|
|
weakHighlightLayer.opacity = 0.0;
|
|
|
|
CABasicAnimation *fadeOut = [CABasicAnimation animationWithKeyPath:@"opacity"];
|
|
fadeOut.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
|
|
fadeOut.fromValue = @(((CALayer *)weakHighlightLayer.presentationLayer).opacity);
|
|
fadeOut.toValue = @0.0;
|
|
fadeOut.fillMode = kCAFillModeBoth;
|
|
fadeOut.duration = ASTextNodeHighlightFadeOutDuration;
|
|
|
|
dispatch_block_t prev = [CATransaction completionBlock];
|
|
[CATransaction setCompletionBlock:^{
|
|
[weakHighlightLayer removeFromSuperlayer];
|
|
}];
|
|
|
|
[weakHighlightLayer addAnimation:fadeOut forKey:fadeOut.keyPath];
|
|
|
|
[CATransaction setCompletionBlock:prev];
|
|
|
|
} else {
|
|
[_activeHighlightLayer removeFromSuperlayer];
|
|
_activeHighlightLayer = nil;
|
|
}
|
|
}
|
|
if (0 != highlightRange.length) {
|
|
// Find layer in hierarchy that allows us to draw highlighting on.
|
|
CALayer *highlightTargetLayer = self.layer;
|
|
while (highlightTargetLayer != nil) {
|
|
if (highlightTargetLayer.as_allowsHighlightDrawing) {
|
|
break;
|
|
}
|
|
highlightTargetLayer = highlightTargetLayer.superlayer;
|
|
}
|
|
|
|
if (highlightTargetLayer != nil) {
|
|
NSArray *highlightRects = [[self _renderer] rectsForTextRange:highlightRange measureOption:ASTextNodeRendererMeasureOptionBlock];
|
|
NSMutableArray *converted = [NSMutableArray arrayWithCapacity:highlightRects.count];
|
|
for (NSValue *rectValue in highlightRects) {
|
|
CGRect rendererRect = [[self class] _adjustRendererRect:rectValue.CGRectValue forShadowPadding:_shadower.shadowPadding];
|
|
CGRect highlightedRect = [self.layer convertRect:rendererRect toLayer:highlightTargetLayer];
|
|
[converted addObject:[NSValue valueWithCGRect:highlightedRect]];
|
|
}
|
|
|
|
ASHighlightOverlayLayer *overlayLayer = [[ASHighlightOverlayLayer alloc] initWithRects:converted];
|
|
overlayLayer.highlightColor = [[self class] _highlightColorForStyle:self.highlightStyle];
|
|
overlayLayer.frame = highlightTargetLayer.bounds;
|
|
overlayLayer.masksToBounds = NO;
|
|
overlayLayer.opacity = [[self class] _highlightOpacityForStyle:self.highlightStyle];
|
|
[highlightTargetLayer addSublayer:overlayLayer];
|
|
|
|
if (animated) {
|
|
CABasicAnimation *fadeIn = [CABasicAnimation animationWithKeyPath:@"opacity"];
|
|
fadeIn.fromValue = @0.0;
|
|
fadeIn.toValue = @(overlayLayer.opacity);
|
|
fadeIn.duration = ASTextNodeHighlightFadeInDuration;
|
|
|
|
[overlayLayer addAnimation:fadeIn forKey:fadeIn.keyPath];
|
|
}
|
|
|
|
[overlayLayer setNeedsDisplay];
|
|
|
|
_activeHighlightLayer = overlayLayer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)_clearHighlightIfNecessary
|
|
{
|
|
if ([self _pendingLinkTap] || [self _pendingTruncationTap]) {
|
|
[self setHighlightRange:NSMakeRange(0, 0) animated:YES];
|
|
}
|
|
}
|
|
|
|
+ (CGColorRef)_highlightColorForStyle:(ASTextNodeHighlightStyle)style
|
|
{
|
|
return [UIColor colorWithWhite:(style == ASTextNodeHighlightStyleLight ? 0.0 : 1.0) alpha:1.0].CGColor;
|
|
}
|
|
|
|
+ (CGFloat)_highlightOpacityForStyle:(ASTextNodeHighlightStyle)style
|
|
{
|
|
return (style == ASTextNodeHighlightStyleLight) ? ASTextNodeHighlightLightOpacity : ASTextNodeHighlightDarkOpacity;
|
|
}
|
|
|
|
#pragma mark - Text rects
|
|
|
|
+ (CGRect)_adjustRendererRect:(CGRect)rendererRect forShadowPadding:(UIEdgeInsets)shadowPadding
|
|
{
|
|
rendererRect.origin.x -= shadowPadding.left;
|
|
rendererRect.origin.y -= shadowPadding.top;
|
|
return rendererRect;
|
|
}
|
|
|
|
- (NSArray *)_rectsForTextRange:(NSRange)textRange measureOption:(ASTextNodeRendererMeasureOption)measureOption
|
|
{
|
|
NSArray *rects = [[self _renderer] rectsForTextRange:textRange measureOption:measureOption];
|
|
NSMutableArray *adjustedRects = [NSMutableArray array];
|
|
|
|
for (NSValue *rectValue in rects) {
|
|
CGRect rect = [rectValue CGRectValue];
|
|
rect = [self.class _adjustRendererRect:rect forShadowPadding:self.shadowPadding];
|
|
|
|
NSValue *adjustedRectValue = [NSValue valueWithCGRect:rect];
|
|
[adjustedRects addObject:adjustedRectValue];
|
|
}
|
|
|
|
return adjustedRects;
|
|
}
|
|
|
|
- (NSArray *)rectsForTextRange:(NSRange)textRange
|
|
{
|
|
return [self _rectsForTextRange:textRange measureOption:ASTextNodeRendererMeasureOptionCapHeight];
|
|
}
|
|
|
|
- (NSArray *)highlightRectsForTextRange:(NSRange)textRange
|
|
{
|
|
return [self _rectsForTextRange:textRange measureOption:ASTextNodeRendererMeasureOptionBlock];
|
|
}
|
|
|
|
- (CGRect)trailingRect
|
|
{
|
|
CGRect rect = [[self _renderer] trailingRect];
|
|
return [self.class _adjustRendererRect:rect forShadowPadding:self.shadowPadding];
|
|
}
|
|
|
|
- (CGRect)frameForTextRange:(NSRange)textRange
|
|
{
|
|
CGRect frame = [[self _renderer] frameForTextRange:textRange];
|
|
return [self.class _adjustRendererRect:frame forShadowPadding:self.shadowPadding];
|
|
}
|
|
|
|
#pragma mark - Touch Handling
|
|
|
|
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesBegan:touches withEvent:event];
|
|
|
|
ASDisplayNodeAssertMainThread();
|
|
|
|
UITouch *touch = [touches anyObject];
|
|
|
|
UIView *view = touch.view;
|
|
CGPoint point = [touch locationInView:view];
|
|
point = [self.view convertPoint:point fromView:view];
|
|
|
|
NSRange range = NSMakeRange(0, 0);
|
|
NSString *linkAttributeName = nil;
|
|
BOOL inAdditionalTruncationMessage = NO;
|
|
|
|
id linkAttributeValue = [self _linkAttributeValueAtPoint:point
|
|
attributeName:&linkAttributeName
|
|
range:&range
|
|
inAdditionalTruncationMessage:&inAdditionalTruncationMessage];
|
|
|
|
NSUInteger lastCharIndex = NSIntegerMax;
|
|
BOOL linkCrossesVisibleRange = (lastCharIndex > range.location) && (lastCharIndex < NSMaxRange(range) - 1);
|
|
|
|
if (inAdditionalTruncationMessage) {
|
|
NSRange truncationMessageRange = [self _additionalTruncationMessageRangeWithVisibleRange:[[self _renderer] visibleRange]];
|
|
[self _setHighlightRange:truncationMessageRange forAttributeName:ASTextNodeTruncationTokenAttributeName value:nil animated:YES];
|
|
} else if (range.length && !linkCrossesVisibleRange && linkAttributeValue != nil && linkAttributeName != nil) {
|
|
[self _setHighlightRange:range forAttributeName:linkAttributeName value:linkAttributeValue animated:YES];
|
|
}
|
|
}
|
|
|
|
|
|
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesCancelled:touches withEvent:event];
|
|
|
|
[self _clearHighlightIfNecessary];
|
|
}
|
|
|
|
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesEnded:touches withEvent:event];
|
|
|
|
if ([self _pendingLinkTap] && [_delegate respondsToSelector:@selector(richTextNode:tappedLinkAttribute:value:atPoint:textRange:)]) {
|
|
CGPoint point = [[touches anyObject] locationInView:self.view];
|
|
[_delegate richTextNode:self tappedLinkAttribute:_highlightedLinkAttributeName value:_highlightedLinkAttributeValue atPoint:point textRange:_highlightRange];
|
|
}
|
|
|
|
if ([self _pendingTruncationTap]) {
|
|
if ([_delegate respondsToSelector:@selector(richTextNodeTappedTruncationToken:)]) {
|
|
[_delegate richTextNodeTappedTruncationToken:self];
|
|
}
|
|
}
|
|
|
|
[self _clearHighlightIfNecessary];
|
|
}
|
|
|
|
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesMoved:touches withEvent:event];
|
|
|
|
[self _clearHighlightIfNecessary];
|
|
}
|
|
|
|
- (void)_handleLongPress:(UILongPressGestureRecognizer *)longPressRecognizer
|
|
{
|
|
// Respond to long-press when it begins, not when it ends.
|
|
if (longPressRecognizer.state == UIGestureRecognizerStateBegan) {
|
|
if ([self.delegate respondsToSelector:@selector(richTextNode:longPressedLinkAttribute:value:atPoint:textRange:)]) {
|
|
CGPoint touchPoint = [_longPressGestureRecognizer locationInView:self.view];
|
|
[self.delegate richTextNode:self longPressedLinkAttribute:_highlightedLinkAttributeName value:_highlightedLinkAttributeValue atPoint:touchPoint textRange:_highlightRange];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (BOOL)_pendingLinkTap
|
|
{
|
|
return (_highlightedLinkAttributeValue != nil && ![self _pendingTruncationTap]) && _delegate != nil;
|
|
}
|
|
|
|
- (BOOL)_pendingTruncationTap
|
|
{
|
|
return [_highlightedLinkAttributeName isEqualToString:ASTextNodeTruncationTokenAttributeName];
|
|
}
|
|
|
|
#pragma mark - Shadow Properties
|
|
|
|
- (CGColorRef)shadowColor
|
|
{
|
|
return _shadowColor;
|
|
}
|
|
|
|
- (void)setShadowColor:(CGColorRef)shadowColor
|
|
{
|
|
if (_shadowColor != shadowColor) {
|
|
if (shadowColor != NULL) {
|
|
CGColorRetain(shadowColor);
|
|
}
|
|
_shadowColor = shadowColor;
|
|
[self _invalidateShadower];
|
|
[self setNeedsDisplay];
|
|
}
|
|
}
|
|
|
|
- (CGSize)shadowOffset
|
|
{
|
|
return _shadowOffset;
|
|
}
|
|
|
|
- (void)setShadowOffset:(CGSize)shadowOffset
|
|
{
|
|
if (!CGSizeEqualToSize(_shadowOffset, shadowOffset)) {
|
|
_shadowOffset = shadowOffset;
|
|
[self _invalidateShadower];
|
|
[self setNeedsDisplay];
|
|
}
|
|
}
|
|
|
|
- (CGFloat)shadowOpacity
|
|
{
|
|
return _shadowOpacity;
|
|
}
|
|
|
|
- (void)setShadowOpacity:(CGFloat)shadowOpacity
|
|
{
|
|
if (_shadowOpacity != shadowOpacity) {
|
|
_shadowOpacity = shadowOpacity;
|
|
[self _invalidateShadower];
|
|
[self setNeedsDisplay];
|
|
}
|
|
}
|
|
|
|
- (CGFloat)shadowRadius
|
|
{
|
|
return _shadowRadius;
|
|
}
|
|
|
|
- (void)setShadowRadius:(CGFloat)shadowRadius
|
|
{
|
|
if (_shadowRadius != shadowRadius) {
|
|
_shadowRadius = shadowRadius;
|
|
[self _invalidateShadower];
|
|
[self setNeedsDisplay];
|
|
}
|
|
}
|
|
|
|
- (UIEdgeInsets)shadowPadding
|
|
{
|
|
return [[self _shadower] shadowPadding];
|
|
}
|
|
|
|
#pragma mark - Truncation Message
|
|
|
|
- (void)setTruncationAttributedString:(NSAttributedString *)truncationAttributedString
|
|
{
|
|
// No-op if they're exactly equal (avoid redrawing)
|
|
if (_truncationAttributedString == truncationAttributedString) {
|
|
return;
|
|
}
|
|
|
|
if (![_truncationAttributedString isEqual:truncationAttributedString]) {
|
|
_truncationAttributedString = [truncationAttributedString copy];
|
|
[self _invalidateTruncationString];
|
|
}
|
|
}
|
|
|
|
- (void)setAdditionalTruncationMessage:(NSAttributedString *)additionalTruncationMessage
|
|
{
|
|
// Short circuit if we're setting to nil (prevent redrawing when we don't need to)
|
|
if (_additionalTruncationMessage == additionalTruncationMessage) {
|
|
return;
|
|
}
|
|
|
|
if (![_additionalTruncationMessage isEqual:additionalTruncationMessage]) {
|
|
_additionalTruncationMessage = [additionalTruncationMessage copy];
|
|
[self _invalidateTruncationString];
|
|
}
|
|
}
|
|
|
|
- (void)setTruncationMode:(NSLineBreakMode)truncationMode
|
|
{
|
|
if (_truncationMode != truncationMode) {
|
|
_truncationMode = truncationMode;
|
|
[self _invalidateRenderer];
|
|
[self setNeedsDisplay];
|
|
}
|
|
}
|
|
|
|
- (BOOL)isTruncated
|
|
{
|
|
return [[self _renderer] truncationStringCharacterRange].location != NSNotFound;
|
|
}
|
|
|
|
- (NSUInteger)lineCount
|
|
{
|
|
return [[self _renderer] lineCount];
|
|
}
|
|
|
|
#pragma mark - Truncation Message
|
|
|
|
- (void)_invalidateTruncationString
|
|
{
|
|
_composedTruncationString = [self _prepareTruncationStringForDrawing:[self _composedTruncationString]];
|
|
[self _invalidateRenderer];
|
|
[self setNeedsDisplay];
|
|
}
|
|
|
|
/**
|
|
* @return the additional truncation message range within the as-rendered text.
|
|
* Must be called from main thread
|
|
*/
|
|
- (NSRange)_additionalTruncationMessageRangeWithVisibleRange:(NSRange)visibleRange
|
|
{
|
|
// Check if we even have an additional truncation message.
|
|
if (!_additionalTruncationMessage) {
|
|
return NSMakeRange(NSNotFound, 0);
|
|
}
|
|
|
|
// Character location of the unicode ellipsis (the first index after the visible range)
|
|
NSInteger truncationTokenIndex = NSMaxRange(visibleRange);
|
|
|
|
NSUInteger additionalTruncationMessageLength = _additionalTruncationMessage.length;
|
|
// We get the location of the truncation token, then add the length of the
|
|
// truncation attributed string +1 for the space between.
|
|
NSRange range = NSMakeRange(truncationTokenIndex + _truncationAttributedString.length + 1, additionalTruncationMessageLength);
|
|
return range;
|
|
}
|
|
|
|
/**
|
|
* @return the truncation message for the string. If there are both an
|
|
* additional truncation message and a truncation attributed string, they will
|
|
* be properly composed.
|
|
*/
|
|
- (NSAttributedString *)_composedTruncationString
|
|
{
|
|
// Short circuit if we only have one or the other.
|
|
if (!_additionalTruncationMessage) {
|
|
return _truncationAttributedString;
|
|
}
|
|
if (!_truncationAttributedString) {
|
|
return _additionalTruncationMessage;
|
|
}
|
|
|
|
// If we've reached this point, both _additionalTruncationMessage and
|
|
// _truncationAttributedString are present. Compose them.
|
|
|
|
NSMutableAttributedString *newComposedTruncationString = [[NSMutableAttributedString alloc] initWithAttributedString:_truncationAttributedString];
|
|
[newComposedTruncationString replaceCharactersInRange:NSMakeRange(newComposedTruncationString.length, 0) withString:@" "];
|
|
[newComposedTruncationString appendAttributedString:_additionalTruncationMessage];
|
|
return newComposedTruncationString;
|
|
}
|
|
|
|
/**
|
|
* - 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 *)_prepareTruncationStringForDrawing:(NSAttributedString *)truncationString
|
|
{
|
|
truncationString = ASCleanseAttributedStringOfCoreTextAttributes(truncationString);
|
|
NSMutableAttributedString *truncationMutableString = [truncationString mutableCopy];
|
|
// Grab the attributes from the full string
|
|
if (_attributedString.length > 0) {
|
|
NSAttributedString *originalString = _attributedString;
|
|
NSInteger originalStringLength = _attributedString.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 = [NSMutableDictionary dictionaryWithDictionary:originalStringAttributes];
|
|
[futureTruncationAttributes addEntriesFromDictionary:attributes];
|
|
[truncationMutableString setAttributes:futureTruncationAttributes range:range];
|
|
}];
|
|
}
|
|
return truncationMutableString;
|
|
}
|
|
|
|
@end
|