AutoLayout을 코드로 정의하기 (Swift)

오토레이아웃

오토레이아웃이 도입되기 이전에도 코코아 및 코코아터치에서는 상위뷰나 윈도가 크기가 변하는 경우에 하위 뷰들의 크기가 그에 따라 어떻게 변할 것인지를 결정해주는 방법이 있었다. 흔히 spring & struts라 불리는 오토리사이징 마스크가 그것이다.

하지만 오토 리사이징 마스크는 한 가지 문제가 있는데, 그것은 오토 리사이징 마스크는 수퍼뷰와 서브뷰 둘 사이의 관계만을 정의하기 때문에 이를 따라 뷰의 크기나 위치가 움직일 때 서브 뷰 간의 레이아웃이 흐트러질 수 있는 가능성이 매우 많다는 것이다.

그리하여 예전에는 화면의 회전이나 키보드가 올라오는 등의 과정이 발생하면[^0-1] 뷰의 크기가 전환되는데 이때 변환된 크기에 따라서 다른 레이아웃을 갖도록 코드상에서 이를 일일이 수정해주어야 했다.

오토레이아웃

오토 리사이징 마스크가 한정된 몇 가지 요소에 의존할 수 없었던 것 때문에 명확한 한계를 가지고 있었고, 이를 보완하기 위해 등장한 오토레이아웃은 뷰의 프레임보다는 뷰의 관계에 대해서 접근하는 방식으로 패러다임 전환을 시도했다. 오토레이아웃은 두 뷰의 관계를 통해서 레이아웃을 결정하는 것이며, 따라서 일련의 제한요소(constraint)등을 정의하여 내적 / 외적인 레이아웃 변경 요인에 대해 전향적으로 대응할 수 있게 한다.

제한 요소

뷰 계층 구조의 레이아웃은 일련의 선형 방정식들에 의해서 결정되며, 이 때 하나의 방정식은 하나의 제한요소를 가리킨다.

image: ../Art/view_formula.pdf

위 그림은 애플 개발자 문서 중 오토레이아웃 가이드에서 가져온 것이다. 이는 제한 요소는 두 뷰의 관계, 그 중에서 특정한 제약 요소를 기술하는 것임을 보여준다.

  • 오른쪽 red 뷰의 왼쪽 지점은 왼쪽 blue 뷰의 오른쪽 끝 지점에 0.8 만큼의 오프셋을 둔 지점과 일치해야 한다.

대부분의 제한 요소는 두개의 UI 요소간의 관계를 정의하나 하나의 요소의 두 속성간의 관계를 정의할 수도 있다. (예를 들어 어떤 뷰의 높이는 가로폭의 절반이라거나 하는 식)

오토레이아웃 속성

오토 레이아웃에서 ‘속성'(attributes)이란 제한 요소의 적용을 받는 어떤 기능을 의미한다. 여기에는 보통 4개의 변과 폭/높이 그리고 가로/세로 방향의 중심이 온다. 이러한 모든 요소들은 NSLayoutAttributes enum타입에 정의되어 잇다.

물론 모든 속성들이 한꺼번에 다 정의될 필요도 없다. (그렇게 하기가 불가능한 경우도 많고) 오토레이아웃을 사용할 때의 목표는 오직 하나의 가능한 솔루션만을 갖는 일련의 방정식들을 정의내리는 것이다. 여기에는 개별 공식들이 충돌하여 솔루션을 만들지 못하는 경우도 있을 수 있고, 반대로 2개 이상의 솔루션을 만드는 모호한 경우가 있을 수도 있다.

일반적으로 2개의 축방향에 대해서 해당 방향으로의 뷰의 위치와 크기를 정의하는 것으로 오토레이아웃을 적용할 수 있다. 하지만 하나의 뷰의 레이아웃을 결정하는 공식 세트가 반드시 하나인 것은 아니다. 아래는 어떤 서브 뷰의 가로 위치와 크기를 결정하는 예이다.

image: ../Art/constraint_examples.pdf

  • 첫번재 예는 왼쪽 여백과 폭을 정의했다. 이는 뷰가 고정된 폭을 가지며, 부모 뷰의 크기가 변경될 때에는 오른쪽 여백만 변경하며 뷰의 위치와 크기가 변하지 않음을 암시한다.
  • 두번째 예는 왼쪽 및 오른쪽 여백을 고정값으로 정의했다. 이는 뷰의 폭이 부모뷰의 폭에서 특정한 오프셋 값만큼 뺀 양으로 정이된다는 것이다. 부모뷰의 크기가 변경될 때 서브 뷰는 여백의 크기를 유지한채로 폭이 변경된다.
  • 세번째 예는 부모 뷰아 중심점을 맞추고 왼쪽 여백을 고정했다.

두번째와 세번째 설정은 설정한 내용은 다르지만 외관상 동일하게 동작한다.

IB를 통한 오토레이아웃 설정 법

건너뛴다.

주요 정책

다음의 가이드라인을 참고하라. 물론 각 규칙에 대한 예외는 어김없이 존재하겠지만, 이러한 규칙을 참고하는 것은 성공적인 접근을 도와준다.

  • 뷰의 기하학적 정의를 frame, bounds, center를 통해서 정의하지 말 것
  • 가능하다면 스택뷰를 사용할 것. 스택뷰는 콘텐츠의 레이아웃을 자동으로 관리하며 따라서 제한요소에 필요한 로직을 대거 줄여줄 수 있다.
  • 뷰의 제한 요소는 가장 가까운 뷰와 관련지어 생성할 것
  • 뷰에 고정폭, 고정높이를 적용하는 것을 피할 것
  • 제한 요소를 설정하는데 어려움을 겪는다면, Pin, Align 도구를 활용할 것. 이는 컨트롤 드래깅(우클릭 드래깅)보다는 느리지만, 정확한 조정에 도움을 준다.
  • 프레임 업데이트를 주의깊게 사용할 것. 부정확한 설정 후에 이 동작을 실행하는 것은 예기치 못한 결과를 만들 수 있다.
  • left, right 대신에 항상 leading, trailing을 사용할 것
  • iOS에서 뷰의 엣지를 뷰 컨트롤러의 루트 뷰에 대응시킬 때 다음을 고려할 것
    • 수평 제한: 대부분의 컨트롤에 대해서 레이아웃 마진값에 대해 0 값을 사용하면 시스템이 알아서 적절한 여백을 생성해준다.
    • 텍스트 객체가 뷰의 폭을 가득 메울 가능성이 있다면 레이아웃 마진 대신에 가독성 콘텐츠 가이드를 사용하라
    • 루트 뷰의 변에서 변을 채우는 콘텐츠를 정의하려면 뷰 자체의 leading, trailing에 0 포인트로 맞춰라
    • 수직 제한: 뷰가 바 아래로 확장되는 경우에 top,bottom 마진을 사용하라.
  • 코드상에서 뷰를 초기화하는 경우 뷰의 translatesAutoresizingMaskIntoConstraints 값을 false로 바꿔라. 기본적으로 시스템에 의해 이 값은 true로 정해지고 그러면 오토레이아웃 제한자를 설정하는 것이 제대로 동작하지 않는다.
  • macOS와 iOS는 레이아웃을 계산하는 방식이 다르다.

스택뷰

image: ../Art/nested_stack_views_2x.png

스택뷰를 쓸 수 있으면 사용해라. 이런 복잡한 레이아웃도 사실 스택뷰를 쓰면 쉽게 정리할 수 있다.

image: ../Art/Nested_Stack_Views_Screenshot_2x.png
UIStackView를 이용하여 구성한 레이아웃

코드상에서 제한요소를 만들기

사실 가능하다면 인터페이스 빌더를 사용해서 오토레이아웃 제한 설정을 만드는 것이 좋다. 인터페이스 빌더는 제한요소를 시각화하고 편집/관리하며 디버깅할 수 있는 많은 도구를 제공한다. 제한 요소들을 분석함으로써 인터페이스 빌더는 디자인 타임에서 발생하는 많은 일반적인 오류들을 찾아내고 자동으로 고칠 수 있다.

또한 기존에는 프로그래밍적으로만 제어할 수 있었던 뷰의 가변 개수에 대한 레이아웃 처리도 스택뷰를 사용하여 IB에서 구성할 수도 있다.

만약 코드상에서 오토레이아웃을 관리하겠다면 여기에는 크게 세가지 접근법이 있다.

1. 앵커를 사용하기

NSLayoutAnchor는 제한요소를 만들기 위한 유틸리티 클래스로 사용하기 쉬운 이터페이스를 제공하여 다양한 제약 요소들을 생성할 수 있다. 대부분의 시각 요소들은 이러한 앵커들을 이미 가지고 있으며, 이들로부터 제약요소를 생성할 수 있다.

let margins = view.layoutMarginsGuide
myView.leadingAnchor.constraint(equalTo: margins.leadingAnchor).aotive = true
myView.trailingAnchor.constraint(equalTo: margins.trailingAnchor).active = true
myView.heightAnchor.constraint(equalTo: myView.widthAnchor, multiplier: 2.0)

위 코드는 myView의 좌우를 기본 레이아웃 마진을 사용하여 부모 뷰에 딱 고정한다. 그리고 myView의 높이를 가로의 2배로 사용한다.

다음의 예를 실제로 만들어보자.

  1. 컨테이너 뷰 안에 3개의 뷰가 들어있다.
  2. 빨간뷰와 파란뷰는 같은 너비이며 양쪽끝에는 표준 마진, 가운데는 8만큼의 공간을 띄운다.
  3. 초록색은 마진을 제외한 전체폭이며, 가운대로 정렬된다. 높이는 빨간색 뷰와 같은 높이를 주며, 컨테이너 내부 마진을 제외하고 8만큼의 여백을 갖는다.

위 조건을 코드로 표현하면 아래와 같다.

let containerView = UIView(frame: CGRect(x:0, y:0, width: 300, heigh: 400))
containerView.backgroundColor = UIColor.white

//: 뷰 세 개를 만들고 오토레이아웃을 적용할 준비를 한다.

let redView = UIView()
redView.backgroundColor = UIColor.red
redView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(redView)

let blueView = UIView()
blueView.backgroundColor = UIColor.blue
blueView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(blueView)

let greenView = UIView()
greenView.backgroundColor = UIColor.green
greenView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(greenView)

//: 컨테이너뷰로부터 표준 마진을 사용한다.
let margins = containerView.layoutMarginsGuide

//: `redView`의 위치는 위, 왼쪽, 폭, 높이를 결정해준다. 
redView.topAnchor.constraint(equalTo: margins.topAnchor).isActive = true
redView.leadingAnchor.constraint(equalTo: margins.leadingAnchor).isActive = true
redView.widthAnchor.constraint(equalTo: margins.widthAnchor, 
    multiplier: 0.5, constant: -4.0).isActive = true
readView.heightAnchor.constraint(equalTo: margins.heightAnchor,
    multiplier: 0.5, constraint: -4.0).isActive = true

//: `blueView`는 좀 더 쉬운데 `redView`를 따라가기만 하면 된다. 
blueView.topAnchor.constraint(equalTo: redView.topAnchor).isActive = true
blueView.widthAnchor.constraint(equalTo: redView.widthAnchor).isActive = true
blueView.heightAnchor.constraint(equalTo: redView.heightAnchor).isActive = true
blueView.leadingAnchor.constraint(equalTo: redView.trailingAnchor, constant: 8).isActive = true

//: `greenView`는 `redView`의 아래쪽으로 전체 폭을 사용한다. 
greenView.topAnchor.constraint(equalTo: redView.bottomAnchor, constant: 8).isActive = true
greenView.conterXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true
greenView.bottomAnchor.containerView(equalTo: margins.bottomAnchor, constraint: -8).isActive = true

  1. Layout Contraints 를 사용하기

앵커를 사용하는 1의 문법들은 모두 조금 더 쓰기 쉬운 인터페이스를 이용해서 제약 요소값들을 생성하는 데, 이를 직접 이용하는 방법이 있다. 이 방법은 매우 무식하며 가독성도 떨어지고 그만큼 실수하기도 쉽다. 따라서 매우 추천하지는 않는다.

NSLayoutConstraint(item: myView, attribute: .leading, relatedBy: .Equal, toItem: view, attribute: .leadingMargin, multiplier: 1.0, constant: 0.0).isActive = true
  1. 비주얼 포맷 사용하기

비주얼 포맷 언어는 마치 아스키 아트처럼 보이는 문자열을 이용해서 제약요소값들을 생성하는 방식이다.

  • 오토레이아웃의 디버깅은 콘솔을 통해 비주얼 포맷을 사용하여 출력한다. 따라서 디버깅시에 사용되는 포맷과 생성에 사용하는 포맷이 일치한다.
  • 비주얼 포맷을 사용하면 여러 제약 요소를 한 번에 만들 수 있다.
  • 유효한 제약요소들만이 만들어진다. (단, 모든 필요한 제약요소가 다 만들어지는 것은 아니다.)
  • 완전성보다는 좋은 시각화에 집중한 방식이다. 따라서 일부 제약요소는 이 방식으로 만들 수 없다.
  • 단 포맷은 컴파일 타임에 체크할 수 없다. 실행하여 확인할 수만 있다.

어쨌든 이 방식은 다음과 같은 식으로 사용된다.

let views = ["myView": myView]
let formatString = "|-[myView]-|"
let constraints = NSLayoutConstraint.constraintsWithVisualFormat(formatString, 
    options: .AlignAllTop, 
    metrics: nil, 
    views: views)

NSLayoutConstraint.activateConstraints(constraints)

비주얼 포맷은 뷰의 한쪽 끝에서 끝까지를 다 정의해야 하는 것은 아니다. 연관있는 뷰들만 포함하는 일부분을 만들 수 있다. 이렇게해서 만들어지는 결과는 Array<NSLayoutConstraint> 타입이므로 이들 결과를 합한 다음에 NSLayoutContraint.activateContraints(_:)를 이용해서 한꺼번에 활성화할 수 있다.

비주얼 포맷을 사용하여 위 세개 뷰의 위치를 정의한 코드는 다음과 같다.

let views = ["redView": redView,
             "blueView": blueView,
             "greenView": greenView]

let format1 = "V:|-[redView]-8-[greenView]-|"
let format2 = "H:|-[redView]-8-[blueView(==redView)]-|"
let format3 = "H:|-[greenView]-|"

var constraints = NSConstraint.constraints(withVisualFormat: format1,
                    options: alignAllLeft,
                    matrics: nil,
                    views: views)
constraints += NSConstraint.constraints(withVisualFormat: format2,
                    options: alignAllTop,
                    matrics: nil,
                    views: views)
constraints += NSConstraint.constraints(withVisualFormat: format3,
                    options: []
                    matrics: nil,
                    views: views)
NSConstraint.activateConstraints(constraints)