
개념 정리
ScrollView: 사용자의 제스처에 따라 vertical, horizontal 방향으로 스크롤 하며 내부 컨텐츠를 보여주거나 줌인, 줌아웃하여 보여주는 뷰Content: 스크롤뷰 내부에 있는 뷰로 사용자에게 직접적으로 보여진다.
오늘은 모든 앱에서 거의 빠지지 않고 쓰이는 ScrollView를 공부해보기로 했다.
현재 진행중인 과제, 계산기에서 숫자 값이 많아지면 123456...과 같은 형식으로 표시되는데 이것을 ScrollView를 이용하여 끊어지지 않고 뒤의 값도 확인할 수 있도록 해 볼 생각이다.
먼저 ScrollView를 정의해주자
class ViewController: UIViewController {
private let scrollView = UIScrollView() // 스크롤뷰 선언
override func viewDidLoad() {
super.viewDidLoad()
}
}
그리고 스크롤뷰의 세팅을 담당하는 메소드를 만든다.
private func setupScrollView() {
scrollView.backgroundColor = .gray
scrollView.contentSize = CGSize(width: scrollView.frame.width * 2, height: scrollView.frame.height)
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
scrollView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -100),
scrollView.heightAnchor.constraint(equalToConstant: 100),
scrollView.widthAnchor.constraint(equalToConstant: 300)
])
}
위 코드에서 contentSize의 height를 스크롤뷰의 frame.height와 같게 고정한 이유는 횡스크롤 즉, horizontal 방향으로만 스크롤 할 수 있도록 만들기 위해서이다. 컨텐츠의 사이즈와 스크롤뷰의 사이즈가 같으면 스크롤이 불가능하기 때문이다.
이제 뷰에 스크롤뷰를 배치하면 된다.
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView() // 뷰에 스크롤뷰 배치
}

이제 스크롤뷰 내부에 배치할 UILabel을 구현할 차례이다.
먼저 스크롤뷰처럼 UILabel을 정의해준다.
private let label = UILabel()
그 뒤 레이블의 값을 세팅하는 메소드를 만든다.
private func setupLabel() {
label.text = "123456" // 기본으로 보여질 텍스트 설정
label.font = UIFont.systemFont(ofSize: 50, weight: .bold)
label.textColor = UIColor.radomColor()
label.backgroundColor = .clear
label.textAlignment = .right // 문자 오른쪽 정렬
label.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(label) // 스크롤뷰의 컨텐츠로 삽입
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: scrollView.topAnchor),
label.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
label.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
label.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor)
])
}
레이블의 오토레이아웃은 스크롤뷰를 따르도록 한다. 그렇게 하지 않으면 시스템이 레이블의 위치를 특정하지 못해 에러를 반환하거나 레이블이 뷰에 표시되지 않는 오류가 발생할 수 있기 때문이다.
이제 뷰에 레이블을 배치한다.
override func viewDidLoad() {
super.viewDidLoad()
setupLabel() // 뷰에 레이블 배치
}

잠깐...
레이블 설정에서 문자를 오른쪽 정렬로 한 이유는 레이블의 텍스트가 오른쪽으로 붙었으면 좋겠다고 생각했기 때문이다. 그런데 스크롤뷰에 넣으니 왼쪽 상단에 붙어버렸다. 즉, 내가 의도한 것과 완전 다른 결과가 나온 것이다.
그럼 수정을 해보자...
우선은 레이블을 스크롤뷰의 오른쪽으로 붙이는 것부터 시작하기로 했다.
어떻게 하면 위치를 변경할 수 있을까 고민하다가 스크롤뷰에서 컨텐츠뷰를 건드리는 옵션이 있는지 확인해 보았다.

있다!
offset, inset 같은 항목도 보이지만 내 눈에 먼저 들어온 것은 contentLayoutGuide였다. 아마도 컨텐츠뷰의 오토레이아웃을 설정하는 코드가 아닐까? 즉시 코드에 적용시켜 보았다.
scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor)
내가 생각하기론 컨텐츠뷰의 trailingAnchor를 스크롤뷰의trailingAnchor를 기준으로 맞춘다는 의미 같다.
가설을 확인하기 위해 빌드를 해보면...

성공...!!!
가설이 들어맞는 것만큼 기분이 좋은 일은 없는 것 같다.
레이블을 옮겼으니 스크롤이 제대로 작동하는지 확인하기 위해 레이블의 텍스트를 길게 늘리고 테스트를 해본다.
label.text = "1234567890987654321"

역시 한번에 될리가 없다...
레이블의 위치는 의도한 대로 옮길 수 있었지만 이렇게 하니 스크롤이 작동하지 않는다.
결국 contentLayoutGuide는 무엇이었는지 찾아봤다.

직역하자면 스크롤되는 콘텐츠의 크기를 지정하는 데 사용되는 레이아웃 가이드라는 뜻이다.
공식문서의 설명만으로는 부족해서 조금 더 자세히 찾아봤다.
결론적으로 contentLayoutGuide는 스크롤뷰의 컨텐츠에 대한 오토레이아웃을 설정하는 키워드인데, 특징으로는 이 코드를 사용하면 컨텐츠뷰에 요소의 크기가 변경되거나 추가될 때 스크롤뷰의 contentSize를 자동으로 조정한다는 것이다.
이 말은 즉, 개발자가 임의로 contentSize를 지정해주지 않더라도 contentLayoutGuide 코드를 사용하면 시스템이 알아서 contentSize를 지정하여 스크롤뷰의 크기를 초과하면 스크롤 할 수 있도록 조정한다는 뜻이었다...
어쩜 이리 훌륭한 코드가 있을까...
내가 실수한 것은 컨텐츠뷰에 들어갈 컨텐츠의 오토레이아웃에 사용해야 하는걸 스크롤뷰 자체에 지정한 것이다. 오류가 발생하지 않은게 오히려 신기하다...
어쨌든 실수한 것을 알았으니 바로 수정을 해본다. 우선 기존의 코드를 지우고 레이블의 오토레이아웃을 다음과 같이 설정한다.
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
label.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor)
])
그리고 다시 빌드를 해보면...

움직인다!!
드디어 의도대로 코드가 작동하기 시작했다...
이제 레이블을 스크롤뷰의 중앙에 오게하면 되는데... 이것도 오토레이아웃을 변경하면 되지 않을까?
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
label.centerYAnchor.constraint(equalTo: scrollView.contentLayoutGuide.centerYAnchor)
])
이렇게 centerY에 대한 값을 스크롤뷰와 같게 한다면...!!

이래서 코딩이 재밌다. 절대로 예상한 대로 흘러가지 않기 때문이다.
어쨌든... 왜 저렇게 되는 것인지 스크롤뷰에 대해 제대로 파악해보도록 하자.
ScrollView는 UIKit에서 스크롤 가능한 영역을 제공하는 뷰이다. 주로 화면 크기보다 더 큰 컨텐츠를 보여주기 위해 사용되며, 스크롤과 확대/축소를 지원한다.
스크롤뷰는 화면에 스크롤뷰가 보여지는 크기와 위치를 정의할 수 있으며, 정의된 부분은 스크롤뷰의 컨텐츠의 일부만을 보여주는 창의 역할을 한다.
contentSize는 스크롤되는 컨텐츠의 전체 크기를 정의하는 속성이다. contentSize가 스크롤뷰 자체의 bounds.size보다 클 때, 해당 방향으로 스크롤이 가능해진다.
이 때, 컨텐츠뷰는 contentOffset이라는 속성을 가지고 있는데, 스크롤을 하면 이 속성값이 변경되며 다른 컨텐츠가 보이는 것이다.

사진은 vertical 타입의 스크롤뷰를 보여주고 있다. 여기서 ScrollView의 위치는 고정되어 있고, 스크롤 방향에 따라 ContentView의 Offset 값이 바뀌며 컨텐츠들을 보여주게 된다.
ContentView의 Offset은 좌표(0, 0) 즉, 스크롤뷰의 Origin으로 기본 설정이 되어있다. 만약 아래와 같이 코드를 입력한다면
scrollview.contentOffset = CGPoint(x: 50, y: 0)
컨텐츠뷰는 x50 만큼 이동하여 보여줄 것이다.
그럼 위에서 centerY 값을 설정했을 때 왜 레이블이 위로 이동했을까?
우선 centerY는 해당 뷰의 Y축의 중심을 의미한다. 그래서 아래 코드는
label.centerYAnchor.constraint(equalTo: scrollView.contentLayoutGuide.centerYAnchor)
레이블의 Y축 중심을 스크롤뷰의 컨텐츠뷰의 Y축 중심과 맞추겠다는 상대적 오프셋을 설정하는 코드이다.
그런데 컨텐츠 뷰의 중심은 왼쪽 상단에 있다. 때문에 레이블이 위로 올라가게 된 것이다.
만약 같은 방법으로 레이블의 centerX를 설정한다면 레이블은 왼쪽으로 이동할 것이다.
label.centerXAnchor.constraint(equalTo: scrollView.contentLayoutGuide.centerXAnchor)

때문에 레이블을 스크롤뷰의 중심에 오도록 만들고 싶다면 컨텐츠뷰가 아닐 스크롤뷰의 값과 상대적 오프셋을 설정해야 하는 것이다.
혹은 직접 값을 주어도 되지만, 정확한 값을 구하기는 어려울 것이다.
label.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
label.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor)

단 이렇게 하면 아까처럼 스크롤이 작동하지 않는다. 왜냐하면 컨텐츠의 위치를 스크롤뷰의 centerX로 고정했기 때문이다.
우리가 원하는 동작을 위해서는 centerX에 대한 값을 제거해야 한다.

그럼 컨텐츠를 오른쪽으로 붙일 수 있는 방법은 없는걸까??
당연히 존재한다.
어떻게 컨텐츠뷰의 위치를 이동시킬 수 있을까.
offset을 조정한다면? 확실히 위치는 바꿀 수 있겠지만 정확한 위치에 설정하는 것은 어려울 것이라고 생각한다...
이 때, 우리는 contentAlignmentPoint 속성을 사용할 수 있다.
contentAlignmentPoint 는 컨텐츠의 정렬을 스크롤뷰의 특정 지점에 맞추는 역할을 하며, x, y의 값을 주어서 바꿀 수 있다.
만약 x를 1로 설정한다면 컨텐츠는 스크롤뷰의 우측 끝의 위치로 이동하게 된다. x에 0.5를 주면 스크롤뷰의 중앙으로 이동한다. 이것은 y에 대한 경우에도 같다.
scrollView.contentAlignmentPoint.x = 1

이렇게 하면 또 스크롤이 안되는게 아닐까?

잘 움직인다!!
원래 contentAlignmentPoint는 컨텐츠를 원하는 위치에서 보여주기 위해 사용하는 코드이다. 때문에 컨텐츠의 크기가 스크롤뷰의 크기보다 크다면 우리가 원하는 오른쪽 정렬이 아니라, contentAlignmentPoint로 설정된 위치부터 컨텐츠뷰를 보여주게 된다.
그러나 이번 구현의 경우 레이블의 값이 고정된 것이 아닌 하나하나 추가되는 것이기 때문에 contentAlignmentPoint를 사용하여 오른쪽 정렬을 해도 큰 문제는 발생하지 않는다.
그런데...
이렇게 구현했을 경우 한 가지 문제가 발생한다.
만약 레이블의 값이 무수히 많을 경우 스크롤뷰의 시작 위치가 애매해지는 것이다. 나는 스크롤뷰가 항상 우측 끝을 가르켰으면 했기 때문에 이를 해결하려고 고군분투 해보았다.
결과는...
무슨 짓을 해도 성공하지 못했다...
2시간 정도 사투를 펼친 것 같은데... offset을 설정하는게 맞는 것 같은데 이상하게 원하는 실행이 되지 않았다. 결국 혼자서 해결할 수 있다는 생각을 포기하고 튜터님께 도움을 요청했고, 무사히 해결할 수 있었다!!!
우선 스크롤뷰를 항상 우측 끝에 보이게 하고 싶다면 이 코드를 작성해야 한다.
scrollView.contentOffset.x = scrollView.contentSize.width - scrollView.frame.width
위에서 설명한 ContentView의 offset 값을 조정하는 것인데, offset의 x값을 컨텐츠뷰의 width크기만큼 빼고 스크롤뷰의 width값 만큼 뺀 값으로 설정하는 것이다.
이렇게 하면 x의 위치가 이동되고, 항상 우측 끝으로 가도록 설정해줄 수 있다!!
단, 이 함수를 호출하는 것은 viewDidLoad가 아닌 viewDidLayoutSubviews에 선언해야 한다. viewDidLayoutSubviews는 UIViewController의 생명주기 메소드 중 하나로, 뷰 컨트롤러의 뷰와 서브뷰들의 레이아웃 설정이 완료된 직후 호출되는 메소드이다.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateScrollViewContentOffset()
}

오늘은 UI 구현에 거의 필수적인 ScrollView에 대해 진득하게 공부해보았다...
이렇게 하면 이렇게 되겠지? 했는데도 너무 안되어서 머리가 아플 지경이었다...
코딩은 정말 재밌다.
늘 내 예상을 벗어나니까.........
두시간이나 열심히 알아보셨는데 해결하지 못하셨지만, 그래도 전 읽으면서 내내 와.. 와.. 했어요 오토레이아웃으로 위치 잡는 내용도 너무 재밌었습니다 주말동안 오토레이아웃 공부 좀 해야겠어요 ㅠㅠ 우리팀의 마빈,, 화이팅!!