Component UI architecture in iOS application



    Hi, Habr!

    My name is Valera, and for two years now I've been developing an iOS application as part of the Badoo team. One of our priorities is easily maintainable code. Because of the large number of new features that come into our hands every week, we need to first think about the architecture of the application, otherwise it will be extremely difficult to add a new feature to the product without breaking existing ones. Obviously, this also applies to the implementation of the user interface (UI), regardless of whether it is done using code, Xcode (XIB), or a mixed approach. In this article I will describe some of the UI implementation techniques that allow us to simplify the development of the user interface, making it flexible and convenient for testing. There is also an English version of this article .

    Before you begin ...


    I will consider the methods of implementing the user interface on the example of an application written in Swift. The application by clicking on the button shows a list of friends.

    It consists of three parts:

    1. Components - custom UI-components, that is, the code related only to the user interface.
    2. Demo application - demo view models and other user interface entities that have only UI dependencies.
    3. The real application is view models and other entities that may contain specific dependencies and logic.

    Why such a separation? I will answer this question below, but for now check out the user interface of our application:


    This is a pop-up view with content on top of another full-screen view. It's simple.

    The full source code of the project is available on GitHub .

    Before delving into the UI code, I want to introduce you to the Observable helper class used here. Its interface looks like this:

    var value: Tfuncobserve(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocolfuncobserveNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol

    It simply notifies all previously signed observers about the changes, so it is a kind of alternative to KVO (key-value observing) or, if you will, reactive programming. Here is an example of use:

    self.observers.append(self.viewModel.items.observe { [weakself] (_, newItems) in
        self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal
        self?.collectionView.reloadSections(IndexSet(integer: 0))
    })

    The controller subscribes to property changes self.viewModel.items, and when a change occurs, the handler executes the business logic. For example, it updates the view state and reloads the collection view with new elements.

    You will see more usage examples below.

    Techniques


    In this section, I will discuss the four techniques of UI development that are used in Badoo:

    1. Implementing the user interface in code.

    2. Using layout anchors.

    3. Components - divide and conquer.

    4. Separation of user interface and logic.

    # 1: UI implementation in code


    In Badoo, most of the user interest is implemented in code. Why don't we use XIBs or storyboards? Fair question. The main reason is the convenience of maintaining the code for a team of medium size, namely:

    • You can clearly see changes in the code, which means that there is no need to analyze the XML storyboard / XIB file in order to find the changes made by a colleague;
    • version control systems (for example, Git) are much easier to work with the code than with the "heavy" XLM-files, especially during minor conflicts; It also takes into account that the contents of XIB / storyboard files change each time they are saved, even if the interface has not changed (although I heard that this problem was fixed in Xcode 9);
    • It may be difficult to change and maintain certain properties in Interface Builder (IB), for example, the properties of CALayer during the process of releasing its child views (layout subviews), which can lead to several sources of truth for the view state;
    • Interface Builder is not the fastest tool, and sometimes it is much faster to work directly with the code.

    Take a look at the following controller (FriendsListViewController):

    finalclassFriendsListViewController: UIViewController{
        structViewConfig{
            let backgroundColor: UIColorlet cornerRadius: CGFloat
        }
        privatevar infoView: FriendsListView!privatelet viewModel: FriendsListViewModelProtocolprivatelet viewConfig: ViewConfiginit(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) {
            self.viewModel = viewModel
            self.viewConfig = viewConfig
            super.init(nibName: nil, bundle: nil)
        }
        requiredinit?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        overridefuncviewDidLoad() {
            super.viewDidLoad()
            self.setupContainerView()
        }
        privatefuncsetupContainerView() {
            self.view.backgroundColor = self.viewConfig.backgroundColor
            let infoView = FriendsListView(
                frame: .zero,
                viewModel: self.viewModel,
                viewConfig: .defaultConfig)
            infoView.backgroundColor = self.viewConfig.backgroundColor
            self.view.addSubview(infoView)
            self.infoView = infoView
            infoView.translatesAutoresizingMaskIntoConstraints = false
            infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
            infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
            infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
            infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
        }
        // ….
    }

    This example shows that you can create a view controller only by providing the view model and view configuration. You can read more about the presentation models, that is, about the MVVM design pattern (Model-View-ViewModel) here . Since the view configuration is a simple structural entity (defining entity) that defines the layout (layout) and the view style, namely, indents, sizes, colors, fonts, etc., I consider it appropriate to provide a standard configuration like this:

    extensionFriendsListViewController.ViewConfig{
        staticvar defaultConfig: FriendsListViewController.ViewConfig {
            returnFriendsListViewController.ViewConfig(backgroundColor: .white,
                                                        cornerRadius: 16)
        }
    }

    The entire view initialization occurs in a method setupContainerViewthat is called only once from viewDidLoad at the moment when the view is already created and loaded but not yet drawn on the screen, that is, all necessary elements (subviews) are simply added to the view hierarchy, and then the markup is applied ( layout) and styles.

    Here’s what the view controller now looks like:

    finalclassFriendsListPresenter: FriendsListPresenterProtocol{
        // …
        funcpresentFriendsList(from presentingViewController: UIViewController) {
            let controller = Class.createFriendsListViewController(
                presentingViewController: presentingViewController,
                headerViewModel: self.headerViewModel,
                contentViewModel: self.contentViewModel)
            controller.modalPresentationStyle = .overCurrentContext
            controller.modalTransitionStyle = .crossDissolve
            presentingViewController.present(controller, animated: true, completion: nil)
        }
        privateclassfunccreateFriendsListViewController(
                presentingViewController: UIViewController,
                headerViewModel: FriendsListHeaderViewModelProtocol,
                contentViewModel: FriendsListContentViewModelProtocol) 
                -> FriendsListContainerViewController{
            
            let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in
                presentingViewController?.dismiss(animated: true, completion: nil)
            }
            let infoViewModel = FriendsListViewModel(
                headerViewModel: headerViewModel,
                contentViewModel: contentViewModel)
            let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock)
            let friendsListViewController = FriendsListViewController(
                viewModel: infoViewModel,
                viewConfig: .defaultConfig)
            let controller = FriendsListContainerViewController(
                contentViewController: friendsListViewController,
                viewModel: containerViewModel,
                viewConfig: .defaultConfig)
            return controller
        }
    }

    You can see a clear division of responsibility , and this concept is not much more difficult than calling segue on a storyboard.

    Creating a view controller is quite simple, given that we have its model and you can simply use the standard view configuration:

    
    let friendsListViewController = FriendsListViewController(
            viewModel: infoViewModel,
            viewConfig: .defaultConfig)
    

    # 2: Using layout anchors


    Here is the layout code:

    self.view.addSubview(infoView)
    self.infoView = infoView
    infoView.translatesAutoresizingMaskIntoConstraints = false
    infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
    infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
    infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
    infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true

    Simply put, this code places infoViewinside the parent view (superview), at coordinates (0, 0) relative to the original size of the superview.

    Why do we use layout anchors? It's quick and easy. Of course, you can set UIView.frame manually and count all positions and sizes on the fly, but sometimes this can turn into too confusing and / or cumbersome code.

    You can also use a text format for markup, as described here , but this often leads to errors, since the format must be strictly followed, and Xcode does not check the markup text at the stage of writing / compiling the code, and you cannot use the Safe Area Layout Guide:

    
    NSLayoutConstraint.constraints(
        withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",
        options: [],
        metrics: metrics,
        views: views)
    

    It's pretty easy to make a mistake or typo in the text line defining the markup, isn't it?

    # 3: Components - divide and conquer


    Our sample user interface is divided into components, each of which performs one specific function, no more.

    For example:

    1. FriendsListHeaderView - Displays information about friends and the "Close" button.
    2. FriendsListContentView - Displays a list of friends with clickable cells, the content is dynamically loaded when reaching the end of the list.
    3. FriendsListView - container for the two previous views.

    As mentioned earlier, we at Badoo love the principle of sole responsibility , where each component is responsible for a separate function. This helps not only in the process of bugfixing (which, perhaps, is not the most interesting part of the work of an iOS developer), but also during the development of a new functional, because such an approach significantly expands the possibilities of reusing the code in the future.

    # 4: Separation of user interface and logic


    And last but not least, the separation of the user interface and logic. A technique that can save time and nerves to your team. In the literal sense: a separate project under the user interface and a separate one - under the business logic.

    Let's return to our example. As you remember, the essence of the presentation (presenter) looks like this:

    funcpresentFriendsList(from presentingViewController: UIViewController) {
        let controller = Class.createFriendsListViewController(
            presentingViewController: presentingViewController,
            headerViewModel: self.headerViewModel,
            contentViewModel: self.contentViewModel)
        controller.modalPresentationStyle = .overCurrentContext
        controller.modalTransitionStyle = .crossDissolve
        presentingViewController.present(controller, animated: true, completion: nil)
    }

    You need to provide only view models of the title and content. The rest is hidden inside the above implementation of UI components.

    The protocol for the header view model is:

    protocolFriendsListHeaderViewModelProtocol{
        var friendsCountIcon: UIImage? { get }
        var closeButtonIcon: UIImage? { get }
        var friendsCount: Observable<String> { get }
        var onCloseAction: VoidBlock? { getset }
    }

    Now imagine that you are adding visual tests for a UI — this is as easy as passing stub models for UI components.

    finalclassFriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol{
        var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count")
        var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross")
        var friendsCount: Observable<String>
        var onCloseAction: VoidBlock?
        init() {
            let friendsCountString = "\(Int.random(min: 1, max: 5000))"
            self.friendsCount = Observable(friendsCountString)
        }
    }

    Looks easy, right? Now we want to add business logic to the components of our application, which may require data providers, data models, and so on:

    finalclassFriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol{
        let friendsCountIcon: UIImage?
        let closeButtonIcon: UIImage?
        let friendsCount: Observable<String> = Observable("0")
        var onCloseAction: VoidBlock?
        privatelet dataProvider: FriendsListDataProviderProtocol
        privatevar observers: [ObserverProtocol] = []
        init(dataProvider: FriendsListDataProviderProtocol,
             friendsCountIcon: UIImage?,
             closeButtonIcon: UIImage?) {
            self.dataProvider = dataProvider
            self.friendsCountIcon = friendsCountIcon
            self.closeButtonIcon = closeButtonIcon
            self.setupDataObservers()
        }
        privatefuncsetupDataObservers() {
            self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weakself] (newCount) in
                self?.friendsCount.value = "\(newCount)"
            })
        }
    }

    What could be easier? Just implement a data provider - and go ahead!

    Implementing the content model looks a bit more complicated, but sharing responsibility is still making life a lot easier. Here is an example of how you can instantiate and display a list of friends at the touch of a button:

    privatefuncpresentRealFriendsList(sender: Any) {
        let avatarPlaceholderImage = UIImage(named: "avatar-placeholder")
        let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage)
        let dataProvider = FriendsListDataProvider(itemFactory: itemFactory)
        let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider)
        var headerViewModel = viewModelFactory.makeHeaderViewModel()
        headerViewModel.onCloseAction = { [weakself] in
            self?.dismiss(animated: true, completion: nil)
        }
        let contentViewModel = viewModelFactory.makeContentViewModel()
        let presenter = FriendsListPresenter(
            headerViewModel: headerViewModel,
            contentViewModel: contentViewModel)
        presenter.presentFriendsList(from: self)
    }

    This technique helps isolate the user interface from business logic. Moreover, it allows you to cover the entire UI with visual tests, passing test data to the components! Therefore, the separation of the user interface and its associated business logic is crucial to the success of the project, whether it is a startup or a finished product.

    Conclusion


    Of course, these are just some of the techniques used in Badoo, and they are not a universal solution for all possible cases. Therefore, use them, pre-assessing whether they are suitable for you and your projects.

    There are other techniques, for example, XIB configurable UI components using Interface Builder (described in our other article ), but for various reasons they are not used in Badoo. Remember that everyone has his own opinion and vision of the overall picture, so in order to develop a successful project, it is necessary to come to a consensus in the team and choose the approach that is most suitable for most scenarios.

    May the Swift be with you!

    Sources


    Also popular now: