Как стать автором
Обновить
0

Vivid UI

Время на прочтение13 мин
Количество просмотров6.4K

Первое, что видит пользователь - UI приложения. И в мобильной разработке больше всего вызовов связано именно с его построением, а большую часть времени разработчик тратит на чизкейк верстку и логику презентационного слоя. В мире существует множество подходов к решению этих задач. Часть того, что мы расскажем, скорее всего уже используется в индустрии. Но мы попытались собрать воедино некоторые из них, и уверены, что это станет вам полезно.

На старте проекта мы захотели прийти к такому процессу разработки фич, чтобы вносить как можно меньше изменений в код при максимальном соответствии с пожеланиями дизайнеров, а также иметь под рукой широкий спектр инструментов и абстракций для борьбы с бойлерплейтом.

Эта статья будет полезна тем, кто хочет тратить меньше времени на рутинные процессы верстки и повторяющуюся логику обработки состояний экранов.

Декларативный стиль UI

View modifiers aka decorator

Начиная разработку, мы решили организовать построение UI компонентов как можно более гибко с возможностью собрать из готовых частей что-то прямо на месте.

Для этого мы решили использовать декораторы: они соответствуют нашему представлению о простоте и переиспользуемости кода.

Декораторы представляют собой структуру с замыканием, которая расширяет функционал вью, без необходимости наследования.

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

}

Почему мы не стали использовать сабклассы:

  • Их трудно соединять в цепочки;

  • Невозможно отказаться от функциональности родительского класса;

  • Нужно описывать отдельно от контекста применения (в отдельном файле)

Декораторы помогли настроить UI компонентов унифицированно и здорово сократили количество кода.

Это также позволило установить связи с дизайн гайдлайнами типовых элементов.

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

В клиентском коде цепочка декораторов выглядит просто и наглядно, позволяя быстро собрать определенную часть интерфейса сразу при объявлении.

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

Здесь мы, например, расширили декоратор заголовка возможностью занимать произвольное количество строк и равнять текст по центру.

А теперь сравним код с декораторами и без них.

Пример использования декоратора:

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

Без декораторов аналогичный код выглядел бы примерно так:

private let fancyLabel: UILabel = {
   let label = UILabel()
   label.text = "???? ? ???‍?"
   label.numberOfLines = 0
   label.font = .f4
   label.textColor = .c1
   label.textAlignment = .center

   return label
}()

Что здесь плохо — 9 строк кода против 4. Внимание рассеивается.

Для navigation bar особенно актуально, так как под строчками вида:

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

Скрывается:

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

Декораторы показали себя хорошим инструментом и помогли нам:

  • Улучшить переиспользование кода

  • Сократить время разработки

  • Через связанность компонентов легко накатывать изменения в дизайне

  • Легко настраивать navigation bar через перегрузку свойства с массивом декораторов базового класса экрана

override var navigationBarDecorators: [ViewDecorator<UINavigationBar>] {
    [.withoutBottomLine, .fillColor(.c0), .titleColor(.c1)]
}
  • Сделать код единообразным: не рассеивается внимание, знаешь где что искать.

  • Получить контекстно-зависимый код: доступны лишь те декораторы, которые применимы для данного визуального компонента.

HStack, VStack

После того, как определились с тем, как будет выглядеть построение отдельных компонентов, мы задумались о том, как сделать удобным расположение компонентов на экране друг относительно друга. Мы также руководствовались тем, чтобы сделать вёрстку простой и декларативной.

Стоит отметить, что история iOS претерпела не одну эволюцию в работе с лейаутом. Чтобы освежить это в памяти и забыть как страшный сон, достаточно взглянуть всего на один простенький пример.

На дизайне выше мы выделили область, для которой мы и будем писать верстку.

Сначала используем наиболее актуальную версию констрейнтов - 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)
])

То же самое можно реализовать на стеках, через нативный UIStackView это будет выглядеть так.

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)

Как видите, в обоих случаях код получился громоздким. Сама идея верстать на стеках имела больше декларативного потенциала. И если быть честными, то этот подход был предложен одним из разработчиков еще до сессии WWDC про SwiftUI. И мы рады, что в данном подразделении Apple работают наши единомышленники! Тут не будет сюрпризов, еще раз взглянем на иллюстрацию, показанную ранее и представим ее в виде стеков.

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

А так выглядит тот же код, если написать его на 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()
    }
}

Коллекции как инструмент построения

Каждый iOS-разработчик знает как неудобно использовать коллекции UITableView и UICollectionView. Надо не забыть зарегистрировать все нужные классы ячеек, проставить делегаты и источники данных. Кроме того, однажды вашей команде может прийти озарение поменять таблицу на коллекцию. Причин для этого масса: невероятные лейауты и кастомные анимированные вставки, свопы и удаления элементов. И вот тогда переписывать придется действительно много.

Собрав все эти идеи воедино, мы пришли к реализации адаптера списков. Теперь для создания динамического списка на экране достаточно всего нескольких строк.

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

И далее настраиваем основные свойства адаптера.

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

И на этом все. Осталось загрузить модели.

listAdapter.reload(items: viewModel.items)

Это помогает избавиться от кучи методов, которые дублируются из класса в класс и сосредоточиться на отличиях коллекций друг от друга.

В итоге:

  • Абстрагировали от конкретной коллекции (UITableView -> UICollectionView).

  • Ускорили время построения экранов со списками

  • Обеспечили единообразие архитектуры всех экранов, построенных на коллекциях

  • На основе адаптера списка разработали адаптер для смеси динамических и статических ячеек

  • Уменьшили количество потенциальных ошибок в рантайме, благодаря компайл тайм проверкам дженерик типов ячеек

Состояния экрана

Очень скоро разработчик замечает, что каждый экран состоит из таких состояний как: начальное состояние, загрузка данных, отображение загруженных данных, отсутствие данных.

Давайте поговорим более подробно о состоянии загрузки экрана.

Shimmering Views

Отображение состояний загрузки разных экранов в приложении обычно не сильно отличается и требует одних и тех же инструментов. В нашем проекте таким визуальным инструментом стали шиммеры (shimmering views).

Шиммер - это такой прообраз реального экрана, где на время загрузки данных на месте финального UI отображаются мерцающие блоки соответствующих этим компонентам размеров.

Также есть возможность кастомизировать лэйаут, выбрав родительскую view, относительно которой будем показывать, а также привязать к различным краям.

Трудно представить себе хоть один экран онлайн приложения, который бы не нуждался в таком скелетоне, поэтому логичным шагом стало создание удобной переиспользуемой логики.

Поэтому мы создали SkeletonView, в который добавили анимацию градиента:

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
}

Основными методами для работы со скелетоном являются показ и скрытие его на экране:

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

}

Для того, чтобы настроить отображение скелетона на конкретном экране используется расширение к протоколу. Внутри самих экранов достаточно добавить вызов:

setupSkeleton()

Smart skeletons

К сожалению, не всегда удается доставить лучший пользовательский опыт, попросту затерев весь пользовательский интерфейс. На некоторых экранах есть необходимость перезагрузить лишь его часть, оставив полностью функционирующей всю остальную. Этой цели служат так называемые умные скелетоны.

Чтобы построить умный скелетон для какого-либо UI компонента требуется знать: список его дочерних компонентов, загрузки данные, которые мы ожидаем, а так же их скелетон-представления:

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

}

Для примера, рассмотрим простенький компонент, состоящий из иконки и лейбла заголовка.

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: [])
        }
    }

}

Теперь мы можем запустить загрузку такого UI компонента с возможностью выбора дочерних элементов для шиммеринга:

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

Таким образом, пользователь видит актуальную информацию, а для блоков, которые требуют загрузки, мы показываем скелетоны.

Машина состояний

Кроме загрузки, есть и другие состояния экрана, переходы между которыми сложно держать в голове. Неумелая организация переходов между ними приводит к неконсистентности информации, отображаемой на экране.

Так как у нас есть конечное число состояний, в котором может находиться экран, и мы можем определить переходы между ними, эта задача прекрасно решается с помощью машины состояний.

Для экрана она выглядит следующим образом:

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

}

Ниже мы привели свою реализацию.

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

Остается вызвать нужные события, чтобы запустить переход состояний.

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

Если есть состояния, то кто-то должен уметь эти состояния показывать.

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

Как можно догадаться, конкретный экран реализует каждый из вышеперечисленных аспектов:

  • Показ ошибок

  • Управление загрузкой

  • Показ скелетонов

  • Показ заглушек с ошибкой и возможностью попытаться снова

  • Показ контента

Также для state machine можно реализовать собственные переходы между состояниями:

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

}

Трейт экрана с машиной состояний

Хорошо, а как все это связать воедино? Для этого потребуется еще один протокол оркестратор.

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

А для перехода по триггерам событий на действия с соответствующим аспектом экрана он использует уже описанную ранее машину состояний.

Отображение ошибок

Еще одной из наиболее часто встречаемых задач является отображение ошибок и обработка реакции пользователя на них.

Для того, чтобы отображение ошибок было одинаковым как для пользователя, так и для разработчиков, мы определились с конечным набором визуального стиля и переиспользуемой логики.

На помощь снова спешат протоколы и трейты.

Для описания представления всех видов ошибок определена единая вьюмодель.

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
}

Дальше мы передаём её в метод протокола ErrorDisplayable:

public protocol ErrorDisplayable: AnyObject {

    func showError(_ viewModel: ErrorViewModel)

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

В зависимости от стиля презентации используем конкретный инструмент отображения.

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

}

Помимо отображения ошибок, существуют еще и сущности бизнес слоя. Каждую из таких сущностей можно в любой момент очень легко вывести на экран, используя приведенную выше вью модель. Таким образом, достигается универсальный и легкий в поддержке механизм отображения ошибок из любой части приложения.

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

}

К слову, баннер может быть использован не только для отображения ошибок, но и для предоставления информации пользователю.

Занимательные цифры

  • Средний размер вьюконтроллера - 196,8934010152 строк

  • Средний размер компонента - 138,2207792208 строк

  • Время написания экрана - 1 день

  • Время написания скрипта для подсчёта этих строк кода - 1 час

Выводы

Благодаря нашему подходу к построению UI новые разработчики довольно быстро вливаются в процесс разработки. Есть удобные и простые в использовании инструменты, которые позволяют сократить время, обычно съедаемое рутинными процессами.

Более того, UI остаётся расширяемым и максимально гибким, что позволяет без труда реализовать интерфейс любой сложности, в соответствии со смелыми замыслами дизайнеров.

Теперь разработчики больше задумываются о самом приложении, а интерфейс легко дополняет бизнес-логику.

Еще не может не радовать сильно похудевшая кодовая база. Это мы осветили в занимательных цифрах. А ясное разделение на компоненты и их взаимное расположение не дают запутаться в коде даже самого сложного экрана.

В конце концов, все разработчики немного дети и стремятся получать удовольствие от разработки. И, кажется, нашей команде это удается!

Теги:
Хабы:
+11
Комментарии0

Публикации

Информация

Сайт
vivid.money
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Германия

Истории