Русский
Русский
English
Статистика
Реклама

Vivid UI

Первое, что видит пользователь - 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 = .bottomstackView.axis = .horizontalstackView.layoutMargins = .init(top: 0, left: 16, bottom: 0, right: 16)stackView.isLayoutMarginsRelativeArrangement = truelet 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])// oractionButton.setLoading(isLoading, shimmering: [.icon, .title])// which is equal toactionButton.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 остаётся расширяемым и максимально гибким, что позволяет без труда реализовать интерфейс любой сложности, в соответсвии со смелыми замыслами дизайнеров.

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

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

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

Источник: habr.com
К списку статей
Опубликовано: 19.01.2021 18:13:47
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании vivid money

Разработка под ios

Разработка мобильных приложений

Swift

Ui

Ios

Vivid.money

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru