📜 ⬆️ ⬇️

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 first need to 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, 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: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol 

It simply notifies all previously signed observers about the changes, so this 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 { [weak self] (_, newItems) in   self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal   self?.collectionView.reloadSections(IndexSet(integer: 0)) }) 

The controller subscribes to changes in the self.viewModel.items property, 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 talk about four UI development techniques that are used in Badoo:

1. The implementation of the user interface in the 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 the 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


Take a look at the following controller (FriendsListViewController):

 final class FriendsListViewController: UIViewController { struct ViewConfig { let backgroundColor: UIColor let cornerRadius: CGFloat } private var infoView: FriendsListView! private let viewModel: FriendsListViewModelProtocol private let viewConfig: ViewConfig init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) { self.viewModel = viewModel self.viewConfig = viewConfig super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.setupContainerView() } private func setupContainerView() { 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 view models, that is, 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 view style, namely, indents, sizes, colors, fonts, etc., I consider it appropriate to provide a standard configuration like this:

 extension FriendsListViewController.ViewConfig {   static var defaultConfig: FriendsListViewController.ViewConfig {       return FriendsListViewController.ViewConfig(backgroundColor: .white,                                                   cornerRadius: 16)   } } 

All view initialization occurs in the setupContainerView method, which 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 markup is applied (layout) and styles.

Here is what the view controller now looks like:

 final class FriendsListPresenter: FriendsListPresenterProtocol {   // …   func presentFriendsList(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)   }   private class func createFriendsListViewController( 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, considering 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 the infoView inside 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 the 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 it reaches the end of the list.
  3. FriendsListView is a 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 re-using 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:

 func presentFriendsList(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:

 protocol FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? { get }   var closeButtonIcon: UIImage? { get }   var friendsCount: Observable<String> { get }   var onCloseAction: VoidBlock? { get set } } 

Now imagine that you are adding visual tests for the UI — it's as easy as passing stub models for UI components.

 final class FriendsListHeaderDemoViewModel: 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:

 final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol {   let friendsCountIcon: UIImage?   let closeButtonIcon: UIImage?   let friendsCount: Observable<String> = Observable("0")   var onCloseAction: VoidBlock?   private let dataProvider: FriendsListDataProviderProtocol   private var observers: [ObserverProtocol] = []   init(dataProvider: FriendsListDataProviderProtocol,        friendsCountIcon: UIImage?,        closeButtonIcon: UIImage?) {       self.dataProvider = dataProvider       self.friendsCountIcon = friendsCountIcon       self.closeButtonIcon = closeButtonIcon       self.setupDataObservers()   }   private func setupDataObservers() {       self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in           self?.friendsCount.value = "\(newCount)"       })   } } 

What could be easier? Just implement the data provider - and more!

Implementing the content model looks a bit more complicated, but sharing responsibility still makes 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:

 private func presentRealFriendsList(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 = { [weak self] 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 to 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 the 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 a team and choose the approach that is most suitable for most scenarios.

May the Swift be with you!

Sources

Source: https://habr.com/ru/post/421559/


All Articles