[번역] Point-Free #3 함수로 UIKit 스타일링하기

본문 링크

오늘은 지난 에피소드에서 얘기했던 도구들을 UIKit 스타일링에 사용해보겠습니다. UI 컴포넌트의 스타일링에 함수를 사용하면 합성 가능성, 재사용성이라는 새로운 세계가 열립니다.

시작하며

지난 두 에피소드에서 우리는 일상에서 볼 수 없고 상관없어 보이는 방식으로 합성에 대해 알아보았습니다. 그리고 이것은 꽤 추상적이기까지 했습니다. 이제는 좀 더 확실한 대상을 찾아서 합성을 적용해보겠습니다. 바로 UIKit이 오늘의 주인공입니다.

import UIKit

final class SignInViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    self.view.backgroundColor = .white

    let gradientView = GradientView()
    gradientView.fromColor = UIColor(red: 0.5, green: 0.85, blue: 1, alpha: 0.85)
    gradientView.toColor = .white
    gradientView.translatesAutoresizingMaskIntoConstraints = false

    let logoImageView = UIImageView(image: UIImage(named: "logo"))
    logoImageView.widthAnchor.constraint(equalTo: logoImageView.heightAnchor, multiplier: logoImageView.frame.width / logoImageView.frame.height).isActive = true

    let gitHubButton = UIButton(type: .system)
    gitHubButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    gitHubButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
    gitHubButton.clipsToBounds = true
    gitHubButton.layer.cornerRadius = 6
    gitHubButton.backgroundColor = .black
    gitHubButton.tintColor = .white
    gitHubButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16)
    gitHubButton.setImage(UIImage(named: "github"), for: .normal)
    gitHubButton.setTitle("Sign in with GitHub", for: .normal)

    let orLabel = UILabel()
    orLabel.font = .systemFont(ofSize: 14, weight: .medium)
    orLabel.textAlignment = .center
    orLabel.textColor = UIColor(white: 0.625, alpha: 1)
    orLabel.text = "or"

    let emailField = UITextField()
    emailField.clipsToBounds = true
    emailField.layer.cornerRadius = 6
    emailField.layer.borderColor = UIColor(white: 0.75, alpha: 1).cgColor
    emailField.layer.borderWidth = 1
    emailField.borderStyle = .roundedRect
    emailField.heightAnchor.constraint(equalToConstant: 44).isActive = true
    emailField.keyboardType = .emailAddress
    emailField.placeholder = "blob@pointfree.co"

    let passwordField = UITextField()
    passwordField.clipsToBounds = true
    passwordField.layer.cornerRadius = 6
    passwordField.layer.borderColor = UIColor(white: 0.75, alpha: 1).cgColor
    passwordField.layer.borderWidth = 1
    passwordField.borderStyle = .roundedRect
    passwordField.heightAnchor.constraint(equalToConstant: 44).isActive = true
    passwordField.isSecureTextEntry = true
    passwordField.placeholder = "••••••••••••••••"

    let signInButton = UIButton(type: .system)
    signInButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    signInButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
    signInButton.clipsToBounds = true
    signInButton.layer.cornerRadius = 6
    signInButton.layer.borderColor = UIColor.black.cgColor
    signInButton.layer.borderWidth = 2
    signInButton.setTitleColor(.black, for: .normal)
    signInButton.setTitle("Sign in", for: .normal)

    let forgotPasswordButton = UIButton(type: .system)
    forgotPasswordButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    forgotPasswordButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
    forgotPasswordButton.setTitleColor(.black, for: .normal)
    forgotPasswordButton.setTitle("I forgot my password", for: .normal)

    let legalLabel = UILabel()
    legalLabel.font = .systemFont(ofSize: 11, weight: .light)
    legalLabel.numberOfLines = 0
    legalLabel.textAlignment = .center
    legalLabel.textColor = UIColor(white: 0.5, alpha: 1)
    legalLabel.text = "By signing into Point-Free you agree to our latest terms of use and privacy policy."

    let rootStackView = UIStackView(arrangedSubviews: [
      logoImageView,
      gitHubButton,
      orLabel,
      emailField,
      passwordField,
      signInButton,
      forgotPasswordButton,
      legalLabel,
      ])

    rootStackView.axis = .vertical
    rootStackView.isLayoutMarginsRelativeArrangement = true
    rootStackView.layoutMargins = UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16)
    rootStackView.spacing = 16
    rootStackView.translatesAutoresizingMaskIntoConstraints = false

    self.view.addSubview(gradientView)
    self.view.addSubview(rootStackView)

    NSLayoutConstraint.activate([
      gradientView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
      gradientView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
      gradientView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
      gradientView.bottomAnchor.constraint(equalTo: self.view.centerYAnchor),

      rootStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
      rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
      rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
      ])
  }
}

UI를 그릴 때 가장 많이 만나는 문제는 바로 스타일의 재사용일 것입니다. 위 코드엔 아직 스타일을 재사용하려는 시도 조차 보이지 않습니다. 단지 코드를 순서대로 실행할 뿐입니다. 이러한 컴포넌트는 대부분 겹치는 스타일이 존재합니다. 그러니 중복을 최대한 줄인 더 나은 컴포넌트 스타일링 방법을 찾아봅시다.

UIAppearance

애플이 제공하는 API를 통해 재사용 가능한 스타일을 만들 수 있는 UIAppearance부터 시작하겠습니다. UIAppearance는 약간의 static 메소드를 가진 프로토콜입니다. 주로 appearance로 쓰입니다. 이 메소드는 보통의 뷰처럼 설정할 수 있는 프록시 뷰를 반환합니다. 이 프록시 뷰가 설정되면 뷰 계층에 추가되는 같은 종류의 뷰는 모두 설정이 적용됩니다.

우리가 만든 버튼은 모두 같은 content edge inset과 font를 가지고 있기 때문에 이 설정을 UIAppearance에 적용해보겠습니다.

UIButton.appearance().contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
UIButton.appearance().titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)

코드에서 겹치는 부분을 주석처리하면 다음과 같을 것입니다.

let gitHubButton = UIButton(type: .system)
// gitHubButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
// gitHubButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)

// …

let loginButton = UIButton(type: .system)
// loginButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
// loginButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)

// …

차이점을 발견하셨나요? 저희가 사용하는 버튼 타입은 버튼 타이틀을 훌륭하게 스타일링해주는 .system입니다. 자세히 살펴보면 프록시 객체를 통해 두 개의 레이어에 접근해서 타이틀 레이블의 폰트를 설정하려고 합니다. UIAppearance의 변화는 뷰 속성에 직접적으로만 작동하기 때문에 제대로 적용되지 않습니다.

UIAppearance의 또 다른 이슈는 클래스 단계에 적용된다는 점입니다. 우리는 이미 UIButton에 대한 재사용 설정의 한계를 알게 되었으니, 더 다양한 재사용 버튼의 종류를 위해 서브클래싱으로 넘어가도록 하겠습니다.

서브클래싱

서브클래싱은 앱에서 재사용할 수 있는 스타일을 관리하는 방법 중 가장 인기있는 방법입니다. 기본이 되는 버튼 클래스를 생성하고 스타일링 코드가 들어간 이니셜라이저도 작성해보겠습니다.

class BaseButton: UIButton {
  override init(frame: CGRect) {
    super.init(frame: frame)
    self.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    self.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

컴파일러가 강제하는 이니셜라이저까지 작성하고나면 완성입니다.

이제 버튼을 생성할 때 BaseButton으로 생성할 수 있게 되었네요.

let gitHubButton = BaseButton(type: .system)
// …

let loginButton = BaseButton(type: .system)
// …

하지만 버튼은 여전히 이상해 보이네요. 버튼 서브클래스는 .system 타입과 제대로 상호작용을 못 하는 것 같으니 버튼 타입을 지금 당장은 쓰지 않도록 하겠습니다.

let gitHubButton = BaseButton()
// …

let loginButton = BaseButton()
// …

드디어 폰트가 원하는 대로 잘 나오네요.

이제 BaseButton 클래스를 직접 사용하고 있지만, 보통 Base라는 접미사가 붙으면 실제로 사용하기 전의 기본이 되는 추상적인 기능을 나타내는 것으로 이해합니다.

그러면 직접적인 버튼 스타일의 이름은 어떤 것이 돼야할까요? 앞에서 작성한 코드에선 “filled”, “border”, “text” 등의 버튼 스타일이 있네요. 먼저 FilledButton 서브클래스를 정의해보겠습니다.

class FilledButton: BaseButton {
  override init(frame: CGRect) {
    super.init(frame: frame)
    self.backgroundColor = .black
    self.tintColor = .white
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

여기도 역시 init(coder:) 이니셜라이저를 제공해야했지만 일단 새로운 버튼 클래스가 생성되었습니다.

앞에서 했던 것처럼 겹치는 부분은 주석처리하겠습니다.

let gitHubButton = FilledButton()
// gitHubButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
// gitHubButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
gitHubButton.clipsToBounds = true
gitHubButton.layer.cornerRadius = 6
// gitHubButton.backgroundColor = .black
// gitHubButton.tintColor = .white

남은 코드인 버튼의 모서리가 동그란 버튼을 위해 새로운 버튼 클래스를 작성해봅시다.

class RoundedButton: BaseButton {
  override init(frame: CGRect) {
    super.init(frame: frame)
    self.clipsToBounds = true
    self.layer.cornerRadius = 6
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

그러면 이 클래스의 순서는 어떻게 될까요? FilledButtonRoundedButton은 둘 다 BaseButton에서 나왔습니다. 지금 당장은 FilledButtonRoundedButton에서 나오는 것이 맞아보이니 그렇게 바꿔보겠습니다.

class FilledButton: RoundedButton {
  // …
}

앞에서 버튼 클래스를 만든 것처럼 BorderButtonTextButton도 작성할 수 있을 것입니다. 적합한 코드를 작성하는 것은 쉽지만 시간이 지나면서 로직이 비슷한 서브 클래스의 경우 서로를 이어주던 고리가 끊어지는 것을 보실 수 있습니다.

이게 바로 다이아몬드 상속 문제입니다. 주어진 기본 클래스에서 두 개의 서브 클래스가 생겼는데 우리는 이 둘 다의 서브클래스를 하나 만들고 싶을 때 문제가 생깁니다. Swift는 다이아몬드 상속을 지원하지 않기 때문에 로직을 공유할 수 있는 다른 대체 방안을 찾아보도록 하겠습니다.

객체 합성

오랜 속담이 하나 있습니다. “상속보다 합성을 선호해라” 여기서 “합성”은 무슨 의미일까요?

서브클래스를 사용하는 것 대신 기본 스타일의 버튼을 만드는 static 속성을 정의해봅시다.

extension UIButton {
  static var base: UIButton {
    let button = UIButton()
    button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
    return button
  }
}

채워진 스타일의 버튼을 만드는 헬퍼도 만들 수 있습니다.

extension UIButton {
  static var filled: UIButton {
    let button = self.base
    button.backgroundColor = .black
    button.tintColor = .white
    return button
  }
}

코드를 보면 filled 스타일을 설정하기 전에 self.base를 호출하는 것을 보셨을 것입니다. 여기서 “합성”이 나옵니다.

모서리가 둥근 버튼의 스타일도 만들 수 있겠죠?

extension UIButton {
  static var rounded: UIButton {
    let button = self.filled
    button.clipsToBounds = true
    button.layer.cornerRadius = 6
    return button
  }
}

이제 우리의 GitHub 버튼은 서브클래싱 대신에 앞에서 선언한 static property를 써서 만들 수 있게 되었습니다.

let gitHubButton = UIButton.filled

헐 근데 버튼이 꽉 채워졌지만 모서리가 둥그렇지 않네요. 왜그럴까요? 연산자의 순서가 잘 못 된 것 같습니다. 이전보다 나아진 것 같지만 지금의 방식도 이전의 상속과 동일한 순서 문제를 가지고 있네요. UIButton에 스타일을 직접 정의하는 것은 절대적으로 더 간결하고 쓸모없는 init(coder:) 같은 이니셜라이저도 정의할 필요가 없습니다. 그럼 지금의 스타일 합성 방식에서 혼란을 걷어낼 수 있는 방법을 찾아봅시다.

함수

우리의 오랜 친구인 함수로 돌아가보겠습니다. 버튼에 기본 스타일을 적용해주는 baseButtonStyle이란 함수를 선언합니다.

func baseButtonStyle(_ button: UIButton) {
  button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
  button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
}

간단명료해서 좋네요.

filledButtonStyle는 어떻게 생겼을까요?

func filledButtonStyle(_ button: UIButton)  {
  button.backgroundColor = .black
  button.tintColor = .white
}

드디어 채워진 스타일이 기본 스타일을 상속하거나 덮어쓸 필요가 없어졌습니다.

roundedButtonStyle도 만들어볼까요?

func roundedButtonStyle(_ button: UIButton)  {
  button.clipsToBounds = true
  button.layer.cornerRadius = 6
}

드디어! 상속 구조나 순서를 걱정할 필요 없는 세 개의 스타일링 함수가 완성됐습니다! 각 함수의 하나의 일만 하고, 매우 잘 합니다. 사용해볼까요?

let gitHubButton = UIButton(type: .system)
baseButtonStyle(gitHubButton)
roundedButtonStyle(gitHubButton)
filledButtonStyle(gitHubButton)

한 눈에 보기엔 좀 복잡해보이네요. 각 단계별로 빼먹기도 쉬울 것 같습니다. 그리고 모든 버튼이 기본적으로 필요할 것 같은 baseBUttonStyle을 직접 적용하고 있네요. 이 스타일들을 합성해서 사용하면 좀 더 편하지 않을까요?

함수 합성

이전 에피소드인 사이드 이펙트에서 단일 타입에서 쓸 수 있는 <> 연산자를 소개해드렸습니다. 이 연산자는 두 가지로 정의할 수 있었는데, 하나는 A를 입력으로 받아서 A를 출력으로 반환하는 것이고, 다른 하나는 inout A를 받아서 Void를 반환하는 것이었습니다.

func <> <A>(f: @escaping (A) -> A, g: @escaping (A) -> A) -> (A) -> A {
  return f >>> g
}
func <> <A>(f: @escaping (inout A) -> Void, g: @escaping (inout A) -> Void) -> (inout A) -> Void {
  return { a in
    f(&a)
    g(&a)
  }
}

우리의 스타일링 함수들은 참조 타입의 변경이라는 세상에서 일어나는 일이니까 (inout A) -> Void 모양을 사용할 수 있는지 봅시다.

func <> <A: AnyObject>(f: @escaping (A) -> Void, g: @escaping (A) -> Void) -> (A) -> Void {
  return { a in
    f(a)
    g(a)
  }
}

제네릭 타입인 A를 모든 참조 타입이 될 수 있는 AnyObject으로 변경합니다. 그리고 inout 어노테이션은 빼는 것이 좋겠네요.

드디어 filledButtonStylebaseButtonStyle을 합성할 수 있게 되었습니다.

let filledButtonStyle =
  baseButtonStyle
    <> {
      $0.backgroundColor = .black
      $0.tintColor = .white
}

roundedButtonStyle도 빼놓을 수 없겠죠.

let roundedButtonStyle =
  baseButtonStyle
    <> {
      $0.clipsToBounds = true
      $0.layer.cornerRadius = 6
}

이제 GitHub 버튼에 더 이상 baseButtonStyle을 적용할 필요가 없어졌습니다. 마지막으로 filledButtonStyleroundedButtonStyle의 우선순위를 정해봅시다.

let filledButtonStyle =
  roundedButtonStyle
    <> {
      $0.backgroundColor = .black
      $0.tintColor = .white
}

이러한 종류의 합성은 훨씬 유연합니다. 섞거나 합칠수도 있고 원하는 스타일만 추출해서 사용할 수 있으며 각 함수는 자신이 맡은 기능만 하기 때문에 다른 스타일링 함수를 호출할 필요가 없습니다. 우리가 스타일을 합성하는 것도 함수 합성을 통해서 가능한 것이죠.

또한, 클래스와 static 속성의 세상에서는 불가능했던 스타일의 재사용이 가능합니다! roundedButtonStyle은 버튼뿐만 아니라 모든 UIView에 대해서 계속해서 사용이 가능합니다. 실제로 UITextField도 같은 코드를 공유하니 이 함수로 대체할 수 있습니다.

그러면 모든 UIView를 대상으로 하는 roundedStyle을 작성해보겠습니다.

let roundedStyle: (UIView) -> Void = {
  $0.clipsToBounds = true
  $0.layer.cornerRadius = 6
}

그리고 roundedButtonStyle 함수는 이 재사용 가능한 함수를 합성해서 사용하면 됩니다.

let roundedButtonStyle =
  baseButtonStyle
    <> roundedStyle

또한 roundedStyle을 사용해서 텍스트 필드의 기본 스타일인 baseTextFieldStyle도 만들 수 있습니다.

let baseTextFieldStyle: (UITextField) -> Void =
  roundedStyle
    <> {
      $0.layer.borderColor = UIColor(white: 0.75, alpha: 1).cgColor
      $0.layer.borderWidth = 1
      $0.borderStyle = .roundedRect
      $0.heightAnchor.constraint(equalToConstant: 44).isActive = true
}

이제 텍스트 필드의 수 많은 코드를 이 함수로 대체할 수 있게 되었네요.

let emailTextField = UITextField()
baseTextFieldStyle(emailTextField)
// …

let passwordTextField = UITextField()
baseTextFieldStyle(passwordTextField)
// …

더 줄일 수 있을 것 같습니다. baseTextFieldStyle를 보시면 테두리를 가진 버튼과 동일한 방식으로 테두리를 설정하는 것을 확인할 수 있습니다. 그러면 borderStyle을 선언해서 재사용할 수 있겠죠?

스타일링 함수를 아주 기본적인 방식으로 작성하면 다음과 같은 문제에 직면하게 됩니다.

let borderStyle: (UIView) -> Void = {
  $0.layer.borderColor = // ???
  $0.layer.borderWidth = // ???
}

이러한 설정은 매번 다를 수 있습니다. 테두리 버튼은 두껍고 까만 테두리를 가지고 있는 반면, 텍스트 필드는 얇고 회색인 테두리를 가지고 있습니다. 이 설정을 함수로 가져와보겠습니다.

func borderStyle(color: UIColor, width: CGFloat) -> (UIView) -> Void {
  return { view in
    view.layer.borderColor = color.cgColor
    view.layer.borderWidth = width
  }
}

이제 baseTextFieldStyle을 작성해보겠습니다.

let baseTextFieldStyle: (UITextField) -> Void =
  roundedStyle
    <> borderStyle(color: UIColor(white: 0.75, alpha: 1), width: 1)
    <> {
      $0.borderStyle = .roundedRect
      $0.heightAnchor.constraint(equalToConstant: 44).isActive = true
}

약간의 문제가 생겼네요.

Value of type 'UIView' has no member 'borderStyle'

지금 합성중인 함수들은 모두 UIView를 대상으로 하고 있는데 마지막 부분에서 UITextField를 기대하고 코드를 작성했기 때문에 타입 시스템에 문제가 생긴 것입니다. 이러한 종류의 에러는 드물지만 고치기 쉬운 편입니다. 타입을 명시적으로 지정해주면 됩니다.

let baseTextFieldStyle: (UITextField) -> Void =
  roundedStyle
    <> borderStyle(color: UIColor(white: 0.75, alpha: 1), width: 1)
    <> { (tf: UITextField) in
      tf.borderStyle = .roundedRect
      tf.heightAnchor.constraint(equalToConstant: 44).isActive = true
}

아주 훌륭하게 작동합니다.

이제 borderButtonStyle을 쉽게 만들 수 있는 borderStyle이 생겼으니 적용해봅시다!

let borderButtonStyle =
  roundedStyle
    <> borderStyle(color: .black, width: 2)

그럼 이제 모든 코드에 앞에서 작성한 것처럼 스타일링 함수를 적용해볼까요?

// base

func autolayoutStyle<V: UIView>(_ view: V) -> Void {
  view.translatesAutoresizingMaskIntoConstraints = false
}

func aspectRatioStyle<V: UIView>(size: CGSize) -> (V) -> Void {
  return {
    $0.widthAnchor
      .constraint(equalTo: $0.heightAnchor, multiplier: size.width / size.height)
      .isActive = true
  }
}

func implicitAspectRatioStyle<V: UIView>(_ view: V) -> Void {
  aspectRatioStyle(size: view.frame.size)(view)
}

func roundedRectStyle<View: UIView>(_ view: View) {
  view.clipsToBounds = true
  view.layer.cornerRadius = 6
}

// buttons

let baseButtonStyle: (UIButton) -> Void = {
  $0.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
  $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
}

let roundedButtonStyle =
  baseButtonStyle
    <> roundedRectStyle

let filledButtonStyle =
  roundedButtonStyle
    <> {
      $0.backgroundColor = .black
      $0.tintColor = .white
}

let borderButtonStyle =
  roundedButtonStyle
    <> {
      $0.layer.borderColor = UIColor.black.cgColor
      $0.layer.borderWidth = 2
      $0.setTitleColor(.black, for: .normal)
}

let textButtonStyle =
  baseButtonStyle <> {
    $0.setTitleColor(.black, for: .normal)
}

let imageButtonStyle: (UIImage?) -> (UIButton) -> Void = { image in
  return {
    $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16)
    $0.setImage(image, for: .normal)
  }
}

let gitHubButtonStyle =
  filledButtonStyle
    <> imageButtonStyle(UIImage(named: "github"))

// text fields

let baseTextFieldStyle: (UITextField) -> Void =
  roundedRectStyle
    <> {
      $0.borderStyle = .roundedRect
      $0.heightAnchor.constraint(equalToConstant: 44).isActive = true
      $0.layer.borderColor = UIColor(white: 0.75, alpha: 1).cgColor
      $0.layer.borderWidth = 1
}

let emailTextFieldStyle =
  baseTextFieldStyle
    <> {
      $0.keyboardType = .emailAddress
      $0.placeholder = "blob@pointfree.co"
}

let passwordTextFieldStyle =
  baseTextFieldStyle
    <> {
      $0.isSecureTextEntry = true
      $0.placeholder = "••••••••••••••••"
}

// labels

func fontStyle(ofSize size: CGFloat, weight: UIFont.Weight) -> (UILabel) -> Void {
  return {
    $0.font = .systemFont(ofSize: size, weight: weight)
  }
}

func textColorStyle(_ color: UIColor) -> (UILabel) -> Void {
  return {
    $0.textColor = color
  }
}

let centerStyle: (UILabel) -> Void = {
  $0.textAlignment = .center
}

// hyper-local

let orLabelStyle: (UILabel) -> Void =
  centerStyle
    <> fontStyle(ofSize: 14, weight: .medium)
    <> textColorStyle(UIColor(white: 0.625, alpha: 1))

let finePrintStyle: (UILabel) -> Void =
  centerStyle
    <> fontStyle(ofSize: 14, weight: .medium)
    <> textColorStyle(UIColor(white: 0.5, alpha: 1))
    <> {
      $0.font = .systemFont(ofSize: 11, weight: .light)
      $0.numberOfLines = 0
}

let gradientStyle: (GradientView) -> Void =
  autolayoutStyle <> {
    $0.fromColor = UIColor(red: 0.5, green: 0.85, blue: 1, alpha: 0.85)
    $0.toColor = .white
}

// stack views

let rootStackViewStyle: (UIStackView) -> Void =
  autolayoutStyle
    <> {
      $0.axis = .vertical
      $0.isLayoutMarginsRelativeArrangement = true
      $0.layoutMargins = UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16)
      $0.spacing = 16
}

final class SignInViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    self.view.backgroundColor = .white

    let gradientView = GradientView()
    gradientStyle(gradientView)

    let logoImageView = UIImageView(image: UIImage(named: "logo"))
    implicitAspectRatioStyle(logoImageView)

    baseButtonStyle(.appearance())

    let gitHubButton = UIButton(type: .system)
    gitHubButton.setTitle("Sign in with GitHub", for: .normal)
    gitHubButtonStyle(gitHubButton)

    let orLabel = UILabel()
    orLabelStyle(orLabel)
    orLabel.text = "or"

    let emailField = UITextField()
    emailTextFieldStyle(emailField)

    let passwordField = UITextField()
    passwordTextFieldStyle(passwordField)

    let signInButton = UIButton(type: .system)
    signInButton.setTitle("Sign in", for: .normal)
    borderButtonStyle(signInButton)

    let forgotPasswordButton = UIButton(type: .system)
    forgotPasswordButton.setTitle("I forgot my password", for: .normal)
    textButtonStyle(forgotPasswordButton)

    let legalLabel = UILabel()
    legalLabel.text = "By signing into Point-Free you agree to our latest terms of use and privacy policy."
    finePrintStyle(legalLabel)

    let rootStackView = UIStackView(arrangedSubviews: [
      logoImageView,
      gitHubButton,
      orLabel,
      emailField,
      passwordField,
      signInButton,
      forgotPasswordButton,
      legalLabel,
      ])
    rootStackViewStyle(rootStackView)

    self.view.addSubview(gradientView)
    self.view.addSubview(rootStackView)

    NSLayoutConstraint.activate([
      gradientView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
      gradientView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
      gradientView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
      gradientView.bottomAnchor.constraint(equalTo: self.view.centerYAnchor),

      rootStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
      rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
      rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
      ])
  }
}

모든 코드에 재사용 가능한 스타일을 적용했습니다! translatesAutoresizingMaskIntoConstraints = false보다 몇 배는 기억하기 쉬운 autolayoutStyle이 생겼으며, 이미지뷰에 오토 레이아웃과 컨텐츠 모드를 aspect ratio로 적용하는 스타일인 aspectRatioStyle도 생겼습니다.

필요에 따라 borderButtonStylefilledButtonStyle 중 원하는 것과 합성할 수 있는 imageButtonStyle도 생겼습니다.

또한 앱 전체적으로 공통인 스택뷰 설정을 할 수 있는 rootStackViewStyle이 생겼습니다.

이 모든 스타일링 함수는 컨트롤러 바깥에 있기 때문에 어디서든지 자유롭게(free) 재사용할 수 있습니다.

뷰 컨트롤러에선 어떨까요? 각각의 뷰의 스타일을 설정하는 코드가 한 줄로 끝나기 때문에 이전보다 훨씬 간결해질 것입니다!

그래서 요점이 무엇인가요?

실제 세상에서 일어나는 예시를 가지고 “그래서 요점이 무엇인가요?” 시간을 가지려고보니 이전보다 더 쉬운 것 같네요. 우리는 <> 연산자를 우리가 매일 만나는 일상적인 코드에 적용해보았고 UIKit 스타일링의 고질적인 문제를 아주 강력하고 유연한 방식으로 해결했습니다. 작성한 함수는 UIKit과 싸우려고 만든 것이 아닙니다. 이 함수를 통해 UIKit은 더욱 훌륭해졌습니다!

스타일링 함수에는 UIAppearance도 넣을 수 있습니다.

baseButtonStyle(UIButton.appearance())

아니면 다음과 같이 멋지게 사용할 수도 있습니다.

baseButtonStyle(.appearance())

오늘은 간단한 함수의 아름다움을 알아보았습니다. 간결한 함수는 유연하며 어디서든 사용할 수 있습니다. 그리고 추상화 계층도 거의 없습니다.

오늘 사용한 방식은 의존성없이 간편하게 코드 베이스에 추가할 수 있기 때문에 저희도 즐겨 쓰는 방식입니다.

다음 시간에 만나요!