聊天界面
输入框焦点交接、消息转场与列表到达判定。
主题范围:Telegram-iOS 聊天界面的应用层 UIKit 技巧,包括气泡、输入、媒体键盘、录音、发送转场、滚动、跳转和编辑菜单;不覆盖通用 ListView、私有 API、Portal/Liquid Glass 或键盘窗口。
1. 先交接第一响应者,再释放旧输入框
做了什么:代码先让新输入节点成为第一响应者。代码随后才让旧节点辞去第一响应者。
为什么这样做:这会保留系统键盘的显示状态。常规的先 resign 再 become 会触发键盘收起和重现。
关键代码摘录
/// Replace the current legacy input node with a freshly-built native node, transferring first-responder
/// status (become-new BEFORE resign-old, so the keyboard is not dismissed and re-presented). The caller sets
/// content on the new node immediately afterwards.
private func replaceLegacyWithNative() {
guard let old = self.richTextInputNode, !old.usesNativeRichTextEngine else {
return
}
let wasFirstResponder = old.isInputFirstResponder
self.loadTextInputNode(useNative: true)
guard let new = self.richTextInputNode, new !== old else {
return
}
if wasFirstResponder {
new.makeInputFirstResponder()
}
old.resignInputFirstResponder()
old.asNode.removeFromSupernode()
}代价与风险:新节点必须先完成可编辑初始化。否则焦点和内容会短暂不同步。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:代码先记录旧节点的焦点状态,加载新节点后转移焦点,最后从父节点移除旧节点。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 文本发送动画分离横纵轴曲线
做了什么:代码把横向和纵向位移拆成两条动画。代码再把输入框背景和内容交给目标气泡。
为什么这样做:气泡从底部输入框飞入时需要不同的速度曲线。单一 UIView 位移动画难以同时匹配两个方向。
关键代码摘录
let horizontalCurve = ChatMessageTransitionNodeImpl.horizontalAnimationCurve
let horizontalTransition: ContainedViewLayoutTransition = .animated(duration: horizontalDuration, curve: horizontalCurve)
let verticalCurve = ChatMessageTransitionNodeImpl.verticalAnimationCurve
let verticalTransition: ContainedViewLayoutTransition = .animated(duration: verticalDuration, curve: verticalCurve)
let combinedTransition = CombinedTransition(horizontal: horizontalTransition, vertical: verticalTransition)
self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -self.contextSourceNode.contentRect.minX, dy: -self.contextSourceNode.contentRect.minY)
self.contextSourceNode.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.maxY - targetAbsoluteRect.maxY), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: verticalCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.endAnimation()
})
self.containerNode.layer.animatePosition(from: CGPoint(x: sourceAbsoluteRect.minX - targetAbsoluteRect.minX, y: 0.0), to: CGPoint(), duration: horizontalDuration, delay: delay, mediaTimingFunction: horizontalCurve.mediaTimingFunction, additive: true)
if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
itemNode.animateContentFromTextInputField(
textInput: ChatMessageBubbleItemNode.AnimationTransitionTextInput(
backgroundView: textInput.backgroundView,
contentView: textInput.contentView,
sourceRect: textInput.sourceRect,代价与风险:坐标必须先换算到同一容器。曲线或时长失配会让背景和文字分离。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 媒体快照以反向缩放交接给气泡
做了什么:代码把媒体输入快照放进过渡容器。代码同时缩放目标内容和反向缩放快照层。
为什么这样做:两个图层在同一视觉尺寸上交接。常规的直接缩放单个快照会暴露目标内容的突变。
关键代码摘录
let combinedTransition = CombinedTransition(horizontal: .animated(duration: horizontalDuration, curve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve), vertical: .animated(duration: verticalDuration, curve: ChatMessageTransitionNodeImpl.verticalAnimationCurve))
if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
itemNode.animateContentFromMediaInput(snapshotView: snapshotView, transition: combinedTransition)
}
self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -self.contextSourceNode.contentRect.minX, dy: -self.contextSourceNode.contentRect.minY)
snapshotView.center = targetAbsoluteRect.center.offsetBy(dx: -self.containerNode.frame.minX, dy: -self.containerNode.frame.minY)
self.containerNode.view.addSubview(snapshotView)
self.contextSourceNode.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.midY - targetAbsoluteRect.midY), to: CGPoint(), duration: horizontalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.horizontalAnimationCurve.mediaTimingFunction, additive: true, force: true)
self.containerNode.layer.animatePosition(from: CGPoint(x: sourceAbsoluteRect.midX - targetAbsoluteRect.midX, y: 0.0), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.verticalAnimationCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.endAnimation()
})
combinedTransition.horizontal.animateTransformScale(node: self.contextSourceNode.contentNode, from: CGPoint(x: sourceBackgroundAbsoluteRect.width / targetAbsoluteRect.width, y: sourceBackgroundAbsoluteRect.height / targetAbsoluteRect.height))
combinedTransition.horizontal.updateTransformScale(layer: snapshotView.layer, scale: CGPoint(x: 1.0 / (sourceBackgroundAbsoluteRect.width / targetAbsoluteRect.width), y: 1.0 / (sourceBackgroundAbsoluteRect.height / targetAbsoluteRect.height)))代价与风险:源、目标尺寸不能为零。比例计算错误会产生跳帧或拉伸。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
CombinedTransition的横向分量驱动animateTransformScale和updateTransformScale,容器位置用两条 additive 动画分别走横向和纵向曲线。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 录音圆球自己飞入语音气泡
做了什么:代码不截取录音圆球的快照。代码让圆球本身飞向消息内的播放按钮位置。
为什么这样做:圆球可以持续自己的动画状态。静态快照会冻结波纹和音量等动态外观。
关键代码摘录
// No snapshot of the blob: it flies itself, in its own container, along the same path as the
// play button here in the chat, crossfading into it at the start.
let sourceAbsoluteRect = convertRenderedSourceRect(localRect, from: container, toAnimatingView: self.view)
let combinedTransition = CombinedTransition(horizontal: .animated(duration: horizontalDuration, curve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve), vertical: .animated(duration: verticalDuration, curve: ChatMessageTransitionNodeImpl.verticalAnimationCurve))
if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
if let contextContainer = itemNode.animateFromMicInput(sourceSize: sourceAbsoluteRect.size, transition: combinedTransition) {
self.containerNode.addSubnode(contextContainer.contentNode)
let targetAbsoluteRect = contextContainer.view.convertAcrossWindows(contextContainer.contentRect, to: self.view)
self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -contextContainer.contentRect.minX, dy: -contextContainer.contentRect.minY)
contextContainer.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)
let _ = audioMicInput.micButton.animateDecorationToSentMessage(targetRect: targetAbsoluteRect, in: self.view, duration: verticalDuration, horizontalCurve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve, verticalCurve: ChatMessageTransitionNodeImpl.verticalAnimationCurve)
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.midY - targetAbsoluteRect.midY), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.verticalAnimationCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self, weak contextContainer] _ in
guard let strongSelf = self else {
return
}
if let contextContainer = contextContainer {
contextContainer.isExtractedToContextPreview = false
contextContainer.isExtractedToContextPreviewUpdated?(false)
contextContainer.addSubnode(contextContainer.contentNode)代价与风险:代码需要在结束时恢复被提取的内容节点。清理遗漏会造成节点丢失或重复挂载。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
animateFromMicInput取出气泡的上下文容器,animateDecorationToSentMessage驱动圆球。动画结束时,代码把内容节点放回原容器。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 媒体拼图按四个角独立决定合并形状
做了什么:代码为拼图的四个角分别计算邻接状态。代码还区分普通合并和气泡合并。
为什么这样做:拼图边缘和消息组边缘会重叠。统一圆角半径无法表达尾巴和外侧边缘。
关键代码摘录
let position = framesAndPositions[mosaicIndex].1
let topLeft: ChatMessageBubbleContentMosaicNeighbor
let topRight: ChatMessageBubbleContentMosaicNeighbor
let bottomLeft: ChatMessageBubbleContentMosaicNeighbor
let bottomRight: ChatMessageBubbleContentMosaicNeighbor
switch firstNodeTopPosition {
case .Neighbour:
topLeft = .merged
topRight = .merged
case .BubbleNeighbour:
topLeft = .mergedBubble
topRight = .mergedBubble
case let .None(status):
if position.contains(.top) && position.contains(.left) {
switch status {
case .Left, .Both:
topLeft = .mergedBubble
case .Right:
topLeft = .none(tail: false)
case .None:
topLeft = .none(tail: false)
}代价与风险:状态组合很多。新增拼图位置时必须补齐每个角的分支。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:代码按单元在拼图中的位置(
.top、.left等)和上下邻居的合并状态做switch。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 反应区会改变气泡的底部合并边
做了什么:代码在有反应且不是预览时覆盖底部合并状态。入站消息取左边,出站消息取右边。
为什么这样做:反应区附在气泡下方。它需要同侧的连续轮廓,而不是沿用下一条消息的合并关系。
关键代码摘录
let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing)
var bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing)
let bubbleReactions: ReactionsMessageAttribute
if needReactions || forceReactionsOutside {
bubbleReactions = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId)) ?? ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: [])
} else {
bubbleReactions = ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: [])
}
if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview {
if incoming {
bottomNodeMergeStatus = .Left
} else {
bottomNodeMergeStatus = .Right
}
}
var currentCredibilityIcon: (EmojiStatusComponent.Content, UIColor?)?
let displayEphemeralBadge: Bool
if ephemeralBadgeText != nil && !mergedTop.merged && !hidesHeaders && item.message.adAttribute == nil {代价与风险:反应显示规则会耦合圆角规则。修改反应位置时要回归测试消息组。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 输入框以文本测量值增长,但最多十二行
做了什么:代码用可用宽度测量富文本高度。代码把高度限制在 1 到 12 行,并返回溢出标记。
为什么这样做:输入框会随文本增长,但不会吞没聊天内容。仅依赖 contentSize 很难同时扣除按钮和内边距。
关键代码摘录
var textFieldHeight: CGFloat
var isOverflow = false
if let richTextInputNode = self.richTextInputNode {
let maxTextWidth = width - textFieldInsets.left - textFieldInsets.right - actualTextInputViewInternalInsets.left - actualTextInputViewInternalInsets.right
let measuredHeight = richTextInputNode.textHeightForWidth(maxTextWidth, rightInset: textInputViewRealInsets.right)
let unboundTextFieldHeight = max(textFieldMinHeight, ceil(measuredHeight))
let maxNumberOfLines = max(1, min(12, (Int(fieldMaxHeight - 11.0) - 33) / 22))
let updatedMaxHeight = (CGFloat(maxNumberOfLines) * (22.0 + 2.0) + 10.0)
textFieldHeight = max(textFieldMinHeight, unboundTextFieldHeight)
isOverflow = textFieldHeight > updatedMaxHeight
textFieldHeight = min(textFieldHeight, updatedMaxHeight)
} else {
textFieldHeight = textFieldMinHeight
}
return (accessoryButtonsWidth, textFieldHeight, isOverflow)代价与风险:行高常量是 22。字体或段落样式变化时必须校准上限公式。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 快速点按录音键改模式,长按才开始录音
做了什么:代码延迟 0.19 秒才调用 beginRecording。用户在定时器存活时松手,代码切换录音模式。
为什么这样做:同一按钮同时承担音频和视频入口。立即开始录音会让普通点按误录。
关键代码摘录
public func micButtonInteractionBegan() {
if self.fadeDisabled {
self.recordingDisabled()
} else {
self.modeTimeoutTimer?.invalidate()
let modeTimeoutTimer = SwiftSignalKit.Timer(timeout: 0.19, repeat: false, completion: { [weak self] in
if let strongSelf = self {
strongSelf.modeTimeoutTimer = nil
strongSelf.beginRecording()
}
}, queue: Queue.mainQueue())
self.modeTimeoutTimer = modeTimeoutTimer
modeTimeoutTimer.start()
}
}
// ...
public func micButtonInteractionCompleted(_ velocity: CGPoint) {
if let modeTimeoutTimer = self.modeTimeoutTimer {
modeTimeoutTimer.invalidate()
self.modeTimeoutTimer = nil
self.switchMode()
}
self.endRecording(true)
}代价与风险:0.19 秒会增加长按开始延迟。阈值必须配合触觉反馈测试。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
micButtonInteractionBegan启动一次性SwiftSignalKit.Timer。micButtonInteractionCompleted发现定时器仍在时,作废它并调用switchMode()。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 录音手势把横移、取消位移和锁定拆开上报
做了什么:代码把控制条横向位移和取消提示位移分别保存。代码在锁定事件中上报锁定,并允许锁定。
为什么这样做:上滑锁定和侧滑取消使用不同视觉元素。单一平移值会让两个反馈互相干扰。
关键代码摘录
public func micButtonInteractionUpdate(_ offset: CGPoint) {
self.controlsOffset = offset.x
self.offsetRecordingControls()
}
public func micButtonInteractionUpdateCancelTranslation(_ translation: CGFloat) {
self.cancelTranslation = translation
self.updateCancelTranslation()
}
public func micButtonInteractionLocked() {
self.updateLocked(true)
}
public func micButtonInteractionRequestedLockedAction() {
}
public func micButtonInteractionStopped() {
self.stopRecording()
}
public func micButtonShouldLock() -> Bool {
return true
}代价与风险:本类无条件返回 true。具体锁定阈值由父类手势实现决定,未在本笔记验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 媒体面板只在允许时自动切回文字输入
做了什么:输入面板请求文字模式时先询问当前实体键盘。代码只把 .media 状态改成 .text。
为什么这样做:某些实体键盘状态不应被自动收回。直接强制切换会打断表情、贴纸或搜索操作。
关键代码摘录
self.textInputPanelNode?.switchToTextInputIfNeeded = { [weak self] in
guard let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction else {
return
}
if let inputNode = strongSelf.inputNode as? ChatEntityKeyboardInputNode, !inputNode.canSwitchToTextInputAutomatically {
return
}
interfaceInteraction.updateInputModeAndDismissedButtonKeyboardMessageId({ state in
switch state.inputMode {
case .media:
return (.text, state.keyboardButtonsMessage?.id)
default:
return (state.inputMode, state.keyboardButtonsMessage?.id)
}
})
}代价与风险:切换许可藏在 canSwitchToTextInputAutomatically。该属性的具体判定未在本笔记验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 实体键盘把隐藏输入框高度当作组件输入
做了什么:代码缓存完整布局状态。代码把被隐藏的文字输入面板高度传给实体键盘组件。
为什么这样做:表情搜索可扩展 118 点,同时要避免内容压住被隐藏的输入区。只使用键盘总高度会丢失这个局部约束。
关键代码摘录
public override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, layoutMetrics: LayoutMetrics, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool) -> (CGFloat, CGFloat) {
self.currentState = (width, leftInset, rightInset, bottomInset, standardInputHeight, inputHeight, maximumHeight, inputPanelHeight, interfaceState, layoutMetrics, deviceMetrics, isVisible, isExpanded)
let innerTransition: ComponentTransition
if let scheduledInnerTransition = self.scheduledInnerTransition {
self.scheduledInnerTransition = nil
innerTransition = scheduledInnerTransition
} else {
innerTransition = ComponentTransition(transition)
}
let wasMarkedInputCollapsed = self.isMarkInputCollapsed
self.isMarkInputCollapsed = false
var expandedHeight = standardInputHeight
if self.isEmojiSearchActive && !isExpanded {
expandedHeight += 118.0
}
var hiddenInputHeight: CGFloat = 0.0
if self.hideInput && !self.adjustLayoutForHiddenInput {
hiddenInputHeight = inputPanelHeight
}代价与风险:hideInput 和 adjustLayoutForHiddenInput 的组合形成状态矩阵。状态切换必须触发重新布局。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. 表情搜索覆盖层只在首次出现时立即建树
做了什么:代码把搜索放在现有键盘上的独立 searchView。首次出现时淡入并按顶部面板位移,退出后淡出再移除。
为什么这样做:Pager 保持存在,搜索内容替换为覆盖层。整体重建键盘会丢失分页位置并增加闪烁。
关键代码摘录
if let current = self.searchView {
searchView = current
} else {
searchViewTransition = .immediate
searchView = ComponentHostView<EntitySearchContentEnvironment>()
self.searchView = searchView
self.addSubview(searchView)
animateIn = true
component.topPanelExtensionUpdated(0.0, transition)
}
// ...
searchViewTransition.setFrame(view: searchView, frame: CGRect(origin: CGPoint(), size: availableSize))
if animateIn {
transition.animateAlpha(view: searchView, from: 0.0, to: 1.0)
transition.animatePosition(view: searchView, from: CGPoint(x: 0.0, y: self.topPanelExtension ?? 0.0), to: CGPoint(), additive: true, completion: nil)
}代价与风险:覆盖层和 Pager 需要一致的 inputHeight。不一致会在键盘边缘出现空隙。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 列表更新前只采样一次已稳定的偏移
做了什么:代码在准备列表事务时读取一次 settledContentOffsets。代码用同一份样本判断是否贴近最新消息和是否填满屏幕。
为什么这样做:正在动画的两次几何读取可能对应不同帧。分别读取会让位置保持决策自相矛盾。
关键代码摘录
if let strongSelf = self, updatedScrollPosition == nil, case .InteractiveChanges = reason, let previous = previous {
// ONE sample feeds both gates below. They are compared against each other to decide a
// scroll position, and this runs while a transaction is being PREPARED — so the
// question is what state the list is in, not where a pass in flight happens to have
// the content at this instant. On ListViewImpl the settled and presented geometries
// are the same thing; under a hosting backend, two separate reads would let "am I
// pinned to the newest message" and "does the content fill the screen" describe
// different moments of the same animation.
let settledOffsets = strongSelf.listView.settledContentOffsets()
if case let .known(offset) = settledOffsets.top, abs(offset) <= 0.9 {
var fillsScreen = true
switch settledOffsets.bottom {
case let .known(bottomOffset):
if bottomOffset <= strongSelf.listView.visibleSize.height - strongSelf.insets.bottom {
fillsScreen = false
}
default:
break
}代价与风险:这依赖列表实现提供稳定偏移。自定义承载后端必须保持该语义。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:代码在
.InteractiveChanges分支开头取一次样本,两个条件都用这份样本。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 未读分割条离屏时强制重新聚焦
做了什么:代码遍历现有项目节点。它找到 ChatUnreadItemNode 后立即确保该节点可见。
为什么这样做:未读分割条是用户重新定位的视觉锚点。仅滚动到消息索引会在事务后把分割条留在屏外。
关键代码摘录
let _ = self.clientId.swap(nextClientId)
nextClientId += 1
self.listView.updateVisibleItemRange(force: true)
}
func refocusOnUnreadMessagesIfNeeded() {
self.forEachItemNode({ itemNode in
if let itemNode = itemNode as? ChatUnreadItemNode {
self.listView.ensureItemNodeVisible(itemNode, animated: false, overflow: 0.0, curve: .Default(duration: nil))
}
})
}
private func maybeInsertPendingAdMessage(historyView: ChatHistoryView, toLaterRange: (Int, Int), toEarlierRange: (Int, Int)) {
if self.pendingDynamicAdMessages.isEmpty {
return
}
var eligibleMessageIndices = Set<MessageIndex>()代价与风险:代码会遍历当前项目节点。超大可见范围下会增加少量主线程工作。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. 回复跳转不能伪装成新消息到达
做了什么:代码在创建内容节点前记录真正的新到达消息。代码明确排除加载另一段历史,包括回复跳转。
为什么这样做:回复跳转的插入项目只是加载结果。把它当作收消息会错误播放到达转场。
关键代码摘录
private func dequeueHistoryViewTransitions() {
if self.enqueuedHistoryViewTransitions.isEmpty || self.hasActiveTransition {
return
}
self.hasActiveTransition = true
let transition = self.enqueuedHistoryViewTransitions.removeFirst()
// Record live arrivals before creating their content nodes. Loading another history
// window (including a reply jump) must never look like a newly received transfer.
if case .InteractiveChanges = transition.reason,
!transition.insertItems.isEmpty,
self.controllerInteraction.canReadHistory,
let previous = self.historyView,
previous.id == transition.historyView.id,
!previous.originalView.isLoading, previous.originalView.laterId == nil,
transition.historyView.originalView.laterId == nil {
let previousIds = Set(previous.originalView.entries.map { $0.message.id })
let previousLastIndex = previous.originalView.entries.map { $0.message.index }.max()
for entry in transition.historyView.originalView.entries {
let message = entry.message代价与风险:到达判定依赖历史视图的 id、加载状态和 laterId。数据源语义改变时必须复核。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:判定条件是:同一个
historyView.id,上一视图不在加载中,前后两个视图的laterId都为nil。满足后,代码再与旧消息 ID 集合比较。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. 跳转请求同时携带高亮、引用和回复设置
做了什么:代码把高亮、引用片段、回复主题和滚动锚点封装进一次 .Scroll 请求。每次请求还生成新的位置标识。
为什么这样做:视觉定位不是单纯的偏移。只传目标消息无法表达引用高亮或跳转后打开回复状态。
关键代码摘录
if let currentMessage = currentMessage {
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(currentMessage.index), quote: nil), anchorIndex: .message(currentMessage.index), sourceIndex: .upperBound, scrollPosition: .top(0.0), animated: true, highlight: true, setupReply: false), id: self.takeNextHistoryLocationId())
}
}
}
public func scrollToStartOfHistory() {
self.beganDragging?()
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .lowerBound, quote: nil), anchorIndex: .lowerBound, sourceIndex: .upperBound, scrollPosition: .bottom(0.0), animated: true, highlight: false, setupReply: false), id: self.takeNextHistoryLocationId())
}
public func scrollToEndOfHistory() {
self.beganDragging?()
switch self.visibleContentOffset() {
case let .known(value) where value <= CGFloat.ulpOfOne:
break
default:
let locationInput = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .upperBound, quote: nil), anchorIndex: .upperBound, sourceIndex: .lowerBound, scrollPosition: .top(0.0), animated: true, highlight: false, setupReply: false), id: self.takeNextHistoryLocationId())
self.chatHistoryLocationValue = locationInput
}
}
public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex, animated: Bool, highlight: Bool = true, quote: (string: String, offset: Int?)? = nil, subject: EngineMessageReplyInnerSubject? = nil, scrollPosition: ListViewScrollPosition = .center(.bottom), setupReply: Bool = false) {
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(toIndex), quote: quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, subject: subject, setupReply: setupReply), anchorIndex: .message(toIndex), sourceIndex: .message(fromIndex), scrollPosition: scrollPosition, animated: animated, highlight: highlight, setupReply: setupReply), id: self.takeNextHistoryLocationId())
}代价与风险:请求参数很长。新增跳转语义时容易遗漏某个布尔值。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 用标识符替换编辑器格式菜单,保留系统菜单其余项
做了什么:代码移除编辑器自带的格式子菜单。代码按固定位置插入聊天输入框自己的格式菜单。
为什么这样做:它保留默认菜单的其他系统能力。按本地化标题筛选会随语言变化而失效。
关键代码摘录
children.append(contentsOf: [
UIAction(title: self.strings?.TextFormat_Strikethrough ?? "Strikethrough", image: nil) { [weak richTextInputNode] _ in
richTextInputNode?.performFormatAction(.strikethrough)
},
UIAction(title: self.strings?.TextFormat_Underline ?? "Underline", image: nil) { [weak richTextInputNode] _ in
richTextInputNode?.performFormatAction(.underline)
}
])
children.append(UIAction(title: self.strings?.TextFormat_Code ?? "Code", image: nil) { [weak richTextInputNode] _ in
richTextInputNode?.performFormatAction(.code)
})
let formatMenu = UIMenu(title: self.strings?.TextFormat_Format ?? "Format", image: nil, children: children)
// Drop the editor's built-in "Format" submenu by its language-independent identifier, then
// splice in the composer's after the system Cut/Copy/Paste actions.
var elements = defaultElements.filter { ($0 as? UIMenu)?.identifier != UIMenu.Identifier("org.telegram.RichTextEditor.format") }
let insertIndex = min(1, elements.count)
elements.insert(formatMenu, at: insertIndex)
return elements
}代价与风险:自定义标识符属于编辑器契约。编辑器升级后必须验证该标识符仍存在。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:代码按
UIMenu.Identifier("org.telegram.RichTextEditor.format")过滤defaultElements,再在索引 1 插入新菜单。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 录音期间禁用交互式返回手势
做了什么:代码注册窗口平移识别器后,提供交互式页面转场的动态禁用回调。录音状态会令回调返回 true。
为什么这样做:录音期间的侧滑返回会中断录音交互。常规返回手势不会知道聊天的录音状态。
关键代码摘录
self.panRecognizer = recognizer
self.view.addGestureRecognizer(recognizer)
self.view.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
guard let strongSelf = self else {
return false
}
if let _ = strongSelf.chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState {
return true
}
var hasChatThemeScreen = false
strongSelf.controller?.window?.forEachController { c in
if c is ChatThemeScreen {
hasChatThemeScreen = true
}
}
if hasChatThemeScreen {
return true
}
return false
}
}代价与风险:主题页面也会禁用返回手势。新增全屏聊天状态时必须同步加入该回调。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。