Nested UIStackView 구현하기

shintwl·2024년 3월 20일
0

3중으로 중첩된 UIStackView를 구현할 일이 있어서 해당 내용을 기록헀습니다

요약

Vertical StackView라면 내부 구성요소의 모든 높이,
Horizontal StackView라면 내부 구성요소의 모든 너비가 명확하게 계산될 수 있도록 하면 됩니다.

목표 UI

결과를 보여주는 텍스트 뷰를 가변적으로 조정되게 하여, 여러 화면 사이즈에 대응할 수 있도록 합니다 (ppt로 만들었습니다)

UI 분석

UI를 아래처럼 구성했습니다

  • Vertical StackView
    • Horizontal StackView
      • Vertical StackView
        • UILabel
        • UIImageView
        • UILabel
        • UIButton
      • Vertical StackView
        • UILabel
        • UIImageView
        • UILabel
        • UIButton
    • Separator (UIView)
    • UILabel
    • UITextView

3-depth의 두 Vertical StackView는 UI 상으로 동일해서 클래스로 따로 분리해내면 재사용이 용이할 것으로 보입니다

와이어프레임 제작하기

가장 바깥의 Vertical StackView 내의 모든 컴포넌트의 높이가 명확하도록 설정합니다
단, UITextView는 높이가 가변적이어야 하므로 최소 높이를 지정합니다

Horizontal StackView의 높이는 내부 구성요소를 채워야 정확하게 정해지기 때문에 지금은 임시값으로 넣어줍니다

Horizontal StackView 내부의 두 UIView는 distribution = .fillEqualy로 인해 너비가 명확하게 정해질 수 있기 때문에 따로 너비를 설정하지 않아도 괜찮습니다

위치가 제대로 잡혔는지 확인하기 위해 임시 배경색을 지정합니다

결과화면

소스코드

final class ViewController: UIViewController {
    
    private lazy var hStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            {
                let view = UIView()
                view.backgroundColor = .green
                return view
            }(),
            {
                let view = UIView()
                view.backgroundColor = .green
                return view
            }(),
        ])
        view.axis = .horizontal
        view.spacing = 20
        view.distribution = .fillEqually
        view.backgroundColor = .systemGray
        return view
    }()
    
    private var separatorView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemGray
        return view
    }()
    
    private var resultTitleLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .red
        return label
    }()
    
    private var resultTextView: UITextView = {
        let view = UITextView()
        view.backgroundColor = .brown
        return view
    }()
    
    private lazy var vStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            hStackView,
            separatorView,
            resultTitleLabel,
            resultTextView
        ])
        view.axis = .vertical
        view.spacing = 20
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        layout()
    }
    
    private func layout() {
        view.backgroundColor = .white
        [vStackView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        let horizontalMargin = 15.0
        let verticalMargin = 10.0
        
        NSLayoutConstraint.activate([
            vStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: verticalMargin),
            vStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: horizontalMargin),
            vStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -1 * horizontalMargin),
            vStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -1 * verticalMargin),
            hStackView.heightAnchor.constraint(equalToConstant: 300),
            separatorView.heightAnchor.constraint(equalToConstant: 2),
            resultTitleLabel.heightAnchor.constraint(equalToConstant: 30),
            resultTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1)
        ])
    }
}

UI 작성하기

Horizontal StackView

SectionView

별도의 클래스로 제작합니다
UI구성은 아래와 같습니다 (위 이미지 참고)

  • Vertical StackView
    • UILabel
    • UIImageView
    • UILabel
    • UIButton

Vertical StackView의 Rect를 SuperView와 동일하게 설정합니다

UIImageView는 가로, 세로를 1:1의 비율로 설정하고, 가로의 길이는 SuperView와 동일하게 설정합니다

Vertical StackView의 alignment가 .fill이 기본이기 때문에 너비는 SuperView만큼 늘어납니다
SuperView의 너비가 명확하게 계산되어야 ImageView의 너비 & 높이에 문제가 생기지 않고 Vertical StackView의 높이도 정해질 수 있습니다

다행히도 Horizontal StackView의 distribution = .fillEqualy로 인해 명확하게 계산되기 때문에 괜찮습니다

Horizontal StackView의 너비 정해짐
=> SectionView 너비 정해짐
=> Vertical StackView의 너비 정해짐
=> ImageView 너비 정해짐
=> ImageView 높이 정해짐
=> Vertical StackView 높이 정해짐
=> SectionView 높이 정해짐

나머지 컴포넌트의 높이를 상수로 설정해줍니다

코드

final class CompanyView: UIView {
    private lazy var companyNameLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 24, weight: .bold)
        return label
    }()
    
    private lazy var imageView: UIImageView = {
        let view = UIImageView()
        view.backgroundColor = .systemGray6
        view.contentMode = .scaleAspectFit
        return view
    }()
    
    private lazy var descriptionLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 20)
        return label
    }()
    
    private var actionButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("실행", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 24)
        return button
    }()
    
    private lazy var vStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            companyNameLabel,
            imageView,
            descriptionLabel,
            actionButton
        ])
        view.axis = .vertical
        view.spacing = 10
        return view
    }()
    
    init(companyName: String, solutionDescription: String, action: UIAction) {
        super.init(frame: .zero) // auto layout 사용
        companyNameLabel.text = companyName
        descriptionLabel.text = solutionDescription
        actionButton.addAction(action, for: .touchUpInside)
        layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func layout() {
        [vStackView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            vStackView.topAnchor.constraint(equalTo: topAnchor),
            vStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            vStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            vStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
            
            companyNameLabel.heightAnchor.constraint(equalToConstant: 30),
            imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),
            descriptionLabel.heightAnchor.constraint(equalToConstant: 26),
            actionButton.heightAnchor.constraint(equalToConstant: 30),
        ])
    }
}

Horizontal StackView 높이 설정

SectionView의 높이가 명확하게 계산될 수 있기 때문에 Horizontal StackView의 높이를 SectionView와 같게 합니다

기존

hStackView.heightAnchor.constraint(equalToConstant: 300),

변경

hStackView.heightAnchor.constraint(equalTo: companyAView.heightAnchor),

그 외

이쪽은 전체 소스코드로 확인해주세요
특별한 건 없습니다

최종

화면

소스코드

final class CompanyView: UIView {
    private lazy var companyNameLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 24, weight: .bold)
        return label
    }()
    
    private lazy var imageView: UIImageView = {
        let view = UIImageView()
        view.backgroundColor = .systemGray6
        view.contentMode = .scaleAspectFit
        return view
    }()
    
    private lazy var descriptionLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 20)
        return label
    }()
    
    private var actionButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("실행", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 24)
        return button
    }()
    
    private lazy var vStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            companyNameLabel,
            imageView,
            descriptionLabel,
            actionButton
        ])
        view.axis = .vertical
        view.spacing = 10
        return view
    }()
    
    init(companyName: String, solutionDescription: String, action: UIAction) {
        super.init(frame: .zero) // auto layout 사용
        companyNameLabel.text = companyName
        descriptionLabel.text = solutionDescription
        actionButton.addAction(action, for: .touchUpInside)
        layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func layout() {
        [vStackView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            vStackView.topAnchor.constraint(equalTo: topAnchor),
            vStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            vStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            vStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
            
            companyNameLabel.heightAnchor.constraint(equalToConstant: 30),
            imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),
            descriptionLabel.heightAnchor.constraint(equalToConstant: 26),
            actionButton.heightAnchor.constraint(equalToConstant: 30),
        ])
    }
}

final class ViewController: UIViewController {
    
    private lazy var companyAView = CompanyView(
        companyName: "회사A",
        solutionDescription: "솔루션A",
        action: UIAction(handler: { _ in
            print("회사A")
        }))
    
    private var companyBView = CompanyView(
        companyName: "회사B",
        solutionDescription: "솔루션B",
        action: UIAction(handler: { _ in
            print("회사B")
        }))
    
    private lazy var hStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            companyAView,
            companyBView
        ])
        view.axis = .horizontal
        view.distribution = .fillEqually
        view.spacing = 20
        return view
    }()
    
    private var separatorView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemGray
        return view
    }()
    
    private var resultTitleLabel: UILabel = {
        let label = UILabel()
        label.text = "결과"
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 24, weight: .bold)
        return label
    }()
    
    private var resultTextView: UITextView = {
        let view = UITextView()
        view.backgroundColor = .systemGray6
        view.isEditable = false
        view.font = .systemFont(ofSize: 18)
        view.text = "" // 생략
        return view
    }()
    
    private lazy var vStackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [
            hStackView,
            separatorView,
            resultTitleLabel,
            resultTextView
        ])
        view.axis = .vertical
        view.spacing = 20
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        layout()
    }
    
    private func layout() {
        view.backgroundColor = .white

        [vStackView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        let horizontalMargin = 15.0
        let verticalMargin = 10.0
        
        NSLayoutConstraint.activate([
            vStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: verticalMargin),
            vStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: horizontalMargin),
            vStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -1 * horizontalMargin),
            vStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -1 * verticalMargin),
            
            hStackView.heightAnchor.constraint(equalTo: companyAView.heightAnchor),
            separatorView.heightAnchor.constraint(equalToConstant: 2),
            resultTitleLabel.heightAnchor.constraint(equalToConstant: 30),
            resultTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1)
        ])
    }
}

2개의 댓글

comment-user-thumbnail
2024년 3월 21일

UIkit으로 뷰를그릴때 스택뷰를 거의안쓰다싶이했는데 정말잘쓰면 편하다고하더라고요 ㅎㅎ
swiftUI를 해보니까 전체적인 메커니즘이 스택뷰기반이라는 느낌도강했던거같아요
이게 애플에서 앞으로 밀고가려는 레이아웃방식이라는 느낌이들어서 유킷에서도 스택뷰를활용을 잘해봐야겠다는생각이드네요

혹시 flexlayout에관해서는 어떻게생각하시는지 궁금하네요!

1개의 답글