LeadKit/docs/tiuielements/placeholder.md

25 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: .init(stateAppearances: [
                .normal: UIButton.DefaultStateAppearance(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 = .make {
        if let normalAppearance = $0.stateAppearances[.normal] {
            normalAppearance.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 = .make {
            if let normalAppearance = $0.stateAppearances[.normal] {
                normalAppearance.textAttributes = .init(font: .systemFont(ofSize: 20),
                                                        color: .black,
                                                        alignment: .natural,
                                                        isMultiline: false)
            }
        }
    }

    if index == 1 {
        buttonStyle.titles = [.normal: "Wait"]
        buttonStyle.appearance = .make {
            if let normalAppearance = $0.stateAppearances[.normal] {
                normalAppearance.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
}

let placeholder = PlaceholderHolderViewController()

Nef.Playground.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)
    }
}

Плейсхолдеры для UIImageView

Вместе с полноразмерными заглушками была добавлена новая UIImageView, способная отображать картинку-плейсхолдер пока не присвоена image

let placeholderImage = UIImage(named: "placeholder-image")

Теперь при использовании данного imageView будет отображаться картинка, созданная выше

let placeholderImageView = DefaultPlaceholderImageView(placeholderImage: placeholderImage)

Здесь все еще отображается placeholderImage

placeholderImageView.image = nil

Здесь placeholderImage спрячится

placeholderImageView.image = UIImage(named: "image")

При обнулении картинки placeholderImage покажется заново

placeholderImageView.image = nil

При этом необязательно создавать картинку плейсхолдера отдельно. Создайте картинку с именем global_image_placeholder_icon в Assets каталоге и она сама подгрузится в DefaultPlaceholderImageView при использовании инициализатора init(image:placeholderImage:)

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

image

import TITableKitUtils
import TableKit

class WorkingCatView: BaseInitializableView, ConfigurableView, AppearanceConfigurable {

    typealias ViewModel = (image: UIImage?, title: String?, subtitle: String?)

    private let catImageView = DefaultPlaceholderImageView()
    private let catLabel = DefaultTitleSubtitleView()

    override func addViews() {
        super.addViews()

        addSubviews(catImageView, catLabel)
    }

    override func configureLayout() {
        super.configureLayout()

        [catImageView, catLabel]
            .forEach { $0.translatesAutoresizingMaskIntoConstraints = false }

        NSLayoutConstraint.activate([
            catImageView.topAnchor.constraint(equalTo: topAnchor, constant: 20),
            catImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
            catImageView.heightAnchor.constraint(equalToConstant: 90),
            catImageView.widthAnchor.constraint(equalToConstant: 90),

            catLabel.topAnchor.constraint(equalTo: catImageView.bottomAnchor, constant: 20),
            catLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
            catLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            catLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant:  -20),
        ])
    }

    override func configureAppearance() {
        super.configureAppearance()

        catImageView.contentMode = .scaleAspectFit
    }

    func configure(with viewModel: ViewModel) {
        catImageView.image = viewModel.image
        catLabel.configure(with: .init(title: viewModel.title, subtitle: viewModel.subtitle))
        catImageView.placeholderImage = UIImage.gifImageWithName("cat-loader")
    }

    func configure(appearance: Appearance) {
        catImageView.configure(appearance: appearance.catImageAppearance)
        catLabel.configure(appearance: appearance.catLabelAppearance)
        configureUIView(appearance: appearance)
    }
}

extension WorkingCatView {
    final class Appearance: UIView.BaseWrappedAppearance<UIView.DefaultWrappedLayout>, WrappedViewAppearance {
        static var defaultAppearance: Self {
            .init()
        }

        var catImageAppearance: DefaultPlaceholderImageView.Appearance
        var catLabelAppearance: DefaultTitleSubtitleView.Appearance

        public init(layout: UIView.DefaultWrappedLayout = .defaultLayout,
                    backgroundColor: UIColor = .clear,
                    border: UIViewBorder = .init(),
                    shadow: UIViewShadow? = nil,
                    catImageAppearance: DefaultPlaceholderImageView.Appearance = .defaultAppearance,
                    catLabelAppearance: DefaultTitleSubtitleView.Appearance = .defaultAppearance) {

            self.catImageAppearance = catImageAppearance
            self.catLabelAppearance = catLabelAppearance

            super.init(layout: layout,
                       backgroundColor: backgroundColor,
                       border: border,
                       shadow: shadow)
        }
    }
}

class CatsViewController: BaseViewController<TableKitTableView, Void> {

    typealias ImageRow = WorkingCatView.InTableRow

    let viewModels: [WorkingCatView.ViewModel] = [
        (image: UIImage(named: "cat-worker"), title: "Pusic", subtitle: "C++ dev"),
        (image: nil, title: "Luke", subtitle: "Jedi"), // image can't be loaded
        (image: .petDog, title: "Marzia", subtitle: "HR"),
        (image: .petFox, title: "Fox", subtitle: "iOS Dev")
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        // I swear it's a network request, images are loading ;)
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in

            let rows = (self?.viewModels ?? []).compactMap {
                self?.createRow($0)
            }
            self?.tableDirector.replace(withRows: rows)
        }
    }

    override func bindViews() {
        super.bindViews()

        tableDirector += viewModels.map {
            createRow((nil, $0.title, $0.subtitle))
        }
    }

    private func createRow(_ viewModel: WorkingCatView.ViewModel) -> ImageRow {
        ImageRow(item: viewModel)
            .with(appearance: Self.rowAppearance)
    }
}

extension CatsViewController {
    static var rowAppearance: ImageRow.Appearance {
        .make { row in
            row.subviewAppearance { container in
                container.layout.insets = .edges(16)
                container.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.3)
                container.border = .init(color: .black, width: 1, cornerRadius: 10, roundedCorners: .allCorners)

                container.catImageAppearance = Self.imageAppearance
                container.catLabelAppearance = Self.textAppearance
            }
        }
    }

    static var imageAppearance: DefaultPlaceholderImageView.Appearance {
        .make {
            $0.border = .init(cornerRadius: 12, roundedCorners: .allCorners)
            $0.subviewAppearance.update {
                $0.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.5)
            }
        }
    }

    static var textAppearance: DefaultTitleSubtitleView.Appearance {
        .make {
            $0.titleAppearance.textAttributes = .init(font: .systemFont(ofSize: 25), color: .black, alignment: .center, isMultiline: false)
            $0.subtitleAppearance.textAttributes = .init(font: .italicSystemFont(ofSize: 18), color: .gray, alignment: .center, isMultiline: false)
        }
    }
}