학생부장님은 아침마다 수백명의 얼굴을 봅니다. 모든 학생들의 얼굴을 외울 수는 없습니다. 교문지도에 걸린 학생들은 학생부장님에게 반, 번호, 이름을 말하고 학생부장님은 그 이름을 걸린 사유와 함께 적어둡니다. 하지만 때때로 불순한 의도를 가진 학생들은 자기 이름을 대는 대신에 다른 친구의 이름을 대곤 합니다.💢
이런 경우에는 나중에 억울한 입장이 생기는 학생은 물론 지도한 선생님들도 모두 곤란한 상황에 놓입니다. 그래서 학생부장님은 항상 이름을 적기 전에 얼굴을 확인하는 습관을 가지고 있습니다.
주된 방법은 학생증입니다. 학생에게 사진이 있는 학생증을 보여달라고 하는 것이지요. 하지만 모든 학생이 학생증을 가지고 다니지는 않습니다.
이럴 때에는 전체 사진첩을 옆에 두고 찾는데 여간 귀찮고 시간이 오래 걸리는 일이 아닙니다. 때로는 사진첩을 찾을 때 몰래 😜 빠져나가는 학생들도 있습니다.
이런 일은 귀찮을 뿐만 아니라 교육적으로도 좋지 않습니다. (모든 학생들이 공평하게 규칙을 적용받아야 하니까요!)
👉 따라서 오늘 구현할 기능은 학년, 반, 번호를 입력하면 바로 학생의 사진을 확인할 수 있는 기능입니다.
컴동쌤이 처음 세운 계획은 아래 그림과 같았습니다. (발퀄 죄송합니다. 😅 )
학년-반-번호를 일렬로 주르륵 쓰면 학번이 됩니다. 이것을 이용해 학생의 사진을 검색하고자 했습니다.
👉 왕초보 개발자도 순식간에 해낼 수 있는 간단한 구조였지요.
컴동쌤은 위의 그림을 들고 학생부장님한테 찾아갔습니다. 하지만 학생부장님은 뭔가 맘에 안 드는 듯 한참을 그림을 보시더니 그림을 한장 그려보였습니다.
"컴동쌤, 내가 스마트폰에 익숙하지 않아서 그런데 좀 더 직관적으로 해줄 수 없을까? 버튼 하나만 딱딱딱 세번 누르면 사진을 찾을 수 있도록 말이야."
첫 기능부터 난관이군요. 그냥 완성하고 보여주면 알아서 쓰셨을텐데라는 원망과 함께 컴동쌤은 자신이 없었지만 일단 해보기로 했습니다.
스택뷰는 자동으로 크기를 계산해서 배열 해주기 때문에 버튼 크기에 구애받지 않고 쉽게 사용할 수 있습니다. 다만 버튼에 등록할 수 있는 selector 함수는 sender만을 인자로 받기 때문에 버튼을 일일히 다 (학년별로, 반별로, 번호별로 🤮 ) 만들 것이 아니라면 별도의 커스텀 버튼 클래스를 구현해야 할 겁니다.
컬렉션 뷰는 cell을 선택하는 기능을 제공하기 때문에 버튼과 비슷하게 사용할 수 있습니다. 다만 cell의 크기를 조절하는데 있어서 신경을 좀 써야겠군요.
collection view를 선택하도록 하겠습니다. 이유는 아래와 같습니다.
위 그림처럼 맨 위에 label로 현재 선택 중인 학생의 정보를 보여주고 학년-반-번호의 단계별로 collection view를 통해 동적으로 다른 버튼 (정확히 말하면 기능을 하는 cell)을 배치하도록 하겠습니다.
스토리보드 대신 코드로 화면을 짤 때는 Entry Point의 역할을 하는 VC를 스토리 보드에서 지정하는 대신에 window에 rootViewController로 지정해야 합니다. 아래 코드는 그 과정을 보여줍니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: scene)
window?.rootViewController = FaceCheckViewController()
window?.makeKeyAndVisible()
}
}
cell을 구현하는 것으로 collection view를 구현해보기 시작할까요?
그 전에 cell의 유형을 정해야 할 것 같군요. 우리가 구현하고자하는 셀은 학년, 반, 번호 별로 총 3가지가 되겠네요. enum으로 구현해 보았습니다.
더불어 유형별로 폰트 사이즈와 cell의 색을 구분하기 위해 두 가지 연산 프로퍼티를 정의해두었습니다.
enum FaceCheckInputType {
case grade, classNumber, number
var cellTitleFontSize: UIFont {
switch self {
case .grade:
return UIFont.systemFont(ofSize: 70)
case .classNumber:
return UIFont.systemFont(ofSize: 50)
case .number:
return UIFont.systemFont(ofSize: 30)
}
}
var cellColor: UIColor {
switch self {
case .grade:
return .green
case .classNumber:
return .yellow
case .number:
return .cyan
}
}
}
이제 본격적으로 cell 클래스 를 만들어 볼까요? 일단 저희가 만드는 셀은 아주 간단합니다. 그냥 한가운데 label 하나만 덩그러니 있는 cell입니다. 코드에 대해 간단하게 설명 드리겠습니다.
class FaceCheckInputCell: UICollectionViewCell {
// MARK: - Properties
//1️⃣ 타입 정의
var inputCellType: FaceCheckInputType? {
didSet {
inputCellLabel.font = inputCellType?.cellTitleFontSize
backgroundColor = inputCellType?.cellColor
}
}
//2️⃣ 레이블
private let inputCellLabel = UILabel()
// MARK: - LifeCycle
override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Helpers
func configureUI() {
self.layer.cornerRadius = 5
self.layer.borderColor = UIColor.black.cgColor
self.layer.borderWidth = 2
addSubview(inputCellLabel)
inputCellLabel.translatesAutoresizingMaskIntoConstraints = false
inputCellLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
inputCellLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}
//3️⃣ API
func setCellText(_ text: String) {
inputCellLabel.text = text
}
}
VC의 UI를 구현해보도록 하겠습니다. 코드가 좀 깁니다. 차근차근 소개해드리겠습니다.
import UIKit
//1️⃣ reuseIdentifier
fileprivate let reuseIdentifier = "FaceCheckInputCell"
class FaceCheckViewController: UIViewController {
// MARK: - Properties
//2️⃣ 뷰모델
let viewModel = FaceCheckViewModel()
//3️⃣ {}()
let studentInfoLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 40)
label.textAlignment = .center
return label
}()
//4️⃣ didSet
var currentInputCellType: FaceCheckInputType = .grade {
didSet {
inputCollectionView.reloadData()
}
}
//5️⃣ UICollectionViewFlowLayout
let inputCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 5
layout.minimumInteritemSpacing = 5
layout.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = false
return collectionView
}()
// MARK: - LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
studentInfoLabel.text = viewModel.studentInfoLabelText
}
// MARK: - Helpers
func configureUI() {
view.backgroundColor = .white
inputCollectionView.delegate = self
inputCollectionView.dataSource = self
inputCollectionView.register(FaceCheckInputCell.self, forCellWithReuseIdentifier: reuseIdentifier)
view.addSubview(studentInfoLabel)
studentInfoLabel.translatesAutoresizingMaskIntoConstraints = false
studentInfoLabel.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
studentInfoLabel.heightAnchor.constraint(equalToConstant: 50).isActive = true
studentInfoLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
studentInfoLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20).isActive = true
view.addSubview(inputCollectionView)
inputCollectionView.translatesAutoresizingMaskIntoConstraints = false
inputCollectionView.topAnchor.constraint(equalTo: studentInfoLabel.bottomAnchor, constant: 50).isActive = true
inputCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10).isActive = true
inputCollectionView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 10).isActive = true
inputCollectionView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -10).isActive = true
}
}
데이터 소스가 담당하는 부분은 cell의 갯수와 cell의 형태입니다.
extension FaceCheckViewController: UICollectionViewDataSource {
//1️⃣ cell 갯수
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
switch currentInputCellType {
case .grade: return 3
case .classNumber: return viewModel.numOfClass
case .number: return viewModel.numOfStudent
}
}
//2️⃣ cell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? FaceCheckInputCell else { return UICollectionViewCell() }
cell.inputCellType = currentInputCellType
let cellText = viewModel.inputCellText(num: indexPath.row + 1, type: currentInputCellType)
cell.setCellText(cellText)
return cell
}
}
collection view를 택한 또 다른 이유는 크기만 정해주면 자동으로 배치해준다는 것입니다. frame을 일일히 정해줄 필요가 없어서 좋습니다. 여기서 크기는 일단은 대강 잡았습니다. 학년은 한 줄에 1개, 반은 한 줄에 3개, 번호는 한 줄에 5개의 cell이 오도록 했습니다. 높이는 학년은 대략 높이의 1/5 나머지 두 종류의 cell의 정사각형이 되도록 했습니다.
코드에 상수를 쓰는 것은 좋지 않지만😅 나중에 리팩토링 할 때 수정하도록 합시다.
extension FaceCheckViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
var width: CGFloat
var height: CGFloat
switch currentInputCellType {
case .grade:
width = view.frame.width - 20
height = view.frame.height / 5
case .classNumber:
width = (view.frame.width - 20 - 10) / 3
height = width
case .number:
width = (view.frame.width - 20 - 20) / 5
height = width
}
return CGSize(width: width, height: height)
}
}
collection view를 버튼의 집합처럼 사용할 수 있는 이유는 바로 이 델리게이트 메소드 덕분입니다. 특정 cell을 선택 (= 터치)할 때 실행됩니다. 선택된 경우 currentInputCellType에 따라서 학년, 반, 번호를 뷰모델에 세팅하고 맨 위에 있는 학생 레이블도 업데이트 합니다. 그리고 currentInputCellType을 다음 입력해야 하는 것으로 설정해줍니다!
아까 currentInputCellType을 선언하면서 didSet에 reloadData를 하도록 해주었으므로 여기서 수정하는 즉시 collection view가 리로드 되면서 다음에 입력할 버튼들이 나옵니다!
extension FaceCheckViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch currentInputCellType {
case .grade:
viewModel.grade = indexPath.row + 1
studentInfoLabel.text = viewModel.studentInfoLabelText
currentInputCellType = .classNumber
case .classNumber:
viewModel.classNumber = indexPath.row + 1
studentInfoLabel.text = viewModel.studentInfoLabelText
currentInputCellType = .number
case .number:
viewModel.number = indexPath.row + 1
studentInfoLabel.text = viewModel.studentInfoLabelText
}
}
}
VC를 구현하면서 계속 viewModel을 썼는데요. viewModel은 여지껏 안 보여드려서 약간 답답함을 느끼는 분도 있으셨을 것 같네요. 얼른 같이 살펴 볼까요?
class FaceCheckViewModel {
var grade: Int?
var classNumber: Int?
var number: Int?
// MARK: - StudentInfoLabelText
var studentInfoLabelText: String {
let gradeText: String = grade != nil ? "\(grade!)학년" : "?학년"
let classNumberText: String = classNumber != nil ? "\(classNumber!)반" : "?반"
let numberText: String = number != nil ? "\(number!)번" : "?번"
return "\(gradeText) \(classNumberText) \(numberText)"
}
// MARK: - inputCellLabel
func inputCellText(row: Int, type: FaceCheckInputType) -> String {
let num = row + 1
switch type {
case .grade:
return "\(num)학년"
case .classNumber:
return "\(num)반"
case .number:
return "\(num)번"
}
}
// MARK: - cellNumberdummyData
var numOfClass: Int {
switch grade {
case 1: return 10
case 2: return 12
case 3: return 9
default: return 0
}
}
var numOfStudent: Int {
return 30 + grade! - classNumber!
}
}
너무 한번에 설명드리기 너무 기네요.. 🙀 차근차근 끊어서 설명드려볼께요.
사용자의 입력값을 저장하는 property입니다. 사용자의 입력을 저장하고 아래 설명할 computed property와 method로 view에 데이터를 전달할 때 사용합니다.
var grade: Int?
var classNumber: Int?
var number: Int?
처음에는 아래처럼 코드를 짰었습니다.
//🤮 code too long...
private var gradeText: String {
guard let grade = self.grade else {
return "?학년"
}
return "\(grade)학년"
}
private var classNumberText: String {
guard let classNumber = classNumber else {
return "?반"
}
return "\(classNumber)반"
}
private var numberText: String {
guard let number = number else {
return "?번"
}
return "\(number)번"
}
var studentInfoLabelText: String {
return "\(gradeText) \(classNumberText) \(numberText)"
}
하지만 단순한 기능인데 너무 길다고 생각을 했고 3항 연산자를 활용해서 아래처럼 바꾸어 주었습니다. force unwrapping을 사용하는 것이 약간 찝찝하지만 nil 확인이 된 상황이므로 문제는 없습니다!
var studentInfoLabelText: String {
let gradeText: String = grade != nil ? "\(grade!)학년" : "?학년"
let classNumberText: String = classNumber != nil ? "\(classNumber!)반" : "?반"
let numberText: String = number != nil ? "\(number!)번" : "?번"
return "\(gradeText) \(classNumberText) \(numberText)"
}
cell에 들어갈 텍스트를 반환합니다. type은 VC만 가지고 있는 것이 좋다고 생각해서 VC에서 인자로 받아왔습니다.
// MARK: - inputCellLabel
func inputCellText(row: Int, type: FaceCheckInputType) -> String {
let num = row + 1
switch type {
case .grade:
return "\(num)학년"
case .classNumber:
return "\(num)반"
case .number:
return "\(num)번"
}
}
아직 서버가 없습니다😭 일단 동적인 척(?)하기 위해서 더미데이터를 넣어줍시다.
// MARK: - cellNumberdummyData
var numOfClass: Int {
switch grade {
case 1: return 10
case 2: return 12
case 3: return 9
default: return 0
}
}
var numOfStudent: Int {
return 30 + grade! - classNumber!
}
결과는 아래와 같습니다. (🙀 디자인은 제발 눈 감아주시기 바랍니다......ㅠㅠ)
학년, 반, 번호를 순서에 맞게 선택할 수 있게 되었습니다!!! 추가적으로 계획한대로 반, 번호의 갯수에 따라 동적으로 버튼을 만들어냅니다.
열심히 코드를 쓰며 시뮬레이터를 돌려보던 컴동쌤! 하지만 곧 잘못 누르면 앱을 껏다켜야한다는 사실을 깨달았습니다. 만약 이 상태로 쓰라고 하면 아마 최악의 사용자 경험을 제공할 것입니다🤮
취소버튼을 간단하게 구현해봅시다!
VC에 캡슐 모양의 취소버튼을 정의합시다. 디자인은 임의로 정했습니다. selector 함수도 하나 연결해줍니다.
let cancelButton: UIButton = {
let button = UIButton()
button.backgroundColor = UIColor(red: 1, green: 0, blue: 0, alpha: 0.3)
button.layer.borderWidth = 1
button.layer.borderColor = UIColor.black.cgColor
button.layer.cornerRadius = 50 / 2
button.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
return button
}()
@objc func cancelTapped() {
switch currentInputCellType {
case .grade: return
case .classNumber:
viewModel.grade = nil
currentInputCellType = .grade
case .number:
viewModel.classNumber = nil
viewModel.number = nil
currentInputCellType = .classNumber
}
}
생각해보니 grade를 선택할 때는 취소 버튼이 안보이는 것이 좋겠군요. (취소할 것이 없으니까요!) 속성을 하나 정의해두고 사용합시다.
private var isCancelButtonHidden: Bool {
return currentInputCellType == .grade ? true : false
}
아래 코드처럼 두 군데 사용했습니다. 처음에 VC가 로드될 때와 currentInputCellType의 didSet에서 사용하면 우리가 의도한대로 버튼이 상황에 맞게 숨겨질 것입니다.
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
studentInfoLabel.text = viewModel.studentInfoLabelText
cancelButton.isHidden = isCancelButtonHidden
}
var currentInputCellType: FaceCheckInputType = .grade {
didSet {
inputCollectionView.reloadData()
cancelButton.isHidden = self.isCancelButtonHidden
studentInfoLabel.text = viewModel.studentInfoLabelText
}
}
취소 버튼에 타이틀을 구현해봅시다. 타이틀을 단순하게 "취소"라고 하기 보다는 "~~ 다시 선택"의 형식으로 동적으로 구현할까요? isHidden과 마찬가지로 계산 프로퍼티를 하나 추가할께요.
private var cancelButtonTitle: String {
switch currentInputCellType {
case .grade:
return ""
case .classNumber:
return "학년 다시 선택"
case .number:
return "반 다시 선택"
}
}
isHidden과 마찬가지의 위치에서 사용하면 됩니다. 다만 isHidden과 타이틀 모두 cancelButton과 관련된 코드인데 이렇게 중복으로 써주기 보다는 하나의 함수로 묶어 줍시다.
func configureCancelButton() {
cancelButton.isHidden = self.isCancelButtonHidden
cancelButton.setTitle(cancelButtonTitle, for: .normal)
}
위 함수를 같은 곳에 VC가 로드될 때와 currentInputCellType의 didSet에서 활용하면 의도한 대로 동작할 것 같습니다!
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
configureCancelButton()
}
var currentInputCellType: FaceCheckInputType = .grade {
didSet {
inputCollectionView.reloadData()
configureCancelButton()
studentInfoLabel.text = viewModel.studentInfoLabelText
}
}
이제 완성된 버튼을 서브뷰에 추가하면 끝입니다.
func configureUI() {
//... 생략...
view.addSubview(cancelButton)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButton.widthAnchor.constraint(equalToConstant: 200).isActive = true
cancelButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
cancelButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10).isActive = true
cancelButton.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -10).isActive = true
}