본 내용은 야곰닷넷의 오토레이이아웃 정복하기 (opens new window) 강의의 요약본입니다.

# 오토레이아웃

오토레이아웃이 필요한 상황은 다양한데, 그 중 몇가지 예시가 있다면 아래와 같다.

  1. 맥에서 윈도우사이즈 변화
  2. 아이패드 스플릿뷰
  3. 기기 회전
  4. 통화중 상단 바
  5. 스크린사이즈
  6. 등등

오토레이아웃 시스템이 추가되기 전 frame 시스템 기반으로 화면을 구성했었는데, 이는 절대좌표나 절대 사이즈값으로 UI의 위치나 크기를 결정하게 되어 반응형으로 대응이 되지 않았다.

# Constraint

제약조건에는 수식이 존재한다. BlueView, RedView 라는 UIView인스턴스가 두개 있고 두 뷰가 수평으로 8포인트 간격을 두고 배치되어 있다고 가정해보자.

RedView.Leading = 1.0 * BlueView.Trailing + 8.0이라는 수식을 갖게 되며 수식의 구성을 살펴보면 아래와 같다.

  1. RedView, BlueView - 아이템
  2. Leading, Trailing - 어트리뷰트
  3. = - relationship
  4. 1.0 - Multiplier
  5. 8.0 - constant

포인트

제약조건을 지정할때 사용되는 단위는 포인트이다. 픽셀과 다르므로 주의하자.

multiplier

스토리보드에서 멀티플라이어를 설정할때 단위는 다양하게 사용 가능하다. 일반 정수형, decimal point, 퍼센트 등이 사용된다.

# 어트리뷰트

제약조건은 어트리뷰트와 어트리뷰트가 아닌 것으로 나눌 수 있다. 어트리뷰트는 위치에 관한 속성과 사이즈에 관한 속성이 있다.

위치는 leading, trailing, top, bottom, baseline, centerX, centerY 등이 있다. 사이즈는 width, height 등이 있다.

제약조건에 대한 방정식 예시 몇가지를 살펴보자.

// 높이 설정
View.height = 0.0 * NotAnAttribute + 40.0

// 수평 위치조정
Button.leading = 1.0 * Button.trailing + 8.0

// 수직 위치조정
Button.leading = 1.0 * Button.leading + 0.0

// 요소 높이설정
Button.width = 1.0 * Button.width + 0.0

// 부모-자식요소 정렬
View.centerX = 1.0 * SuperView.centerX + 0.0
View.centerY = 1.0 * SuperView.centerY + 0.0

// aspect ratio - 사이즈 비율조정
View.height = 2.0 * View.width + 0.0

제약조건을 다룰 때에 항상 가지고 가야 할 사고는 Equality, Not Assignment이다. 제약조건 방정식은 이퀄싸인으로 되어 있어 개발자 입장에서 보면 왼쪽의 제약조건에 오른쪽 제약조건 값을 새롭게 대입한다는 의미로 해석되기 쉬운데, 이는 제약조건을 다룬다는 조건 하에서는 잘못된 사고방식이다.

반드시 왼쪽의 제약조건 어트리뷰트 값과 오른쪽 제약조건 어트리뷰트 계산결과값이 동일하도록 제약조건을 설정해야하며 그렇지 못한 경우에 발생하는 것이 스토리보드의 빨간줄이다.

# Nonambiguous Layout

레이아웃 설정시 고려해야할 사항은 X축에서의 위치와 너비, Y축에서의 위치와 너비이다.

두 뷰를 세로 100%와 가로의 적당한 길이를 주고 둘 사이의 간격을 주는 레이아웃을 구성해본다면 필요한 제약조건은 아래와 같게 된다.

  1. 두 뷰 각각 Y축기준 top과 bottom을 수퍼뷰의 top과 bottom기준 constant 0값으로 맞춘다.
  2. 좌측 뷰에 trailing을 0, 우측 뷰에 leading을 0으로 부여하고 두 뷰를 선택하여 Equal width 속성을 부여한다.
  3. 두 뷰간 간격 조절을 위해 leftView.trailing = 1.0 * rightView.leading - 20의 관계를 맺는다.

이때 스토리보의 Equal width 속성은 X축 제약조건, 즉 두 뷰의 trailing과 leading의 관계 + 각 뷰에서의 leading과 trailing으로 위치가 어느정도 지정된 이후에 자동으로 계산되는 속성이다.

예컨대 leftView.leading과 rightView.trailing만 설정하고 두 뷰 사이의 간격 없이 equal width를 설정하려고 하면 너비에 대한 앰비규어스가 발생하여 스토리보드에 빨간줄이 나타난다.

제약조건의 중복

제약조건은 관계이다. y = 2x + 3이라는 방정식이 x = (y - 3)/2로 표현되는 것처럼 제약조건도 반대 표현이 동일하게 가능한데 앰비규어스가 없는 제약조건이라면 하나만 설정하는것이 좋다.

가령 두 뷰 사이의 간격을 지정할때 leftView.trailing = rightView.leading - 8rightView.leading = leftView.trailing + 8이 되는데 둘 중 하나의 제약조건만 설정하면 된다는 것이다.

이와 마찬가지로 두 제약조건 사이의 관계엣 constant만 바뀐 제약조건을 새롭게 추가하면 앰비규어스를 발생시킨다. 기존 제약조건을 갱신하지 않는다는 점을 반드시 주의하자.

# Constraint Priority

제약조건의 relation에는 이퀄싸인만 있는게 아니다. 부등호도 가능하다.

View.width >= 0.0 * NotAnAttribute + 40.0

제약조건은 중복 추가가 가능한데 이러한 이유는 각 제약조건에 우선순위, priority라는 속성을 갖고 있기 때문이다. 두 제약조건이 특정 상황에서 충돌이 일어날때 우선순위가 낮은 제약조건은 무시된다.

우선순위는 1~1000의 값을 가지며 높을수록 우선된다. UILayoutPriority에는 열거형으로 미리 250, 500, 750, 1000으로 정의된 것이 있다. 자유롭게 설정도 가능하다.

# Intrinsic Content Size

Intrinsic Content Size는 고유 사이즈라고 생각하면 된다. 일반적으로 텍스트가 포함된 뷰들에게 적용된다. 텍스트 기준 마진이 기본적으로 부여되기때문에 이들은 위치만 잡아줘도 사이즈 조정 없이 제약조건이 완성된다.

Intrinsic Content Size는 UIView에서도 설정할 수 있다. 스토리보드상에서 UIView의 intrinsic Size를 설정하기 위해 placeholder로 속성을 변경하여도 실제 앱 빌드 결과물에는 적용되지 않는데 이를 실제 결과물에도 적용하기 위해서는 먼저 스토리보드에서 placeholder를 default(system)으로 변경해준다.

이후 UIView클래스를 새로 정의한다. CGSize타입의 intrinsicContentSize속성은 UIView에서 기본 제공한다.

@IBDesignable
class MyView: UIView{
    override var intrinsicContentSize: CGSize{
        return CGSize(width: 50, height: 50)
    }
}

이후 스토리보드에서 적용하고자 하는 뷰의 Custom Class를 해당 클래스와 연결해주면 된다.

# Content compression & Content Hugging

Content compression은 압축을 버티는 힘, 최소크기이고 Content hugging은 특정 범위 이상 늘어나지 않으려는 힘을 말한다. 이들은 자신들이 갖고 있는 intrinsic content size를 기준으로 힘의 작용 여부를 결정하게 된다.

레이블뷰 A, B, C가 오토레이아웃 leading, trailing을 기준으로 서로 수평정렬되어 존재할때 특정 레이블의 길이가 길어지게 되면 어떻게 될까? 각각 레이블의 intrinsic content size의 합이 자신들이 배치되어 있는 모니터 전체 너비보다 커지는 경우가 생길 수 있다.

이때 누군가는 자신의 크기를 줄이는 수 밖에 없는데 이러한 부분에 있어서 양보를 하게 되는 요소가 priority 값이 낮게 설정된 것이다. intrinsic content size에 따라 컨텐츠를 외력으로부터 지키고자 하는 힘이 컴프레션 레지스턴스 우선순위이다.

반면 Content Hugging priority는 밖으로 늘어나려고 하는 속성일까? 절대로 그런 의미로 이해해서는 안된다. UI요소는 자의를 가지고 자동으로 자신의 길이를 늘릴 수 없고, 철저히 우선순위 시스템에 의해 돌아간다고 이해해야 한다.

컨텐츠 허깅 우선순위는 오토레이아웃 좌표시스템과 함께 이해해야한다. 위의 예시에서 레이블 세개를 단순히 드래그 앤 드롭으로 앰비규어스 한 배치를 하는 것이 아니라, trailing, leading 좌표에 맞추어 배치를 해놓은 상태이다.

기본 UILabel 내에 텍스트를 몇자 채워넣지 않으면 intrinsic content size의 전체 합이 모니터 전체 너비보다 작아지게 된다. A.intrinsicRow + B.intrinsicRow + C.intrinsicRow + superView-A간격 + A-B간격 + B-C간격 + C-superView간격 = 모니터 너비가 되어야 앰비규어스 하지 않은 오토레이아웃 설정이 되는 것인데, 레이블 내의 텍스트 길이가 충분하지 않게 되면 길이에 대한 제약조건이 앰비규어스 하게 되는 것이다.

컨텐츠 컴프레션 우선순위는 다른 컨텐츠가 너무 길어서 자신의 영역을 침범할때 어떻게 대처하는 지에 대한 이야기였다면, 컨텐츠 허깅은 다른 컨텐츠가 너무 작아서 자신이 오히려 해당 영역을 억지로 갖게 되어야 하는 상황에 적용되는 것이다.

따라서 A 레이블에 텍스트 안녕하세요가 있을때, B,C 레이블이 너무 길지만 컴프레션 우선순위가 B와 C보다 크다면 안녕하세요 컨텐츠가 그대로 유지가 되는 것이고

안녕하세요 텍스트만 있을때 B,C 레이블의 길이가 너무 짧은데 컨텐츠 허깅 우선순위가 낮다면 자신이 그들에게 맞춰 컨텐츠 길이를 억지로 늘러줘야 하므로 A 레이블의 빈 공간이 커지게 되는 것이다.

width priority

우선순위 시스템은 intrinsic content size에만 적용되는 것이 아니다. 제약조건마다 우선순위를 기본 속성으로 갖는다.

예를 들어 두 뷰의 너비를 화면에서 1:2의 비율로 맞추고 너비가 작은 뷰의 최소너비를 150포인트로 잡는다면 뷰의 간격과 너비 합산의 총 합이 수퍼뷰의 width보다 큰 포인트값을 갖게될 수 있다.

이때 최소한의 길이라는 속성의 우선순위값을 1:2라는 비율의 속성 우선순위값보다 높게 잡으면 비율 유지가 되지 않더라도 150포인트라는 뷰의 너비값을 먼저 확보하게 된다.

# Stack View

스택뷰에서 고려할 프로퍼티는 다음과 같다.

  1. distribution
  2. alignment
  3. spacing
  4. axis

# 1. distribution

스택뷰 내의 UI 요소들에 대한 크기 분배를 어떻게 진행할 것인지에 대한 속성이다.

  1. fill: 내부 요소들을 axis 기준에 맞춰 각각을 최대한 늘리게 된다. 늘리게 되는 기준은 각 요소의 스택뷰 축에 맞는 Content Hugging Priority이다. 본연의 intrinsic content size에 맞춰 늘어나지 않으려는 속성이 컨텐츠 허깅 우선순위이므로, 순위값이 낮은 요소들을 기준으로 컨텐츠 크기가 늘어나게 된다.
  2. fillEqually: 스택뷰 축을 기준으로 동일한 너비 또는 높이로 분배받는다.
  3. fillProportionally: 컨텐츠 비율이 계산된 후 해당 비율대로 너비 또는 높이가 고정되어 분배된다.
  4. equalSpacing: fill을 기반으로 동작하되 각 컨텐츠간 스페이싱을 동일하게 가져간다.
  5. equalCentering: 스택뷰 메인 축을 기준으로 크로스되는 축을 각 내부 요소들에 부여하고, 해당 축들간 간격을 동일하게 맞추는 방식이다. 뷰가 네개라면 축 사이의 간격은 3개이므로 해당 간격들을 동일하게 하면 된다.

# 2. alignment

  1. fill: 스택뷰 메인 축의 크로스되는 축을 기준으로 내부 요소들을 꽉 채우게 된다.
  2. leading: 스택뷰 메인 축의 leading에 맞춘다.
  3. top: 스택뷰 top에 맞춘다.
  4. firstBaseline: 내부에 텍스트 기반 UI 요소들이 (텍스트필드, 레이블 등) 배치될때 첫째 줄 기준으로 배치해준다.
  5. center: 메인축을 기준으로 스택뷰 센터에 맞춰준다.
  6. trailing: 스택뷰 trailing에 맞춘다.
  7. bottom: 스택뷰 하단에 맞춘다.
  8. lastBaseline: 텍스트 최하단 줄 기준으로 배치해준다.

# Constraints with code

오토레이아웃을 코드로 작성하는 방법이다. 오토레이아웃을 코드로 작성하는 방법은 크게 두가지로, 각 제약조건에 anchor를 걸어주거나 NSLayoutConstraints 인스턴스를 생성하는 것이다.

visual format이라는 방법도 있지만 거의 사용되지 않는다.

뷰컨트롤러 최상단 view에 버튼을 부착하는 코드이다.

override func viewDidLoad() {
    super.viewDidLoad()

    let button = UIButton()
    button.setTitle("BUTTON", for: .normal)
    button.setTitleColor(.white, for: .normal)
    button.backgroundColor = .systemGreen
    view.addSubview(button)

    button.translatesAutoresizingMaskIntoConstraints = false

    // safeArea 가이드라인 자체 제공
    let safeArea = view.safeAreaLayoutGuide

    // isActive
    button.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 16).isActive = true
}
  1. UI 인스턴스를 생성한 뒤 내부 설정을 해준다.
  2. UI를 부착할 곳에 addSubview로 부착해준다.
  3. UI요소에 대해 translatesAutoresizingMaskIntoContraints = false로 설정해준다.
  4. 위 예제에서는 버튼을 safeArea 기준으로 부착하기 때문에 뷰컨트롤러 최상단 뷰에서 제공하는 safeAreaLayoutGuide를 변수에 저장한다.
  5. 버튼의 leading을 safeArea의 leading기준 16포인트에 올린다. constraint메서드는 NSLayoutConstraint타입 인스턴스를 생성하므로 isActive속성을 true로 바로 설정하면 따로 변수에 할당할 필요가 없어진다.

코드로 제약조건에 대해 우선순위를 설정하기 위해서는 priority 속성을 수정해주면 된다. 열거형 케이스 defaultLow, defaultHigh를 사용 가능하지만 기본 우선순위 값이 250과 750이므로 직접 커스텀하기 위해서는 .init(rawValue) 생성자 함수를 사용하면 된다.

translatesAutoresizingMaskIntoContraints

NSLayoutConstraint 인스턴스를 활용하는 방법은 아래와 같다.

override func viewDidLoad() {
    super.viewDidLoad()

    let button = UIButton()
    button.setTitle("Button", for: .normal)
    button.setTitleColor(.white, for: .normal)
    button.backgroundColor = .systemGreen
    view.addSubview(button)

    button.translatesAutoresizingMaskIntoConstraints = false
    let safeArea = view.safeAreaLayoutGuide
    // 위까지는 동일


    // 아래부터 NSLayoutConstraint 인스턴스 생성
    let leading = NSLayoutConstraint(item: button, attribute: .leading, relatedBy: .equal, toItem: safeArea, attribute: .leading, multiplier: 1, constant: 16)

    let trailing = NSLayoutConstraint(item: button, attribute: .trailing, relatedBy: .equal, toItem: safeArea, attribute: .trailing, multiplier: 1, constant: -16)

    let bottomSafeArea = NSLayoutConstraint(item: button, attribute: .bottom, relatedBy: .lessThanOrEqual, toItem: safeArea, attribute: .bottom, multiplier: 1, constant: -16)
    bottomSafeArea.priority = .defaultHigh

    let bottomView = NSLayoutConstraint(item: button, attribute: .bottom, relatedBy: .lessThanOrEqual, toItem: view, attribute: .bottom, multiplier: 1, constant: -16)

    NSLayoutConstraint.activate([leading, trailing, bottomView, bottomSafeArea])
}

NSLayoutConstraint 메서드를 활용하면 된다. 제약조건 인스턴스 생성 이후 마지막으로 activate 메서드 배열 내에 전달하여 제약조건들을 활성화까지 시켜줘야한다.

# Scroll View

화면 내에서 스크롤이 이루어지는 UI로 테이블뷰, 컬렉션 뷰 등이 있지만 스크롤 뷰도 존재한다. 스크롤 뷰는 다른 뷰와 다르게 제약조건에 대해 고려해야할 점이 몇가지 더 있다.

다음은 애플 공식문서에 나타나있는 레이아웃 가이드 중 스크롤 뷰 사용법에 대한 내용이다.

  1. Add the scroll view to the scene.
  2. Draw constraints to define the scroll view’s size and position, as normal.
  3. Add a view to the scroll view. Set the view’s Xcode specific label to Content View.
  4. Pin the content view’s top, bottom, leading, and trailing edges to the scroll view’s corresponding edges. The content view now defines the scroll view’s content area.
  5. (Optional) To disable horizontal scrolling, set the content view’s width equal to the scroll view’s width. The content view now fills the scroll view horizontally.
  6. (Optional) To disable vertical scrolling, set the content view’s height equal to the scroll view’s height. The content view now fills the scroll view horizontally.
  7. Lay out the scroll view’s content inside the content view. Use constraints to position the content inside the content view as normal.

scroll content view

The content view does not have a fixed size at this point. It can stretch and grow to fit any views and controls you place inside it.

스크롤뷰를 부착하는 초기에 컨텐츠 뷰의 사이즈가 명확하지 않은 상태이기 때문에 직접 뷰의 크기를 드래그로 늘려줘야한다.

  1. 뷰컨트롤러에 스크롤 뷰를 얹는다.
  2. 스크롤 뷰에 대한 제약조건(사이즈 및 포지션)을 추가한다. 수치에 대한 추가만 하면 앰비규어스 오류가 발생하여 스토리보드에 반영되지 않으니 직접 사이즈에 맞게 그려줘야한다.
  3. 스크롤 뷰에 뷰를 추가한다. 뷰 layout structure에 추가했던 뷰에 대해 특정 이름을 추가하라고 나와있는데, 이 부분은 헷갈리지 않기 위함이니 하지 않아도 된다.
  4. 뷰의 top, bottom, leading, trailing엣지를 스크롤뷰의 엣지에 고정시킨다. 컨텐츠 뷰가 스크롤뷰의 전체 컨텐츠 영역을 정의하게 된다.
    • 뷰를 스토리보드에서 선택하고 Ctrl + 드래그로 각 leading, trailing, bottom, top에 맞춘다.
    • Add New constraints에서 각 수치를 0으로 맞추면 가장 가까운 요소를 기준으로 맞추므로 frame layout guide에 따라 뷰의 사이즈가 조정된다. 이것은 잘못된 것이므로, 컨트롤 + 드래그로 직접 수퍼뷰인 scroll view와의 제약조건 관계를 맺어줘야 한다.
  5. 수평스크롤을 강제하기 위해서는 스크롤뷰와 내부 뷰의 높이를 같게하고, 수직스크롤을 강제하기 위해서는 스크롤뷰와 내부 뷰의 너비를 같게한다.
  6. 스크롤뷰는 컨텐츠 레이아웃 가이드와 프레임 레이아웃 가이드가 있다. 컨텐츠는 화면 밖을 벗어날 수 있고 프레임은 화면 안에서의 컨텐츠 배치를 담당한다.
    • 컨텐츠뷰 내의 서브뷰에 컨텐츠를 배치한다.
    • 서브뷰에 배치할 때에 height에 대한 정보도 top, bottom 제약조건으로 확정지어줘야 스크롤 뷰 내의 컨텐츠 영역이 확정된다.

frame layout

컨텐츠 뷰 내에서 스크롤에 따라 컨텐츠가 함께 움직이도록 하는 것이 아니라 뷰 내에 컨텐츠를 고정시키고 싶으면 좌측 레이아웃 스트럭쳐에서 스크롤뷰 서브뷰 내에 추가한 UI요소를 ctrl + 드래그로 frame layout guide와 관계를 맺어주면 된다.

top, leading 등 원하는 포지셔닝 제약조건을 걸면 된다.

# Dynamic types

휴먼 인터페이스 가이드라인의 typography 섹션에는 Dynamic types라는 것이 있다.

사용자 접근성 측면에 대한 이야기인데, 폰트 사이즈를 상수로 고정해둔 채로 앱을 개발하게 된다면 사용자 시력에 따라 시스템 폰트 사이즈를 키워도 앱 자체 폰트크기에는 변화가 없게 된다. 이때 다이나믹 타입을 활용함으로써 이러한 측면의 문제를 해결할 수 있다.

상단 메뉴의 xcode - open developer tool - Accessibility inspector를 열어보면 폰트 크기 조절을 통해 다이나믹 타입이 적용된 부분을 테스트해볼 수 있다.

UILabel인스턴스를 기준으로 label.adjustsFontForContentSizeCategory 속성을 true값으로 설정해주면 다이나믹 타입이 적용된다.

UIButton의 경우 adjustsFontForContentSizeCategory가 내부 title 속성에 적용되기 때문에 노티피케이션을 통해 속성값을 바꿔줘야 한다.

override func viewDidLoad(){
    super.viewDidLoad()

    NotificationCenter.default.addObserver(self, selector: #selector(adjustsButtonDynamicType), name: UIContentSizeCategory.didChangeNotification, object: nil)
}

@objc func adjustsButtonDynamicType(){
    button.title?.adjustsFontForContentSizeCategory = true
}

# Reference

  1. 야곰닷넷 - 오토레이아웃 정복하기 (opens new window)
  2. Apple layout guide - Working with Scroll Views (opens new window)
  3. Human interface guidelines - Spectations (opens new window)