在 iOS 开发中,UITextView
作为核心文本展示控件,其底层基于 Text Kit 框架实现。而 NSLayoutManagerDelegate
正是 Text Kit 中最为强大的扩展接口,它允许开发者以手术刀般的精度干预文本布局与渲染的每一个环节。本文将彻底揭开 NSLayoutManagerDelegate
的神秘面纱,从基础原理到实战技巧,为你呈现一套完整的文本定制化开发指南。
一、Text Kit 核心架构与 NSLayoutManagerDelegate 定位
1.1 Text Kit 三剑客
在深入 NSLayoutManagerDelegate
之前,必须理解 Text Kit 的核心组件协作关系:
组件
职责
NSTextStorage
负责文本内容的存储与属性管理
NSLayoutManager
协调文本布局计算,将字符转换为可视元素
NSTextContainer
定义文本布局的几何区域(如形状、排除路径)
NSLayoutManagerDelegate
是 NSLayoutManager
的代理协议,它像监听器一样插入文本布局的各个关键节点,让开发者可以:
-
干预布局决策(如换行规则、行高调整)
-
控制渲染行为(如字形替换、颜色覆盖)
-
实时监控布局状态(如布局完成事件)
1.2 为何需要 NSLayoutManagerDelegate?
当遇到以下场景时,系统默认的文本布局无法满足需求:
-
复杂排版需求:诗歌的断行规则、数学公式的上下标对齐
-
动态交互效果:点击链接时的高亮渲染、文本折叠展开
-
性能优化:海量文本的分页渲染、延迟计算
此时 NSLayoutManagerDelegate
便是解决问题的终极武器。
二、NSLayoutManagerDelegate 核心方法全解
2.1 换行与断词控制
方法:shouldBreakLineByWordBeforeCharacterAtIndex:
objectivec
- (BOOL)layoutManager:(NSLayoutManager *)layoutManager
shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex;
-
作用:决定是否允许在指定字符前按单词换行
-
参数 :
charIndex
当前检查的字符位置 -
返回值 :
YES
允许换行,NO
禁止 -
使用场景:
-
禁止在中文标点(如逗号、句号)前换行
-
保持特定短语(如 URL)的完整性
// 禁止在中文逗号前换行
- (BOOL)layoutManager:(NSLayoutManager *)layoutManager shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex { NSString *text = layoutManager.textStorage.string; unichar c = [text characterAtIndex:charIndex]; return (c != 0xFF0C); // 0xFF0C 是中文逗号的 Unicode 值 }
2.2 行间距动态调整
方法:lineSpacingAfterGlyphAtIndex:withProposedLineFragmentRect:
objectivec
- (CGFloat)layoutManager:(NSLayoutManager *)layoutManager
lineSpacingAfterGlyphAtIndex:(NSUInteger)glyphIndex
withProposedLineFragmentRect:(CGRect)rect;
-
作用:指定某一行之后的额外间距
-
参数:
-
glyphIndex
:当前行最后一个字形的索引 -
rect
:系统建议的行布局区域
-
-
返回值:额外间距的高度值
-
使用场景:
-
段落之间的间距大于行间距
-
根据内容动态调整间距(如标题与正文)
代码示例:
objectivec
// 每三行添加额外 20pt 间距
- (CGFloat)layoutManager:(NSLayoutManager *)layoutManager
lineSpacingAfterGlyphAtIndex:(NSUInteger)glyphIndex
withProposedLineFragmentRect:(CGRect)rect {
static NSUInteger lineCount = 0;
lineCount++;
return (lineCount % 3 == 0) ? 20 : 0;
}
2.3 控制字符处理
方法:shouldUseAction:forControlCharacterAtIndex:
objectivec
- (NSControlCharacterAction)layoutManager:(NSLayoutManager *)layoutManager
shouldUseAction:(NSControlCharacterAction)action
forControlCharacterAtIndex:(NSUInteger)charIndex;
-
作用 :干预控制字符(如
\n
、\t
)的处理方式 -
参数:
-
action
:系统默认处理动作 -
charIndex
:控制字符的位置
-
-
返回值:调整后的处理动作
-
使用场景:
-
自定义制表符宽度
-
将换行符替换为段落分隔符
-
代码示例:
objectivec
// 将 Tab 符转换为 4 个空格
- (NSControlCharacterAction)layoutManager:(NSLayoutManager *)layoutManager
shouldUseAction:(NSControlCharacterAction)action
forControlCharacterAtIndex:(NSUInteger)charIndex {
NSString *text = layoutManager.textStorage.string;
if ([text characterAtIndex:charIndex] == '\t') {
[layoutManager.textStorage replaceCharactersInRange:NSMakeRange(charIndex, 1)
withString:@" "];
return NSControlCharacterActionZeroAdvancement; // 阻止默认处理
}
return action;
}
2.4 布局完成监听
方法:didCompleteLayoutForTextContainer:atEnd:
objectivec
- (void)layoutManager:(NSLayoutManager *)layoutManager
didCompleteLayoutForTextContainer:(NSTextContainer *)textContainer
atEnd:(BOOL)layoutFinishedFlag;
-
作用:在某个文本容器的布局完成后触发
-
参数:
-
textContainer
:完成布局的容器 -
layoutFinishedFlag
:是否全部内容已布局
-
-
使用场景:
-
动态调整父视图尺寸
-
实现分页浏览功能
代码示例:
objectivec
// 布局完成后更新 ScrollView 的 contentSize
- (void)layoutManager:(NSLayoutManager *)layoutManager
didCompleteLayoutForTextContainer:(NSTextContainer *)textContainer
atEnd:(BOOL)layoutFinishedFlag {
if (layoutFinishedFlag) {
CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer];
self.scrollView.contentSize = CGSizeMake(usedRect.size.width, usedRect.size.height);
}
}
2.5 自定义字形渲染
方法:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:
objectivec
- (NSUInteger)layoutManager:(NSLayoutManager *)layoutManager
shouldGenerateGlyphs:(const CGGlyph *)glyphs
properties:(const NSGlyphProperty *)props
characterIndexes:(const NSUInteger *)charIndexes
font:(UIFont *)aFont
forGlyphRange:(NSRange)glyphRange;
-
作用:在生成字形时进行拦截和修改
-
参数:
-
glyphs
:原始字形数组 -
props
:字形属性数组 -
charIndexes
:字符索引数组 -
aFont
:当前字体 -
glyphRange
:当前处理的字形范围
-
-
返回值:处理后的字形数量
-
使用场景:
-
实现文本高亮
-
自定义表情符号渲染
-
高级示例:
ini
// 将所有字母 "a" 渲染为红色
- (NSUInteger)layoutManager:(NSLayoutManager *)layoutManager
shouldGenerateGlyphs:(const CGGlyph *)glyphs
properties:(NSGlyphProperty *)props
characterIndexes:(const NSUInteger *)charIndexes
font:(UIFont *)aFont
forGlyphRange:(NSRange)glyphRange {
NSMutableArray *modifiedGlyphs = [NSMutableArray array];
NSMutableIndexSet *highlightIndices = [NSMutableIndexSet indexSet];
for (NSUInteger i = 0; i < glyphRange.length; i++) {
NSUInteger charIndex = charIndexes[i];
unichar c = [layoutManager.textStorage.string characterAtIndex:charIndex];
if (c == 'a' || c == 'A') {
// 标记需要高亮的字形
props[i] |= NSGlyphPropertyColor;
[highlightIndices addIndex:i];
}
[modifiedGlyphs addObject:@(glyphs[i])];
}
// 应用修改后的字形
[layoutManager setGlyphs:modifiedGlyphs.array
properties:props
characterIndexes:charIndexes
font:aFont
forGlyphRange:glyphRange];
// 设置高亮颜色
[highlightIndices enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
[layoutManager setTemporaryAttributes:@{NSForegroundColorAttributeName: [UIColor redColor]}
forCharacterRange:NSMakeRange(charIndexes[idx], 1)];
}];
return glyphRange.length;
}
三、实战案例:实现代码编辑器的高亮与缩进
3.1 需求分析
假设要开发一个简易代码编辑器,需要实现以下功能:
-
关键字高亮(如
for
、if
显示为蓝色) -
智能缩进(按 Tab 键插入 4 个空格)
-
禁止在运算符(如
+
、-
)前换行
3.2 实现步骤
步骤 1:设置 NSLayoutManagerDelegate
less
@interface CodeEditorViewController () <NSLayoutManagerDelegate>
@end
@implementation CodeEditorViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.textView.layoutManager.delegate = self;
}
@end
步骤 2:关键字高亮
objectivec
// 在 shouldGenerateGlyphs 方法中检测关键字
- (NSUInteger)layoutManager:(NSLayoutManager *)layoutManager
shouldGenerateGlyphs:(const CGGlyph *)glyphs
properties:(NSGlyphProperty *)props
characterIndexes:(const NSUInteger *)charIndexes
font:(UIFont *)aFont
forGlyphRange:(NSRange)glyphRange {
NSString *text = layoutManager.textStorage.string;
NSRange lineRange = [text lineRangeForRange:NSMakeRange(charIndexes[0], 0)];
NSString *line = [text substringWithRange:lineRange];
NSArray *keywords = @[@"for", @"if", @"while"];
for (NSString *keyword in keywords) {
NSRange range = [line rangeOfString:keyword];
if (range.location != NSNotFound) {
NSRange globalRange = NSMakeRange(lineRange.location + range.location, range.length);
[layoutManager addTemporaryAttributes:@{NSForegroundColorAttributeName: [UIColor blueColor]}
forCharacterRange:globalRange];
}
}
return glyphRange.length;
}
步骤 3:智能缩进
objectivec
// 拦截 Tab 键输入
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
if ([text isEqualToString:@"\t"]) {
[textView replaceRange:[textView textRangeFromPosition:textView.selectedTextRange.start
toPosition:textView.selectedTextRange.start]
withText:@" "];
return NO;
}
return YES;
}
步骤 4:运算符换行控制
ini
// 禁止在 +、- 前换行
- (BOOL)layoutManager:(NSLayoutManager *)layoutManager
shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex {
NSString *text = layoutManager.textStorage.string;
unichar c = [text characterAtIndex:charIndex];
return !(c == '+' || c == '-');
}
四、性能优化与陷阱规避
4.1 性能优化技巧
-
批量操作 :在
textStorage
的beginEditing
/endEditing
块中进行多次修改 -
异步布局 :对超大文本使用
NSLayoutManager
的backgroundLayoutEnabled
-
缓存计算:对重复使用的布局信息(如段落高度)进行缓存
4.2 常见陷阱
-
循环调用:在代理方法中修改文本会导致递归调用
-
线程安全:Text Kit 组件非线程安全,必须主线程操作
-
内存泄漏 :强引用
NSLayoutManager
需注意释放时机
五、总结
通过 NSLayoutManagerDelegate
,开发者可以突破系统文本渲染的限制,实现从简单的行距调整到复杂的语法高亮引擎。关键在于深入理解 Text Kit 的工作流程,并在适当的时机插入自定义逻辑。