Swift Generics: cтили для UIView и не только +4



Вступление


Идея для публикации возникла после прочтения перевода CSS для Swift: использование стилей для любых подклассов UIView. Подход достаточно интересный, но он оказался не очень гибким, т.к. не позволяет объединять стили разных типов. Подробнее можно прочитать в комментарии.


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


Декорации


Введем понятие декорации, которое будет олицетворять придание неких свойств объекту:


typealias Decoration<T> = (T) -> Void

Декорация

Декорация — это обобщенное замыкание, которое можно применить к объекту соответствующего класса или к объекту, чей класс является подклассом используемого для создания декорации класса.


Пример использования декорации для придания свойств объекту
let decoration: Decoration<UIView> = { (view: UIView) -> Void in
    view.backgroundColor = UIColor.orange
    view.alpha = 0.5
    view.isOpaque = true
}
let view = UIView()     // класс
decoration(view)
let label = UILabel()   // подкласс
decoration(label)

Преимущества применения декораций над обычным приданием свойств объекту:


  • Можно одновременно придавать сразу несколько свойств объекту
  • Свойство описывается один раз и не требует изменений во всех местах применения декорации при рефакторинге (DRY)
  • Меньше кода и больше наглядности в местах применения декораций
  • Объединение декораций путем создания декорации, содержащей несколько других декораций
  • Стильно, модно, молодежно

Декоратор и методы экзмепляра


Чтобы применить декорацию следует передать экземпляр в декорирующее замыкание. Однако, более естественным процессом будет передача декораций в метод экземпляра.


Методы экземпляра

Методы экземпляра являются функциями, которые принадлежат экземплярам конкретного класса, структуры или перечисления. Они обеспечивают функциональность этих экземпляров, либо давая возможность доступа и изменения свойств экземпляра, либо обеспечивая функциональность экземпляра в соответствии с его целью. Метод экземпляра может быть вызван только для конкретного экземпляра типа, которому он принадлежит. Его нельзя вызвать в изоляции, без существующего экземпляра.


Для решения данной задачи можно использовать промежуточное звено — декоратор. Декоратор является обобщенной структурой, которая имеет указатель на экземпляр класса, к которому будут применяться декорации.


struct Decorator<T> {
    let object: T
}

С помощью обобщенного протокола для декорируемого экземпляра можно получить декоратор. Для целей публикации декоратор можно будет получить для экземпляра любого класса, наследуемого от UILabel.


protocol DecoratorCompatible {
    associatedtype DecoratorCompatibleType
    var decorator: Decorator<DecoratorCompatibleType> { get }
}

extension DecoratorCompatible {
    var decorator: Decorator<Self> {
        return Decorator(object: self)
    }
}

extension UILabel: DecoratorCompatible {}

Простые и обобщенные протоколы

Простой протокол строго задаёт все типы — параметры своих требований. Протокол сам определяет тип, подходящий для объявления параметра функции или переменной.


Обобщённый протокол — содержащий в своём определении подстановочное имя типа. Точный тип вычисляется только во время задания соответствия протоколу. Обобщённый протокол определяет некоторую концепцию, задавая ряд подстановочных имён для независимых типов и связывая их воедино с функциями и переменными — требованиями протокола.


Дополним структуру декоратора методом экземпляра, который будет принимать декорации. Стоит обратить внимание, что декорации будут применяться в той последовательности, в которой будут переданы декоратору. Это касается случаев, когда несколько декораций меняют одно и то же свойство объекта.


struct Decorator<T> {
    let object: T
    func apply(_ decorations: Decoration<T>...) -> Void {
        decorations.forEach({ $0(object) })
    }
}

Пример


Для целей публикации был создан репозиторий на github, который содержит пример использования. Также доступна установка через cocoapods: pod 'Decorator'.


Во-первых, следует создать набор нужных декораций любым удобным способом. Например, вот так:


struct Style {
    static var fontNormal: Decoration<UILabel> {
        return { (view: UILabel) -> Void in
            view.font = UIFont.systemFont(ofSize: 14.0)
        }
    }
    static var fontTitle: Decoration<UILabel> {
        return { (view: UILabel) -> Void in
            if #available(iOS 8.2, *) {
                view.font = UIFont.systemFont(ofSize: 17.0, weight: UIFontWeightBold)
            } else {
                view.font = UIFont.boldSystemFont(ofSize: 17.0)
            }
        }
    }
    static func corners(rounded: Bool) -> Decoration<UIView> {
        return { [rounded] (view: UIView) -> Void in
            switch rounded {
            case true:
                let mask = CAShapeLayer()
                let size = CGSize(width: 10, height: 10)
                let rect = view.bounds
                let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: size)
                mask.path = path.cgPath
                view.layer.mask = mask
            default:
                view.layer.mask = nil
            }
        }
    }
}

Стоит обратить внимание на тот факт, что декорации представлены двумя видами:


Decoration<UIView>
Decoration<UILabel>

Оба вида можно применять одновременно несмотря на то, что применяться они будут к объекту класса UILabel. Применение декораций через декоратора происходит следующим образом:


let labelNormal = UILabel()
labelNormal.decorator.apply(Style.fontNormal, Style.corners(rounded: false))
let labelTitle = UILabel()
labelNormal.decorator.apply(Style.fontTitle, Style.corners(rounded: true))

Заключение


Подход получился более гибким, чем в переводе статьи, т.к. удалось добиться применения разных стилей одновременно. Если есть идеи по улучшения подхода — комментарии приветствуются. Спасибо за внимание.



Комментарии (11):

  1. Dominion1
    /#10198788

    Имею похожее решение, но не такое изящное. Спасибо!

  2. dimsmol
    /#10199246 / +4

    В Кикстартере для стилей используют линзы, выглядит примерно так:


    public func cardStyle <V: UIViewProtocol> (cornerRadius radius: CGFloat = Styles.cornerRadius) -> ((V) -> V) {
    
      return roundedStyle(cornerRadius: radius)
        <> V.lens.layer.borderColor .~ UIColor.ksr_grey_500.cgColor
        <> V.lens.layer.borderWidth .~ 1.0
        <> V.lens.backgroundColor .~ .white
    }

    пример использования:


        _ = self.cardView
          |> cardStyle()
          |> dropShadowStyle()
          |> UIView.lens.layer.borderColor .~ UIColor.ksr_navy_500.cgColor

    (взято отсюда и отсюда)


    Выглядит достаточно элегантно и возможности композиции на очень хорошем уровне.
    Библиотека, позволяющая такие чудеса, называется Kickstarter-Prelude.


    Концепция линз взята из функционального программирования, широко используется в Хаскеле (см. lens package).


    На Свифте, из-за синтаксических особенностей, выглядит не так красиво, но достаточно неплохо.
    Также, Хаскель позволяет генерировать код для линз автоматически (используя Template Haskell), а тут приходится писать их вручную. Но, поскольку Kickstarter для нас уже постарался, можно использовать готовые.

  3. snakendead
    /#10199268

    Пожалуй, это будет в проекте сегодня

  4. werediver
    /#10199718

    Около полугода назад опубликовал на GitHub решение для стилизации UIView/NSView, (микро-) фреймворк StyleSheet — идея перекликается со статьёй.


    Стили ассоциируются с (пустыми) протоколами-маркерами и привязываются к подклассам UIView/NSView через protocol conformance:


    // Style-marker protocols
    protocol BodyFontStyle {}
    protocol MultilineLabelStyle {}
    
    func appStyle(palette p: PaletteProtocol) -> StyleProtocol {
        return StyleSheet(styles: [
            // Styles implementation for different base-classes.
    
            Style<BodyFontStyle, UILabel> { $0.font = p.font.body },
            Style<BodyFontStyle, UITextField> { $0.font = p.font.body },
            Style<BodyFontStyle, UITextView> { $0.font = p.font.body },
    
            Style<MultilineLabelStyle, UILabel> {
                $0.numberOfLines = 0
                $0.lineBreakMode = .byWordWrapping
            },
        ])
    }
    
    final class BodyLabel: UILabel, BodyFontStyle, MultilineLabelStyle {}
    
    // Perform on app initialization
    try! RootStyle.autoapply(style: appStyle(palette: DefaultPalette())) // Specify `mode: .appearance` to use `UIAppearance`-hitchhiking

    Таблицы стилей могут каскадироваться. Что такое "стиль" и "таблица стилей" описано в Style.swift.


    Автоматическое применение стилей реализуется одним из двух механизмов, на выбор: через swizzling или через UIAppearance-hitchhiking (см. RootStyle.swift). В обоих случаях применение стилей происходит так же, как при использовании UIAppearance (доступно для iOS и tvOS).


    Автоматическое применение использовать не обязательно, но удобно. Установка через Carthage и CocoaPods.


    Используется в production и приносит пользу :)

    • iWheelBuy
      /#10200348

      Вопрос вот какого плана: допустим есть какой-то подкласс с объявленными протоколами-стилями, то изменить стили для этого класса уже не получится?

      • werediver
        /#10200522

        Не вполне ясно, о чем вопрос. В примере выше есть BodyLabel, подкласс UIView с двумя стилями. Очевидно, вы можете изменить декларацию этого подкласса, если имеете доступ к коду. Вы также можете изменить реализацию стилей.


        Можно добавить дополнительные стили (protocol conformance) через extension.


        Если вопрос об изменении внешнего вида компонента во время выполнения, то эта задача решается несколько другими способами, независимо от используемого механизма стилизации.

        • iWheelBuy
          /#10200542

          Действительно, я плохо сформулировал вопрос. Вопрос был именно про изменение внешнего вида компонента во время выполнения. Какими механизмами?

          • werediver
            /#10200642 / +1

            Независимо от того, стилизуете ли вы компонент через Interface Builder, из кода непосредственно, через UIAppearance или с помощью какого-то фреймворка, компонент, который изменяет свой внешний вид, должен иметь параметры отображения для каждого своего состояния.


            К примеру, если у вас есть on/off button, то у неё должны быть свойства onColor и offColor. Вы можете задать им нужные значения любым доступным способом, а кнопка сама будет выбирать, цветом из какого свойства покраситься :)

            • iWheelBuy
              /#10204422

              Последую вашему хорошему замечанию про набор состояний. Возможно даже на вторую публикацию материала наберется!

  5. iWheelBuy
    /#10204418

    *removed*