최근에는 기존에 유지보수 및 새로운 기능 추가를 진행하던 프로젝트에 더해, 새로운 프로젝트에 참여하게 되었다. 기존에 맡았던 앱은 SwiftUI로 구현되어있지만, 새롭게 참여하게된 프로젝트는 UIKit으로 진행하면서 MVVM + Combine으로 Input/Output 패턴을 적용해서 구현 중에 있다. 구현하면서 새롭게 알게 된 개념들이나, 혹은 대충 넘긴 부분들 및 고민했던 부분들에 대해서 추후 포스트에 작성하기 위해서 개인 노션에 리스트를 작성 중이다. 일부는 프로젝트를 진행하면서 포스트를 통해 미리 정리해둘 것이고 이번 포스트가 그 중 하나다.

다음은 내가 이번에 구현해야 하는 뷰다. 현재 디자인 및 명세가 조금씩 바뀌고 있는 상황에 구현도 많이 남아서, 다른 부분들을 진행하면서 했던 고민들에 대해서 모두 이 포스트에 나열하면 너무 길어질 것 같아서, 해당 뷰가 완성되는 시점에 새로운 포스트로 글을 작성할 것 같다. 그래도 간략하게 처음에 했던 생각에 대해서 언급해보자면, 일단 상단은 UISegmentedControl로 구현하면 될 것 같고, 각 문제를 표현하는 방법은 테이블뷰나 컬렉션 뷰 중에서 사용하는 것이 보편적일 것 같은데, 드롭다운과 '모두' 및 '오답만'을 선택하는 메뉴를 어떻게 구현할지다. 물론 이번 포스트에서는 저 메뉴에 대해서 중점적으로 다루겠지만, 현재 생각으로는 드롭다운도 따로 커스텀 뷰로 넣어두고 unfold될 때 나오는 드롭다운 메뉴들에 대해서 컬렉션 뷰를 이용할까 생각중이다.
그렇다면 저 '모두'와 '오답만'에 대해서 어떻게 구현하는 것이 좋을까? 저 부분 역시 가장 먼저 들었던 생각이 '컬렉션 뷰를 사용해볼까?'였다. CollectionView가 물론 반복적으로 이용되는 어떠한 리스트나 그리드에서 많이 사용되기는 하지만, 뭔가 셀의 수가 동적으로 변경되거나, 표현해야하는 양이 어느 정도 존재할 때 사용한다. 그렇기 때문에 굳이 컬렉션뷰를 사용해야하나 라는 생각이 가장 먼저 들었다. 왜냐하면 오직 두 개의 정보를 정적으로 보여주는 테이블이기 때문이다.
그래서 고민을 하다가 최근에 팀원의 코드를 리뷰하던 중 스택뷰를 사용했던 것이 기억 났다. 로그인 뷰에서 headerview 및 footerview와 함께 중앙에 차지하는 뷰를 따로 구현해서 스택뷰에 넣었던 것으로 기억한다. 때마침 SwiftUI에서는 VStack이나 HStack, ZStack을 매번 사용하지만, UIKit에서는 StackView를 혼자 뷰를 그릴 때도 딱히 사용해본 적이 없기 때문에, 이번 기회를 통해 정리해보고자 한다. 비록 구현은 이미 스택뷰를 적용해놨다.
A streamlined interface for laying out a collection of views in either a column or a row.
스택 뷰는 오토레이아웃의 힘을 활용하여 기기의 방향, 화면 크기, 사용 가능한 공간의 변화에 따라 동적으로 적용되는 유저 인터페이스를 제공 가능함. 스택뷰는 뷰들의 레이아웃을 arrangedSubviews라는 프로퍼티를 통해서 관리한다. 이러한 뷰들은 스택의 axis에 따라 arrangedSubviews 배열 순서에 맞게 위치한다. 정확한 레이아웃은 스택 뷰의 axis, distribution, alignment, spacing, 및 다른 프로퍼티들에 의해 결정된다.
스택 뷰를 사용하기 위해서, 스토리보드를 연 다음 Horizontal Stack View나 Vertical Stack View를 Object library로 부터 드래그 한 다음, 위치 시키고 싶은 곳에 놓는다. 그 다음으로, view나 control을 스택 안에 놓는다. 추가적인 뷰나 컨트롤을 필요에 따라 스택에 추가하는 것이 가능하다. Interface Builder는 내부의 컨텐츠에 따라 스택의 크기를 조절한다. 스택의 컨텐츠를 Attributes inspector에서 Stack View의 프로퍼티를 수정함으로써 조절 가능하다.
Note
You're responsible for defining the position and (optionally) the size of the stack view. The stack view then manages the layout and size of its content.
스택 뷰는 arranged view의 위치 및 사이즈를 조정하기 위해 오토 레이아웃을 사용한다. 스택 뷰는 스택의 축에 맞게 가장자리에 첫 번째와 마지막 arranged view를 정렬한다. Horizontal stack에서는, 첫 번째 뷰의 leading edge가 stack의 leading edge에 꽂힌다는 뜻이며, 마지막 뷰의 trailing edge가 stack view의 trailing edge에 꽂힌다는 뜻이다. Vertical stack에서는, 비슷하게 상단과 하단의 edge가 stack view의 상단과 하단에 꽂힌다는 뜻이다. 만약 stack view의 isLayoutMarginsRelative 프로퍼티가 true로 되어있다면, 스택 뷰는 이 컨텐츠를 스택의 테두리가 아닌 근접한 margin에 놓는다.
UIStackView.Distribution.fillEuqally distribution을 제외한 모든 distribution에서는, 스택 뷰는 각각의 뷰의 intrinsicContentSize 프로퍼티를 이용해 사이즈를 계산한다. UIStackView.Distribution.fillEqually는 모든 arranged view의 사이즈를 재조정해서 스택 뷰의 축에 맞게 같은 크기로 채운다. 가능하다면, 스택 뷰는 모든 arranged view들을 축 방향으로 가장 긴 intrinsic size를 가진 뷰에 맞게 확장시킨다.
UIStackView.Alignment.fill 배열을 제외하고는, 스택 뷰는 각각의 arranged view의 intrinsicContentSize 프로퍼티를 이용하여 스택의 축에 수직인 사이즈를 계산한다. UIStackView.Alignment.fill은 모든 arranged view의 크기를 재조정하여 축의 수직 방향으로 채워놓을 수 있도록 한다. 가능하다면, 스택 뷰는 모든 arranged view들을 스택의 축에 수직인 가장 큰 intrinsic size에 맞추려고 한다.
다음은 스택 뷰를 이용하여 컨텐츠를 레이아웃 하는 일반적인 방법들이다:
스택 뷰는 arranged view들의 위치와 크기를 관리한다. 스택 뷰는 몇 가지 프로퍼티를 통해서 스택 뷰가 컨텐츠를 어떻게 레이아웃할지 정의한다.
일반적으로, 하나의 스택 뷰를 통해 적은 수의 아이템을 표현한다. 스택 뷰 안에 스택 뷰를 삽입함으로써, 보다 복잡한 계층 구조의 뷰를 표현할 수 있다.
또한 추가적인 arranged view에 대한 제약 조건을 통해 arranged view들의 표현을 세부적으로 조절할 수 있다. 예를 들어, 제약조건을 사용해서 뷰의 최소 혹은 최대 크기를 지정할 수 있다. 또는 aspect ratio를 정의할 수 있다. 스택 뷰는 이러한 constraint들을 레이아웃을 배치할 때 이용한다.
Note
Be careful to avoid introducing conflicts when adding constraints to views inside a stack view. As a general rule, if a view's size defaults back to its intrinsic content size for a given dimension, you can safely add a constraint for that dimension.
스택 뷰는 arrangedSubviews 프로퍼티가 항상 subviews 프로퍼티의 부분집합임을 보장한다. 특별하게, 스택 뷰는 다음과 같은 규칙을 보장한다:
arrangedSubviews가 항상 subviews 배열의 부분집합을 포함하고 있음에도, 이 배열들의 순서는 독립적이다.
스택 뷰는 자동으로 arrangedSubviews 배열에 뷰가 추가되거나 제거되거나 삽입될 때, 또 arranged subview의 isHidden 프로퍼티가 변경될 때마다 자동으로 레이아웃을 업데이트한다.
// Appears to remove the first arranged view from the stack.
// The view is still inside the stack, it's just no longer visible, and no longer contriubtes to the layout.
let firstView = stackView.arrangedSubviews[0]
firstView.isHidden = true
스택뷰는 자동으로 프로퍼의 변화에 대해서 반응한다. 예를 들어, 스택의 시작을 stack view의 axis 프로퍼티 업데이트로 동적으로 변경할 수 있다.
// Toggle between a vertical and horizontal stack.
if stackView.axis == .horizontal {
stackView.axis = .vertical
} else {
stackViwe.axis = .horizontal
}
arranged subviews의 isHidden 프로퍼티에 대한 변화와 스택뷰의 프로퍼티를 애니메이션 블록 안에 넣음으로써 변화에 대한 애니메이션을 제공할 수 있다.
// Animates removing the first item in the stack.
UIView.animate(withDuration: 0.25) { () -> Void in
let firstView = stackView.arrangedSubviews[0]
firstView.isHidden = true
}
마지막으로, Interface Builder에서 직접 stack view의 프로퍼티 값들을 size-class 특정 값을 정의할 수 있다. 시스템은 자동으로 이러한 변화에 맞게 자동으로 표시한다.

보다싶이, 나는 아주 단순하게 구현한 상황이기 때문에 사실 너무나도 간단하게 구현할 수 있었다. 처음 코드를 구현했을 때는 일단 생각한대로 잘 나왔었는데, 자꾸 xcode 터미널 창에서 layout 에러가 발생하는 것이다. 기억이 잘 안나지만, 아마 내 생각에 그 때의 스택뷰 상태가 아마 distribution.fillEqually가 켜져있고, 각각의 arrangedSubview들의 크기가 모두 상수로 저장되어있었으며, 스택뷰의 크기 또한 지정해줘서 생겼던 에러로 기억한다. 그렇게 해서 현재 코드에는 각각의 arrangedSubview들의 높이를 지정해주고 스택뷰 자체의 크기에 대해서는 생략하고 위치만 잡아줬다.(위에서 공식문서에서 오토레이아웃 관련해서 정리해둔 부분 참고)
또, 왼쪽에서 살짝 떨어져야하는 부분이 있기 때문에, 이러한 부분에 대해서 마진을 이용했고 위에서 정리한 프로퍼티 중에서 isLayoutMarginsRelativeArrangement 프로퍼티를 true로 세팅 후, inset을 설정해줬다.
final class WrongQuestionMenuItems: UIStackView {
// MARK: - Properties
private let totalOption: WrongQuestionMenuItem = .init(title: "모두")
private let incorrectOnlyOption: WrongQuestionMenuItem = .init(title: "오답만")
let input: PassthroughSubject<WrongQuestionViewModel.Input, Never> = .init()
// MARK: - Intializers
init() {
super.init(frame: .zero)
addViews()
initStack()
setUI()
setItemsState(isIncorrectOnly: false)
setActions()
}
required init(coder: NSCoder) {
fatalError("no initializer for coder: OnlyIncorrectMenu")
}
// MARK: - Methods
func setItemsState(isIncorrectOnly: Bool) {
if isIncorrectOnly {
totalOption.textColor = .coolNeutral400
incorrectOnlyOption.textColor = .coolNeutral800
} else {
totalOption.textColor = .coolNeutral800
incorrectOnlyOption.textColor = .coolNeutral400
}
}
private func initStack() {
axis = .vertical
distribution = .fillEqually
alignment = .leading
isLayoutMarginsRelativeArrangement = true
layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
}
private func setUI() {
backgroundColor = .white
layer.borderWidth = 1
layer.borderColor = UIColor.coolNeutral200.cgColor
layer.masksToBounds = true
layer.cornerRadius = 8
}
private func setActions() {
totalOption.tag = 0
incorrectOnlyOption.tag = 1
totalOption.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sendMenuOptionClicked(_:))))
incorrectOnlyOption.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sendMenuOptionClicked(_:))))
}
@objc private func sendMenuOptionClicked(_ sender: UITapGestureRecognizer) {
let idx = sender.view?.tag ?? 0
if idx == 0 {
input.send(.menuItemClicked(isIncorrectOnly: false))
} else if idx == 1 {
input.send(.menuItemClicked(isIncorrectOnly: true))
}
}
}
// MARK: - Auto Layout
extension WrongQuestionMenuItems {
private func addViews() {
addArrangedSubview(totalOption)
addArrangedSubview(incorrectOnlyOption)
NSLayoutConstraint.activate([
totalOption.heightAnchor.constraint(equalToConstant: 41),
totalOption.widthAnchor.constraint(equalToConstant: 123)
])
}
}
이 컴포넌트를 가지고 있는 뷰컨에서는 오토레이아웃에서 위치만 지정해줬다.
private func addViews() {
self.view.addSubview(menuItems)
menuItems.translatesAutoresizingMaskIntoConstraints = false
// [...]
NSLayoutConstraint.activate([
// [...]
menuItems.topAnchor.constraint(equalTo: menuButton.bottomAnchor, constant: 16),
menuItems.trailingAnchor.constraint(equalTo: menuButton.trailingAnchor)
])
여러 Stack View의 중요한 property 들에 대해서 배웠다. axis나 spacing은 이미 알고 있었지만, distribution과 alignment의 차이(방향), isLayoutMarginRelativeArrangement 등에 대해서 알게 되었고, 추가적으로 여러 상황에서 어떻게 오토레이아웃을 적절하게 사용할 수 있을지에 대해서 알게 되었다. 또, 비록 스택 뷰와는 직접적인 연관이 있다고 하기는 애매하지만, 위에서 정리한 내용처럼 arrangedSubviews 배열은 순서에 맞게 배치되는 반면, subviews 배열에서는 index에 맞게 z축 방향으로 배치되는 것은 처음 알게된 사실이다. 이전까지는 그냥 subview로 붙여놓고, 필요에 따라 bringSubviewToFront 메소드를 이용해서 위에 위치하게 했었다.
생각보다 내용이 많아서, 나중에도 스택 뷰를 사용할 때마다 기억이 나지 않는 부분이 있다면 이 포스트를 참고하면서 진행할 것 같다. 다음에는 프로젝트를 진행하면서 배우거나 느꼈던 다른 부분들에 대해서 다뤄보겠다.