25 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: .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.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 = .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 есть несколько стандартных стилей, которые можно использовать для ускоренной разработки и изменять под свои нужды при необходимости:
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
}
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:)
Пример контроллера
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)
}
}
}



