Bubbles
(气泡)作为一种展示UI,几乎与我们工作生活密不可分;如果消息只是一段纯文本或一个图片,那就没什么可说的;但 Telegram 中的情况就很复杂,因为消息中的元素很多,比如文本、富文本、markdowm 文本、图片、相册、视频、文件、网页、位置等;因为一条消息可以包含多个任意类型的元素,所以它就显得更加复杂了。
本文将阐述 Telegram 如何在其异步 UI 框架上构建消息气泡。
1、Classes 概述
ChatControllerImpl 是管理用户消息聊天界面的核心控制器;它的内容控制器 ChatControllerNode 主要由以下 node 构成:
class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate {
...
let backgroundNode: WallpaperBackgroundNode // background wallpaper
let historyNode: ChatHistoryListNode // message list
let loadingNode: ChatLoadingNode // loading UI
...
private var textInputPanelNode: ChatTextInputPanelNode? // text input
private var inputMediaNode: ChatMediaInputNode? // media input
let navigateButtons: ChatHistoryNavigationButtons // the navi button at the bottom right
}
-
ChatHistoryListNode 作为 ListView 的子类,负责渲染消息列表以及其他信息
nodes
;它有两种 UI 模式:bubbles 和 list;bubbles
模式用于普通聊天,list
用于在聊天消息详情页面按媒体、文件、语音等类型筛选出对应的历史聊天记录;本文仅讨论bubbles
模式。
作为其核心数据属性的 items 有三种类型的 ListViewItem 可供使用;每个 item 都实现nodeConfiguredForParams
方法用来返回对应的UI node
。
public protocol ListViewItem {
...
func nodeConfiguredForParams(
async: @escaping (@escaping () -> Void) -> Void,
params: ListViewItemLayoutParams,
synchronousLoads: Bool,
previousItem: ListViewItem?,
nextItem: ListViewItem?,
completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void
)
}
-
ChatMessageItem 根据一条或一组聊天消息展示对应的
bubble
消息。 -
ChatMessageItemView 的四个子类是
bubble
不同类型的node
容器。 -
ChatMessageBubbleItemNode 实现了展示具有多个元素内容的
bubble
消息功能,这些元素内容都是 ChatMessageBubbleContentNode 的子类。
2、List 倒置
聊天消息列表将最新消息放在底部,垂直滚动指示器也从底部开始;实际上,它是 iOS 上很常见的 list UI
倒置;
Telegram 使用 AsyncDisplayKit
里 ASTableNode 类似的 UI 变换伎俩;ChatHistoryListNode
利用 ASDisplayNode
的 transform
属性旋转了 180° ,因此它所有的 content node
也要被旋转。
// rotate the list node
public final class ChatHistoryListNode: ListView, ChatHistoryNode {
public init(...) {
self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
}
}
// rotate content nodes
public class ChatMessageItemView: ListViewItemNode {
public init(...) {
self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
}
}
final class ChatMessageShadowNode: ASDisplayNode {
override init() {
self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
}
}
final class ChatMessageDateHeaderNode: ListViewItemHeaderNode {
init() {
self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
}
}
...
以下屏幕截图演示了应用逐步被旋转后的样子:
3、ListView Items
- ChatBotInfoItem:如果 Peer 是 Telegram 机器人,则将机器人标识插入到 items 的第一个位置。
- ChatUnreadItem:区分未读和已读消息的标识。
- ChatMessageItem:它将聊天消息按以下建模:
public final class ChatMessageItem: ListViewItem, CustomStringConvertible {
...
let chatLocation: ChatLocation
let controllerInteraction: ChatControllerInteraction
let content: ChatMessageItemContent
...
}
public enum ChatLocation: Equatable {
case peer(PeerId)
}
public enum ChatMessageItemContent: Sequence {
case message(
message: Message,
read: Bool,
selection: ChatHistoryMessageSelection,
attributes: ChatMessageEntryAttributes)
case group(
messages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes)])
}
-
ChatControllerInteraction 是一个维护了
ChatControllerImpl
96个操作回调数据类;它通过items
传递,使它们能够在不引用控制器的情况下触发回调。 -
ChatMessageItemContent 的结构很有趣;它作为一个枚举,可以是一条或一组消息;在我看来,它可以被简化成
.group
用一个.message
元素组来表示。 - Message 通过 MessageAttribute 和 Media 两个协议描述消息中的内容元素。
public final class Message {
....
public let author: Peer?
public let text: String
public let attributes: [MessageAttribute]
public let media: [Media]
...
}
public protocol MessageAttribute: AnyObject, PostboxCoding { ... }
public protocol Media: AnyObject, PostboxCoding {
var id: MediaId? { get }
...
}
Message
的实例始终有一个 text
条目和一些可选的 MessageAttribute
;如果attributes
有 TextEntitiesMessageAttribute 条目,则可以通过 stringWithAppliedEntities 构造属性字符串;然后可以在 bubble
中展示富文本。
// For example, this one states the entities inside a text
public class TextEntitiesMessageAttribute: MessageAttribute, Equatable {
public let entities: [MessageTextEntity]
}
public struct MessageTextEntity: PostboxCoding, Equatable {
public let range: Range<Int>
public let type: MessageTextEntityType
}
public enum MessageTextEntityType: Equatable {
public typealias CustomEntityType = Int32
case Unknown
case Mention
case Hashtag
case Url
case Email
case Bold
case Italic
case Code
...
case Strikethrough
case BlockQuote
case Underline
case BankCard
case Custom(type: CustomEntityType)
}
协议 Media
及其类的实现描述了一组丰富的媒体类型,如 TelegramMediaImage、TelegramMediaFile、TelegramMediaMap 等。
总而言之,Message
基本上是带有几个媒体附件的属性字符串,而ChatMessageItem
实际上是一组 Message
实例;这种设计可以灵活地表达复杂的消息内容并轻松的保持向后兼容;例如,将 grouped album 表示为具有多个消息的 item
,而每个消息的媒体为 TelegramMediaImage。
4、Bubble Nodes
ChatMessageItem
实现 nodeConfiguredForParams 用数据匹配对应 bubble nodes
;如果我们查看代码,会发现它对 item
结构有一些规定。
- 如果第一条消息有一个小于 128 KB 的
Sticker
动画媒体文件,则选择 ChatMessageAnimatedStickerItemNode 渲染带有Sticker
的bubble
;该item
中的其他消息和媒体数据将被忽略。 -
large emoji
在 app 中默认处于打开状态;如果一条消息只有一个 emoji 字符或所有字符都是 emoji,则使用ChatMessageAnimatedStickerItemNode
或者 ChatMessageStickerItemNode 对大的效果渲染,而不是当成纯文本。
- 如果某条消息的第一条消息有即时视频文件,则选择 ChatMessageInstantVideoItemNode 来显示即时视频,其他内容将被忽略。
- 另外,ChatMessageBubbleItemNode 负责处理结构化消息;
ChatMessageBubbleItemNode
通过将数据映射到 ChatMessageBubbleContentNode 的 18 个子类来遍历item
并构建sub-nodes
;contentNodeMessagesAndClassesForItem 是维护其映射逻辑的核心函数:
private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> [(Message, AnyClass, ChatMessageEntryAttributes)] {
var result: [(Message, AnyClass, ChatMessageEntryAttributes)] = []
...
outer: for (message, itemAttributes) in item.content {
inner: for media in message.media {
if let _ = media as? TelegramMediaImage {
result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes))
} else if {...}
}
var messageText = message.text
if !messageText.isEmpty ... {
result.append((message, ChatMessageTextBubbleContentNode.self, itemAttributes))
}
}
...
}
5、Layout
bubble
的布局由 ListView
的异步布局机制驱动;上图显示了重点布局方法的调用流程;需要注意的一件事是 ListView
不会缓存布局结果。
参考资料:
-
Source Code Walkthrough of Telegram-iOS Part 6: Bubbles
资料版本:Telegram - 6.1.2
校正版本:Telegram - 8.7.1