Bubbles is a type of UI that‘s almost an integral part of our daily life. It’s a trivial job if a message is either a piece of plain text or one image file. The problem in Telegram is difficult as there are many message elements, such as texts, styled texts, markdown texts, images, albums, videos, files, web pages, locations, and more. It gets more challenging as one message can have almost multiple elements of arbitrary types. This article shows how Telegram-iOS builds message bubbles upon its asynchronous UI framework.
1. Overview of Classes
ChatControllerImpl
is the core controller that manages the message list UI. Its content ChatControllerNode
composes the UI structure with the following major nodes:
1
2
3
4
5
6
7
8
9
10
11
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
}
As a subclass of ListView
, ChatHistoryListNode
renders a list of messages and other information nodes. It has two UI modes: bubbles
and list
. The mode bubbles
is used for normal chats and list
is used in the peer info panel to list chat history per type as Media, Files, Voice, etc. This post only talks about the mode bubbles
.
Its core data property items
can take three types of ListViewItem
. Each item implements nodeConfiguredForParams
to return the corresponding UI node.
1
2
3
4
5
6
7
8
9
10
11
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
represents a chat message or a group of chat messages that should be rendered as bubbles. Four subclasses of ChatMessageItemView
are the container nodes for different types of bubbles.
ChatMessageBubbleItemNode
implements a mechanism to render a message bubble having multiple content elements that are subclasses of ChatMessageBubbleContentNode
.
2. List Inversion
A chat message list places the latest message at the bottom and the vertical scroll indicator starts at the bottom too. It’s actually an inversion of the common list UI on iOS. Telegram-iOS uses a similar UI transformation trick that’s present in ASTableNode
of AsyncDisplayKit. ChatHistoryListNode
is rotated by 180° using the property transform
of ASDisplayNode
, then all content nodes are rotated too.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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)
}
}
...
The following screenshot demonstrates what it looks like after applying the transformation step by step:
3. ListView Items
ChatBotInfoItem
. The bot information card is inserted at the first position of items if the peer is a Telegram bot.ChatUnreadItem
. It’s an indicator that separates unread and read messages.ChatMessageItem
. It models a chat message as following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
is a data class that maintains 77 action callbacks for ChatControllerImpl
. It’s passed through items to enable them trigger callbacks without a reference to the controller.
The structure of ChatMessageItemContent
is interesting. It’s an enum that can be either one message or a group of messages. IMO, it could be simplified to just .group
as .message
can be expressed by a group with one element.
Message
works with two protocols MessageAttribute
and Media
to describe content elements inside a message.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Message {
....
public let author: Peer?
public let text: String
public let attributes: [MessageAttribute]
public let media: [Media]
...
}
public protocol MessageAttribute: class, PostboxCoding { ... }
public protocol Media: class, PostboxCoding {
var id: MediaId? { get }
...
}
An instance of Message
always has one text
entry and some optional MessageAttribute
. If attributes
has an entry of TextEntitiesMessageAttribute
, an attributed string can be constructed via stringWithAppliedEntities
. Then a rich formatted text can be rendered inside a bubble.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 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)
}
The protocol Media
and its class implementations describe a rich set of media types, such as TelegramMediaImage
, TelegramMediaFile
, TelegramMediaMap
, etc.
To summarize, Message
is basically an attributed string with several media attachments, while ChatMessageItem
is a group of Message
instances. This design is flexible to represent complex message content and keep backward compatibility easily. For example, a grouped album is expressed as an item that has several messages, while each has a media of TelegramMediaImage
.
4. Bubble Nodes
ChatMessageItem
implements nodeConfiguredForParams
to set up bubble nodes to match the data. If we look into the code, it has some rules on the item structure.
- If the first message has an animated sticker media file that is smaller than 128 KB,
ChatMessageAnimatedStickerItemNode
is chose to render a bubble with the sticker. Other messages and media data in the item would be ignored. - By default, the setting of large emoji support is turned on in the app. If a text message only has one emoji character or all characters are emojis,
ChatMessageAnimatedStickerItemNode
orChatMessageStickerItemNode
is used to achieve a large rendering effect instead of plain text.
- If the first message of an item has an instant round video file,
ChatMessageInstantVideoItemNode
is selected to display the round video and other content would be ignored. - Otherwise,
ChatMessageBubbleItemNode
handles the structured messages.
ChatMessageBubbleItemNode
checks through an item and builds the sub-nodes by mapping the data to 16 subclasses of ChatMessageBubbleContentNode
. contentNodeMessagesAndClassesForItem
is the core function that maintains the mapping logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
The layout of bubbles is driven by the asynchronous layout mechanism of ListView
. The graph above shows the invocation flow of the most important layout methods. During my test on an iPhone 6s with iOS 13.5, the FPS is able to keep above 58 which is better than other apps that have long and complex list UIs. It definitely proves AsyncDisplayKit
is a good choice for Telegram’s scenario.
One thing to note is that ListView
doesn’t cache layout results. If your device is really slow, you would see empty cells during scrolling.
6. Conclusion
This post briefly explains the data model and UI structures for message bubbles in Telegram-iOS. The data structure is flexible for complex messages, which is a good reference to check if you start to design your own messenger. I encourage you to dive into the code following my introduction as more details are not covered here.