[iOS Programming Big Nerd Ranch] 5. Programmatic Views, 뷰를 코드로 생성하기


view를 코드로 생성해보자.


이미 이전에 컨트롤러 두 개와 각 뷰들을 스토리보드로 만들었다. 그 중 맵 컨트롤러의 View를 선택해서 지운다.

image

Creating a View Programmatically

앞에서 뷰 컨트롤러의 뷰를 코드로 만들기 위해 UIViewController의 메서드 loadView()를 사용한다는 것을 봤다.

MapViewController.swift의 코드를 아래와 같이 작성한다.

import UIKit
import MapKit

class MapViewController: UIViewController {
    var mapView: MKMapView!
    
    override func loadView() {
        // Create a map view 
        mapView = MKMapView()
        // Set it as *the* view of this view controller
        view = mapView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        print("MapViewController loaded its view.")
    }
}

뷰 컨트롤러가 생성되었을 때, view 프로퍼티는 nil로 설정되어 있다. 뷰 컨트롤러가 view를 요청했고 이 view가 nil이면, loadView() 메서드가 호출된다.

이제 앱을 실행시키면 전과 같은 화면이 나온다.

Programmatic Constraints

앞에서는 Interface Builder를 이용해서 Auto Layout constraints를 설정했는데, 이제는 코드로 해 볼 것이다.

애플은 간읗나 뷰를 생성하고 제약을 설정하는 것을 Interface Builder에서 하라고 하지만, 뷰가 코드로 생성되었다면 제약 또한 코드로 해야 한다.

~.translatesAutoresizingMaskIntoConstraints = false 구문은 예전 시스템의 사이즈를 조정하는 인터페이스인 autoresizing masks를 설정한다. Auto Layout이 소개되기 이전에, iOS 애플리케이션은 autoresizing mask를 이용해서 실행시간에 다른 크기의 화면들에 맞게 뷰의 크기를 조정하는 것을 가능하게 해줬다.

모든 뷰는 autoresizing mask를 가지고 있다. 기본적으로, iOS 는 autoresizing mask에 해당하는 제약을 생성해서 뷰에 추가한다. 이 전환된 constraint들은 레이아웃에 명시된 제약들과 충돌을 일으킬 수 있고, 문제가 생길 수 있다. 이 설정을 false로 설정해서 default translation을 꺼놓는 것이다.

loadView()안에 코드를 작성한다.

let segmentedControl = UISegmentedControl(items: ["Standard", "Hybrid", "Satellite"])
segmentedControl.backgroundColor = UIColor.white.withAlphaComponent(0.5)
segmentedControl.selectedSegmentIndex = 0
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(segmentedControl)

Anchors

Auto Layout을 코드로 구현할 때, constraint를 만들기 위해 anchor를 사용한다. Anchor는 anchor를 다른 뷰에 맞춰 설정하고 싶은 속성들에 일치하는 뷰의 속성이다.

Anchor들은 두 anchor 사이에 제약을 생성하는 constraint(equalTo:)라는 메서드가 있다. NSLayoutAnchor에 그 외에 다른 제약을 만드는 메서드가 있다.

let topConstraint = segmentedControl.topAnchor.constraint(equalTo: view.topAnchor)
let leadingConstraint = segmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor)
let trailingConstraint = segmentedControl.trailingAnchor.constraint(equalTo: view.trailingAnchor)

Activating constraints

이제 세 개의 NSLayoutConstraint 인스턴스가 있지만, isActive 프로퍼티를 true로 만들어서 명시적으로 활성화하지 않는 이상 아무런 효과를 가지고 있지 않다.

topConstraint.isActive = true
leadingConstraint.isActive = true
trailingConstraint.isActive = true

제약들은 가장 제약과 연관이 있는 뷰의 가장 최근의 common ancestor에 추가되어야 한다. 아래 그림은 두 뷰의 common ancestor를 함께 보여주는 뷰 계층을 나타낸다.

image

만약 제약이 오직 하나의 뷰에 관련되어 있다면 뷰가 common ancestor가 된다.

active 프로퍼티를 true로 만들면 제약은 계층에서 제약이 추가되어야 하는 common ancestor를 찾는다. 그리고 적절한 뷰에서 addConstraint 메서드를 실행시킨다.

하지만 지금까지 만든 뷰를 보면 위의 세그먼트 컨트롤이 가장 위에 있어서 가장 위의 시간을 표시하는 부분과 겹친다.

image

새로 알게된 점

위에 작성한 코드에서, loadView()에서 원래는

mapView = MKMapView()

로 작성해야 하는 걸 생략해버려서 mapView가 계속 nil인 상태였다. nil인 상태에서

view.addSubview(segmentedControl)

해버렸으니 nil에 addSubView를 한 것이었다. 위에서도 언급이 되었고 https://stackoverflow.com/questions/4986098/viewdidload-infinite-loop-issue-ios/7370917에서도 언급이 되었듯이, self.view가 존재하지 않으면 iOS는 loadView, viewDidLoad 메서드를 계속 불러서 뷰를 생성하려고 한다. 이게 무한 루프에 빠지게 해서 앱이 정상적으로 작동하지 않는 것이었다.

Layout guides

뷰 컨트롤러는 topLayoutGuidebottomLayoutGuide를 이용해서 레이아웃을 지원한다. topLayoutGuide를 사용하면 화면 상단에 있는 status bar나 navigation bar와 컨텐츠가 겹치지 않게 할 수 있다. 반대로 bottomLayoutGuide는 화면의 바닥에 있는 탭 바와 겹치지 않게 해준다.

let topConstraint = segmentedControl.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor,
        constant: 8)

와 같이 topConstraint를 수정한다.

Margins

이제 leading과 trailing 끝을 superview 안으로 좀 넣어보겠다.

모든 뷰는 컨텐츠를 표시할 때 사용하는 기본 여백을 나타내는 layoutMargins 프로퍼티를 가지고 있다. 이 프로퍼티는 UIEdgeInsets의 인스턴스이고, 프레임의 한 타입이라고 생각할 수 있다. 제약을 추가할 때, layoutMargins의 끝에 묶여진 anchor를 나타내는 layoutMarginsGuide를 사용한다.

margin을 사용하는 주 장점은 디바이스 타입과 디바이스 사이즈에 따라 margins이 바뀔 수 있다는 것이다. 코드를 아래와 같이 수정한다.

let margins = view.layoutMarginsGuide
let leadingConstraint = segmentedControl.leadingAnchor.constraint(equalTo: margins.leadingAnchor)
let trailingConstraint = segmentedControl.trailingAnchor.constraint(equalTo: margins.trailingAnchor)

image

예쁘게 바뀐 걸 확인할 수 있다.

Explicit constraints

메서드가 제약을 어떻게 생성하는지 이해하는 것이 도움이 된다. NSLayoutConstraint는 아래의 이니셜라이저를 갖는다.

convenience init(item view1: Any,
                  attribute attr1: NSLayoutAttribute,
                  relatedBy relation: NSLayoutRelation, toItem view2: Any?,
                  attribute attr2: NSLayoutAttribute, multiplier: CGFloat,
                  constant c: CGFloat)

이 이니셜라이저는 두 뷰 객체의 레이아웃 어트리뷰트를 이용해서 단일한 제약을 생성한다. 여기서 multiplier가 비율에 따른 제약을 생성하는 키다. 여기서 상수는 고정된 숫자고, 여백에 관련된 제약에 사용한 것과 비슷하다.

레이아웃 어트리뷰트는 NSLayoutConstraint 클래스에서 정의된 상수다.

  • NSLayoutAttribute.left
  • NSLayoutAttribute.right
  • NSLayoutAttribute.leading
  • NSLayoutAttribute.trailing
  • NSLayoutAttribute.top
  • NSLayoutAttribute.bottom
  • NSLayoutAttribute.width
  • NSLayoutAttribute.height
  • NSLayoutAttribute.centerX
  • NSLayoutAttribute.cetnerY
  • NSLayoutAttribute.firstBaseline
  • NSLayoutAttribute.lastBaseline

이 외에도 NSLayoutAttribute.leadingMargin과 같이 뷰와 관련된 margins를 다루는 추가의 attribute가 있다.

예를 들어, 이미지의 가로가 세로의 1.5배 길이였으면 좋겠을 때 아래의 코드를 사용한다.

let aspectConstraint = NSLayoutConstraint(item: imageView,
  attribute: .width,
  relatedBy: .equal,
  toItem: imageView,
  attribute: .height,
  multiplier: 1.5,
  constant: 0.0
)

이 생성자가 어떻게 동작하는지 알기 위해, 아래의 그림을 참고한다.

image

한 뷰의 레이아웃 속성을 다른 뷰의 레이아웃 속성에 multiplier와 단일 constraint를 정의하기 위한 constant를 이용해서 관련짓는다.

Programmatic Controls

UISegmentedControlUIControl의 하위클래스다. Controls는 어떤 이벤트에 대해 target이 반응할 메서드를 호출할 책임이 있다.

Control 이벤트는 UIControlEvents의 타입이다.

  • UIControlEvents.touchDown : 터치하는 이벤트
  • UIControlEvents.touchUpInside : 컨트롤의 경계 안에서 누르고 떼는 것
  • UIControlEvents.valueChanged : 값을 바꾸는 터치
  • UIControlEvents.editingCHanged : UITextField를 바꾸는 터치

아래와 같이 segmentedControl 에 .valueCHanged 이벤트와 관련된 타겟 액션을 추가하고 그 함수를 추가한다.

segmentedControl.addTarget(self, action: #selector(MapViewController.mapTypeChanged(_:)), for: .valueChanged)
@objc func mapTypeChanged(_ segControl: UISegmentedControl) {
    switch segControl.selectedSegmentIndex {
    case 0:
        mapView.mapType = .standard
    case 1:
        mapView.mapType = .hybrid
    case 2:
        mapView.mapType = .satellite
    default:
        break
    }
}

이제 앱을 실행시키면 맨 위의 segmentedControl 탭을 터치해서 바꿀 때마다 mapview의 타입이 바뀐다.

image image image

NSAutoresizingMaskLayoutConstraint

앞서서 Auto Layout이 나오기 전에 iOS 앱은 레이아웃을 관리하기 위해 autoresizing mask를 이용한다고 했다. 각 뷰는 superview와 관련된 제약인 autoresizing mask가 있지만, 이 mask는 형제 view들간의 관계에는 영향을 미치지 않았다.

기본적으로, 뷰들은 autoresizing mask를 바탕으로 제약을 만들고 추가한다. 하짐만 이 translated constraints들이 레이아웃제약과충돌을일으켜서제약 문제를 일으키고는 했다.

이걸 직접 확인하려면 segmentedControl.translatesAutoresizingMaskIntoConstraints = false 부분을 주석처리한다.

이제 segmented control은 constraint로 전환될 resizing mask를 가지고 있다. 앱을 이제 실행시키면 내가 원하던 대로 UI가 나오지 않고 콘솔이 문제를 출력한다.

image

2021-05-16 16:11:35.411792+0900 WorldTrotter[92421:10949877] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want. 
	Try this: 
		(1) look at each constraint and try to figure out which you don't expect; 
		(2) find the code that added the unwanted constraint or constraints and fix it. 
	(Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints) 
(
    "<NSAutoresizingMaskLayoutConstraint:0x600001430cd0 h=--& v=--& UISegmentedControl:0x7feb4de2e9b0.minX == 0   (active, names: '|':MKMapView:0x7feb51834000 )>",
    "<NSLayoutConstraint:0x600001442760 UISegmentedControl:0x7feb4de2e9b0.leading == UILayoutGuide:0x600000e28460'UIViewLayoutMarginsGuide'.leading   (active)>",
    "<NSLayoutConstraint:0x600001442670 'UIView-leftMargin-guide-constraint' H:|-(16)-[UILayoutGuide:0x600000e28460'UIViewLayoutMarginsGuide'](LTR)   (active, names: '|':MKMapView:0x7feb51834000 )>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x600001442760 UISegmentedControl:0x7feb4de2e9b0.leading == UILayoutGuide:0x600000e28460'UIViewLayoutMarginsGuide'.leading   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.
2021-05-16 16:11:35.413187+0900 WorldTrotter[92421:10949877] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want. 
	Try this: 
		(1) look at each constraint and try to figure out which you don't expect; 
		(2) find the code that added the unwanted constraint or constraints and fix it. 
	(Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints) 
(
    "<_UILayoutSupportConstraint:0x600001442580 _UILayoutSpacer:0x600000845ef0'UIVC-topLayoutGuide'.height == 20   (active)>",
    "<_UILayoutSupportConstraint:0x600001442530 V:|-(0)-[_UILayoutSpacer:0x600000845ef0'UIVC-topLayoutGuide']   (active, names: '|':MKMapView:0x7feb51834000 )>",
    "<NSAutoresizingMaskLayoutConstraint:0x600001430d70 h=--& v=--& UISegmentedControl:0x7feb4de2e9b0.minY == 0   (active, names: '|':MKMapView:0x7feb51834000 )>",
    "<NSLayoutConstraint:0x6000014425d0 V:[_UILayoutSpacer:0x600000845ef0'UIVC-topLayoutGuide']-(8)-[UISegmentedControl:0x7feb4de2e9b0]   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x6000014425d0 V:[_UILayoutSpacer:0x600000845ef0'UIVC-topLayoutGuide']-(8)-[UISegmentedControl:0x7feb4de2e9b0]   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.

Auto Layout은 Unalbe to simultaneously satisfy constraints라는 메세지를 찍고 있다. 이거는 constraint가 충돌하는 뷰 계층이 있을 때 발생한다.

그리고 콘솔은 연관된 제약 리스트를 출력한다.

image

이거는 메모리 주소 0x600001442760에 있는 제약이 0x7feb4de2e9b0 에 있는 UISegmentedCOntrol의 leading edge를 0x600000e28460 에 있는 UILayoutGuide의 마진의 leading edge와 일치시키고 있다는 뜻이다.

마지막으로, Auto Layout은 이 충돌되는 제약들을 무시한 내역을 출력해서 어떻게 이 문제를 해결했는지 보여준다. 여기서는 NSAutoresizingMaskLayoutConstraint 대신에 NSLayoutConstraint의 명시된 인스턴슬ㄹ 무시해서 UI가 이상하게 보이는 것이다.

제약 리스트를 출력하는 것 전에 나온 note는 도움이 된다. : NSAutoresizingMaskLayoutCOnstraint가 지워져야 한다는 내용이다.