[Разработка под iOS, Разработка мобильных приложений, Swift] Vivid UI
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Первое, что видит пользователь - UI приложения. И в мобильной разработке больше всего вызовов связано именно с его построением, а большую часть времени разработчик тратит на чизкейк верстку и логику презентационного слоя. В мире существует множество подходов к решению этих задач. Часть того, что мы расскажем, скорее всего уже используется в индустрии. Но мы попытались собрать воедино некоторые из них, и уверены, что это станет вам полезно.На старте проекта мы захотели прийти к такому процессу разработки фич, чтобы вносить как можно меньше изменений в код при максимальном соответствии с пожеланиями дизайнеров, а также иметь под рукой широкий спектр инструментов и абстракций для борьбы с бойлерплейтом.Эта статья будет полезна тем, кто хочет тратить меньше времени на рутинные процессы верстки и повторяющуюся логику обработки состояний экранов.Декларативный стиль UIView 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<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
===========
Похожие новости:
- [Разработка под iOS, Swift] Делаем свой Widget в iOS 14
- [Разработка под iOS, Разработка под MacOS] Apple заблокировала установку iOS-приложений на Mac с чипом M1 через сторонние сервисы
- [Разработка под iOS, Разработка мобильных приложений, Голосовые интерфейсы] Как мы интегрировали Яндекс.Музыку с Siri. Доклад Яндекса
- [Разработка мобильных приложений] Разработка эффективного гибридного приложения
- [Программирование микроконтроллеров, Гаджеты, 3D-принтеры, Интернет вещей, DIY или Сделай сам] Mark gauntlet v4.2: мануал по созданию
- Релиз goredo 1.0.0, реализации системы сборки redo, предложенной DJB
- [Работа с 3D-графикой, Компьютерная анимация] Рендеринг острова из «Моаны» менее чем за 10 000 строк кода на Swift (перевод)
- [Разработка под iOS, Разработка игр, Разработка под Android, Unity, Игры и игровые приставки] ALT CITY: Online. Как я в одиночку создавал “GTA Online” для мобильных устройств. Часть 2
- [Разработка под Arduino, DIY или Сделай сам] Крутые часы на адресных диодах (часть 2)
- [Разработка под Arduino, DIY или Сделай сам] Крутые часы на адресных диодах (часть 3)
Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_swift, #_ui, #_swift, #_ios, #_vivid.money, #_blog_kompanii_vivid_money (
Блог компании Vivid Money
), #_razrabotka_pod_ios (
Разработка под iOS
), #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_swift
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 10:45
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Первое, что видит пользователь - UI приложения. И в мобильной разработке больше всего вызовов связано именно с его построением, а большую часть времени разработчик тратит на чизкейк верстку и логику презентационного слоя. В мире существует множество подходов к решению этих задач. Часть того, что мы расскажем, скорее всего уже используется в индустрии. Но мы попытались собрать воедино некоторые из них, и уверены, что это станет вам полезно.На старте проекта мы захотели прийти к такому процессу разработки фич, чтобы вносить как можно меньше изменений в код при максимальном соответствии с пожеланиями дизайнеров, а также иметь под рукой широкий спектр инструментов и абстракций для борьбы с бойлерплейтом.Эта статья будет полезна тем, кто хочет тратить меньше времени на рутинные процессы верстки и повторяющуюся логику обработки состояний экранов.Декларативный стиль UIView 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) } } }
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 }() 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 } } }
override var navigationBarDecorators: [ViewDecorator<UINavigationBar>] {
[.withoutBottomLine, .fillColor(.c0), .titleColor(.c1)] }
На дизайне выше мы выделили область, для которой мы и будем писать верстку.Сначала используем наиболее актуальную версию констрейнтов - 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) ]) 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) view.layoutUsing.stack {
$0.hStack( alignedTo: .bottom, $0.vStack( expireDateTitleLabel, $0.vGap(fixed: 2), expireDateLabel ), $0.hGap(fixed: 44), cvcCodeView, $0.hGap() ) } var body: some View {
HStack(alignment: .bottom) { VStack { expireDateTitleLabel Spacer().frame(width: 0, height: 2) expireDateLabel } Spacer().frame(width: 44, height: 0) cvcCodeView Spacer() } } 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)
Трудно представить себе хоть один экран онлайн приложения, который бы не нуждался в таком скелетоне, поэтому логичным шагом стало создание удобной переиспользуемой логики.Поэтому мы создали 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()
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: []) } } } 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
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 ] ] ) } } 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 } 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 ) } } Занимательные цифры
=========== Источник: habr.com =========== Похожие новости:
Блог компании Vivid Money ), #_razrabotka_pod_ios ( Разработка под iOS ), #_razrabotka_mobilnyh_prilozhenij ( Разработка мобильных приложений ), #_swift |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 10:45
Часовой пояс: UTC + 5