LeadKit/docs/tiuielements/skeletons.md

16 KiB
Raw Permalink Blame History

Skeletons API

При импорте TIUIElements вы можете использовать API для показа скелетонов.

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

При использовании методов показа скелетонов:

  1. происходит скрытие всех subview в иерархии той view, на которой был вызван метод
  2. далее происходит проход по view, которые можно сконвертировать в скелетоны (список либо определяется пользователем, либо конвертация будет происходить автоматически), создается CALayer типа SkeletonLayer, представляющий конвертируемую view
  3. поверх view с которой начался показ, добавляются все созданные SkeletonLayer

Таким образом скелетоны не модифицируют размеры view и не изменяют ее положение

Как начать пользоваться

Базовая настройка для показа скелетонов не требуется. UIView и UIViewController уже имеют все необходимые методы для работы:

  • showSkeletons(viewsToSkeletons:_:) : используется для показа скелетонов, если была передана конфигурация анимации, то она запустится автоматически. viewsToSkeletons - опциональный массив UIView, определяющий, какие вью будут конвертироваться в скелетоны. Если nil, то проход будет осуществляться по списку subview
  • hideSkeletons() : используется для скрытия скелетонов
  • 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 без subviews
  • UILabel
  • UITextView
  • UIImageView

Для контейнеров в качестве 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)