[내배캠] UIKit 입문 팀 프로젝트

nbcamp swift uikit
Written on Sep 4, 2023


6주차가 되었습니다. (2023년 8월 14일 ~ 21일)
개인 과제 때 학습한 내용을 기반으로 UIKit을 활용하여 팀 프로젝트를 진행했습니다.

이번 과제는 팀원의 의견을 수렴하여 스토리보드 없이 코드로 UI를 작성하기로 결정하였습니다. UI 개발에 어려움을 겪을 것이 예상되어, 최대한 심플한 UI와 함께 과제 요구조건에 부합하는 최소 기능을 개발하기로 했습니다. Pinterest App에서 제공하는 UI와 기능이 가장 심플하여 선택하게 되었습니다.

핀터레스트 앱 디자인

Pinterest Main Page Pinterest Detail Page Pinterest Profile Page Pinterest Edit Profile Page

과제 결과

Main Page Create New Item Page Detail Page Profile Page Edit Profile Page

프로젝트 목표

  • UITableView / UICollectionView를 활용하며 피드 화면을 구현합니다.
  • UITabBarController를 활용해서 다양한 메뉴 화면에 접근할 수 있는 UI를 제공합니다.
  • UIStackView와 UIScrollView를 활용하여 프로필 화면을 구현합니다.
  • 사용자가 상호작용할 수 있는 다양한 기능을 제공합니다.
  • UITextView와 UITextField를 활용하여 화면을 구성합니다.
  • UIImagePickerController 혹은 PHPickerViewController를 활용하여 사진을 가져옵니다.

프로젝트 구현

저는 Main.storyboard 없이 코드로 UI를 작성하기 위해 개발환경을 셋업하는 작업과 UICollectionView를 활용하여 Pinterest 스타일의 CollectionView를 구현하는 작업을 진행했습니다.

개발환경 셋업

첫번째로 Main.storyboard와 함께 Info.plist와 Targets의 Info 탭에서 Main을 제거했습니다. 그 후, SceneDelegate.swift에서 ViewController를 직접 생성하여 화면에 보여주도록 작성했습니다.

화면에 첫번째로 보여줄 ContainerViewController인 TabBarController를 작성했습니다. 각 탭에 해당하는 ViewController를 생성하여 TabBarController에 추가해주었습니다.

Language:swift
final class TabBarController: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let tabs: [(vc: UIViewController.Type, icon: String)] = [
            (HomeViewController.self, "house"),
            (NewPostViewController.self, "plus.app"),
            (ProfileViewController.self, "person"),
        ]

        setViewControllers(tabs.map { vc, icon in
            let navigationController = UINavigationController(rootViewController: vc.init())
            let tabBarItem = UITabBarItem(title: nil, image: .init(systemName: icon), selectedImage: .init(systemName: "\(icon).fill"))
            navigationController.tabBarItem = tabBarItem
            return navigationController
        }, animated: false)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        let paddingTop: CGFloat = 10.0
        tabBar.frame = .init(
            x: tabBar.frame.origin.x,
            y: tabBar.frame.origin.y - paddingTop,
            width: tabBar.frame.width,
            height: tabBar.frame.height + paddingTop
        )
    }
}

그리고 SceneDelegate에서 TabBarController를 생성하여 화면에 보여주도록 작성했습니다.

Language:swift
// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        window?.makeKeyAndVisible()

        AuthService.shared.login()
        let tabBarController = TabBarController()
        window?.rootViewController = tabBarController
    }
    // ...
}

Pinterest 스타일의 CollectionView 구현

Pinterest Style의 CollectionView를 구현하기 위해 Custom FlowLayout을 작성해주었습니다. 또한, JSON 형식의 데이터를 준비하여 메인 페이지에 그려주는 방식으로 구현하였습니다. 네트워크에서 불러온다고 가정하여 비동기로 불러오도록 구현하고 UI를 보여주기 전에 Progress Bar를 작성하여 변환 진척도를 사용자에게 보여주었습니다.

UICollectionView

UICollectionView에서 기본으로 사용되는 UICollectionViewFlowLayout은 높이가 유동적으로 변하는 Cell을 구현하기에 적합하지 않습니다. 해당 문서에 따르면 FlowLayout은 항상 동일한 높이에 아이템을 배치하기 때문에, Cell 간 간격이 일정하지 않고 Cell의 높이에 따라 달라지게 됩니다. 그런 이유로 Pinterest 스타일의 CollectionView를 구현하기 위해서는 Custom FlowLayout을 작성해야 합니다.

CollectionViewFlowLayout

PinterestCollectionViewFlowLayout

전체 코드 보기

Language:swift
import UIKit

@objc protocol PinterestCollectionViewDelegateFlowLayout: AnyObject {
    @objc optional func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, contentHeightAt indexPath: IndexPath) -> CGFloat
    @objc optional func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, contentPaddingForSectionAt section: Int) -> CGFloat
}

final class PinterestCollectionViewFlowLayout: UICollectionViewFlowLayout {
    var numberOfColumns = 1
    var contentPadding: CGFloat = 0
    var headerHeight: CGFloat = 0

    weak var delegate: PinterestCollectionViewDelegateFlowLayout?

    private var attributesCache: [UICollectionViewLayoutAttributes] = []
    private var columnHeights: [CGFloat] = []

    override var collectionViewContentSize: CGSize {
        guard let collectionView else { return .zero }
        return .init(
            width: collectionView.bounds.width,
            height: (columnHeights.max() ?? 0) + headerHeight * 2)
    }

    override func prepare() {
        super.prepare()
        guard let collectionView else { return }

        attributesCache = []
        columnHeights = .init(repeating: 0, count: numberOfColumns)

        for section in 0..<collectionView.numberOfSections {
            let contentPadding = delegate?.collectionView?(collectionView, layout: self, contentPaddingForSectionAt: section) ?? contentPadding
            let contentWidth = (collectionView.bounds.width - (CGFloat(numberOfColumns + 1) * contentPadding)) / CGFloat(numberOfColumns)
            let columnOffsets: [CGFloat] = (0..<numberOfColumns).map { CGFloat($0) * (contentWidth + contentPadding) + contentPadding }

            var column = 0
            for item in 0..<collectionView.numberOfItems(inSection: section) {
                let indexPath = IndexPath(item: item, section: section)
                let contentHeight = contentPadding * 2 + (delegate?.collectionView?(collectionView, layout: self, contentHeightAt: indexPath) ?? contentWidth)
                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                attributes.frame = CGRect(x: columnOffsets[column], y: columnHeights[column] + headerHeight + contentPadding, width: contentWidth, height: contentHeight)
                attributesCache.append(attributes)

                columnHeights[column] = columnHeights[column] + contentHeight + contentPadding
                column = (column + 1) % numberOfColumns
            }
        }
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var attributes = attributesCache.filter { $0.frame.intersects(rect) }
        if let headerAttributes = layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) {
            attributes.append(headerAttributes)
        }
        return attributes
    }

    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if elementKind == UICollectionView.elementKindSectionHeader {
            let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: indexPath)
            attributes.frame = .init(x: 0, y: 0, width: collectionView?.frame.width ?? 0, height: headerHeight)
            return attributes
        }
        return nil
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        attributesCache[indexPath.item]
    }
}

전체 코드 보기

Custom FlowLayout을 작성하기 위해서 UICollectionViewFlowLayout을 상속합니다. 그리고 몇가지 프로퍼티와 함수를 오버라이드해야 합니다.

  • collectionViewContentSize: CollectionView의 ContentSize를 반환합니다. Cell의 높이에 따라 CollectionView의 높이가 유동적으로 변하므로 이를 계산해야 합니다.
  • prepare: CollectionView의 레이아웃을 준비합니다. Cell의 위치를 계산하고, CollectionView의 ContentSize를 계산한 뒤에 메모리에 저장해둡니다. IndexPath에 대응하는 레이아웃 속성을 저장합니다.
  • layoutAttributesForElements: CollectionView의 레이아웃을 반환합니다. prepare에서 계산한 레이아웃을 반환합니다.
  • layoutAttributesForItem: IndexPath에 대응하는 레이아웃을 반환합니다. prepare에서 계산한 레이아웃을 반환합니다.

모든 Cell의 높이가 다르므로 Cell의 높이를 저장하고 있을 속성이 필요합니다. 클래스에 contentHeights 속성을 선언하고 prepare에서 계산한 높이를 저장합니다.

Language:swift
private var columnHeights: [CGFloat] = []

prepare이 실행되면 contentHeights를 column의 개수만큼의 요소를 가진 배열로 초기화합니다. 그리고 CollectionView가 가진 요소를 모두 순회하며 너비와 높이를 계산합니다.

  • contentWidth: CollectionView의 너비와 열 개수를 통해 구한 하나의 열의 너비입니다.
  • contentHeight: 외부에서 주입된 높이 값입니다.

이를 기반으로 Cell의 위치를 계산하여 frame을 생성한 뒤, attributesCache에 저장합니다.

contentHeights에 Cell의 높이를 모두 저장합니다. column 속성을 통해 현재 순회가 몇 번째 위치에 열인지 저장합니다.

Language:swift
override func prepare() {
    super.prepare()
    guard let collectionView else { return }

    attributesCache = []
    columnHeights = .init(repeating: 0, count: numberOfColumns)

    for section in 0..<collectionView.numberOfSections {
        let contentPadding = delegate?.collectionView?(collectionView, layout: self, contentPaddingForSectionAt: section) ?? contentPadding
        let contentWidth = (collectionView.bounds.width - (CGFloat(numberOfColumns + 1) * contentPadding)) / CGFloat(numberOfColumns)
        let columnOffsets: [CGFloat] = (0..<numberOfColumns).map { CGFloat($0) * (contentWidth + contentPadding) + contentPadding }

        var column = 0
        for item in 0..<collectionView.numberOfItems(inSection: section) {
            let indexPath = IndexPath(item: item, section: section)
            let contentHeight = contentPadding * 2 + (delegate?.collectionView?(collectionView, layout: self, contentHeightAt: indexPath) ?? contentWidth)
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = CGRect(x: columnOffsets[column], y: columnHeights[column] + headerHeight + contentPadding, width: contentWidth, height: contentHeight)
            attributesCache.append(attributes)

            columnHeights[column] = columnHeights[column] + contentHeight + contentPadding
            column = (column + 1) % numberOfColumns
        }
    }
}

attributesCacheUICollectionViewLayoutAttributes 타입으로 Cell의 위치와 크기를 저장합니다. 이는 layoutAttributesForElementslayoutAttributesForItem에서 사용됩니다.

  • layoutAttributesForElements: attributesCache에서 현재 보이는 화면인 rect와 겹치는 레이아웃을 반환합니다.
  • layoutAttributesForItem: attributesCache에서 indexPath에 해당하는 레이아웃을 반환합니다.
Language:swift
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    attributesCache.filter { $0.frame.intersects(rect) }
}

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    attributesCache[indexPath.item]
}

마지막으로 collectionViewContentSize를 오버라이드하여 CollectionView의 ContentSize를 반환합니다. columnHeights에서 최대 높이를 가져와서 반환합니다.

Language:swift
override var collectionViewContentSize: CGSize {
    guard let collectionView else { return .zero }
    return .init(
        width: collectionView.bounds.width,
        height: (columnHeights.max() ?? 0) + headerHeight * 2)
}

이로써 Pinterest 스타일의 CollectionView를 구현할 수 있습니다.

회고

이번 과제에서는 CollectionViewFlowLayout에 대해 알아보고 Pinterest 스타일의 레이아웃을 직접 작성해보았습니다. frame을 활용해서 Cell의 높이와 너비 그리고 x, y 좌표를 설정해서 직접 배치하는 방법으로 UI을 그리는 방법이 생각보다 동작을 잘해서 놀라웠던 경험이었습니다. 이를 구현하기 위해 여러 UICollectionView 사용방법을 보면서 이 뿐만 아니라 굉장히 다양한 용도로 사용하고 있었고, 또 애플에서 제공하는 기능이 훨씬 방대해서 UICollectionView를 더 많이 다양하게 사용해볼 필요성을 느꼈습니다.