Vivid UI

Marina Zvyagina
Life at Vivid
Published in
12 min readDec 15, 2021

Introduction

The first thing any user of an application sees is the Application UI. The main challenge in mobile development is connected with UI composing. Layout & presentation logic takes the biggest part of a developer’s time. There are a lot of solutions for these kinds of tasks. Some things are already used by some companies. We made an effort to assemble some of them together. We hope it will be helpful.

At the very beginning of the project, we wanted to achieve a feature development flow that would encourage less coding while having most designers’ wishes implemented, and have a wide range of tools to combat boilerplate.

This article will be useful for those who want to spend less time on routine layouts and common screen state management.

Declarative UI style

View modifiers aka decorator

Sooner or later big projects become a mess of duplicated styling code as well as UIView subclasses.

To avoid this problem at the beginning of development we decided to organize the composing of UI component flexibly. UI components also had to have the opportunity to be composed with different other components as is.

For these possibilities, we decided to use decorators. They match our idea of simplicity and reusability of the code.

Decorators are a structure with a closure that extends the functionality of a view without inheritance.

public struct ViewDecorator<View: UIView> {

let decoration: (View) -> Void

func decorate(_ view: View) {
decoration(view)
}

}
public protocol DecoratableView: UIView {}extension DecoratableView { public init(decorator: ViewDecorator<Self>) {
self.init(frame: .zero)
decorate(with: decorator)
}

@discardableResult
public func decorated(with decorator: ViewDecorator<Self>) -> Self {
decorate(with: decorator)
return self
}

public func decorate(with decorator: ViewDecorator<Self>) {
decorator.decorate(self)

currentDecorators.append(decorator)
}

}

Why we didn’t use subclasses:

  • It’s hard to chain them together;
  • It’s impossible to refuse the functionality of the parent class;
  • Must be described separately from the context of the application (in a separate file).

The decorators helped to set up the UI components in a unified way and reduced the amount of code.

This also allowed connections to the design guidelines of the typical elements.

static var headline2: ViewDecorator<View> {
ViewDecorator<View> {
$0.decorated(with: .font(.f2))
$0.decorated(with: .textColor(.c1))
}
}

In client code, the decorator chain looks simple and intuitive, allowing you to quickly assemble a particular part of the interface immediately upon declaration.

private let titleLabel = UILabel()
.decorated(with: .headline2)
.decorated(with: .multiline)
.decorated(with: .alignment(.center))

Here, for example, we have extended the title decorator with the ability to occupy any number of lines and centre the text.

Now let’s compare the code with and without decorators.

Example of using the decorator:

private let fancyLabel = UILabel(
decorator: .text("🐕💩 🐩🦮🐕‍🦺 🦥🕊🦢"))
.decorated(with: .cellTitle)
.decorated(with: .alignment(.center))

The same code without decorators:

private let fancyLabel: UILabel = {
let label = UILabel()
label.text = "🐕🦥🕊💩 🦢 🐩🦮🐕‍🦺"
label.numberOfLines = 0
label.font = .f4
label.textColor = .c1
label.textAlignment = .center
return label
}()

What’s bad here is 9 lines of code vs. 4 lines of code. The attention is scattered.

This is especially true for the navigation bar, because under the lines:

navigationController.navigationBar
.decorated(with: .titleColor(.purple))
.decorated(with: .transparent)

Сode is hiding:

static func titleColor(_ color: UIColor) -> ViewDecorator<UINavigationBar> {
ViewDecorator<UINavigationBar> {
let titleTextAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.f3,
.foregroundColor: color
]
let largeTitleTextAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.f1,
.foregroundColor: color
]

if #available(iOS 13, *) {
$0.modifyAppearance {
$0.titleTextAttributes = titleTextAttributes
$0.largeTitleTextAttributes = largeTitleTextAttributes
}
} else {
$0.titleTextAttributes = titleTextAttributes
$0.largeTitleTextAttributes = largeTitleTextAttributes
}
}
}

and

static var transparent: ViewDecorator<UINavigationBar> {
ViewDecorator<UINavigationBar> {
if #available(iOS 13, *) {
$0.isTranslucent = true
$0.modifyAppearance {
$0.configureWithTransparentBackground()
$0.backgroundColor = .clear
$0.backgroundImage = UIImage()
}
} else {
$0.setBackgroundImage(UIImage(), for: .default)
$0.shadowImage = UIImage()
$0.isTranslucent = true
$0.backgroundColor = .clear
}
}
}

The decorators proved to be a good tool and helped us:

  • To improve code reuse;
  • To reduce development time;
  • Apply design changes easily due to the connectivity of components with decorators;
  • Customize the navigation bar easily by overloading a property with an array of decorators of the base class of the screen;
override var navigationBarDecorators: [ViewDecorator<UINavigationBar>] {
[.withoutBottomLine, .fillColor(.c0), .titleColor(.c1)]
}
  • Make the code consistent: no scattered attention, you know where to look for what.
  • Get context-sensitive code: only those decorators that are applicable to a given visual component are available

HStack, VStack

After deciding what the layout of the individual components would look like, we thought about how to make it easy to arrange the components on the screen in relation to each other. We were also guided by the goal of making the layout simple and declarative.

It is worth noting that the history of iOS has undergone more than one evolution in the handling of layouts. To refresh your memory and forget this as a bad dream, it is enough to look at just one simple example.

On the design above, we highlighted the area for which we will write the layout.

First, we use the most actual version of the constraints — anchors.

[expireDateTitleLabel, expireDateLabel, cvcCodeView].forEach {
view.addSubview($0)
$0.translatesAutoresizingMaskIntoConstraints = false
}

NSLayoutConstraint.activate([
expireDateTitleLabel.topAnchor.constraint(equalTo: view.topAnchor),
expireDateTitleLabel.leftAnchor.constraint(equalTo: view.leftAnchor),

expireDateLabel.topAnchor.constraint(equalTo: expireDateTitleLabel.bottomAnchor, constant: 2),
expireDateLabel.leftAnchor.constraint(equalTo: view.leftAnchor),
expireDateLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor),

cvcCodeView.leftAnchor.constraint(equalTo: expireDateTitleLabel.rightAnchor, constant: 44),
cvcCodeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
cvcCodeView.rightAnchor.constraint(equalTo: view.rightAnchor)
])

The same can be implemented on stacks, through the native UIStackView it will look like this:

let stackView = UIStackView()
stackView.alignment = .bottom
stackView.axis = .horizontal
stackView.layoutMargins = .init(top: 0, left: 16, bottom: 0, right: 16)
stackView.isLayoutMarginsRelativeArrangement = true

let expiryDateStack: UIStackView = {
let stackView = UIStackView(
arrangedSubviews: [expireDateTitleLabel, expireDateLabel]
)
stackView.setCustomSpacing(2, after: expireDateTitleLabel)
stackView.axis = .vertical
stackView.layoutMargins = .init(top: 8, left: 0, bottom: 0, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true
return stackView
}()

let gapView = UIView()
gapView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
gapView.setContentHuggingPriority(.defaultLow, for: .horizontal)

stackView.addArrangedSubview(expiryDateStack)
stackView.addArrangedSubview(gapView)
stackView.addArrangedSubview(cvcCodeView)

As you can see, in both cases the code turned out to be huge.

The very idea of layout on stacks had more declarative potential. And to be honest, this approach was suggested by one of the developers even before the WWDC session about SwiftUI. And we are happy to have like-minded developers in this division of Apple!

There will be no surprises here, let’s look again at the illustration shown earlier and present it in the form of stacks.

view.layoutUsing.stack {
$0.hStack(
alignedTo: .bottom,
$0.vStack(
expireDateTitleLabel,
$0.vGap(fixed: 2),
expireDateLabel
),
$0.hGap(fixed: 44),
cvcCodeView,
$0.hGap()
)
}

And this is what the same code looks like if you write it in SwiftUI:

var body: some View {
HStack(alignment: .bottom) {
VStack {
expireDateTitleLabel
Spacer().frame(width: 0, height: 2)
expireDateLabel
}
Spacer().frame(width: 44, height: 0)
cvcCodeView
Spacer()
}
}

Collections as a building tool

Every iOS developer knows how inconvenient it is to use UITableView and UICollectionView collections. You have to remember to register all the necessary cell classes, set delegates and data sources. Also, one day your team may get the idea to switch from a table to a collection. There are lots of reasons for that: incredible layouts and custom animated inserts, swaps and deletes. And that’s when you really have to rewrite a lot.

Putting all these ideas together, we came up with the implementation of a list adapter. Now the developer only needs to write a few lines to create a dynamic list on the screen.

private let listAdapter = VerticalListAdapter<CommonCollectionViewCell>()
private let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewFlowLayout()
)

And then configure the basic properties of the adapter:

func setupCollection() {
listAdapter.heightMode = .fixed(height: 8)
listAdapter.setup(collectionView: collectionView)
listAdapter.spacing = Constants.standardSpacing
listAdapter.onSelectItem = output.didSelectPocket
}

And that's it. All that's left is to reload the table with the models.

listAdapter.reload(items: viewModel.items)

It helps to get rid of the batch of methods that are duplicated from class to class and to focus on the differences of collections.

Summary:

  • Abstraction from a particular collection (UITableView, UICollectionView);
  • Accelerated the time to build list screens
  • Ensured uniform architecture of all screens built on collections;
  • Based on the list adapter, we developed an adapter for a mixture of dynamic and static cells;
  • Reduced the number of potential errors in runtime, thanks to compile-time checks of generic cell types

Screen states.

Sooner or later the developer will notice every screen consists of various states such as: initial state, data loading, rendering loaded data, data emptiness and(or) failure state.

Let’s talk in more detail about the state of the loading screen.

Shimmering Views

Displaying the loading states of different screens in an application is usually not very different and requires the same tools. In our project, such a visual tool is shimmering views.

Shimmer is a prototype of a real screen, where flickering blocks of corresponding sizes are displayed in place of the final UI while the data is loaded.

It is also possible to customize the layout by selecting the parent view relative to which we will show, as well as linking to different edges.

It’s hard to imagine a single online application screen that wouldn’t need such a skeleton, so the logical step was to create easy-to-use reusable logic.

So we created a SkeletonView, to which we added a gradient animation:

func makeStripAnimation() -> CAKeyframeAnimation {
let animation = CAKeyframeAnimation(keyPath: "locations")

animation.values = [
Constants.stripGradientStartLocations,
Constants.stripGradientEndLocations
]
animation.repeatCount = .infinity
animation.isRemovedOnCompletion = false

stripAnimationSettings.apply(to: animation)

return animation
}

The main methods for working with the skeleton are showing and hiding it on the screen:

protocol SkeletonDisplayable {...}

protocol SkeletonAvailableScreenTrait: UIViewController, SkeletonDisplayable {...}

extension SkeletonAvailableScreenTrait {

func showSkeleton(animated: Bool = false) {
addAnimationIfNeeded(isAnimated: animated)

skeletonViewController.view.isHidden = false

skeletonViewController.setLoading(true)
}

func hideSkeleton(animated: Bool = false) {
addAnimationIfNeeded(isAnimated: animated)

skeletonViewController.view.isHidden = true

skeletonViewController.setLoading(false)
}

}

In order to customize the display of the skeleton on a particular screen, an extension to the protocol is used. Within the screens themselves, it is sufficient to add a call:

setupSkeleton()

Smart skeletons

Unfortunately, it is not always possible to deliver the best user experience by simply mashing the entire user interface. On some screens, there is a need to reload only part of it, leaving the rest fully functioning. The so-called smart skeletons serve this purpose.

To build a smart skeleton for a UI component, we need to know the list of its children, the loading data we expect, as well as their skeleton representations:

public protocol SkeletonDrivenLoadableView: UIView {

associatedtype LoadableSubviewID: CaseIterable

typealias SkeletonBone = (view: SkeletonBoneView, excludedPinEdges: [UIRectEdge])

func loadableSubview(for subviewId: LoadableSubviewID) -> UIView

func skeletonBone(for subviewId: LoadableSubviewID) -> SkeletonBone

}

For example, consider a simple component consisting of an icon and a header label.

extension ActionButton: SkeletonDrivenLoadableView {

public enum LoadableSubviewID: CaseIterable {
case icon
case title
}

public func loadableSubview(for subviewId: LoadableSubviewID) -> UIView {
switch subviewId {
case .icon:
return solidView
case .title:
return titleLabel
}
}

public func skeletonBone(for subviewId: LoadableSubviewID) -> SkeletonBone {
switch subviewId {
case .icon:
return (ActionButton.iconBoneView, excludedPinEdges: [])
case .title:
return (ActionButton.titleBoneView, excludedPinEdges: [])
}
}

}

Now we can start loading such a UI component with the ability to select child elements for shimmering:

actionButton.setLoading(isLoading, shimmering: [.icon])
// or
actionButton.setLoading(isLoading, shimmering: [.icon, .title])
// which is equal to
actionButton.setLoading(isLoading)

This way, the user sees actual information, and for blocks that require loading, we show skeletons.

State machine

In addition to loading, there are other states of the screen, the transitions between which are difficult to keep in mind. Failure to organize the transitions between them leads to inconsistencies in the information displayed on the screen.

Since we have a finite number of states that the screen can be in, and we can define transitions between them, this problem is solved perfectly with a state machine.

For a typical screen it looks like this:

final class ScreenStateMachine: StateMachine<ScreenState, ScreenEvent> {    /// Initializes the ScreenStateMachine.
public init() {
super.init(state: .initial,
transitions: [
.loadingStarted: [.initial => .loading, .error => .loading],
.errorReceived: [.loading => .error],
.contentReceived: [.loading => .content, .initial => .content]
])
}}

We have given our implementation below.

class StateMachine<State: Equatable, Event: Hashable> {

public private(set) var state: State {
didSet {
onChangeState?(state)
}
}

private let initialState: State
private let transitions: [Event: [Transition]]
private var onChangeState: ((State) -> Void)?

public func subscribe(onChangeState: @escaping (State) -> Void) {
self.onChangeState = onChangeState
self.onChangeState?(state)
}

@discardableResult
open func processEvent(_ event: Event) -> State {
guard let destination = transitions[event]?.first(where: { $0.source == state })?.destination else {
return state
}
state = destination
return state
}

public func reset() {
state = initialState
}

The only thing left is to call the necessary events to trigger the transition of states.

func reloadTariffs() {
screenStateMachine.processEvent(.loadingStarted)
interactor.obtainTariffs()
}

If there are states, then someone should be able to show these states.

protocol ScreenInput: ErrorDisplayable,
LoadableView,
SkeletonDisplayable,
PlaceholderDisplayable,
ContentDisplayable

As you can guess, a particular screen implements each of the above aspects:

  • Showing errors
  • Loading management
  • Showing shimmers
  • Showing error placeholders with the ability to retry

You can also implement your own transitions between states for the state machine:

final class DogStateMachine: StateMachine<ConfirmByCodeResendingState, ConfirmByCodeResendingEvent> {

init() {
super.init(
state: .laying,
transitions: [
.walkCommand: [
.laying => .walking,
.eating => .walking,
],
.seatCommand: [.walking => .sitting],
.bunnyCommand: [
.laying => .sitting,
.sitting => .sittingInBunnyPose
]
]
)
}

}

Screen trait with a state machine

Okay, but how do you connect it all together? That would require another orchestrator protocol.

public extension ScreenStateMachineTrait {

func setupScreenStateMachine() {
screenStateMachine.subscribe { [weak self] state in
guard let self = self else { return }

switch state {
case .initial:
self.initialStateDisplayableView?.setupInitialState()
self.skeletonDisplayableView?.hideSkeleton(animated: false)
self.placeholderDisplayableView?.setPlaceholderVisible(false)
self.contentDisplayableView?.setContentVisible(false)
case .loading:
self.skeletonDisplayableView?.showSkeleton(animated: true)
self.placeholderDisplayableView?.setPlaceholderVisible(false)
self.contentDisplayableView?.setContentVisible(false)
case .error:
self.skeletonDisplayableView?.hideSkeleton(animated: true)
self.placeholderDisplayableView?.setPlaceholderVisible(true)
self.contentDisplayableView?.setContentVisible(false)
case .content:
self.skeletonDisplayableView?.hideSkeleton(animated: true)
self.placeholderDisplayableView?.setPlaceholderVisible(false)
self.contentDisplayableView?.setContentVisible(true)
}
}
} private var skeletonDisplayableView: SkeletonDisplayable? {
view as? SkeletonDisplayable
} // etc.
}

This uses the state machine already described earlier to switch from event triggers to actions with the corresponding aspect of the screen.

Displaying errors

Another of the most common tasks is displaying errors and handling user reactions to them.

To ensure that the display of errors is the same for both users and developers, we decided on the set of visual styles and overused logic.

Once again, protocols and traits come to the rescue.

A single viewmodel is defined to describe the representation of all types of errors:

struct ErrorViewModel {
let title: String
let message: String?
let presentationStyle: PresentationStyle
}enum PresentationStyle {
case alert
case banner(
interval: TimeInterval = 3.0,
fillColor: UIColor? = nil,
onHide: (() -> Void)? = nil
)
case placeholder(retryable: Bool = true)
case silent
}

Then we pass it to the ErrorDisplayable protocol method:

public protocol ErrorDisplayable: AnyObject {

func showError(_ viewModel: ErrorViewModel)

}public protocol ErrorDisplayableViewTrait: UIViewController, ErrorDisplayable, AlertViewTrait {}

Depending on the style of presentation, we use a specific display tool.

public extension ErrorDisplayableViewTrait {

func showError(_ viewModel: ErrorViewModel) {
switch viewModel.presentationStyle {
case .alert:
// show alert
case let .banner(interval, fillColor, onHide):
// show banner
case let .placeholder(retryable):
// show placeholder
case .silent:
return
}
}

}

In addition to displaying errors, there are also business layer entities. Each of these entities can be displayed very easily at any time using the view model above. In this way, a universal and easy-to-maintain mechanism for displaying errors from any part of the application is achieved.

extension APIError: ErrorViewModelConvertible {

public func viewModel(_ presentationStyle: ErrorViewModel.PresentationStyle) -> ErrorViewModel {
.init(
title: Localisation.network_error_title,
message: message,
presentationStyle: presentationStyle
)
}

}

extension CommonError: ErrorViewModelConvertible {

public func viewModel(_ presentationStyle: ErrorViewModel.PresentationStyle) -> ErrorViewModel {
.init(
title: title,
message: message,
presentationStyle: isSilent ? .silent : presentationStyle
)
}

}

By the way, the banner can be used not only to display errors but also to provide information to the user.

Funny numbers

  • ViewController average size — 196.8934010152 lines
  • Component average size — 138.2207792208 lines
  • Feature developing time — 1 day
  • Script for this calculations time 🙈 — 1 hour

Conclusions

Thanks to our approach to building UI, new developers get into the development process quickly. There are convenient and easy-to-use tools that reduce the time usually eaten up by routine processes.

Moreover, the UI remains extensible and as flexible as possible, which allows you to easily implement an interface of any complexity, according to the bold ideas of designers.

Developers now think more about the application itself, and the interface easily complements the business logic.

I am also very happy about the greatly reduced codebase. I covered this in funny numbers. And the clear division into components and their mutual arrangement do not let you get confused in the code of even the most complex screen.

After all, all developers are a bit childish and strive to have fun with development. And our team seems to be succeeding!

--

--