16 KiB
Skeletons API
При импорте TIUIElements вы можете использовать API для показа скелетонов.
Принцип работы
При использовании методов показа скелетонов:
- происходит скрытие всех subview в иерархии той view, на которой был вызван метод
- далее происходит проход по view, которые можно сконвертировать в скелетоны (список либо определяется пользователем, либо конвертация будет происходить автоматически), создается
CALayerтипаSkeletonLayer, представляющий конвертируемую view - поверх view с которой начался показ, добавляются все созданные
SkeletonLayer
Таким образом скелетоны не модифицируют размеры view и не изменяют ее положение
Как начать пользоваться
Базовая настройка для показа скелетонов не требуется. UIView и UIViewController уже имеют все необходимые методы для работы:
showSkeletons(viewsToSkeletons:_:): используется для показа скелетонов, если была передана конфигурация анимации, то она запустится автоматически.viewsToSkeletons- опциональный массивUIView, определяющий, какие вью будут конвертироваться в скелетоны. Если nil, то проход будет осуществляться по списку subviewhideSkeletons(): используется для скрытия скелетоновstartSkeletonAnimation(): используется для старта анимации на скелетонах (если анимания не была сконфигурирована в методеshowSkeletons(viewsToSkeletons:_:)то ничего не произойдет)stopSkeletonAnimation(): используется для остановки анимации на скелетонах
import TIUIKitCore
import TIUIElements
import UIKit
final class SkeletonableButton: UIButton, Skeletonable {
var viewsToSkeletons: [UIView] {
[]
}
}
class CanShowAndHideSkeletons: BaseInitializableViewController {
private let imageView = UIImageView(image: UIImage(systemName: "apple.logo"))
private let label = UILabel()
private let button = SkeletonableButton(type: .custom)
override func addViews() {
super.addViews()
view.addSubviews(imageView, label, button)
}
override func configureLayout() {
super.configureLayout()
[imageView, label, button]
.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(equalToConstant: 40),
imageView.widthAnchor.constraint(equalToConstant: 40),
imageView.centerYAnchor.constraint(equalTo: label.centerYAnchor),
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
label.topAnchor.constraint(equalTo: view.topAnchor),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor),
label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor),
label.heightAnchor.constraint(equalToConstant: 60),
button.leadingAnchor.constraint(equalTo: view.leadingAnchor),
button.topAnchor.constraint(equalTo: label.bottomAnchor),
button.trailingAnchor.constraint(equalTo: view.trailingAnchor),
button.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
override func bindViews() {
super.bindViews()
button.addTarget(self, action: #selector(toggleSkeletons), for: .touchUpInside)
}
override func configureAppearance() {
super.configureAppearance()
label.text = "Hello from SkeletonableViewController"
button.setTitle("show skeletons", for: .normal)
let textAttributes = BaseTextAttributes(font: .systemFont(ofSize: 25), color: .black, alignment: .natural, isMultiline: false)
view.configureUIView(appearance: UIView.DefaultAppearance(background: UIViewColorBackground(color: .white)))
label.configureUILabel(appearance: UILabel.DefaultAppearance.make {
$0.textAttributes = textAttributes
})
button.configureUIButton(appearance: UIButton.DefaultAppearance.make {
$0.textAttributes = textAttributes
})
}
@objc private func toggleSkeletons() {
// Т.к. передается nil, скелетониться будут все subview (в данном случае view.subview == [button, label, imageView])
showSkeletons(viewsToSkeletons: nil, .init())
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in
self?.hideSkeletons()
}
}
}
Skeletonable
Если необходимо изменить список конвертируемых в скелетоны view у какой-нибудь из отдельных view в иерархии, можно подписать его под протокол Skeletonable
extension UITableViewCell: Skeletonable {
public var viewsToSkeletons: [UIView] {
contentView.subviews
}
}
SkeletonsPresenter
Чтобы не приходилось постоянно передавать в методы необходимые параметры для конфигурации можно соответствовать протоколу SkeletonsPresenter. Протокол дает возможность определять свойства для конфигурации скелетонов внутри view или viewController, вызывать метод showSkeletons() без передачи каких-либо параметров
Перепишем CanShowAndHideSkeletons под использование протокола
class CanShowAndHideWithSkeletonsPresenter: CanShowAndHideSkeletons, SkeletonsPresenter {
var skeletonsConfiguration: SkeletonsConfiguration {
SkeletonsConfiguration(skeletonsBackgroundColor: .gray)
}
}
let canShowAndHideController = CanShowAndHideWithSkeletonsPresenter()
Skeletons will be shown with custom configuration
canShowAndHideController.showSkeletons()
Конфигурация внешнего вида
Для конфигурации скелетонов существует класс SkeletonsConfiguration
class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter {
var skeletonsConfiguration: SkeletonsConfiguration {
.init(skeletonsBackgroundColor: .blue)
}
}
Возможные опции для настройки:
- анимация
- цвет
- форма
- отступы
При этом все view делятся на:
UIViewс subviews (контейнеры)UIViewбез subviewsUILabelUITextViewUIImageView
Для контейнеров в качестве
borderColorиспользуется тот же цвет, что и для других скелетонов
Анимация
SkeletonsConfiguration для настройки анимации принимает тип (SkeletonsLayer) -> CAAnimationGroup. Это означает, что при необходимости вы можете создать любую необходимую анимацию. Анимация будет применена к каждому скелетону.
Однако для удобства существует уже определенный класс SkeletonsAnimationBuilder со статическим методом createDirectionalGradientAnimation(_:) для создания анимаций в одну из сторон:
public enum SkeletonsAnimationDirection {
case leftToRight
case rightToLeft
case topToBottom
case bottomToTop
case topLeftToBottomRight
case topRightToBottomLeft
case bottomLeftToTopRight
case bottomRightToTopLeft
}
let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in
let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .leftToRight, duration: 1.5)
return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig)
})
let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in
let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .topToBottom, duration: 1.5)
return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig)
})
Цвет
За настройку цвета отвечает параметр skeletonsBackgroundColor: основной цвет скелетонов, им будт заливаться фон и выделяться border
let confWithRedBackgroundColor = SkeletonsConfiguration(skeletonsBackgroundColor: .red)
Для скрытия view от пользователя скелетонами накладывается специальный CALayer, чей цвет заполнения равен backgroundColor view, с которой были запущены скелетоны. Однако этот цвет можно переопределить переопределить с помощью параметра HolderViewSkeletonsConfiguration.backgroundColor
let confWithRedBaseBackgroundColor = SkeletonsConfiguration(holderViewConfiguration: .init(backgroundColor: .red))
Форма
Форму можно настраивать отдельно для UILabel, UITextView, UIImageView и остальных вью. Например, картинки можно сделать круглыми, а лейблы прямоугольные с закругленными краями:
var confWithShape: SkeletonsConfiguration {
let labelConf = TextSkeletonsConfiguration(shape: .rectangle(cornerRadius: 10))
let imageConf = BaseViewSkeletonsConfiguration(shape: .circle)
return .init(labelConfiguration: labelConf,
imageViewConfiguration: imageConf)
}
Для UILabel и UITextView есть возможность настроить высоту каждой строчки, расстояние между ними и их количество.
var confWithLabelSettings: SkeletonsConfiguration {
let labelConf = TextSkeletonsConfiguration(numberOfLines: 3,
lineHeight: 10,
lineSpacing: 5)
return .init(labelConfiguration: labelConf)
}
Для контейнеров можно настроить borderWidth. Стандартно он равняется 0, а значит контейнеры не будут показываться без дополнительной настройки ширины.
var skeletonsConfiguration: SkeletonsConfiguration {
let containerConf = ContainerViewSkeletonsConfiguration(borderWidth: 4,
shape: .rectangle(cornerRadius: 10))
return .init(containerViewConfiguration: containerConf)
}
Отступы
Отступы можно настроить отдельно для UILabel, UITextView, UIImageView и остальных вью. Например, для предыдущего примера можно добавить горизонтальный padding для лейбла:
var confWithPadding: SkeletonsConfiguration {
let labelConf = TextSkeletonsConfiguration(padding: .horizontal(left: 15), shape: .rectangle(cornerRadius: 10))
let imageConf = BaseViewSkeletonsConfiguration(shape: .circle)
return .init(labelConfiguration: labelConf,
imageViewConfiguration: imageConf)
}
Что если нужно больше?
Если стандартной настройки не хватает, то в конфигуратор можно передать объект, соответствующий протоколу SkeletonsConfigurationDelegate через который можно настроить слой скелетона для каждой вью отдельно
public protocol SkeletonsConfigurationDelegate: AnyObject {
func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType)
}
class SkeletonsConfDelegate: SkeletonsConfigurationDelegate {
func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) {
if case .imageView(_) = type {
layer.frame = .init(x: layer.frame.minX - 20,
y: layer.frame.minY - 20,
width: layer.frame.width,
height: layer.frame.height)
}
}
}
let delegate = SkeletonsConfDelegate()
let confWithDelegate = SkeletonsConfiguration(configurationDelegate: delegate)
Особенности
Т.к. размеры view на основе которой строятся скелетоны не модифицируются, может возникнуть ситуация, когда одни скелетоны перекрывают другие. Например, когда размер view меньше ее скелетонов. В таких случаях как раз может помочь установка позиции или размеров в методе делегата SkeletonsConfigurationDelegate
Также в качестве способа обойти такую ситуацию можно передавать во view моковые данные для увеличения ее размеров, чтобы размеры были хотя бы примерно похожи на размер скелетонов, как в примере:
extension DefaultTitleSubtitleView: SkeletonsPresenter {
public var skeletonsConfiguration: SkeletonsConfiguration {
.init(labelConfiguration: .init(numberOfLines: 3))
}
}
let titleSubtitleView = DefaultTitleSubtitleView()
titleSubtitleView.configure(appearance: .make {
$0.titleAppearance.update {
$0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true)
}
$0.subtitleAppearance.update {
$0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true)
}
})
titleSubtitleView.configure(with: .init(title: "very very long mock string to make multiple lines",
subtitle: "very very long mock string to make multiple lines"))
titleSubtitleView.showSkeletons()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) {
titleSubtitleView.configure(with: .init(title: "normal data from a request",
subtitle: "normal data from a request"))
titleSubtitleView.hideSkeletons()
}
Если на конкретной view необходимо отслеживать появление скелетонов, то можно законформить ее под протокол Skeletonable:
extension DefaultPlaceholderImageView: Skeletonable {
public func skeletonsChangedState(_ state: SkeletonsState) {
switch state {
case .shown:
wrappedView.isHidden = false
case .hidden:
wrappedView.isHidden = true
}
}
}
Тестовый сконфигурированный контроллер
canShowAndHideController.view.frame = .init(origin: .zero, size: .init(width: 250, height: 100))
canShowAndHideController.hideSkeletons()
confWithLeftToRightAnim.labelConfiguration = .init(numberOfLines: 2)
Nef.Playground.liveView(canShowAndHideController)
canShowAndHideController.showSkeletons(viewsToSkeletons: nil, confWithLeftToRightAnim)