列表、滚动与文本
NSTableView 行高自管、滚动锚点与 Core Text 选区。
本主题范围:TelegramSwift 的 packages/TGUIKit 与 packages/InputView 中的 macOS 列表、滚动和文本系统。
1. 用 CAEmitterLayer 私有行为做可交互隐形墨水
做了什么:代码动画 emitterBehaviors.randomAttractor0 的私有属性。它让粒子吸引力和位置持续随机变化。
为什么这样做:公开 CAEmitterLayer API 不提供吸引器行为。这个做法把“墨水会流动”的物理感留在渲染服务端。
关键代码摘录
let falloffAnimation1 = CABasicAnimation(
keyPath: "emitterBehaviors.randomAttractor0.falloff"
)
falloffAnimation1.beginTime = 0.0
falloffAnimation1.fillMode = .both
falloffAnimation1.isRemovedOnCompletion = false
falloffAnimation1.autoreverses = true
falloffAnimation1.repeatCount = .infinity
falloffAnimation1.duration = 2.0
falloffAnimation1.fromValue = -20.0 as NSNumber
falloffAnimation1.toValue = 60.0 as NSNumber
falloffAnimation1.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
self.emitterLayer?.add(
falloffAnimation1,
forKey: "emitterBehaviors.randomAttractor0.falloff"
)
let positionAnimation1 = CAKeyframeAnimation(
keyPath: "emitterBehaviors.randomAttractor0.position"
)
positionAnimation1.beginTime = 0.0
positionAnimation1.fillMode = .both
positionAnimation1.isRemovedOnCompletion = false
positionAnimation1.autoreverses = true
positionAnimation1.repeatCount = .infinity
positionAnimation1.duration = 3.0
positionAnimation1.calculationMode = .discrete代价与风险:emitterBehaviors、randomAttractor0 是明文 KVC key。它们不是字符串拼接或 base64 混淆。代码在 L415-L416 也以同一前缀写入 fingerAttractor。因此可确认真实私有 API 名为 emitterBehaviors。私有 API 有审核拒绝和系统兼容风险。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:运行时拼类名与
behaviorWithType:,IMP 函数指针调用。 - REA 第 1 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:
"CA"、"Emitter"、"Behavior"以 Swift 小字符串存于__DATA.__data0x5e75208/18/28;"behaviorWith"、"Type:"存于 0x5e751c0/d0。完整名CAEmitterBehavior、behaviorWithType出现 0 次。"simpleAttractor"编成 5 处、"valueOverLife"编成 3 处MOVZ/MOVK立即数。KVC 路径emitterBehaviors.fingerAttractor.*、randomAttractor0/1.*以明文存于__cstringmacOS 26.7:类存在;
+behaviorWithType:存在;两种类型都能创建结论:已发布,当前系统仍可用
2. 用 Core Text 反查空偏移,修正跨行选区尾部
做了什么:如果选区结束索引的 Core Text 横向偏移为零,代码向前逐字符重试。它随后用修正后的两端偏移绘制高亮矩形。
为什么这样做:换行、行尾和某些 glyph 组合会返回零偏移。直接使用零值会让跨行选区变成反向宽度或消失。
关键代码摘录
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
var width: CGFloat = CGFloat(
CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
)
let startOffset = CTLineGetOffsetForStringIndex(line, beginLineIndex, nil)
var endOffset = CTLineGetOffsetForStringIndex(line, endLineIndex, nil)
if beginLineIndex < endLineIndex {
var index = endLineIndex - 1
while endOffset == 0 && index > 0 {
endOffset = CTLineGetOffsetForStringIndex(line, index, nil)
index -= 1
}
}
width = endOffset - startOffset代价与风险:这段循环依赖 Core Text 的索引语义。复杂组合字符的边界仍需人工回归。未验证全部 Unicode 边界情况。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:以
CTLineGetOffsetForStringIndex逐字符回退。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
3. 按稳定标识恢复滚动锚点,而不是恢复裸 offset
做了什么:更新前保存可见行的 stableId 和屏幕相对位置。更新后代码查找同一行,再重算 NSClipView 的 y 坐标。
为什么这样做:插入消息或重算行高会改变绝对 offset。稳定锚点可避免聊天阅读位置跳动,并兼容正向和翻转坐标。
关键代码摘录
let strideTo: StrideTo<Int> = stride(
from: visibleItems.count - 1,
to: -1,
by: -1
)
for i in strideTo {
let visible = visibleItems[i]
if let item = self.item(stableId: visible.0.stableId) {
nrect = rectOf(item: item)
if let view = viewNecessary(at: i) {
if view.isInsertionAnimated {
break
}
}
let y: CGFloat
if !tableView.isFlipped {
y = nrect.minY - (frame.height - visible.1) + nrect.height
} else {
y = nrect.minY - visible.1
}
self.clipView.updateBounds(to: NSMakePoint(0, y))
break
}
}代价与风险:锚点行被删除时会退到其他可见行。插入动画中的行会中断恢复,以避免追随尚未稳定的几何状态。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:逆序扫描可见项,遇到插入动画中则跳过。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
4. 平滑滚动保存目标点,并可同步结算中途动画
做了什么:TGClipView 为每次动画滚动保存唯一目标。它在完成时按真实 bounds.origin 验证结果。
为什么这样做:NSScrollView 的普通滚动没有业务级完成结果。这里可让列表在动画结束后继续处理排队 transition。
关键代码摘录
if point == self.destinationOrigin {
return
}
self.scrollCompletion = completion
self.destinationOrigin = point
if animated {
self.point = point
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
let timingFunction = CAMediaTimingFunction(
controlPoints: 0.5, 1.0 + 0.4 / 3.0, 1.0, 1.0
)
ctx.timingFunction = timingFunction
self.animator().setBoundsOrigin(point)
}, completionHandler: {
self.destinationOrigin = nil
self.point = nil
self.scrollCompletion?(point == self.bounds.origin)
self.scrollDidComplete?(point == self.bounds.origin)
})
} else {
self.updateBounds(to: point)
self.point = nil
self.destinationOrigin = nil
self.scrollCompletion?(true)
self.scrollDidComplete?(true)
}代价与风险:scrollCompletion 只有一个槽位。新请求会覆盖旧回调。调用方必须把并发滚动串行化。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
NSAnimationContext包裹animator().setBoundsOrigin。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
5. 把粘性标题移到滚动视图同级,并适配翻转列表
做了什么:代码在可见行中按坐标方向寻找最近的 TableStickItem。TableView 随后把 TableStickView 放在滚动内容之外。
为什么这样做:NSTableView 没有可靠的聊天式粘性行机制。翻转坐标会让“前一行”方向反转,所以代码显式分支。
关键代码摘录
let range = self.tableView.rows(in: NSMakeRect(
visibleRect.minX,
visibleRect.minY,
visibleRect.width,
visibleRect.height - stickTopInset - secondaryOffset
))
if let stickClass = stickClass, !updating {
let flipped = tableView.isFlipped
var index: Int = flipped
? range.location
: range.location + range.length - 1
let scrollInset = self.documentOffset.y - stickTopInset
+ (flipped ? 0 : frame.height)
var item: TableRowItem? = optionalItem(at: index)
if !flipped {
while let s = item, !s.isKind(of: stickClass) {
index += 1
item = self.optionalItem(at: index)
}
} else {
while let s = item, !s.isKind(of: stickClass) {
index -= 1
item = self.optionalItem(at: index)
}
}
}代价与风险:算法与行排序和 isFlipped 强耦合。列表更新期间刻意跳过,以避免过渡中取到错误行。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:基于可见行
NSRange与滚动 inset 选取。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
6. 粘性标题压住下一标题时,手算 y 轴推挤和层级
做了什么:代码以候选标题的实际 row rect 计算覆盖标题的 y 偏移。它为第二个粘性标题预留 secondaryOffset。
为什么这样做:普通 sticky header 只固定顶部。聊天日期标题在相邻标题出现时必须被推出去,并且还要和另一层标题共存。
关键代码摘录
let rect: NSRect = self.rectOf(item: item)
let dif: CGFloat
if currentStick != nil {
dif = min(scrollInset - rect.maxY, item.heightValue)
} else {
dif = item.heightValue
}
var yTopOffset: CGFloat
if !flipped {
yTopOffset = min(
(scrollInset - rect.maxY - secondaryOffset) - rect.height,
0
)
} else {
yTopOffset = min(
-(rect.height + (scrollInset - rect.minY - secondaryOffset)),
0
)
}
if yTopOffset <= -rect.height {
yTopOffset = 0
}
let updatedPoint = NSMakePoint(
0,
yTopOffset + stickTopInset + secondaryOffset
)代价与风险:几何公式复杂。改变边距或加入第三层标题时,维护者必须同步修改分支。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:结合
scrollInset/secondaryOffset与行高求偏移。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
7. 用 presentation layer 接住行位移,再人工补 Core Animation
做了什么:列表插入时读取 presentation() 层的当前位置。它从屏幕上的实际位置继续动画,而不是从 model layer 跳变后的值开始。
为什么这样做:连续 merge 时,前一帧位置动画可能未结束。直接读取 model layer 会造成行跳帧。
关键代码摘录
if let view = table.viewNecessary(at: idx), let layer = view.layer {
var inset = (layer.frame.minY - height)
if let presentLayer = layer.presentation(),
presentLayer.animation(forKey: "position") != nil {
inset = presentLayer.position.y
}
let from: CGPoint = NSMakePoint(0, inset)
let to: CGPoint = NSMakePoint(0, layer.position.y)
layer.animatePosition(
from: from,
to: to,
duration: 0.2,
timingFunction: .easeOut
)
animatedItems.append(
AnimateItem(index: added[0].index, from: from, to: to)
)
}代价与风险:逻辑依赖自定义 animatePosition 扩展和动画 key position。更改动画 key 会静默失效。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:检测动画 key
position,构造animatePosition。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
8. 为倒序消息流写独立的增量 diff
做了什么:mergeListsStableWithUpdatesReversed 以反向比较规则生成删除、插入和更新索引。它还保留更新项的旧索引。
为什么这样做:倒序聊天数据不能直接复用正序 merge。保留旧索引可让视图选择正确的插入和移动动画。
关键代码摘录
if let left = left, let right = right {
if left.stableId == right.stableId && (left != right || allUpdated) {
updatedIndices.append((i, right, previousIndices[left.stableId]!))
i += 1
j += 1
} else {
if left == right && !allUpdated {
i += 1
j += 1
} else if left > right {
removeIndices.append(i)
i += 1
} else if !(left < right) {
removeIndices.append(i)
i += 1
} else {
j += 1
}
}
} else if let _ = left {
removeIndices.append(i)
i += 1
} else if let _ = right {
j += 1
} else {
break
}代价与风险:泛型元素必须同时正确实现 Comparable、Identifiable 和相等性。排序不稳定会触发大量错误 diff。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
9. 只重排可见行,并用手工行高通知驱动 NSTableView
做了什么:窗口改宽时,代码只对可见 range 调用 makeSize。只有高度变化的行才 reload 并通知 NSTableView 重算高度。
为什么这样做:聊天行文本布局昂贵。全表重排会在缩放窗口时卡顿,并破坏当前阅读位置。
关键代码摘录
beginTableUpdates()
for i in range.min ..< range.max {
let item = self.item(at: i)
let before = item.heightValue
let updated = item.makeSize(frame.width, oldWidth: oldWidth)
let after = item.heightValue
if (before != after && updated) || item.instantlyResize || inLiveResize {
reloadData(row: i, animated: false)
noteHeightOfRow(i, false)
}
}
endTableUpdates()
if !tableView.inLiveResize && oldWidth != 0 {
saveScrollState(visibleItems)
}
for listener in scrollListeners {
listener.handler(self.scrollPosition().current)
}代价与风险:不可见行延迟到可见时才会重排。行项必须保证 makeSize 幂等。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
10. 用 TextNode 复用相同输入的 Core Text layout
做了什么:layoutText 把约束尺寸、截断策略、镂空、选中态和 attributed string 都作为缓存键。全部相等时直接复用旧 layout。
为什么这样做:Core Text 断行和 glyph run 构造是聊天滚动热点。缓存避免同一文本在布局阶段重复测量。
关键代码摘录
let existingLayout: TextNodeLayout? = maybeNode?.currentLayout
let layout: TextNodeLayout
if let existingLayout = existingLayout,
existingLayout.constrainedSize == constrainedSize,
existingLayout.maximumNumberOfLines == maximumNumberOfLines,
existingLayout.truncationType == truncationType,
existingLayout.cutout == cutout,
existingLayout.selected == selected,
existingLayout.alignment == alignment {
let stringMatch: Bool
if let existingString = existingLayout.attributedString,
let string = attributedString {
stringMatch = existingString == string
} else if existingLayout.attributedString == nil && attributedString == nil {
stringMatch = true
} else {
stringMatch = false
}
if stringMatch {
layout = existingLayout
} else {
layout = TextNode.getlayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, cutout: cutout,selected:selected, alignment: alignment, lineSpacing: lineSpacing)
}
}代价与风险:缓存匹配依赖 NSAttributedString 相等性。属性对象的相等实现不稳定时,会降低命中率。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
11. 绕过 NSTextView,直接逐 glyph run 绘制消息文本
做了什么:TextNode 自己翻转 Core Graphics 坐标系。它逐行、逐 CTRun 绘制文本,并计算居中 pen offset。
为什么这样做:大量不可编辑消息不需要 NSTextView 的编辑、布局和子视图成本。Core Text 绘制也便于同时控制选择、spoiler 和嵌入层。
关键代码摘录
if let layout = self.currentLayout {
let textMatrix = context.textMatrix
let textPosition = context.textPosition
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
for i in 0 ..< layout.lines.count {
let line = layout.lines[i]
var penFlush: CGFloat = 0.0
if layout.alignment == .center {
penFlush = 0.5
}
let penOffset = CGFloat(
CTLineGetPenOffsetForFlush(line.line, penFlush, Double(dirtyRect.width))
)
context.textPosition = CGPoint(
x: penOffset + dirtyRect.minX + line.frame.minX,
y: line.frame.minY + dirtyRect.minY
)
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
for run in glyphRuns {
let run = run as! CTRun
CTRunDraw(run, context, CFRangeMake(0, CTRunGetGlyphCount(run)))
}
}
context.textMatrix = textMatrix
context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y)
}代价与风险:实现者负责坐标、截断、可访问性和交互。强制转换 run as! CTRun 依赖 Core Text 返回类型。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
12. 将内嵌内容留在文本流中,再交给独立图层渲染
做了什么:代码从 CTLine 精确得到内嵌范围的左右边界。它使用 floor/ceil 和备用偏移,减少非整数 glyph 坐标的缝隙。
为什么这样做:动态表情等内容不能只靠普通字符绘制。独立 embeddedContainer 图层可以播放动画,同时文字仍参与断行。
关键代码摘录
func addEmbeddedItem(
item: AnyHashable,
line: CTLine,
ascent: CGFloat,
descent: CGFloat,
startIndex: Int,
endIndex: Int,
rightInset: CGFloat = 0.0
) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(
line, startIndex, &secondaryLeftOffset
)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(
line, endIndex, &secondaryRightOffset
)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
let lineRange = NSMakeRange(
CTLineGetStringRange(line).location,
CTLineGetStringRange(line).length
)
}代价与风险:内嵌范围跨行时会被后续逻辑拒绝。内嵌对象必须和原 attributed string 的索引保持同步。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
13. spoiler 遮罩时同步隐藏浮在文字上的嵌入层
做了什么:代码以嵌入层中心点和 spoiler rect 相交为条件,把对应 layer 的 opacity 置零。
为什么这样做:spoiler 只遮住 Core Text glyph 时,独立动画表情会泄露内容。此处补齐两个渲染管线。
关键代码摘录
public func checkEmbeddedUnderSpoiler() {
if let layout = self.textLayout {
let rects = layout.spoilerRects().map { $0.0 }
let sublayers = embeddedContainer.sublayers ?? []
for subview in sublayers {
var isHidden = false
loop: for rect in rects {
if NSIntersectsRect(
NSMakeRect(subview.frame.midX, subview.frame.midY, 1, 1),
rect
) {
isHidden = true
break loop
}
}
subview.opacity = isHidden ? 0 : 1
}
}
}代价与风险:中心点判断不能表示部分覆盖。极宽嵌入内容可能在边缘与 spoiler 视觉不一致。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
14. 输入框用第二套 TextKit 栈测量高度,避免污染显示布局
做了什么:InputTextView 用独立的 storage、layout manager 和无限高 container 测量高度。它仅在文本或宽度改变时使测量布局失效。
为什么这样做:复用屏幕上的 layout manager 会与插入点、选择和附件布局竞争。单独测量可保持输入视图稳定。
关键代码摘录
public func textHeightForWidth(_ width: CGFloat) -> CGFloat {
let measureSize = CGSize(width: width, height: 1000000.0)
if self.measurementTextStorage != self.attributedString()
|| self.measurementTextContainer.size != measureSize {
self.measurementTextStorage.setAttributedString(self.attributedString())
self.measurementTextContainer.size = measureSize
self.measurementLayoutManager.invalidateLayout(
forCharacterRange: NSRange(
location: 0,
length: self.measurementTextStorage.length
),
actualCharacterRange: nil
)
self.measurementLayoutManager.ensureLayout(
for: self.measurementTextContainer
)
}
var textSize = self.measurementLayoutManager.usedRect(
for: self.measurementTextContainer
).size
if string.isEmpty {
textSize.height += 2
}
return textSize.height + insets.top + insets.bottom
+ self.textContainerOrigin.y * 2
}代价与风险:每次文本改变都比较完整 attributed string。超长草稿会增加比较和复制成本。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
15. 重写 NSTextContainer 的行片段,为引用块预留不规则边距
做了什么:输入框在 TextKit 请求每个 line fragment 时检查 quote 属性。引用块行会被动态缩窄并右移。
为什么这样做:单一 textContainerInset 不能表现首行和普通行不同的引用装饰。重写 fragment 几何能让文字自然绕开边栏。
关键代码摘录
override func lineFragmentRect(
forProposedRect proposedRect: CGRect,
at characterIndex: Int,
writingDirection baseWritingDirection: NSWritingDirection,
remaining remainingRect: UnsafeMutablePointer<CGRect>?
) -> CGRect {
var result = super.lineFragmentRect(
forProposedRect: proposedRect,
at: characterIndex,
writingDirection: baseWritingDirection,
remaining: remainingRect
)
result.origin.x -= 5.0
result.size.width -= 5.0
if let textStorage = self.layoutManager?.textStorage {
let string: NSString = textStorage.string as NSString
let index = Int(characterIndex)
if index >= 0 && index < string.length {
let attributes = textStorage.attributes(at: index, effectiveRange: nil)
let blockQuote = attributes[TextInputAttributes.quote] as? NSObject
if let blockQuote = blockQuote {
result.origin.x += 9.0
result.size.width -= 9.0
result.size.width -= 7.0代价与风险:依赖 TextInputAttributes.quote 的对象相等性。TextKit 2 不会自动复用此覆写。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
16. 自定义表情不是 NSTextAttachment,而是 glyph rect 上的叠加层
做了什么:代码用 customEmoji 属性找字符范围,再转换为 glyph range 和 bounding rect。外层的 emojiContent 接收这些 rect 并渲染表情。
为什么这样做:NSTextAttachment 难以承载复杂动画,也不易和 spoiler 一起控制。叠加层保留 TextKit 的自然换行。
关键代码摘录
private func validateEmojies() {
let textStorage = self.customTextStorage
var rects: [(CGRect, TextInputTextCustomEmojiAttribute)] = []
textStorage.enumerateAttributes(
in: NSMakeRange(0, textStorage.length),
options: [],
using: { attributes, range, _ in
if let value = attributes[TextInputAttributes.customEmoji]
as? TextInputTextCustomEmojiAttribute {
if let spoiler = attributes[TextInputAttributes.spoiler] as? NSNumber {
if spoiler == 1 {
return
}
}
let glyphRange = self.customLayoutManager.glyphRange(
forCharacterRange: range,
actualCharacterRange: nil
)
if self.customLayoutManager.isValidGlyphIndex(glyphRange.location)
&& self.customLayoutManager.isValidGlyphIndex(
glyphRange.location + glyphRange.length - 1
) {
} else {
return
}代价与风险:多行或变形 glyph 的 bounding rect 需要额外处理。代码会跳过处于 spoiler 的表情。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
17. 发送键先检查 hasMarkedText(),避免截断中文等输入法候选
做了什么:Enter 只有在没有 marked text 时才进入发送委托。输入法组合文本存在时,事件继续交给 NSTextView。
为什么这样做:把 Enter 一律当发送会吞掉候选确认。该判断保留系统输入法的 commit 语义。
关键代码摘录
public override func keyDown(with theEvent: NSEvent) {
if let delegate = self.customDelegate {
if delegate.inputViewIsEnabled(theEvent) {
if isEnterEvent(theEvent) && !self.hasMarkedText() {
let result = delegate.inputViewProcessEnter(theEvent)
if (!result && (theEvent.modifierFlags.contains(.command)
|| theEvent.modifierFlags.contains(.shift))) {
super.insertNewline(self)
return
}
if result {
return
}
} else if theEvent.keyCode == 53,
delegate.inputViewMaybeClosed() {
return
}
if !theEvent.modifierFlags.contains(.command)
|| !isEnterEvent(theEvent) {
super.keyDown(with: theEvent)
}
}
} else {
super.keyDown(with: theEvent)
}
}代价与风险:依赖 AppKit 的 hasMarkedText()。自定义输入法或事件注入工具仍需实机验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
18. 为输入控件单独持久化拼写和语法检查开关
做了什么:InputTextView 覆写 AppKit 拼写和语法开关。它以 TGGrowingTextView 后缀写入 UserDefaults。
为什么这样做:该输入框可独立保存检查偏好。它不必完全依赖系统控件当前的临时状态。
关键代码摘录
public override var isContinuousSpellCheckingEnabled: Bool {
get {
return UserDefaults.standard.bool(
forKey: "ContinuousSpellCheckingEnabled\(settingsKey)"
)
}
set {
UserDefaults.standard.set(
newValue,
forKey: "ContinuousSpellCheckingEnabled\(settingsKey)"
)
super.isContinuousSpellCheckingEnabled = newValue
}
}
public override var isGrammarCheckingEnabled: Bool {
get {
return UserDefaults.standard.bool(
forKey: "GrammarCheckingEnabled\(settingsKey)"
)
}
set {
UserDefaults.standard.set(
newValue,
forKey: "GrammarCheckingEnabled\(settingsKey)"
)
super.isGrammarCheckingEnabled = newValue
}
}代价与风险:bool(forKey:) 对未设置的键返回 false。首次使用会关闭这些检查,除非其他代码先写入默认值。未验证默认值注册路径。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
19. 横向滚动直接把纵向滚轮 delta 映射到 x,并在边界冒泡
做了什么:横向容器把触控板的垂直 delta 转为 x 方向。它把 x 对齐到屏幕像素,并在无可滚动内容时交给父视图。
为什么这样做:横向媒体或标签列表也应响应普通滚轮手势。像素对齐减少非整数 offset 下的模糊。
关键代码摘录
override open func scrollWheel(with event: NSEvent) {
if let applyExternalScroll = self.applyExternalScroll,
applyExternalScroll(event) {
return
}
var scrollPoint = contentView.bounds.origin
let isInverted: Bool = System.isScrollInverted
if event.scrollingDeltaY != 0 {
if isInverted {
scrollPoint.x += -event.scrollingDeltaY
} else {
scrollPoint.x -= event.scrollingDeltaY
}
scrollPoint.x = min(
max(0, floorToScreenPixels(backingScaleFactor, scrollPoint.x)),
documentView!.frame.width - frame.width
)
clipView.scroll(to: scrollPoint)
window?.scrollWheel(with: event)
return
}
if documentView!.frame.width > frame.width {
if event.scrollingDeltaY != 0 || event.scrollingDeltaX != 0 {
super.scrollWheel(with: event)
}
} else {
superview?.scrollWheel(with: event)
}
}代价与风险:主动向 window 或父视图转发事件。嵌套滚动容器的手势竞争需要逐页验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
20. 数字计数器把字符串拆成多个 TextView,分别滚入和滚出
做了什么:DynamicCounterTextView 不直接替换整段文本。它让离场和入场的 token 各自沿 y 轴移动,并叠加透明度动画。
为什么这样做:整段 cross-fade 无法表达数值增减方向。拆分 token 还能保留未变化字符的图层实例。
关键代码摘录
if animated {
switch updated.animation {
case .crossFade:
field.layer?.animateAlpha(
from: 1,
to: 0,
duration: duration - 0.1,
timingFunction: timingFunction,
removeOnCompletion: false,
completion: { [weak field] _ in
field?.removeFromSuperview()
}
)
case .numeric:
field.layer?.animateAlpha(
from: 1,
to: 0,
duration: duration - 0.1,
timingFunction: timingFunction,
removeOnCompletion: false,
completion: { [weak field] _ in
field?.removeFromSuperview()
}
)
let direction = addition[key.hashValue]
switch direction {
case .forward?:
field.layer?.animatePosition(
from: field.frame.origin,
to: NSMakePoint(field.frame.minX, field.frame.maxY),
timingFunction: timingFunction,
removeOnCompletion: false
)代价与风险:token 用 hashValue 关联。哈希碰撞虽罕见,但会误匹配文本视图。未验证该类型是否额外保证唯一性。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
21. 强制关闭自动行高和默认背景,完全接管 NSTableView 的行几何
做了什么:TGFlipableTableView 关闭交替背景、自动行高和图层重绘策略。外层 TableView 负责尺寸、焦点和辅助元素。
为什么这样做:聊天行需要自定义高度缓存、翻转坐标和独立文本渲染。AppKit 的自动行高会和手工 heightOfRow 发生重复测量。
关键代码摘录
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
backgroundColor = .clear
self.autoresizesSubviews = false
usesAlternatingRowBackgroundColors = false
layerContentsRedrawPolicy = .never
usesAutomaticRowHeights = false
}
override func becomeFirstResponder() -> Bool {
return false
}
override func rect(ofRow row: Int) -> NSRect {
return super.rect(ofRow: row)
}
override func row(at point: NSPoint) -> Int {
return super.row(at: point)
}
override func rows(in rect: NSRect) -> NSRange {
return super.rows(in: rect)
}
override func isAccessibilityElement() -> Bool {
return false
}
override func accessibilityParent() -> Any? {
return nil
}
override public static var isCompatibleWithResponsiveScrolling: Bool {
return true
}代价与风险:该类把自己从辅助功能树移除。外层若没有补充可访问性实现,辅助技术可能无法识别列表。isCompatibleWithResponsiveScrolling 是公开 API,无明显审核风险。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
22. 滚动列表更新时按可视范围筛选动画对象
做了什么:TableAnimationInterface 先把插入和删除限制在可见行范围。它再以这些行的高度决定是否需要补偿 content offset。
为什么这样做:屏外行不需要图层动画。筛选减少 merge 期间的 view 创建、CA transaction 和重绘。
关键代码摘录
var range: NSRange = table.visibleRows(height)
let added = added.filter { item in
if item.index < range.location
|| item.index > range.location + range.length {
return false
} else {
return true
}
}
let removed = removed.filter { item in
if item.index < range.location
|| item.index > range.location + range.length {
return false
} else {
return true
}
}
for item in added {
height += item.heightValue
}
for item in removed {
height -= item.heightValue
}
if previousRange.length == 0 {
return []
}代价与风险:屏外变更没有过渡动画。快速滚动到刚变更区域时,用户只能看到最终状态。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。