[TIL]코드로 뷰를 짜는 방법에 관해

rbw·2022년 12월 19일
0

TIL

목록 보기
57/99

참조

https://betterprogramming.pub/ios-view-codes-handbook-1a08e28b0420

위 글을 보고 정리한 글. 자세한 내용은 위 글 참조 BaRam


코드 파이프라인

구성 요소를 화면에 표시하기 전에 세 단계를 거쳐야 합니다.

1. Build hierarchy

각 하위 뷰를 상위에 추가하여 각 하위 뷰에 대한 계층 구조를 설정합니다. addSubview(:), addArrangedSubview

2. Constraints

여기서 높이 및 너비 앵커와 자식간의 거리를 알려서 각 뷰 간의 관계를 정의해야 합니다.

3. View Configuration

뷰에 대한 추가 구성을 정의하는 단계입니다. 보통 뷰의 초기화 구문에서 정의되었을 수 있지만 일부 데이터는 나중에 채워야 하는 경우도 존재합니다.

여기서 중요한 점은 제약조건을 정의하기 전에 반드시 계층 구조 단계부터 적용 한다는 점입니다. 따라서 Xcode는 이것이 해결되지 않다면 혼란을 느끼고 에러를 발생시킬겁니다.

뷰 코드 프로토콜

뷰 코드 파이프라인을 트리거하는 방법을 자동으로 제공하는 것은 좋은 방법입니다. 따라서 프로토콜을 정의하는 방법이 있습니다.

public protocol ViewCodeProtocol {
    func buildViewHierarchy()
    func setupConstraints()
    func configureViews()
}

public extension ViewCodeProtocol {
    
    func applyViewCode() {
        buildViewHierarchy()
        setupConstraints()
        configureViews()
    }
    
    func configureViews() { }
}

위 프로토콜에서 3개의 메서드는 파이프라인의 각 단계에 해당하고 4번째 메서드는 적절한 순서로 파이프라인의 메서드를 호출하기 위해 별도로 구현됩니다.

configureViews 메서드는 선택사항이며 applyViewCode로 전체 파이프라인을 트리거 합니다. 새 클래스(UIView, UIViewController)의 하위 클래스가 인스턴스화되면 applyViewCode를 초기화 구문에서 호출하기만 하면 UI가 완성됩니다.

이 파이프라인을 슈퍼클래스에서 정의하여 모든 뷰에 상속하고 일부 뷰 코드 메서드도 선언이 가능합니다.

public class BaseView: UIView {
    
    open func buildViewHierarchy() { }
    open func setupConstraints() { }
    open func configureViews() { }
    
    func applyViewCode() {
        buildViewHierarchy()
        setupConstraints()
        configureViews()
    }
}

class CustomView: BaseView {
    init() {
        super.init(frame: .zero)
        applyViewCode()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func buildViewHierarchy() {
        /* Do something*/
    }
    
    override func setupConstraints() {
        /* Do something */
    }
    
    override func configureViews() {
        /* Do something */
    }
}

위 접근 방식에서는 한 가지 부정적인 점이 있습니다. 프로토콜이나 슈퍼클래스가 각 메서드를 정의하기 때문에 private로 메서드를 선언할 수 없습니다. 이는 뷰 모델이나 슈퍼 뷰와 같은 다른 곳에서 파이프라인을 호출하는 것을 피할 수 있는 메커니즘이 없음을 의미합니다. UI 캡슐화를 손상 시킬 수 있습니다.

이제 이미지 1개와 4개의 레이블(이름, 직책, 나이, 성별)을 포함하는 프로필 뷰를 하나 만들어 보겠습니다. 일부 축에 정렬된 여러 요소를 처리할 때는 스택 뷰를 사용하여 제약 조건을 단순화하고 단일 동작을 할당하는 것이 좋습니다.

import UIKit

class ProfileView: UIView {
  // MARK: - UI properties
    private lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.layer.borderWidth = 1
        imageView.clipsToBounds = true
        return imageView
    }()
    
    private lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()
    
    private lazy var nameLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var occupationLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var ageLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var genderLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
}

translatesAutoresizingMaskIntoConstraintsfalse로 하는 이유는 간단하게 말하자면, 인터페이스가 자체 프레임 레이아웃 대신 상위 뷰에서 해당 위치를 설정하기 위해 제약 조건에 의존하기를 원하기 때문에 이렇게 설정해줍니다.

Building Hierarchy

뷰에 모든 요소를 나열했으므로 이제 요소가 따를 계층을 정의할 차례입니다.

extension ProfileView: ViewCodeProtocol {
    func buildViewHierarchy() {
        addSubview(imageView)
        addSubview(stackView)
        stackView.addArrangedSubview(nameLabel)
        stackView.addArrangedSubview(occupationLabel)
        stackView.addArrangedSubview(ageLabel)
        stackView.addArrangedSubview(genderLabel)
    }
    
    func setupConstraints() {
      // TO DO
    }
    
    func configureViews() {
       // TO DO
    }
}

Constraints

이제 계층 구조에서 동일한 부모에 속하는 각 요소 간의 관계를 명시해야 합니다. 상수들은 따로 enum으로 묶어서 명시해주었습니다.

// ProfileView.swift
private enum Constants {
    static let imageLeading: CGFloat = 16
    static let imageHeight: CGFloat = 80
    static let imageWidth: CGFloat = 80
    static let imageToStackSpacing: CGFloat = 48
    static let stackTop: CGFloat = 8
    static let stackBotton: CGFloat = -8
    static let stackTrailing: CGFloat = -8
}

// 이를 활용해서 위의 setupConstraints 메서드도 채워보겠습니다.
 public func setupConstraints() {
    NSLayoutConstraint.activate([
        imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
        imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.imageLeading),
        imageView.heightAnchor.constraint(equalToConstant: Constants.imageHeight),
        imageView.widthAnchor.constraint(equalToConstant: Constants.imageWidth),
        
        stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.stackTop),
        stackView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: Constants.imageToStackSpacing),
        stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Constants.stackTrailing),
        stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.stackBotton)
    ])
}

추가로 NSLayoutConstraint를 활성화할 때 특정 조건에만 유효하게 만들어서 동적인 레이아웃을 설정하는 방법도 있습니다. 아래 코드와 같이 true 값을 변수로 하여 동적으로 레이아웃을 구성할 수 있습니다.

var shouldDeclareConstraint = true
firstLabel.bottomAnchor.constraint(equalTo: secondLabel.topAnchor, constant: 
12).isActive = shouldDeclareConstraint

Additional Configuration

이제 파이프라인의 세 번째 단계인 구성단계 입니다. 데이터는 동적이고, 수명 주기 동안 변경될 수 있기 때문에 추가로 메서드를 만들 필요가 존재합니다.

뷰 모델을 주입받는다고 가정하고 아래와 같이 작성합니다.

func configureViews() {
    imageView.image = UIImage(named: viewModel.image)
    nameLabel.text = viewModel.name
    occupationLabel.text = viewModel.occupation
    ageLabel.text = viewModel.age
    genderLabel.text = viewModel.gender
    backgroundColor = .white
    // 루트 뷰의 속성 중 일부도 설정하고 있습니다. 더 적절한 위치가 없다고 설명을 함
    layer.cornerRadius = 8
    layer.borderWidth = 1
}

// MARK: - Initializer
public init(viewModel: ViewModel,
         frame: CGRect) {
   self.viewModel = viewModel
   super.init(frame: frame)
   applyViewCode()
 }

layoutSubviews

제약 조건은 구성 요소의 동작 방식을 설명하기 위해 만든 개체일 뿐입니다. 레이아웃이 유효성을 검사한 후에만 유효합니다. 이는 생성 또는 일부 레이아웃 업데이트 후에 발생하거나 더 정확히 말하면 layoutSubviews 메서드에서 적용됩니다.

위 프로필 화면에서 이미지는 원형을 원했습니다. 이를 위해 corner radius는 높이와 너비의 절반이어야 합니다. 그러나 우리는 초기화 블록에서 선언을 하였고 이 때의 제약 조건과 레이아웃은 정의가 되어 있지 않습니다. 따라서 높이와 너비가 0이라는 의미이고 원형이미지의 UI는 작동하지 않습니다.

그래서 우리는 레이아웃이 정의된 후 불리는 layoutSubviews 메소드 내부에 구현해줍니다.

override func layoutSubviews() {
    super.layoutSubviews
    imageView.layer.cornerRadius = imageView.frame.width / 2
}

뷰의 라이프 사이클 동안 레이아웃의 일부 업데이트가 필요한 경우네는 레이아웃 변경이 필요함을 알리고 setNeedsLayout함수를 호출해야 하지만 즉각적인 레이아웃 변경이 필요한 경우에는 layoutSubviews를 호출해야 합니다.

제약조건 애니메이션

버튼을 탭하면 높이를 업데이트하는 뷰가 있다고 가정하겠습니다. 높이의 상수를 변경하고 레이아웃 변경을 강제하지 않는다면 동작하지 않습니다.

따라서 애니메이션 블록에서 이를 변경시 레이아웃 변경을 강제합니다.

self.heightConstraint.constant = self.isOn ? 100 : 10
UIView.animate(withDuration: 0.5, animations: {
    self.layoutIfNeeded()
})

Intrinsic Content Size

슈퍼 뷰의 너비와 높이가 내부 콘텐츠에 의해 정의가 된다면 이를 내부 콘텐츠에 맞는 뷰의 크기인 intrinsic content size(고유 콘텐츠 크기)로 부릅니다. 이 intrinsicContentSizeUIView의 계산 프로퍼티로 서브뷰와 제약조건에 기반으로 크기를 계산합니다.

이미 intrinsic content size가 있고 크기에 대한 제약 조건을 선언한 보기가 있는 경우 높이가 두 번 정의되므로 제약 조건 간의충돌에 대한 경고가 표시되므로 모호성이 있습니다.

애플의 공식 문서에 따르면 뷰 자체의 속성을 고려한 수신 보기의 자연스러운 크기라고 정의하고 있다 (The natural size for the receiving view, considering only properties of the view itself.)

잘 이해(번역도,,)가 안되는데 아마 뷰 내부 속성들로만 계산되는 크기 라고 이해했다 .

profile
hi there 👋

0개의 댓글