17 KiB
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.AppearancewithButton(_:): метод для добавления новой кнопки. Принимает в себя фунцию с переменной типа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 есть несколько стандартных стилей, которые можно использовать для ускоренной разработки и изменять под свои нужды при необходимости:
errorStyleloadingDataErrorStyleemptyStateStyle
У PlaceholderFactory есть готовые методы для создания плейсхолдера с такими стилями
let errorStylePlaceholder = factory.createErrorPlaceholder()
let loadingDataErrorStylePlaceholder = factory.createLoadingDataErrorPlaceholder()
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)
}
}


