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.

AsyncDisplayKit

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.

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

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

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 and split. It’s nice to have one container controller to support both iPhone and iPad instead of extract efforts on UISplitViewController.
  • 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 the containerLayoutUpdated 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 to 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 after the first online WWDC in two weeks.