PinLayout 처럼 Auto Layout 코드 작성하기

PinLayout이란?

PinLayout

왜 PinLayout을 쓰지 않는지?

PinLayout이 Auto Layout보다 성능이 좋다고는 하나, 좋은 성능의 폰이 보급됨에 따라 그 성능이 차이가 크지 않다고 느끼며, 그에 따라 굳이 PinLayout에서 제공하는 수많은 기능을 공부하기엔 시간 낭비같았다. 실제로 PinLayout에서 제공하는 퍼포먼스 표를 보면 5s부터 X까지만 비교가 되어있다. 아마 최신 기종일수록 그 격차가 좁혀져서 유의미한 차이는 없을거라고 본다. 하지만 one view, one line 은 인상깊었다. 나는 뷰를 만들때 스토리보드를 쓰지 않고, 코드로 작성한다. 간단하게 UIImageViewUILabel 두개만 있는 뷰를 구성한다고 가정해보자. 그러면 보통 다음과 같이 작성할 것이다.

import UIKit

import Then

final class TestView: UIView {
    private let imageView = UIImageView().then {
        $0.layer.cornerRadius = 12
        $0.backgroundColor = .lightGray
        $0.clipsToBounds = true
    }
    
    private let label = UILabel().then {
        $0.text = "레이블입니다."
        $0.font = .systemFont(ofSize: 20)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        configureLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("not support")
    }
    
    private func configureLayout() {
        addSubview(imageView)
        addSubview(label)
        
        imageView.translatesAutoresizingMaskIntoConstraints = false
        label.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: topAnchor),
            imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
            imageView.widthAnchor.constraint(equalToConstant: 200),
            imageView.heightAnchor.constraint(equalToConstant: 200),
            
            label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 10),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            label.centerYAnchor.constraint(equalTo: imageView.centerYAnchor)
        ])
    }
}

그러면 다음과 같은 View가 만들어진다. 되게 간단한 구성임에도 여러 줄을 작성해야 해서 디자인 작업을 하다보면 타이핑에 지칠 때도 있다. 또 다음에 보면 줄이 너무 길어서 가독성이 떨어질 때도 많았다. 그래서 PinLayout의 one view, one line을 도입해보기로 했다.

UIView Extension

최초의 아이디어는 UIView를 리턴해주는 메소드를 만들고, 그 안에서 코드를 작성해두면 체이닝으로 쓸 수 있겠다고 생각했다. 그리고 UIView를 만들게 되면 translatesAutoresizingMaskIntoConstraintsfalse로 줘야해서 관련 extension도 추가했다.

extension UIView {
    func addSubviews(_ views: UIView...) {
        views.forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            addSubview($0)
        }
    }
    
    @discardableResult
    func top(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> UIView {
        topAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func leading(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> UIView {
        leadingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func bottom(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> UIView {
        bottomAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func trailing(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> UIView {
        trailingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func width(constant: CGFloat) -> UIView {
        widthAnchor.constraint(equalToConstant: constant).isActive = true
        return self
    }

    @discardableResult
    func width(to anchor: NSLayoutAnchor<NSLayoutDimension>, constant: CGFloat = 0) -> UIView {
        widthAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func height(constant: CGFloat) -> UIView {
        heightAnchor.constraint(equalToConstant: constant).isActive = true
        return self
    }

    @discardableResult
    func height(to anchor: NSLayoutAnchor<NSLayoutDimension>, constant: CGFloat = 0) -> UIView {
        heightAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func centerX(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> UIView {
        centerXAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }

    @discardableResult
    func centerY(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> UIView {
        centerYAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
}

덕분에 이렇게 코드를 작성할 수 있게 됐다.

private func configureLayout() {
    backgroundColor = .white
    
    addSubviews(imageView, label)
    
    imageView.top(to: topAnchor).leading(to: leadingAnchor).bottom(to: bottomAnchor).width(constant: 200).height(constant: 200)
    label.leading(to: imageView.trailingAnchor, constant: 10).trailing(to: trailingAnchor, constant: -10).centerY(to: imageView.centerYAnchor)
}

처음 코드와 비교해보면 비약적으로 단축되었고, one view, one line이 되었다. 그런데 사용하면서 조금 불편한 점이 있었다. 바로 topAnchor, leadingAnchor 등 UIView의 Property와 네이밍이 중복되어 같은 레이어에서 보여지는 것이 불편했고, 가끔 실수나 잘못사용하게 될 수 있다고 생각했다.

그래서 PinLayout 처럼 계층을 분리하고, 내가 새로 추가한 코드만 쓸 수 있게 따로 클래스를 정의하기로 했다. 우선 나는 Hook 라는 네이밍을 사용하기로 했고 Property를 추가했다.

extension UIView {
    var hook: Hook {
        Hook(view: self)
    }
}

그리고 Hook 클래스를 정의했다.

final class Hook {
    private var view: UIView
    
    init(view: UIView) {
        self.view = view
    }
    
    // MARK: - Top, Leading, Bottom, Trailing
    
    @discardableResult
    func top(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.topAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func leading(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.leadingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func bottom(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.bottomAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func trailing(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.trailingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    // MARK: - Combination
    
    @discardableResult
    func all(to superView: UIView, topConstant: CGFloat = 0, leadingConstant: CGFloat = 0, bottomConstant: CGFloat = 0, trailingConstant: CGFloat = 0) -> Hook {
        view.hook
            .top(to: superView.topAnchor, constant: topConstant)
            .leading(to: superView.leadingAnchor, constant: leadingConstant)
            .bottom(to: superView.bottomAnchor, constant: bottomConstant)
            .trailing(to: superView.trailingAnchor, constant: trailingConstant)
        
        return self
    }
    
    // MARK: - Width & Height
    
    @discardableResult
    func width(constant: CGFloat) -> Hook {
        view.widthAnchor.constraint(equalToConstant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func width(to anchor: NSLayoutAnchor<NSLayoutDimension>, constant: CGFloat = 0) -> Hook {
        view.widthAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func height(constant: CGFloat) -> Hook {
        view.heightAnchor.constraint(equalToConstant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func height(to anchor: NSLayoutAnchor<NSLayoutDimension>, constant: CGFloat = 0) -> Hook {
        view.heightAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    // MARK: - Center
    
    @discardableResult
    func centerX(to anchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.centerXAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
    
    @discardableResult
    func centerY(to anchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, constant: CGFloat = 0) -> Hook {
        view.centerYAnchor.constraint(equalTo: anchor, constant: constant).isActive = true
        return self
    }
}

이제는 UIViewhook 프로퍼티에서만 내가 추가한 코드를 쓸 수 있어 기존 코드와 분리가 되었고, 더욱 명확하고 안전하게 쓸 수 있게 되었다.

결과

자 이제 코드가 이렇게 바뀌었다.

private func configureLayout() {
    backgroundColor = .white
    
    addSubviews(imageView, label)
    
    imageView.hook.top(to: topAnchor).leading(to: leadingAnchor).bottom(to: bottomAnchor).width(constant: 200).height(constant: 200)
    label.hook.leading(to: imageView.trailingAnchor, constant: 10).trailing(to: trailingAnchor, constant: -10).centerY(to: imageView.centerYAnchor)
}

CocoaPods & SPM

이렇게 쓰다가 이 글을 남긴지 6개월이 지나고 점점 이것저것 추가해서 쓰면서 NSLayoutConstraint를 Wrapping해서 아예 라이브러리를 만들게 되었다. CocoaPods와 SPM 둘다 지원합니다.

Hook (Github 바로가기)