🟣 화면은 결국 사각형의 겹침이다
iOS 화면을 처음 뜯어볼 때 가장 도움이 됐던 관점은 이거였다. 눈에 보이는 모든 것은 결국 사각형 하나다. 버튼도 사각형, 레이블도 사각형, 이미지도 사각형이다. 이 사각형 하나하나가 UIView이고, 화면은 이 뷰들이 서로 겹치고 포개진 결과다.
UIView는 화면 위의 한 영역을 맡는 객체다. 자기 영역에 색을 칠하고, 터치를 받고, 그 안에 다른 뷰를 담는다. 우리가 쓰는 UIButton, UILabel, UIImageView는 전부 UIView를 상속한 특수한 뷰다. 그래서 뷰 하나의 동작을 이해하면 나머지 컴포넌트는 그 위에 기능이 얹힌 것으로 읽힌다.
처음엔 버튼과 뷰가 완전히 다른 종류인 줄 알았다. 알고 보니 버튼은 "탭을 처리하고 제목을 그릴 줄 아는 뷰"일 뿐이었다. 이 사실을 받아들이고 나서야 모든 컴포넌트를 같은 규칙으로 다룰 수 있다는 감이 왔다.
🟣 뷰는 트리 구조로 담긴다
뷰는 자기 안에 다른 뷰를 담을 수 있다. 담는 쪽을 슈퍼뷰(superview), 담기는 쪽을 서브뷰(subview)라 부른다. 이 관계가 반복되면 화면 전체는 하나의 트리가 된다. 뿌리에는 UIWindow가 있고, 그 아래 뷰컨트롤러의 루트 뷰가 붙고, 그 아래로 우리가 만든 뷰들이 가지처럼 뻗는다.
서브뷰를 붙이는 코드는 addSubview다. 뷰를 만드는 것만으로는 화면에 나타나지 않는다. 어딘가의 서브뷰로 붙어야 비로소 트리에 들어가 그려진다. 입문기에 "코드는 다 맞는데 아무것도 안 보인다"의 절반은 addSubview를 빠뜨린 경우였다.
let label = UILabel()
label.text = "안녕하세요"
view.addSubview(label) // 이 줄이 있어야 화면에 올라간다트리에서의 위치는 그리는 순서도 정한다. 같은 슈퍼뷰 아래에서 나중에 추가한 서브뷰가 더 위에 그려진다. 겹친 두 뷰 중 하나가 가려진다면 대개 이 순서 문제다. bringSubviewToFront나 insertSubview(_:at:)로 이 순서를 조정한다.
🚀 부모가 사라지면 자식도 사라진다
트리 구조라서 생기는 편리한 성질이 하나 있다. 슈퍼뷰를 화면에서 떼거나 감추면 그 안의 서브뷰가 전부 따라 사라진다. 여러 뷰를 하나로 묶어 컨테이너 뷰에 담아 두면, 그 컨테이너 하나만 껐다 켜도 묶음 전체가 함께 제어된다. 뷰를 굳이 그룹으로 감싸는 이유가 여기 있다.
메모리 관점에서도 슈퍼뷰는 서브뷰를 강하게 붙들고 있다. 뷰를 트리에서 떼려면 그 뷰에서 removeFromSuperview를 호출한다. 이 관계를 정리하지 않으면 화면에서 안 보여도 객체가 메모리에 남아 있을 수 있다.
🟣 frame과 bounds: 같은 사각형을 보는 두 시선
뷰의 위치와 크기를 다룰 때 반드시 걸려 넘어지는 짝이 frame과 bounds다. 둘 다 사각형(원점과 크기)이지만 기준으로 삼는 좌표계가 다르다.
frame은 슈퍼뷰의 좌표계에서 이 뷰가 어디에 놓였는지를 말한다. "부모 안에서 왼쪽 위로부터 얼마나 떨어진 자리에 얼마만 한 크기로 있는가"이다. 반면 bounds는 이 뷰 자신의 내부 좌표계다. 원점은 보통 (0, 0)이고, 크기는 자기 크기와 같다. 서브뷰를 배치할 때 기준이 되는 좌표가 바로 이 bounds다.
let box = UIView(frame: CGRect(x: 20, y: 40, width: 100, height: 100))
// 슈퍼뷰 기준으로 (20, 40)에 놓인다. frame.origin = (20, 40)
// 자기 내부에서 보면 왼쪽 위는 여전히 (0, 0). bounds.origin = (0, 0)둘의 차이가 실감 나는 건 스크롤뷰를 만났을 때다. 스크롤은 콘텐츠를 실제로 옮기는 게 아니라 bounds.origin을 이동시킨다. 즉 "내가 내 콘텐츠의 어느 지점을 보고 있는가"를 바꾼다. 콘텐츠는 가만히 있고 보는 창이 미끄러지는 셈이다. 이 사실을 알고 나면 스크롤 위치를 코드로 읽고 쓰는 일이 훨씬 자연스러워진다.
회전이나 확대를 적용하면 차이가 더 벌어진다. transform으로 뷰를 45도 돌리면 frame은 그 기울어진 뷰를 감싸는 큰 사각형으로 바뀌지만, bounds는 원래 크기를 그대로 유지한다. 그래서 크기를 다룰 땐 bounds, 부모 안 위치를 다룰 땐 frame이라는 기준을 세워 두면 헷갈릴 일이 준다.
🟣 좌표는 왼쪽 위가 원점이다
iOS의 좌표계는 왼쪽 위가 원점 (0, 0)이고, 오른쪽으로 갈수록 x가 커지고 아래로 갈수록 y가 커진다. 수학 시간에 익힌 좌표계와 y 방향이 반대라 처음엔 자꾸 위아래를 뒤집어 생각하게 된다.
한 뷰의 좌표를 다른 뷰의 좌표계로 옮겨야 할 때도 있다. 예를 들어 어떤 버튼이 화면 전체 기준으로 어디에 있는지 알아야 한다면 convert(_:to:)로 좌표계를 변환한다. 각 뷰가 자기만의 좌표계를 갖는다는 사실을 잊으면, 좌표 값이 왜 예상과 다른지 한참을 헤매게 된다.
// button의 frame을 화면(window) 좌표계 기준으로 변환
let rectInWindow = button.superview?.convert(button.frame, to: nil)🟣 정리
- 화면의 모든 요소는
UIView이고, 버튼·레이블·이미지뷰는 전부 이를 상속한 특수한 뷰다. 그래서 하나의 규칙으로 다룰 수 있다. - 뷰는 슈퍼뷰와 서브뷰 관계로 트리를 이룬다.
addSubview로 트리에 붙어야 화면에 그려지고, 나중에 붙인 것이 위에 올라간다. frame은 슈퍼뷰 좌표계에서의 위치,bounds는 자기 내부 좌표계다. 크기는bounds, 부모 안 위치는frame으로 다룬다.- 좌표 원점은 왼쪽 위이고 y는 아래로 갈수록 커진다. 뷰마다 좌표계가 달라 필요하면
convert로 변환한다. - 스크롤은 콘텐츠가 아니라
bounds.origin이 움직이는 것이다. 이 관점이 스크롤과 변환을 이해하는 열쇠다.
실무에서 이 기본기는 "화면에 뷰가 안 보인다", "터치 영역이 이상하다", "스크롤 위치가 안 맞는다" 같은 문제의 1차 진단 도구가 된다. 면접에서도 frame과 bounds의 차이는 UIKit 이해도를 가늠하는 단골 질문이다. 두 개념을 스크롤과 transform 예시로 설명할 수 있으면 그 자체로 충분한 답이 된다.
화면을 이루는 재료는 이제 손에 잡힌다. 그런데 이 뷰들을 누가 만들어 붙이고, 화면이 나타나고 사라지는 순간에 무엇을 해야 하는지는 아직 비어 있다. 다음 장에서 그 화면 하나를 책임지는 객체, 뷰컨트롤러를 본다.