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
@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