CoreText進階(七) 新增自定義View和對其

aron1992發表於2019-04-04

本文講的主要內容是如何將CoreText繪圖和自定義的View結合在一起,進行無縫的排版,並且可以控制自定義View元素的對其方式(頂部對其、底部對其、居中對其)

其它文章:
CoreText 入門(一)-文字繪製
CoreText入門(二)-繪製圖片
CoreText進階(三)-事件處理
CoreText進階(四)-文字行數限制和顯示更多
CoreText進階(五)- 文字排版樣式和效果
CoreText進階(六)-內容大小計算和自動佈局
CoreText進階(七)-新增自定義View和對其

效果

Demo:CoreTextDemo

效果
效果

 實現程式碼如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.edgesForExtendedLayout = UIRectEdgeNone;
    self.view.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1];
    
    CGRect frame = CGRectMake(0, 100, self.view.bounds.size.width, 400);
    YTDrawView *textDrawView = [[YTDrawView alloc] initWithFrame:frame];
    textDrawView.backgroundColor = [UIColor whiteColor];
    
    // 新增普通的文字
    [textDrawView addString:@"Hello World " attributes:self.defaultTextAttributes clickActionHandler:^(id obj) {
        
    }];
    
    // 新增連結
    [textDrawView addLink:@"http://www.baidu.com" clickActionHandler:^(id obj) {
        UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"連結點選" message:[NSString stringWithFormat:@"點選物件%@", obj] preferredStyle:(UIAlertControllerStyleAlert)];
        [alert addAction:[UIAlertAction actionWithTitle:@"取消" style:(UIAlertActionStyleCancel) handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];
    }];
    
    // 新增圖片
    [textDrawView addImage:[UIImage imageNamed:@"tata_img_hottopicdefault"] size:CGSizeMake(30, 30) clickActionHandler:^(id obj) {
        UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"圖片點選" message:[NSString stringWithFormat:@"點選物件%@", obj] preferredStyle:(UIAlertControllerStyleAlert)];
        [alert addAction:[UIAlertAction actionWithTitle:@"取消" style:(UIAlertActionStyleCancel) handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];
    }];
    
    // 新增連結
    [textDrawView addLink:@"http://www.baidu.com" clickActionHandler:^(id obj) {
        
    }];
    
    // 新增普通的文字
    [textDrawView addString:@"這是一個最好的時代,也是一個最壞的時代;" attributes:self.defaultTextAttributes clickActionHandler:^(id obj) {
        
    }];
    
    // 新增連結
    [textDrawView addLink:@" 這是明智的時代,這是愚昧的時代;這是信任的紀元,這是懷疑的紀元;這是光明的季節,這是黑暗的季節;這是希望的春日,這是失望的冬日; " clickActionHandler:^(id obj) {
        
    }];
    
    // 新增自定義的View,預設是底部對其
    UIView* customView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 160, 50)];
    customView.backgroundColor = [UIColor colorWithRed:1 green:0.7 blue:1 alpha:0.51];
    [customView bk_whenTapped:^{
        NSLog(@"customView Tapped");
    }];
    UILabel *labelInCustomView = [UILabel new];
    labelInCustomView.textAlignment = NSTextAlignmentCenter;
    labelInCustomView.font = [UIFont systemFontOfSize:12];
    labelInCustomView.text = @"可點選的自定義的View";
    [customView addSubview:labelInCustomView];
    [labelInCustomView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.equalTo(customView);
    }];
    [textDrawView addView:customView size:customView.frame.size clickActionHandler:nil];
    
    // 新增普通的文字
    [textDrawView addString:@" Hello " attributes:self.defaultTextAttributes clickActionHandler:nil];

    
    // 新增居中對其的自定義的View
    UIView *unClickableCustomView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 160, 50)];
    unClickableCustomView.backgroundColor = [UIColor colorWithRed:1 green:0.7 blue:1 alpha:0.51];
    UILabel *labelInUnClickableCustomView = [UILabel new];
    labelInUnClickableCustomView.textAlignment = NSTextAlignmentCenter;
    labelInUnClickableCustomView.font = [UIFont systemFontOfSize:12];
    labelInUnClickableCustomView.text = @"居中對其自定義的View";
    [unClickableCustomView addSubview:labelInUnClickableCustomView];
    [labelInUnClickableCustomView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.equalTo(unClickableCustomView);
    }];
    [textDrawView addView:unClickableCustomView size:unClickableCustomView.frame.size align:(YTAttachmentAlignTypeCenter) clickActionHandler:nil];

    // 新增普通的文字
    [textDrawView addString:@" 我們面前應有盡有,我們面前一無所有; " attributes:self.defaultTextAttributes clickActionHandler:nil];
    
    // 新增自定義的按鈕,預設是底部對其
    UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
    button.frame = CGRectMake(0, 0, 80, 30);
    [button setTitle:@"我是按鈕" forState:UIControlStateNormal];
    button.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1];
    [button bk_addEventHandler:^(id sender) {
        NSLog(@"button Clicked");
    } forControlEvents:UIControlEventTouchUpInside];
    [textDrawView addView:button size:button.frame.size clickActionHandler:nil];
    
    [textDrawView addString:@" " attributes:self.defaultTextAttributes clickActionHandler:nil];
    
    // 新增頂部對其按鈕
    button = [UIButton buttonWithType:UIButtonTypeSystem];
    button.frame = CGRectMake(0, 0, 90, 30);
    [button setTitle:@"頂部對其按鈕" forState:UIControlStateNormal];
    button.titleLabel.font = [UIFont systemFontOfSize:14];
    button.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1];
    [button bk_addEventHandler:^(id sender) {
        NSLog(@"button Clicked");
    } forControlEvents:UIControlEventTouchUpInside];
    [textDrawView addView:button size:button.frame.size align:(YTAttachmentAlignTypeTop) clickActionHandler:nil];
    
    // 新增普通的文字
    [textDrawView addString:@" 我們都將直上天堂,我們都將直下地獄。 " attributes:self.defaultTextAttributes clickActionHandler:nil];
    
    [self.view addSubview:textDrawView];
    self.textDrawView = textDrawView;
}
複製程式碼

新增View

新增View其實和新增圖片的處理方式很類似,只不過新增圖片我們是使用CG繪圖的方式把圖片繪製在View上,而新增View是使用UIkit的方法addSubview把View新增到View的層級上,這裡有個稍微有個需要注意的地方就是座標的問題,UI座標系和CG座標系的顛倒的,需要做個額外的處理

首先定義一個新增View的方法,在該方法中主要是進行資料模型的儲存以及生產特殊的佔位屬性字串,然後新增屬性字串的RunDelegate

- (void)addView:(UIView *)view size:(CGSize)size align:(YTAttachmentAlignType)align clickActionHandler:(ClickActionHandler)clickActionHandler {
    YTAttachmentItem *imageItem = [YTAttachmentItem new];
    [self updateAttachment:imageItem withFont:self.font];
    imageItem.align = align;
    imageItem.attachment = view;
    imageItem.type = YTAttachmentTypeView;
    imageItem.size = size;
    imageItem.clickActionHandler = clickActionHandler;
    [self.attachments addObject:imageItem];
    NSAttributedString *imageAttributeString = [self attachmentAttributeStringWithAttachmentItem:imageItem size:size];
    [self.attributeString appendAttributedString:imageAttributeString];
}
複製程式碼

設定佔位屬性字串的方法和新增圖片時候使用到的是一樣的程式碼

- (NSAttributedString *)attachmentAttributeStringWithAttachmentItem:(YTAttachmentItem *)attachmentItem size:(CGSize)size {
    // 建立CTRunDelegateCallbacks
    CTRunDelegateCallbacks callback;
    memset(&callback, 0, sizeof(CTRunDelegateCallbacks));
    callback.getAscent = getAscent;
    callback.getDescent = getDescent;
    callback.getWidth = getWidth;
    
    // 建立CTRunDelegateRef
//    NSDictionary *metaData = @{YTRunMetaData: attachmentItem};
    CTRunDelegateRef runDelegate = CTRunDelegateCreate(&callback, (__bridge void * _Nullable)(attachmentItem));
    
    // 設定佔位使用的圖片屬性字串
    // 參考:https://en.wikipedia.org/wiki/Specials_(Unicode_block)  U+FFFC  OBJECT REPLACEMENT CHARACTER, placeholder in the text for another unspecified object, for example in a compound document.
    unichar objectReplacementChar = 0xFFFC;
    NSMutableAttributedString *imagePlaceHolderAttributeString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithCharacters:&objectReplacementChar length:1] attributes:[self defaultTextAttributes]];
    
    // 設定RunDelegate代理
    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)imagePlaceHolderAttributeString, CFRangeMake(0, 1), kCTRunDelegateAttributeName, runDelegate);
    
    // 設定附加資料,設定點選效果
    NSDictionary *extraData = @{YTExtraDataAttributeTypeKey: attachmentItem.type == YTAttachmentTypeImage ? @(YTDataTypeImage) : @(YTDataTypeView),
                                YTExtraDataAttributeDataKey: attachmentItem,
                                };
    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)imagePlaceHolderAttributeString, CFRangeMake(0, 1), (CFStringRef)YTExtraDataAttributeName, (__bridge CFTypeRef)(extraData));
    
    CFRelease(runDelegate);
    return imagePlaceHolderAttributeString;
}
複製程式碼

接下來就是需要計算新增的View所在父View中的位置,進行相應的儲存,這裡需要注意的是座標系的問題,需要做一個額外的轉換

- (void)calculateContentPositionWithBounds:(CGRect)bounds {
    
    int imageIndex = 0;
    
    // CTFrameGetLines獲取但CTFrame內容的行數
    NSArray *lines = (NSArray *)CTFrameGetLines(self.ctFrame);
    // CTFrameGetLineOrigins獲取每一行的起始點,儲存在lineOrigins陣列中
    CGPoint lineOrigins[lines.count];
    CTFrameGetLineOrigins(self.ctFrame, CFRangeMake(0, 0), lineOrigins);
    for (int i = 0; i < lines.count; i++) {
        CTLineRef line = (__bridge CTLineRef)lines[i];
        
        NSArray *runs = (NSArray *)CTLineGetGlyphRuns(line);
        for (int j = 0; j < runs.count; j++) {
            CTRunRef run = (__bridge CTRunRef)(runs[j]);
            NSDictionary *attributes = (NSDictionary *)CTRunGetAttributes(run);
            if (!attributes) {
                continue;
            }
            // ..... 部分程式碼省略
            
            // 找到代理則開始計算圖片位置資訊
            CGFloat ascent;
            CGFloat desent;
            // 可以直接從metaData獲取到圖片的寬度和高度資訊
            CGFloat width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &desent, NULL);
            CGFloat height = ascent + desent;
            
            // CTLineGetOffsetForStringIndex獲取CTRun的起始位置
            CGFloat xOffset = lineOrigins[i].x + CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
            CGFloat yOffset = lineOrigins[i].y;
            
            // 更新ImageItem物件的位置
            if (imageIndex < self.attachments.count) {
                YTAttachmentItem *imageItem = self.attachments[imageIndex];
                // 使用CG繪圖的位置不用矯正,使用UI繪圖的座標Y軸會上下顛倒,所以需要做調整
                if (imageItem.type == YTAttachmentTypeView) {
                    yOffset = bounds.size.height - lineOrigins[i].y - ascent;
                } else if (imageItem.type == YTAttachmentTypeImage) {
                    yOffset = yOffset - desent;
                }
                imageItem.frame = CGRectMake(xOffset, yOffset, width, height);
                imageIndex ++;
            }
        }
    }
}
複製程式碼

對其實現

處理對其方式之前要了解字形度量的一些概念,然後在此基礎上進行分析不同的對其方式下需要如何正確的設定排版的引數,才能渲染繪製出理想中內容

字形度量的一些概念

下面的這張圖片來自蘋果官方的參考文件:Typographical Concepts

字形度量
字形度量

字形度量中的幾個概念的說明參考 使用CoreText繪製文字 的是內容如下

bounding box(邊界框),這是一個假想的框子,它儘可能緊密的裝入字形。
baseline(基線),一條假想的線,一行上的字形都以此線作為上下位置的參考,在這條線的左側存在一個點叫做基線的原點。
ascent(上行高度),從原點到字型中最高(這裡的高深都是以基線為參照線的)的字形的頂部的距離,ascent是一個正值。
descent(下行高度),從原點到字型中最深的字形底部的距離,descent是一個負值(比如一個字型原點到最深的字形的底部的距離為2,那麼descent就為-2)。

三種對其方式的分析

以下對其方式的分析都是以下面的這些資料為標準的

Font.fontAscent = 33.75.   
Font.fontDescent = 27.04. 
LineHeight = Font.fontAscent + Font.fontDescent = 60.8. 
複製程式碼

頂部對其.

頂部對其
頂部對其

頂部對其,需要設定ascent值為文字內容的ascentdescent值為attachmen的高度減去ascent,如下圖所示(圖片上的標註是2x,並且數值因為是手動使用工具標註,會有一些細微的偏差),內容的高度為40,所以有:

  • ascent= Font.fontAscent = 33.75.
  • descent = 40 - ascent = 6.25.
ascent = 33.75. 
descent = 6.25. 
height = ascent + descent = 40. 
baseline = 33.75. 
複製程式碼

底部對其

底部對其
底部對其

底部對其,需要設定descent值為文字內容的descentascent值為attachmen的高度減去ascent,如下圖所示(圖片上的標註是2x,並且數值因為是手動使用工具標註,會有一些細微的偏差),內容的高度為40,所以有:

  • descent= Font.fontDescent = 27.04.
  • ascent = 40 - descent = 12.95.
ascent = 12.95. 
descent = 27.04. 
height = ascent + descent = 40. 
複製程式碼

居中對其.

居中對其
居中對其

居中對其,descent值和ascent值需要經過一些簡單的計算,先計算ascent值,ascent值為文字內容的ascent減去頂部的那一段差值,(如下圖示準中的值為21處的高度),然後descent值為attachmen的高度減去ascent,如下圖所示(圖片上的標註是2x,並且數值因為是手動使用工具標註,會有一些細微的偏差),內容的高度為40,所以有:

  • ascent = Font.fontAscent - (LineHeight - 40)/2 = 23.35.
  • descent = 40 - ascent = 16.64.
ascent = 23.35. 
descent = 16.64. 
height = ascent + descent = 40. 
複製程式碼

程式碼實現

首先需要在Attachment模型中新增如下幾個屬性,這些屬性在計算attachment內容的descentascent是必須要用到的

@property (nonatomic, assign) YTAttachmentAlignType align;///<對其方式
@property (nonatomic, assign) CGFloat ascent;///<文字內容的ascent,用於計算attachment內容的ascent
@property (nonatomic, assign) CGFloat descent;///<文字內容的descent,用於計算attachment內容的descent
@property (nonatomic, assign) CGSize size;///<attachment內容的大小
複製程式碼

然後根據以上分析,我們可以很容易的寫出如下的幾個RunDelegate回撥方法的程式碼:

// MARK: - CTRunDelegateCallbacks 回撥方法
static CGFloat getAscent(void *ref) {
    YTAttachmentItem *attachmentItem = (__bridge YTAttachmentItem *)ref;
    if (attachmentItem.align == YTAttachmentAlignTypeTop) {
        return attachmentItem.ascent;
    } else if (attachmentItem.align == YTAttachmentAlignTypeBottom) {
        return attachmentItem.size.height - attachmentItem.descent;
    } else if (attachmentItem.align == YTAttachmentAlignTypeCenter) {
        return attachmentItem.ascent - ((attachmentItem.descent + attachmentItem.ascent) - attachmentItem.size.height) / 2;
    }
    return attachmentItem.size.height;
}

static CGFloat getDescent(void *ref) {
    YTAttachmentItem *attachmentItem = (__bridge YTAttachmentItem *)ref;
    if (attachmentItem.align == YTAttachmentAlignTypeTop) {
        return attachmentItem.size.height - attachmentItem.ascent;
    } else if (attachmentItem.align == YTAttachmentAlignTypeBottom) {
        return attachmentItem.descent;
    } else if (attachmentItem.align == YTAttachmentAlignTypeCenter) {
        return attachmentItem.size.height - attachmentItem.ascent + ((attachmentItem.descent + attachmentItem.ascent) - attachmentItem.size.height) / 2;
    }
    return 0;
}

static CGFloat getWidth(void *ref) {
    YTAttachmentItem *attachmentItem = (__bridge YTAttachmentItem *)ref;
    return attachmentItem.size.width;
}
複製程式碼

另外,在更新全域性字型的時候需要同步的更新YTAttachmentItem中的descentascent屬性

- (void)setFont:(UIFont *)font {
    _font = font;
    [self.attributeString yt_setFont:_font];
    [self updateAttachments];
}

- (void)updateAttachments {
    for (YTAttachmentItem *attachment in self.attachments) {
        [self updateAttachment:attachment withFont:self.font];
    }
}
複製程式碼

總結

以上就是使用Core Text新增自定義的View以及設定對其方式的一點小總結,如有不妥之處,還請不吝賜教。

參考

Typographical Concepts
使用CoreText繪製文字

相關文章