Skip to content

Instantly share code, notes, and snippets.

@dmpv
Last active April 20, 2020 12:23
Show Gist options
  • Save dmpv/6145deff70dcaf026ef5381eb430854e to your computer and use it in GitHub Desktop.
Save dmpv/6145deff70dcaf026ef5381eb430854e to your computer and use it in GitHub Desktop.
UI Development

Styling Kit

Consists of Style (just a function) and UIView extension

public typealias Style<ViewT> = (ViewT) -> Void

public protocol Stylable {}

public extension Stylable {
    @discardableResult
    func apply(_ style: (Self) -> Void) -> Self {
        style(self)
        return self
    }
}

extension UIView: Stylable {}

public func + <ViewT>(_ style1: @escaping Style<ViewT>, style2: @escaping Style<ViewT>) -> Style<ViewT> {
    return {
        style1($0)
        style2($0)
    }
}

Basic usage

let label = UILabel()

label.apply {
    $0.textColor = .black
    $0.textAlignment = .left
}

Apply multiple styles

let headerLabelStyle: Style<UILabel> = { $0.font = UIFont.systemFont(ofSize: 24) }
let fancyLabelStyle: Style<UILabel> = { $0.textColor = .purple }

label
    .apply(headerLabelStyle)
    .apply(fancyLabelStyle)

Create and apply style cascade

let defaultLabelStyle: Style<UILabel> = { $0.font = UIFont.systemFont(ofSize: 12) }

let headerLabelStyle: Style<UILabel> =
    defaultLabelStyle
    + { $0.font = $0.font.withSize(24) }

label.apply(headerLabelStyle)

Real-world example

This example implements the view on the gif above

import Foundation
import UIKit
import SnapKit

public final class SimpleView: UIView {
    public var appearance = Appearance() {
        didSet { appearanceDidChange() }
    }
    
    private var titleLabel: UILabel!
    private var textLabel: UILabel!
    private var imageView: UIImageView!
    private var actionButton: UIButton!
    
    private var layout: Layout = .none {
        didSet { layoutDidChange(from: oldValue) }
    }
    
    private var pendingLayoutChange: (from: Layout, to: Layout)?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSubviews()
        setupBindings()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func setupSubviews() {
        titleLabel = UILabel()
        addSubview(titleLabel)
        
        textLabel = UILabel()
        addSubview(textLabel)
        
        imageView = UIImageView()
        addSubview(imageView)

        actionButton = UIButton()
        addSubview(actionButton)
        
        appearanceDidChange()
    }
    
    private func setupBindings() {
        stateDidChange(state: (title: "Title", description: "description", button: "action"))
    }
    
    public override func updateConstraints() {
        defer {
            pendingLayoutChange = nil
            super.updateConstraints()
        }
        guard let pendingLayoutChange = pendingLayoutChange else { return }
    
        switch pendingLayoutChange.to {
        case .none:
            return
        case .full(let offset, true):
            titleLabel.snp.remakeConstraints {
                $0.top.leading.trailing.equalTo(layoutMarginsGuide)
            }
            titleLabel.snp.contentHuggingVerticalPriority = 1000
            
            textLabel.snp.remakeConstraints {
                $0.top.equalTo(titleLabel.snp.bottom).offset(offset)
                $0.leading.trailing.equalTo(layoutMarginsGuide)
            }
            textLabel.snp.contentHuggingVerticalPriority = 1000
            
            imageView.snp.remakeConstraints {
                $0.top.equalTo(textLabel.snp.bottom).offset(offset)
                $0.centerX.equalTo(layoutMarginsGuide)
                $0.size.equalTo(100)
            }
            
            actionButton.snp.remakeConstraints {
                $0.top.greaterThanOrEqualTo(imageView.snp.bottom).offset(offset)
                $0.leading.bottom.trailing.equalTo(layoutMarginsGuide)
                $0.height.equalTo(40)
            }
        case .full(let offset, false):
            titleLabel.snp.remakeConstraints {
                $0.top.leading.trailing.equalTo(layoutMarginsGuide)
            }
            titleLabel.snp.contentHuggingVerticalPriority = 1000
            
            textLabel.snp.remakeConstraints {
                $0.top.equalTo(titleLabel.snp.bottom).offset(offset)
                $0.leading.trailing.equalTo(layoutMarginsGuide)
            }
            textLabel.snp.contentHuggingVerticalPriority = 1000
            
            imageView.snp.remakeConstraints {
                $0.top.equalTo(textLabel.snp.bottom).offset(offset)
                $0.centerX.equalTo(layoutMarginsGuide)
                $0.size.equalTo(0)
            }
            
            actionButton.snp.remakeConstraints {
                $0.top.greaterThanOrEqualTo(textLabel.snp.bottom).offset(offset)
                $0.leading.bottom.trailing.equalTo(layoutMarginsGuide)
                $0.height.equalTo(40)
            }
        case .compact(let offset):
            titleLabel.snp.remakeConstraints {
                $0.top.leading.equalTo(layoutMarginsGuide)
            }
            titleLabel.snp.contentHuggingVerticalPriority = 1000
            
            textLabel.snp.remakeConstraints {
                $0.top.equalTo(titleLabel.snp.bottom).offset(offset)
                $0.leading.equalTo(layoutMarginsGuide)
                $0.bottom.lessThanOrEqualTo(layoutMarginsGuide)
            }
            textLabel.snp.contentHuggingVerticalPriority = 1000
            
            imageView.snp.remakeConstraints {
                $0.top.equalTo(textLabel.snp.bottom).offset(offset)
                $0.leading.equalTo(textLabel.snp.leading)
                $0.size.equalTo(0)
            }
        
            actionButton.snp.remakeConstraints {
                $0.top.bottom.trailing.equalTo(layoutMarginsGuide)
                $0.leading.greaterThanOrEqualTo(titleLabel.snp.trailing)
                $0.leading.greaterThanOrEqualTo(textLabel.snp.trailing)
            }
        }
    }
    
    private func stateDidChange(state: (title: String, description: String, button: String)) {
        titleLabel.text = state.title
        textLabel.text = state.description
        actionButton.setTitle(state.button, for: .normal)
    }
    
    private func appearanceDidChange() {
        layout = appearance.layout
        applyStyle()
    }
    
    private func layoutDidChange(from: Layout) {
        guard from != layout else { return }
        pendingLayoutChange = (from: from, to: layout)
        setNeedsUpdateConstraints()
    }
    
    private func applyStyle() {
        apply(appearance.viewStyle)
        titleLabel.apply(appearance.titleLabelStyle)
        textLabel.apply(appearance.textLabelStyle)
        imageView.apply(appearance.imageViewStyle)
        actionButton.apply(appearance.actionButtonStyle)
    }
}

extension SimpleView {
    public enum Layout: Equatable {
        case none
        case full(offset: CGFloat = 20, showImage: Bool = false)
        case compact(offset: CGFloat = 10)
    }
    public struct Appearance {
        var layout: Layout = .full()
        var backgroundColor = theme.backgroundColor
        var labelTextColor = theme.textColor
        var labelTextAlignment: NSTextAlignment = .center
        var textLabelFontSize: CGFloat = 14
        var titleLabelFontSize: CGFloat = 24
        var buttonBackgroundColor = theme.accentColor
        
        public static var full: Self { Self() }
        public static var fullWithImage: Self {
            var appearance = full
            appearance.layout = .full(showImage: true)
            return appearance
        }
        public static var compact: Self {
            Self(
                layout: .compact(),
                labelTextAlignment: .left,
                textLabelFontSize: 10,
                titleLabelFontSize: 16
            )
        }
    }
}

extension SimpleView.Appearance {
    var viewStyle: Style<SimpleView> {
        return {
            $0.backgroundColor = self.backgroundColor
        }
        + CommonStyle.debugView(borderColor: labelTextColor)
    }
        
    private var labelStyle: Style<UILabel> {
        return {
            $0.textAlignment = self.labelTextAlignment
            $0.textColor = self.labelTextColor
        }
        + CommonStyle.debugView(borderColor: labelTextColor)
    }
    
    var titleLabelStyle: Style<UILabel> {
        labelStyle
        + {
            $0.font = UIFont.systemFont(ofSize: self.titleLabelFontSize)
        }
    }
    
    var textLabelStyle: Style<UILabel> {
        labelStyle
        + {
            $0.font = UIFont.systemFont(ofSize: self.textLabelFontSize)
        }
    }
    
    var imageViewStyle: Style<UIImageView> {
        CommonStyle.debugView(borderColor: labelTextColor, backgroundColor: .lightGray)
    }
    
    var actionButtonStyle: Style<UIButton> {
        CommonStyle.actionButton
        + {
            $0.backgroundColor = self.buttonBackgroundColor
            $0.layer.shadowColor = self.buttonBackgroundColor.cgColor
        }
        + CommonStyle.debugView(borderColor: labelTextColor)
    }
}
// Theme (SportCast-like)
public var theme = Theme()

public struct Theme {
    var accentColor: UIColor = .red
    var textColor: UIColor = .black
    var backgroundColor: UIColor = .white

    public static let bright = Self()
    public static let dark = Self(accentColor: .blue, textColor: .white, backgroundColor: .black)
}

// App Common Styles
public class CommonStyle: Namespace {
    public static let actionButton: Style<UIButton> = {
        $0.backgroundColor = .l
@tonyhex
Copy link

tonyhex commented Apr 20, 2020

Я тоже думал и пробовал накатить конкатенацию стилей, но мне по итогу не понравились два момента

  • Стили могу содержать одинаковые атрибуты и возможно перетирание. В этом нет ничего плохого, просто может случится конфуз
  • Если ты видишь что-то вида style1 + style2, то надо переключаться между ними и держать в голове какие параметры выставляются и где. Может получится сложно. Или на уровне code style вводить ограничения на это.

Эти два момента не являются критичными по мне. Предлагаю вынести на общее обсуждение. Если зайдет, я не против сложения стилей.

Наверное мне нравится создание вьюх не лениво в объявлении, а в спец месте. Но тогда я предлагаю сделать либо BaseView либо BaseViewProtocol для всех наших вьюх, что бы структура вызовов сборщиков представления была унифицирована.

Все, что касается layout уже "эээ сложнааа". Т.е., со второго раза, понятнее, но думаю сложно будет на уровень проекта это занести. Пример хороший, но таких вьюх, имеющих несколько представлений у нас мало. На вскидку только тикеты в чате и может что-то еще. Без учета вышесказанного, хотелось бы наверное, что бы констрейнты применялись как и стиль в отдельном блоке, что бы switch был коротким и емким. Но, мне кажется, ты тоже так хотел, просто лень писать уже было.

Вынесение применение стилей в отдельный экстеншн-неймспейс поддерживаю, сам так начал делать в следущей ветке, уже больно много места в основном классе это съедает.

Итого, лойс за доведение моего накида до такого состояния, предлагаю на общее выкинуть.

@dmpv
Copy link
Author

dmpv commented Apr 20, 2020

Стили

Для меня идеальная система стилей — БЭМ из CSS. Рекомендую глянуть — там просто и красиво. Давно хочу реализоввать подобное на iOS.

  1. Перетирание — мб путанно в том виде, в котором есть сейчас. В БЭМе нормально живут с этим, тк легко можно посмотреть, какие свойства какими стилями переопределены. Попробую сделать подобный механизм.
  2. Каскад (+) — надо попробовать. Этот оператор удобен в таких ситуациях:
// Styles for buttons of AuthView
extension AuthView.Appearance {
    var actionButton: Style<UIButton> {
        $0.backgroundColor = theme.accentColor
        $0.tintColor = .white
        $0.layer.cornerRadius = 8
        $0.layer.shadowOpacity = 0.5
        $0.layer.shadowRadius = 4
        $0.layer.shadowOffset = CGSize(width: 0, height: 2)
    }
    
    var signInButtonStyle: Style<UIButton> {
        actionButton
        + {
            $0.backgroundColor = self.signInButtonBackgroundColor
        }
    }
    
    var signUpButtonStyle: Style<UIButton> {
        actionButton
        + {
            $0.backgroundColor = self.signUpButtonBackgroundColor
        }
    }
}

Инициализация вьюх

Ты про нейминг и порядок вызовов методов (eg setupSubiviews, setupBindings, applyStyle)? Я считаю, что здесь достаточно соглашений. Мой опыт говорит, что описывать приватные методы в протоколе или в специальном бейзклассе — порочный путь

Лейаут

Ты прав, в примере с SimpleView нужно разнести наложение разных типов лейаута на несколько методов, а в updateConstrainsts оставить минимум логики. Это в след серии )

В примере показал, что вся работа с констреинтами происходит в updateConstraints.

Итог

Цель примера сSimpleView — предложить единообразный подход к созданию вьюх.
Наложение стилей, инициализация вьюх и работа с лейаутом не пересекаются и происходят в определенных местах.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment