Vivid UI

The first thing that the user sees is the UI of the application. And in mobile development, most of the challenges are related to its construction, and most of the time the developer spends on cheesecake layout and logic of the presentation layer. There are many approaches to solving these problems in the world. Some of what we’ll cover is likely already in use in the industry. But we tried to put together some of them, and we are sure that it will be useful to you.

At the start of the project, we wanted to come to such a process of developing features in order to make as few changes as possible to the code while maximally meeting the wishes of the designers, as well as to have at hand a wide range of tools and abstractions to deal with the boilerplate.

This article will be useful for those who want to spend less time on routine layout processes and repetitive logic for processing screen states.

Declarative UI style

View modifiers aka decorator

Starting development, we decided to organize the construction of UI components as flexibly as possible with the ability to assemble something from ready-made parts right on the spot.

For this, we decided to use decorators: they correspond to our idea of ​​simplicity and reusability of code.

Decorators are a closure structure that extends the functionality of the view without the need for 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)
    }

    public func redecorate() {
        currentDecorators.forEach {
            $0.decorate(self)
        }
    }

}

Why we didn’t use subclasses:

  • They are difficult to chain;

  • There is no way to drop the functionality of the parent class;

  • Should be described separately from the context of use (in a separate file)

Decorators helped to customize the UI of the components in a uniform manner and reduced the amount of code a lot.

It also made it possible to establish links with the design guidelines of typical elements.

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

In the client code, the decorator chain looks simple and clear, allowing you to quickly assemble a certain 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 header decorator with the ability to occupy an arbitrary number of lines and align the text to the center.

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

An example of using a decorator:

private let fancyLabel = UILabel(
    decorator: .text("?? ???‍?   ???"))
    .decorated(with: .cellTitle)
    .decorated(with: .alignment(.center))

Without decorators, similar code would look something like this:

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 versus 4. Attention is scattered.

For the navigation bar, it is especially relevant, since under the lines of the form:

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

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
        }
    }
}
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
        }
    }
}

Decorators proved to be a good tool and helped us:

  • Improve code reuse

  • Reduce development time

  • It’s easy to roll out design changes through the coherence of components

  • Easily customize the navigation bar through property overloading with an array of screen base class decorators

override var navigationBarDecorators: [ViewDecorator<UINavigationBar>] {
    [.withoutBottomLine, .fillColor(.c0), .titleColor(.c1)]
}
  • Make the code consistent: attention is not scattered, you know where what to look for.

  • Get context sensitive code: Only those decorators are available that are applicable to the given visual component.

HStack, VStack

After we decided on how the construction of individual components would look like, we thought about how to make it convenient for the components to be positioned on the screen relative to each other. We were also guided by the idea of ​​making the layout simple and declarative.

It should be noted that the history of iOS has undergone more than one evolution in working with layout. To refresh your memory and forget like a bad dream, just look at one simple example.

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

First, we will use the most current 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 cumbersome. The very idea of ​​typesetting 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 glad that our like-minded people work in this Apple division! There will be no surprises here, take another look at the illustration shown earlier and represent 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 how 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 awkward it is to use the UITableView and UICollectionView collections. We must not forget to register all the necessary cell classes, assign delegates and data sources. In addition, one day your team may have an insight to swap a table for a collection. There are a lot of reasons for this: incredible layouts and custom animated inserts, swaps and deletions of elements. And then you really have to rewrite a lot.

Putting all these ideas together, we come up with an implementation of a list adapter. Now, just a few lines are enough to create a dynamic list on the screen.

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

And then we set up the basic properties of the adapter.

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

And that is all. It remains to load the models.

listAdapter.reload(items: viewModel.items)

This helps to get rid of a bunch of methods that are duplicated from class to class and focus on how collections differ from each other.

Eventually:

  • Abstracted from a specific collection (UITableView -> UICollectionView).

  • Speed ​​up the time to build list screens

  • Ensure consistency in the 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 for generic cell types

Screen states

Very soon, the developer will notice that each screen consists of such states as: initial state, loading data, displaying loaded data, lack of data.

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

Shimmering Views

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

A shimmer is such a prototype of a real screen, where flickering blocks of sizes corresponding to these components are displayed in place of the final UI at the time of loading data.

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

It is hard to imagine even one screen of an online application that would not need such a skeleton, so the logical step was to create convenient reusable logic.

Therefore, 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 a 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 specific screen, an extension to the protocol is used. Inside the screens themselves, it’s enough to add a call:

setupSkeleton()

Smart skeletons

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

To build a smart skeleton for any UI component, you need to know: the list of its child components, the loading data that 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

}

As an example, consider a simple component consisting of an icon and a title 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 children for shimmering:

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

Thus, the user sees relevant information, and for the blocks that require loading, we show skeletons.

State machine

In addition to loading, there are other screen states, the transitions between which are difficult to keep in mind. The inept organization of the transitions between them leads to inconsistency of the information displayed on the screen.

Since we have a finite number of states in which the screen can be, and we can determine the transitions between them, this task is perfectly solved using a state machine.

For the screen, it looks like this:

final class ScreenStateMachine: StateMachine<ScreenState, ScreenEvent> {

    public init() {
        super.init(state: .initial,
           transitions: [
               .loadingStarted: [.initial => .loading, .error => .loading],
               .errorReceived: [.loading => .error],
               .contentReceived: [.loading => .content, .initial => .content]
           ])
    }

}

Below we have given our implementation.

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
    }
  
}

All that remains is to trigger the necessary events to trigger the state transition.

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 might guess, a specific screen implements each of the above aspects:

  • Show errors

  • Boot management

  • Skeleton show

  • Show stubs with an error and the ability to try again

  • Content display

Also for the state machine, you can implement your own transitions between states:

final class DogStateMachine: StateMachine&lt;ConfirmByCodeResendingState, ConfirmByCodeResendingEvent> {

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

}

State Machine Screen Trait

Okay, how do you tie it all together? This will 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.
}

And to navigate through event triggers to actions with the corresponding aspect of the screen, he uses the state machine already described earlier.

Displaying errors

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

To ensure that the error display is the same for both the user and the developer, we have decided on a finite set of visual style and reusable logic.

Protocols and traits rush to the rescue again.

To describe the representation of all types of errors, a single view model is defined.

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 {}

We use a specific display tool depending on the presentation style.

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 above view model. Thus, 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.

Amusing numbers

  • Average viewcontroller size – 196.8934010152 lines

  • Average component size – 138.2207792208 lines

  • Screen writing time – 1 day

  • The time to write a script to count these lines of code is 1 hour

conclusions

Thanks to our approach to building the UI, new developers get into the development process pretty quickly. There are handy and easy-to-use tools that can help you reduce the time usually consumed by routine processes.

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

Now developers are thinking more about the application itself, and the interface easily complements the business logic.

The heavily thinned codebase cannot but rejoice. We highlighted this in entertaining numbers. A clear division into components and their relative position do not allow even the most complex screen to get confused in the code.

At the end of the day, all developers are a bit of a kid and want to have fun developing. And it seems that our team succeeds!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *