Telegram-iOS builds most UIs upon AsyncDisplayKit. It’s folked as a submodule of the project, in which many features have been removed and some of them are re-implemented in Swift. This post talks about the component structure and the UI programming pattern in the project.
1. Overview
AsyncDisplayKit is an asynchronous UI framework that was originally born from Facebook. It’s adopted by Pinterest and was renamed to Texture in 2017. Its core concept is using node
as an abstraction of UIView
, which is a bit similar to the ideas from React Virtual DOM. Nodes are thread-safe, which helps move expensive UI operations off the main thread, like image decoding, text sizing, etc. Nodes are also lightweight, which allows you to NOT reuse cells in tables and collections.
As illustrated in the diagram, Telegram-iOS keeps around 35% code that’s denoted in blue boxes and removes the others from the official version.
- The latest commit that’s merged from upstream is
ae2b3af9
. - The fundamental nodes like
ASDisplayNode
,ASControlNode
,ASEditableTextNode
, andASScrollNode
are mostly intact. ASImageNode
and its subclasses are removed. Telegram uses MTProto instead of HTTPS to download files from data centers. The network image support from the official version is not useful, so the dependency onPINRemoteImage
is deleted too.- All official node containers are removed, such as
ASCollectionNode
,ASTableNode
,ASViewController
, etc. Tables and view controllers on nodes are re-implemented in Swift without depending onIGListKit
. - The project prefers manual layout. The official layout API inspired by CSS Flexbox is removed, so is the yoga engine.
- The internal logging and debugging supports are removed.
Basically speaking, Telegram-iOS keeps a minimal set of the core node system and then extends it with several hundreds of node subclasses. The code spreads inside submodules like Display
, TelegramUI
, ItemListUI
and others that support the main Telegram UI features.
2. Core Nodes
There are a few node classes as fundamental blocks to build the app’s user interface. Let’s check them out as listed in the diagram.
An arrowed edge means the right node is a subclass of the left one. Nodes at the same level without edges means they share the same parent class as the leftmost one.
Text
TextNode
, ImmediateTextNode
, and ASTextNode
are responsible for text rendering.
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
29
30
31
32
33
public class TextNode: ASDisplayNode {
public internal(set) var cachedLayout: TextNodeLayout?
public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode)
}
public class ImmediateTextNode: TextNode {
public var attributedText: NSAttributedString?
public var textAlignment: NSTextAlignment = .natural
public var truncationType: CTLineTruncationType = .end
public var maximumNumberOfLines: Int = 1
public var lineSpacing: CGFloat = 0.0
public var insets: UIEdgeInsets = UIEdgeInsets()
public var textShadowColor: UIColor?
public var textStroke: (UIColor, CGFloat)?
public var cutout: TextNodeCutout?
public var truncationMode: NSLineBreakMode
public var linkHighlightColor: UIColor?
public var trailingLineWidth: CGFloat?
public var highlightAttributeAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)?
public var tapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)?
public var longTapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)?
...
}
public class ASTextNode: ImmediateTextNode {
override public var attributedText: NSAttributedString? {
didSet {
self.setNeedsLayout()
}
}
...
}
TextNode
leverages CoreText
to render an NSAttributedString
. It has a method calculateLayout
to compute a line based text layout and overrides the class method draw
to render the text. A public class method asyncLayout
is present to invoke layout computation asynchronously and cache the result. It’s the callee’s responsibility to invoke asyncLayout
by design. Otherwise, it wouldn’t render anything as the cached layout is nil. It’s also great that the implementation supports RTL and Accessibility.
ImmediateTextNode
enriches TextNode
by adding more properties to control the text layout styles. It also supports link highlighting and tap actions.
ASTextNode
simply updates the layout on setting the property attributedText
. It’s not the same one in AsyncDisplayKit project although it shares the same class name.
EditableTextNode
extends ASEditableTextNode
to support RTL input detection.
Image
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
open class ASImageNode: ASDisplayNode {
public var image: UIImage?
}
public class ImageNode: ASDisplayNode {
public func setSignal(_ signal: Signal<UIImage?, NoError>)
}
open class TransformImageNode: ASDisplayNode {
public var imageUpdated: ((UIImage?) -> Void)?
public var contentAnimations: TransformImageNodeContentAnimations = []
public func setSignal(_ signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>, attemptSynchronously: Bool = false, dispatchOnDisplayLink: Bool = true)
public func setOverlayColor(_ color: UIColor?, animated: Bool)
}
ASImageNode
renders a UIImage
and uses the image size as its node size. Again it’s not the same class in the official project.
ImageNode
accepts a Signal to set the image content asynchronously. It’s solely used by AvatarNode
although its name looks common.
TransformImageNode
is the most widely used class for asynchronous images. It supports an alpha animation on changing images and supports color overlay.
Button
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
open class ASButtonNode: ASControlNode {
public let titleNode: ImmediateTextNode
public let highlightedTitleNode: ImmediateTextNode
public let disabledTitleNode: ImmediateTextNode
public let imageNode: ASImageNode
public let disabledImageNode: ASImageNode
public let backgroundImageNode: ASImageNode
public let highlightedBackgroundImageNode: ASImageNode
}
open class HighlightTrackingButtonNode: ASButtonNode {
public var highligthedChanged: (Bool) -> Void = { _ in }
}
open class HighlightableButtonNode: HighlightTrackingButtonNode {
...
}
ASButtonNode
models a button with an image and a title that have three states: normal, highlighted, and disabled.
HighlightableButtonNode
adds a highlight animation when a button is in tracking.
Status
ActivityIndicator
replicates the style of UIActivityIndicatorView
and provides flexible options to customize details like color, diameter, and line width.
Media
Telegram-iOS implements a rich set of components to support different media types. This article just takes a peek at it. It’s worth writing a dedicated post in this series, including FFMpeg integration, in-app video playback for 3rd-party video sites, sticker animation, etc.
MediaPlayNode
is a node class in the submodule MediaPlayer
to render video frames on AVSampleBufferDisplayLayer
.
WebEmbedPlayerNode
plays a video that’s inside a web page by embedding a WKWebView
. It supports videos from Youtube, Vimeo, Twitch, etc.
AnimatedStickerNode
plays the gorgeous animation from an AnimatedStickerNodeSource
.
Bar
SearchBarNode
, NavigationBar
, TabBarNode
, and ToolbarNode
mimic the features of the UIKit counterparts. It also eliminates the impact of the inconsistent behavior across OS versions, which is always an unpleasant issue to patch as UIKit internals are not visible to developers.
StatusBar
shows an in-call text notice in the system status bar area.
List
ListView
is one of the most complex node classes designed for a scrolling list. It leverages a hidden UIScrollView
and borrows its pan gesture to get the scrolling behavior as what we learned from WWDC 2014. Besides managing the visibility of items in a list no matter it’s small or huge, it provides additional neat features, such as convenient item headers, customizable scroll indicators, recording items, over scroll nodes, snapping to bounds, etc.
GridNode
is another scrolling UI component for grid layout. It supports features like sticker selection screen, wallpaper settings, etc.
3. Controllers
ViewController
makes UIViewController
work as a container of node hierarchies. Unlike the official node controller class ASViewController
, it doesn’t have features like visibility depth and intelligent preloading.
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
29
30
31
32
@objc open class ViewController: UIViewController, ContainableController {
// the root content node
private var _displayNode: ASDisplayNode?
public final var displayNode: ASDisplayNode {
get {
if let value = self._displayNode {
return value
}
else {
self.loadDisplayNode()
...
return self._displayNode!
}
}
...
}
open func loadDisplayNode()
open func displayNodeDidLoad()
// shared components
public let statusBar: StatusBar
public let navigationBar: NavigationBar?
private(set) var toolbar: Toolbar?
private var scrollToTopView: ScrollToTopView?
// customizations of navigationBar
public var navigationOffset: CGFloat
open var navigationHeight: CGFloat
open var navigationInsetHeight: CGFloat
open var cleanNavigationHeight: CGFloat
open var visualNavigationInsetHeight: CGFloat
public var additionalNavigationBarHeight: CGFloat
}
Each ViewController manages the node hierarchy by a root content node, which is stored in the displayNode
property of the class. There are functions loadDisplayNode
and displayNodeDidLoad
to achieve the same lazy view loading behavior as what we are familiar with in UIViewController
.
As a base class, it prepares several shared node components for subclasses: a status bar, a navigation bar, a toolbar, and a scrollToTopView
. There are also convenient properties to customize its navigation bar which is still a cumbersome problem for a normal UIViewController
.
ViewController
is rarely used in isolation, there are over 100 controller subclasses in the project for different user interfaces. The two most commonly used container controllers in UIKit, UINavigationController
and UITabBarController
, are re-implemented as NavigationController
and TabBarController
.
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
open class NavigationController: UINavigationController, ContainableController, UIGestureRecognizerDelegate {
private var _viewControllers: [ViewController] = []
// NavigationControllerNode
private var _displayNode: ASDisplayNode?
private var theme: NavigationControllerTheme
// manage layout and transition animation
private func updateContainers(layout rawLayout: ContainerViewLayout, transition: ContainedViewLayoutTransition)
// push with a completion handler
public func pushViewController(_ controller: ViewController, animated: Bool = true, completion: @escaping () -> Void)
}
// NavigationLayout.swift
enum RootNavigationLayout {
case split([ViewController], [ViewController])
case flat([ViewController])
}
// NavigationContainer.swift
final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelegate
override func didLoad() {
// the interactive pop gesture
let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: ...)
}
}
NavigationController
extends UINavigationController
to borrow its public APIs as it might work with normal view controllers. It rebuilds everything internally including the followings:
- Direct management of sub-controllers. It gives freedom to tweak some edge cases for stack manipulation as it’s just a simple array.
- Transition animation. You can find all the animation details in
ContainedViewLayoutTransition
. - Interactive pop gesture. An
InteractiveTransitionGestureRecognizer
is added to enable the pop gesture to all screen area. - Split master details layout for large screens like iPad. It supports two types of layout:
flat
andsplit
. It’s nice to have one container controller to support both iPhone and iPad instead of extract efforts onUISplitViewController
. - Themes. It’s easy to customize the look and feel via the property
theme
.
TabBarController
is only used in the root screen, so it’s a subclass of ViewController
instead of UITabBarController
since there is no need to keep the APIs. The same rule applies to ActionSheetController
, AlertController
, and ContextMenuController
. The implementation perfectly covers details inside the system view controllers, the user experience is almost identical to users IMO.
ItemListController
is equivalent to UITableViewController
by managing a ListView
. It also supports custom overlay nodes, search view, and reordering items.
4. Layout
The Flexbox layout system in AsyncDisplayKit is replaced by a mixed layout mechanism:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
// NavigationBar.swift
// layout in the main thread
open class NavigationBar: ASDisplayNode {
override open func layout() {
super.layout()
if let validLayout = self.validLayout, self.requestedLayout {
self.requestedLayout = false
self.updateLayout(size: validLayout.0, defaultHeight: validLayout.1, additionalHeight: validLayout.2, leftInset: validLayout.3, rightInset: validLayout.4, appearsHidden: validLayout.5, transition: .immediate)
}
}
func updateLayout(size: CGSize, defaultHeight: CGFloat, additionalHeight: CGFloat, leftInset: CGFloat, rightInset: CGFloat, appearsHidden: Bool, transition: ContainedViewLayoutTransition)
}
// TabBarController.swift
// layout in the main thread
open class TabBarController: ViewController {
override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.tabBarControllerNode.containerLayoutUpdated(layout, toolbar: self.currentController?.toolbar, transition: transition)
...
}
}
// ListView.swift
// asynchronously load visible items by the scrolling event
open class ListView: ASDisplayNode, ... {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateScrollViewDidScroll(scrollView, synchronous: false)
}
private func updateScrollViewDidScroll(_ scrollView: UIScrollView, synchronous: Bool) {
...
self.enqueueUpdateVisibleItems(synchronous: synchronous)
}
private func enqueueUpdateVisibleItems(synchronous: Bool) {
...
strongSelf.updateVisibleItemsTransaction(synchronous: synchronous, completion:...)
}
private func updateVisibleItemsTransaction(synchronous: Bool, completion: @escaping () -> Void)
}
- All layout is done manually. It’s apparently the engineers don’t like the concept of auto layout.
- Layout computation runs in the main thread for simple UIs. The layout code can be put inside the
layout
method for nodes, or in thecontainerLayoutUpdated
method for view controllers. ListView
builds a flexible layout mechanism for its item nodes, which supports both synchronous and asynchronous computation.
5. Conclusion
I’m impressed by Telegram’s approach of integrating AsyncDisplayKit. It rebuilds the whole family of UIKit components upon nodes for efficiency and full control. The chat message list feels fluid on old devices although the bubbles UI is complex to render. There are few codes to deal with the system upgrade debt, which always costs some “happy time” from most engineers after WWDC every year. Let’s see what might be broken for other products after the first online WWDC in two weeks.