LeadKit/docs/tiuielements/placeholder.md

17 KiB
Raw Permalink Blame History

Placeholder API

TIUIElements добавляет DefaultPlaceholderView - плейсхолдер. Он представляет собой 3 вертикально расположенные view: UIImageView, DefaultTitleSubtitleView, UIStackView.

UIStackView используется для добавления кнопок в различном количестве и с необходимым расположением (горизонтальным/вертикальным)

Принцип работы

Для создания и конфигурации плейсхолдера существует фабрика PlaceholderFactory. Для этого следует воспользоваться методом createImageStylePlaceholder(_:).

import TIUIElements
import TIUIKitCore
import UIKit

let factory = PlaceholderFactory()
let defaultPlaceholderView = factory.createLoadingDataErrorPlaceholder()

Конфигурация

Метод createImageStylePlaceholder(_:) принимает в себя аргумент типа DefaultPlaceholderStyle, позволяющий полностью сконфигурировать создаваемую view

let styleWithText = DefaultPlaceholderStyle(titleSubtitle: .init(title: "Server error has occured!"))
let placeholderWithErrorText = factory.createImageStylePlaceholder(styleWithText)

Возможные опции для конфигурации:

  • картинка
  • текст (title, subtitle)
  • внешний вид плейсхолдера в целом и каждой отдельной view
  • расположение view внутри плейсхолдера относительно друг друга
  • расоложение кнопок внутри stackView (горизонтальное/вертикальное)
  • внешний вид кнопок
  • текст и картинка кнопок
  • действия кнопок

Стоит учитывать, что если картинка плейсхолдера не указывается, то UIImageView будет скрыт. Тот же принцип касается и кнопок - если вы не добавили стилей кнопок, то UIStackView скрывается.

let customStyle = DefaultPlaceholderStyle(
    image: UIImage(named: "proj-placeholder-image"),
    titleSubtitle: .init(title: "An error has occured", subtitle: "Please, reload the page"),
    appearance: .make { placeholder in
        placeholder.backgroundColor = .blue

        placeholder.textViewAppearance { textView in
            textView.titleAppearance { title in
                title.textAttributes = .init(font: .systemFont(ofSize: 20),
                                             color: .black,
                                             alignment: .natural,
                                             isMultiline: false)
            }
            textView.subtitleAppearance = textView.titleAppearance
        }

    }, buttonsStyles: [
        .init(titles: [.normal: "Reload"],
              appearance: [
                .normal: UIButton.DefaultAppearance(textAttributes: .init(font: .systemFont(ofSize: 20),
                                                                          color: .black,
                                                                          alignment: .natural,
                                                                          isMultiline: false))
              ])
    ])

let placeholderView = factory.createImageStylePlaceholder(customStyle)

Методы конфигурации

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

  • make(_:): статический метод для создания стиля. Принимает в себя функцию с переменной типа создоваемого стиля
  • update(_:): метод для изменения уже существующего стиля. Принимает в себя функцию с переменной типа создаваемого стиля
  • updateAppearance(_:): метод для изменения внешнего вида плейсхолдера. Принимает в себя функцию с переменной типа ViewAppearance. В случае DefaultPlaceholderStyle это DefaultPlaceholderView.Appearance
  • withButton(_:): метод для добавления новой кнопки. Принимает в себя фунцию с переменной типа PlaceholderButtonStyle.
  • withButtons(_:axis:_:): метод для добавления/изменения срузу нескольких кнопок. Первым агрументом указывается количество кнопок, вторым их расположение, третьим - функция двух переменных, где первая переменная - это номер (индекс) кнопки, вторая - PlaceholderButtonStyle
let styleFromMake = DefaultPlaceholderStyle.make { style in
    style.image = UIImage(named: "proj-placeholder-image")
    style.titleSubtitle = .init(title: "An error has occured", subtitle: "Please, reload the page")
}

let styleWithUpdatedImage = styleFromMake.update { style in
    style.image = UIImage(named: "proj-other-placeholder-image")
}

let styleWithAppearance = styleFromMake.updateAppearance { placeholder in
    placeholder.backgroundColor = .blue

    placeholder.textViewAppearance { textView in
        textView.titleAppearance { title in
            title.textAttributes = .init(font: .systemFont(ofSize: 20),
                                         color: .black,
                                         alignment: .natural,
                                         isMultiline: false)
        }

        textView.subtitleAppearance = textView.titleAppearance
    }
}

let styleWithButton = styleWithAppearance.withButton { buttonStyle in
    buttonStyle.titles = [.normal: "Reload"]
    buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in
        button.textAttributes = .init(font: .systemFont(ofSize: 20),
                                      color: .black,
                                      alignment: .natural,
                                      isMultiline: false)
    }]
}

Стоит обратить внимание на то, что если бы метод использовался на styleWithButton, то у уже добавленной кнопки (она была бы с индексом 0) сохранился стиль, так что его можно было либо дополнить, либо переопределить

let styleWithTwoButtons = styleWithAppearance.withButtons(2, axis: .vertical) { index, buttonStyle in
    if index == .zero {
        buttonStyle.titles = [.normal: "Reload"]
        buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in
            button.textAttributes = .init(font: .systemFont(ofSize: 20),
                                          color: .black,
                                          alignment: .natural,
                                          isMultiline: false)
        }]
    }

    if index == 1 {
        buttonStyle.titles = [.normal: "Wait"]
        buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in
            button.textAttributes = .init(font: .systemFont(ofSize: 20),
                                          color: .black,
                                          alignment: .natural,
                                          isMultiline: false)
        }]
    }
}

Стандартные стили

Для удобства у PlaceholderFactory есть несколько стандартных стилей, которые можно использовать для ускоренной разработки и изменять под свои нужды при необходимости:

  • errorStyle
  • loadingDataErrorStyle
  • emptyStateStyle

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

image

let errorStylePlaceholder = factory.createErrorPlaceholder()

image

let loadingDataErrorStylePlaceholder = factory.createLoadingDataErrorPlaceholder()

image

let emptyStateStylePlaceholder = factory.createEmptyStatePlaceholder()

Кастомизация стандартных стилей

Стили errorStyle, loadingDataErrorStyle, emptyStateStyle могут показывать, лежащие в Assets. Для этого необходимо только называть картинки: placeholder_error_icon, placeholder_loading_data_icon, placeholder_empty_state_icon соответственно. Если нужной картинки не будет, то она просто не отобразится в плейсхолдере. При таком изменении никаких дополнительных настроек делать не нужно.

Также при создании фабрики, можно передать в нее объект соответствующий протоколу PlaceholderLocalizationProvider для добавления необходимого текста.

При добавлении иных изменений нужно либо делать наследника PlaceholderFactory, либо передавать стиль через метод createImageStylePlaceholder(_:). В обоих случаях изменить стандартный стиль можно через соответствующие методы фабрики:

  • errorStyle()
  • loadingDataErrorStyle()
  • emptyStateStyle()
class CustomViewController: BaseInitializableViewController {
    private var currentPlaceholder: UIView?

    private var customErrorStyle: DefaultPlaceholderStyle {
        factoryWithCustomErrorStyle.errorStyle()
            .update { style in
                style.withButtons(1, axis: .vertical) { _, buttonStyle in
                    buttonStyle.action = (target: nil, action: #selector(closePlaceholder), event: .touchUpInside)
                }
            }
    }

    let factoryWithCustomErrorStyle = PlaceholderFactory()

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // some activity with error result...

        let currentPlaceholder = factoryWithCustomErrorStyle.createImageStylePlaceholder(customErrorStyle)

        // custom presentation of the placeholder...

        self.currentPlaceholder = currentPlaceholder
    }

    @objc private func closePlaceholder() {
        currentPlaceholder?.removeFromSuperview()
    }
}

Использование плейсхолдеров без фабрики

Если необходимо использовать заглушки без фабрики, то их конфигурацию можно доверить методу apply(style:) у каждого DefaultPlaceholderView

class PlaceholderHolderViewController: BaseInitializableViewController, ConfigurableView {

    private let placeholder = DefaultPlaceholderView()

    override func addViews() {
        super.addViews()

        view.addSubview(placeholder)
    }

    override func configureLayout() {
        super.configureLayout()

        placeholder.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            placeholder.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            placeholder.topAnchor.constraint(equalTo: view.topAnchor),
            placeholder.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            placeholder.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }

    override func configureAppearance() {
        super.configureAppearance()

        placeholder.isHidden = true
    }

    func configure(with error: ErrorType) {
        switch error {
        case .internetConnection:
            placeholder.apply(style: Self.internetConnectionErrorStyle)

        case .unknown:
            placeholder.apply(style: Self.unknownErrorStyle)
        }

        placeholder.isHidden = false
    }
}

extension PlaceholderHolderViewController {
    static var internetConnectionErrorStyle: DefaultPlaceholderStyle {
        factory.errorStyle().update { _ in
            // some configurations
        }
    }

    static var unknownErrorStyle: DefaultPlaceholderStyle {
        factory.errorStyle().update { _ in
            // some configurations
        }
    }
}

enum ErrorType {
    case internetConnection
    case unknown
}

import PlaygroundSupport

let placeholder = PlaceholderHolderViewController()

PlaygroundPage.current.liveView = placeholder

placeholder.configure(with: .internetConnection)

Создание кастомных заглушек

Если необходимо показывать что-то кроме UIImageView, можно создать наследника BasePlaceholderView.

В качестве примера показан заглушка с lottie анимацией:

import Lottie

public final class LottiePlaceholderStyle: BasePlaceholderStyle<LottiePlaceholderView.Appearance>, PlaceholderStyle {

    public static var defaultStyle: LottiePlaceholderStyle {
        .init()
    }

    public var animationName: String
    public var animationSpeed: CGFloat
    public var loopMode: LottieLoopMode

    public init(titleSubtitle: DefaultTitleSubtitleViewModel = .init(),
                appearance: LottiePlaceholderView.Appearance = .defaultAppearance,
                controlsViewAxis: NSLayoutConstraint.Axis = .vertical,
                buttonsStyles: [PlaceholderButtonStyle] = [],
                animationName: String = "",
                animationSpeed: CGFloat = 1,
                loopMode: LottieLoopMode = .loop) {

        self.animationName = animationName
        self.animationSpeed = animationSpeed
        self.loopMode = loopMode

        super.init(titleSubtitle: titleSubtitle,
                   appearance: appearance,
                   controlsViewAxis: controlsViewAxis,
                   buttonsStyles: buttonsStyles)
    }
}

public final class LottiePlaceholderView: BasePlaceholderView<LottieAnimationView> {
    public override var isImageViewHidden: Bool {
        super.isImageViewHidden || imageView.animation == nil
    }

    public func apply(style: LottiePlaceholderStyle) {
        imageView.animation = LottieAnimation.named(style.animationName)
        imageView.animationSpeed = style.animationSpeed
        imageView.loopMode = style.loopMode
        imageView.play()

        super.applyBaseStyle(style: style)

        configureImageSizeConstraints(size: imageView.animation?.size ?? .zero)
    }

    public func configure(appearance: Appearance) {
        configureAppearance(appearance: appearance)
    }

    private func configureImageSizeConstraints(size: CGSize) {
        guard size != .zero else {
            return
        }

        if size.height.isFinite, size.height > .zero {
            imageViewConstraints?.widthConstraint?.constant = size.height
        }

        if size.width.isFinite, size.width > .zero {
            imageViewConstraints?.widthConstraint?.constant = size.width
        }
    }
}

extension LottiePlaceholderView {
    public final class Appearance: BaseAppearance<UIView.DefaultWrappedAppearance>, ViewAppearance {
        public static var defaultAppearance: Self {
            .init()
        }
    }
}

class LottieAnimationViewController: BaseViewController<UIView, Void> {
    let placeholderFactory = PlaceholderFactory()

    static var lottieStyle: LottiePlaceholderStyle {
        .make {
            $0.animationName = "cat"
            $0.titleSubtitle = .init(title: "Long time no see, Nyan cat")
        }
        .updateAppearance {
            $0.imageViewAppearance.layout {
                $0.size = .fixedHeight(250)
            }

            $0.textViewAppearance {
                $0.titleAppearance {
                    $0.textAttributes = .init(font: .boldSystemFont(ofSize: 25), color: .systemPink, alignment: .center, isMultiline: false)
                }
            }
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        showPlaceholder()
    }

    func showPlaceholder() {
        let placeholder = LottiePlaceholderView()
        placeholder.apply(style: Self.lottieStyle)

        placeholder.frame = view.frame
        view.addSubview(placeholder)
    }
}

Others DefaultPlaceholderView