6주차가 되었습니다. (2023년 8월 14일 ~ 21일)
개인 과제 때 학습한 내용을 기반으로 UIKit을 활용하여 팀 프로젝트를 진행했습니다.
이번 과제는 팀원의 의견을 수렴하여 스토리보드 없이 코드로 UI를 작성하기로 결정하였습니다. UI 개발에 어려움을 겪을 것이 예상되어, 최대한 심플한 UI와 함께 과제 요구조건에 부합하는 최소 기능을 개발하기로 했습니다. Pinterest App에서 제공하는 UI와 기능이 가장 심플하여 선택하게 되었습니다.
핀터레스트 앱 디자인
과제 결과
프로젝트 목표
- 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에 추가해주었습니다.
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를 생성하여 화면에 보여주도록 작성했습니다.
// 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을 작성해야 합니다.
PinterestCollectionViewFlowLayout
전체 코드 보기
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
에서 계산한 높이를 저장합니다.
private var columnHeights: [CGFloat] = []
prepare
이 실행되면 contentHeights
를 column의 개수만큼의 요소를 가진 배열로 초기화합니다. 그리고 CollectionView가 가진 요소를 모두 순회하며 너비와 높이를 계산합니다.
contentWidth
: CollectionView의 너비와 열 개수를 통해 구한 하나의 열의 너비입니다.contentHeight
: 외부에서 주입된 높이 값입니다.
이를 기반으로 Cell의 위치를 계산하여 frame을 생성한 뒤, attributesCache
에 저장합니다.
contentHeights
에 Cell의 높이를 모두 저장합니다. column
속성을 통해 현재 순회가 몇 번째 위치에 열인지 저장합니다.
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
}
}
}
attributesCache
는 UICollectionViewLayoutAttributes
타입으로 Cell의 위치와 크기를 저장합니다. 이는 layoutAttributesForElements
와 layoutAttributesForItem
에서 사용됩니다.
layoutAttributesForElements
:attributesCache
에서 현재 보이는 화면인rect
와 겹치는 레이아웃을 반환합니다.layoutAttributesForItem
:attributesCache
에서indexPath
에 해당하는 레이아웃을 반환합니다.
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
에서 최대 높이를 가져와서 반환합니다.
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를 더 많이 다양하게 사용해볼 필요성을 느꼈습니다.