The first thing that the user sees is the UI of the application. And in mobile development, most of the challenges are associated with its construction, and most of the time the developer spends on the 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)
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 , , .
, -.
. . .
, . , , !