鮮やかなUI

ユーザーが最初に目にするのは、アプリケーションのUIです。また、モバイル開発では、ほとんどの課題はその構築に関連しており、ほとんどの時間、開発者はチーズケーキの レイアウトとプレゼンテーションレイヤーのロジックに費やしてい ます。世界にはこれらの問題を解決するための多くのアプローチがあります。ここで取り上げる内容の一部は、業界ですでに使用されている可能性があります。しかし、私たちはそれらのいくつかをまとめようとしました、そしてそれがあなたに役立つと確信しています。





プロジェクトの開始時に、設計者の要望を最大限に満たしながらコードにできるだけ変更を加えないようにするため、および定型文を処理するためのさまざまなツールと抽象化を手元に用意するために、機能を開発するこのようなプロセスに到達したいと考えました。





この記事は、画面の状態を処理するためのルーチンのレイアウトプロセスと反復ロジックに費やす時間を減らしたい人に役立ちます。





宣言的なUIスタイル

ビューモディファイア、別名デコレータ

開発を開始し、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 , , .





, -.





. . .





, . , , !








All Articles