학생부장님을 위한 기능은 다 구현이 된 것 같군요. 학생부장님은 아침에 학생들을 잡아서(?!) 얼굴을 확인하고 이름을 확인하면 끝입니다.
이제부터는 다른 선생님들을 위한 기능입니다. 특히 담임선생님과 봉사담당선생님입니다. 이 선생님들께서는 교문에서 누가누가 걸렸는지 확인해야합니다.
특히 담임선생님은 우리반 누가 걸렸는지, 봉사담당선생님은 우리 학년에 누가 걸렸는지 구분해서 보실 필요가 있습니다. 따라서 이번에는 리스트에 필터링 기능까지 달아보도록 하겠습니다.
탭바 컨트롤러를 구현해봅시다. 탭바 컨트롤러는 컨트롤러들을 배열의 형태로 가지고 있습니다.
import Foundation
import UIKit
class MainTabController: UITabBarController {
// MARK: - Properties
// MARK: - LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
configureViewControllers()
}
// MARK: - Helpers
func configureViewControllers() {
//1️⃣
let appearance = UITabBarAppearance()
appearance.configureWithDefaultBackground()
appearance.backgroundColor = .systemGray6
self.tabBar.standardAppearance = appearance
self.tabBar.scrollEdgeAppearance = appearance
//2️⃣
self.tabBar.setValue(UIColor.black, forKey: "tintColor")
let faceCheck = FaceCheckViewController()
let nav1 = createNavigationController(image: UIImage(systemName: "checkmark"), title: "교문 지도", rootViewController: faceCheck)
let studentList = StudentListViewController()
let nav2 = createNavigationController(image: UIImage(systemName: "doc.text"), title: "지도 명단", rootViewController: studentList)
//3️⃣
self.viewControllers = [nav1, nav2]
}
//4️⃣
func createNavigationController(image: UIImage?, title: String, rootViewController: UIViewController) -> UINavigationController {
let nav = UINavigationController(rootViewController: rootViewController)
//5️⃣
nav.tabBarItem.image = image
nav.tabBarItem.title = title
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
appearance.backgroundColor = .lightGray
appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor : UIColor.white, NSAttributedString.Key.font : UIFont.systemFont(ofSize: 25)]
let bar = nav.navigationBar
bar.standardAppearance = appearance
bar.compactAppearance = appearance
bar.scrollEdgeAppearance = appearance
bar.overrideUserInterfaceStyle = .dark
return nav
}
}
filter 종류는 늘어나거나 바뀔 가능성이 없어서 확장성과는 크게 관계없지만 enum으로 따로 선언해두지 않으면 segment control의 index로 VM이나 TableView와 소통해야합니다. 이렇게 되면 View에 너무 많은 부담을 지우게 되므로 따로 enum을 선언해서 관리하겠습니다. 코딩할 때도 미리 이렇게 선언해두는 편이 실수를 줄일 수 있고 좋습니다.
Int를 상속 받은 후에 첫번째 case에 원시값 0을 주면 다른 case들이 자동으로 원시값 1, 2를 순서대로 가지게 됩니다. 이는 segment control의 index와 연동해서 사용할 때 유용합니다.
caseIterable의 allCases 내부에 사용해서 segment control에서 사용할 배열을 얻는 점에도 주목하시면 좋겠네요.
enum StudentListFilter: Int, CaseIterable {
case all = 0
case myClass
case myGrade
var description: String {
switch self {
case .all: return "전체"
case .myClass: return "우리 반"
case .myGrade: return "우리 학년"
}
}
static let segmentItems = StudentListFilter.allCases.map({ filter in filter.description })
}
코드로 세그먼트를 구현해보았습니다. 세그먼트를 items를 array 형태로 받는데 String뿐만 아니라 Image가 들어있는 배열로도 만들 수 있습니다.
//1️⃣
var currentStudentListFilter = StudentListFilter.all {
didSet {
//TODO: - 테이블뷰 리로드
}
}
//2️⃣
lazy var filteringSegmentControl: UISegmentedControl = {
let sg = UISegmentedControl(items: StudentListFilter.segmentItems)
//3️⃣
sg.selectedSegmentIndex = self.currentStudentListFilter.rawValue
sg.setTitleTextAttributes([NSAttributedString.Key.font: UIFont.systemFont(ofSize: 20)], for: .normal)
sg.setTitleTextAttributes([NSAttributedString.Key.font: UIFont.systemFont(ofSize: 30)], for: .selected)
//4️⃣
sg.addTarget(self, action: #selector(segmentValueChanged(sender:)), for: .valueChanged)
return sg
}()
@objc func segmentValueChanged(sender: UISegmentedControl) {
let selectedIndex = sender.selectedSegmentIndex
//5️⃣
guard let newFilter = StudentListFilter(rawValue: selectedIndex) else { return }
currentStudentListFilter = newFilter
}
서브뷰에 더하는 코드는 생략하겠습니다. 결과는 아래와 같습니다.
tableView를 구현하기 전에 tableView에 전달할 data의 모델부터 구현해야 합니다. 생활지도라는 뜻의 Guidance 구조체를 만들어줍시다. 교칙을 위반한 학생과 이유로 구성이 되어 있습니다.
struct Guidance {
let student: Student
let reason: GuidanceReason
}
임시로 사용할 더미데이터를 포함한 뷰모델을 구현해보았습니다. 더미데이터 부분은 나중에 서버에서 받아오는 데이터로 대체되게 됩니다.
뷰 모델 부분을 보면 내부에서 사용하는 _guidance와 외부에서 참조 가능한 guidance로 구분이 되어 있습니다. 원래는 viewModel이 filter를 멤버 변수로 가지고 있고 계산 프로퍼티로 guidance를 만들려고 했습니다. 하지만 그렇게 하면 tableview가 guidance를 참조할 때마다 계산 프로퍼티를 정의한 로직을 실행합니다. 내부가 고차함수 filter로 이루어져있기 때문에 그렇게 할 경우에는 성능문제가 발생할 수 있습니다.
그래서 아래처럼 guidance를 두 가지로 만들고 filter가 바뀔 때마다 메소드를 changeFilter 실행시켜 guidances변수에 미리 배결을 저장해놓기로 했습니다. 이렇게 하면 똑같은 로직이 filter가 바뀔 때 한번만 실행됩니다.
그리고 우리 학년은 1학년 우리 반은 1학년 1반으로 가정했습니다.
var dummyGuidances = [
Guidance(student: Student(id: 1, grade: 1, classNumber: 1, number: 1, name: "김철수", profilePicture: nil), reason: .wrongClothes),
Guidance(student: Student(id: 2, grade: 1, classNumber: 1, number: 2, name: "김영희", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 3, grade: 1, classNumber: 1, number: 3, name: "김영수", profilePicture: nil), reason: .trespassing),
Guidance(student: Student(id: 4, grade: 1, classNumber: 1, number: 4, name: "이철수", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 5, grade: 1, classNumber: 1, number: 5, name: "박철수", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 6, grade: 1, classNumber: 2, number: 1, name: "최영남", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 7, grade: 1, classNumber: 2, number: 2, name: "조철수", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 8, grade: 1, classNumber: 3, number: 1, name: "김철규", profilePicture: nil), reason: .noShoes),
Guidance(student: Student(id: 9, grade: 2, classNumber: 3, number: 2, name: "최철수", profilePicture: nil), reason: .wrongClothes),
Guidance(student: Student(id: 10, grade: 3, classNumber: 3, number: 1, name: "이영희", profilePicture: nil), reason: .others(detail: "길에 껌 뱉음"))
]
class StudentListViewModel {
// TODO: - get data from server
private let _guidances = dummyGuidances
lazy var guidances: [Guidance] = _guidances
func changeFilter(to filter: StudentListFilter) {
switch filter {
case .all:
self.guidances = _guidances
case .myGrade:
self.guidances = _guidances.filter { guidance in
guidance.student.grade == 1
}
case .myClass:
self.guidances = _guidances.filter { guidance in
guidance.student.grade == 1 && guidance.student.classNumber == 1
}
}
}
}
//1️⃣
func configureTableView() {
tableView.dataSource = self
tableView.delegate = self
tableView.register(StudentListCell.self, forCellReuseIdentifier: reuseIdentifier)
tableView.separatorStyle = .none
}
extension StudentListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.guidances.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? StudentListCell else { return UITableViewCell() }
cell.guidance = viewModel.guidances[indexPath.row]
return cell
}
}
//2️⃣
extension StudentListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100
}
}
계획에 따르면 cell에 보여줄 정보는 프로필 사진과 학년-반-번호-이름, 그리고 생활지도 사유입니다. imageView 1개와 label 2개를 넣어줍시다.
import UIKit
class StudentListCell: UITableViewCell {
// MARK: - Properties
//1️⃣
var guidance: Guidance? {
didSet {
self.viewModel = StudentListCellViewModel(guidance: guidance!)
configure()
}
}
var viewModel: StudentListCellViewModel?
let profileImageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.widthAnchor.constraint(equalToConstant: 48).isActive = true
iv.heightAnchor.constraint(equalToConstant: 48).isActive = true
iv.layer.cornerRadius = 48 / 2
return iv
}()
let infoLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 20)
return label
}()
let reasonLabel = UILabel()
// MARK: - LifeStyle
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configureUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//2️⃣
override func layoutSubviews() {
super.layoutSubviews()
contentView.frame = contentView.frame.inset(by: UIEdgeInsets(top: 5, left: 10, bottom: 5, right: 10))
}
// MARK: - Helpers
func configureUI() {
//3️⃣
contentView.backgroundColor = UIColor.init(red: 255/256, green: 252/256, blue: 220/256, alpha: 1)
contentView.layer.cornerRadius = (100 - 10) / 4
contentView.addSubview(profileImageView)
profileImageView.translatesAutoresizingMaskIntoConstraints = false
profileImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
profileImageView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 10).isActive = true
contentView.addSubview(infoLabel)
infoLabel.translatesAutoresizingMaskIntoConstraints = false
infoLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: -20).isActive = true
infoLabel.leftAnchor.constraint(equalTo: profileImageView.rightAnchor, constant: 20).isActive = true
infoLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
contentView.addSubview(reasonLabel)
reasonLabel.translatesAutoresizingMaskIntoConstraints = false
reasonLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 20).isActive = true
reasonLabel.leftAnchor.constraint(equalTo: profileImageView.rightAnchor, constant: 20).isActive = true
reasonLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
}
func configure() {
infoLabel.text = viewModel?.infoLabelText
reasonLabel.attributedText = viewModel?.reasonAttributedText
profileImageView.image = viewModel?.profileImage
}
}
import UIKit
struct StudentListCellViewModel {
// MARK: - Properties
let guidance: Guidance
// MARK: - LabelText
var infoLabelText: String {
let student = guidance.student
return "\(student.grade)학년 \(student.classNumber)반 \(student.number)번 \(student.name)"
}
//1️⃣
var reasonAttributedText: NSAttributedString {
let reason = guidance.reason
let attributedString = NSMutableAttributedString(string: "사유: ", attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor.gray])
let reasonString: String = {
if case .others(let detail) = reason {
return "\(reason.description)(\(detail ?? ""))"
} else {
return "\(reason.description)"
}
}()
attributedString.append(NSAttributedString(string: reasonString, attributes: [.font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.black]))
return attributedString
}
// MARK: - profileImage
lazy var profileImage: UIImage = {
if let image = guidance.student.profilePicture {
return image
} else {
return UIImage(systemName: "person.fill")!
}
}()
}
지금까지는 교문지도 탭에서 추가를 해도 데이터에는 변화가 없었습니다. 정확히 얘기하면 데이터 자체가 없었습니다. 이제는 더미 데이터이지만 데이터가 있으니 데이터를 추가하는 로직을 만들어 봅시다.
이렇게만 해두면 자동으로 tableView에서 업데이트 될 것이라고 생각하기 쉽지만 생각보다 추가적인 수정이 필요합니다.
func registerGuidance(reason: GuidanceReason) {
let guidance = Guidance(student: student, reason: reason)
dummyGuidances.append(guidance)
}
우리의 StudentListViewModel()은 객체가 처음에 생성될 때 1번만 전역에서 dummyData를 읽어옵니다. 전역 더미데이터에 추가하더라도 list VC가 init된 시점의 (TabBarController가 생성된 시점과 동일합니다.) 더미데이터만 가지고 있습니다.
따라서 VM에 해당 메소드를 추가해서 전역에서 새로 데이터를 읽어오도록 합시다.
func resetGuidances() {
self._guidances = dummyGuidances
}
데이블 데이터가 수정되면 테이블뷰를 리로드할 헬퍼 함수를 하나 만들어 놓습니다. 새로 전역에서 더미데이터를 읽어오고 필터링을 다시 한 다음에 tableView를 리로드합니다. 이것을 뷰가 보여질 때마다 실행해주면 됩니다.
func reloadTableView() {
viewModel.resetGuidances()
viewModel.changeFilter(to: currentStudentListFilter)
tableView.reloadData()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadTableView()
}
새로운 학생이 추가되는 것을 볼 수 있습니다.
추가가 있으면 삭제가 있어야하는 것이 인지상정입니다. iOS의 table view는 자체적으로 삭제할 수 있는 api를 제공합니다만 컴동쌤은 cell에 삭제 버튼을 넣고 delegate pattern을 통해서 삭제기능을 구현 해보고자 합니다.
let deleteButton: UIButton = {
let button = UIButton()
button.widthAnchor.constraint(equalToConstant: 50).isActive = true
button.heightAnchor.constraint(equalToConstant: 50).isActive = true
button.setImage(UIImage(systemName: "trash"), for: .normal)
button.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside)
return button
}()
func configureUI() {
// ...생략...
//1️⃣
contentView.addSubview(deleteButton)
deleteButton.translatesAutoresizingMaskIntoConstraints = false
deleteButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5).isActive = true
deleteButton.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
}
//2️⃣
protocol StudentListCellDelegate: AnyObject {
func deleteButtonTapped(in cell: StudentListCell)
}
@objc func deleteButtonTapped() {
delegate?.deleteButtonTapped(in: self)
}
델리게이프 패턴을 사용하기 위해서는 cell의 델리게이트를 정해주어야 합니다. 현재 VC로 지정하는 코드를 넣습니다.
델리게이트 메소드를 정의 해봅시다. cell에서 guidance를 가져오고 viewModel에 정의된 (이제 앞으로 정의할) 삭제 메소드를 실행합니다. 그리고 테이블 뷰를 리셋합니다. 추가 로직에서 설명했듯이 해당 메소드는 전역에서 데이터를 다시 가져와서 filter를 하고 실행해줍니다.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? StudentListCell else { return UITableViewCell() }
cell.guidance = viewModel.guidances[indexPath.row]
cell.delegate = self
return cell
}
// MARK: - StudentListCellDelegate
extension StudentListViewController: StudentListCellDelegate {
func deleteButtonTapped(in cell: StudentListCell) {
guard let guidance = cell.guidance else { return }
viewModel.deleteGuidance(guidance)
reloadTableView()
}
}
삭제를 하려면 cell에서 전달 받은 guidance 인스턴스와 동일한 인스턴스를 전역에 있는 더미데이터에서 찾아야 합니다. 그려려면 인스턴스 고유하고 유일한 id가 필요합니다. 일단 임시로 UUID를 넣어줍시다. (서버가 있다면 서버에서 guidance를 DB에 저장하면서 만들어 줄 것입니다.)
이렇게 id가 생기면 filter를 통해서 삭제 로직을 구현할 수 있습니다.
struct Guidance {
let id = UUID()
let student: Student
let reason: GuidanceReason
}
func deleteGuidance(_ guidance: Guidance) {
let toDeleteID = guidance.id
dummyGuidances = dummyGuidances.filter { guidance in
guidance.id != toDeleteID
}
}
필터에 관계없이 전역에서 데이터가 삭제되서 필터를 바꾸어도 해당 인스턴스가 삭제된 것을 볼 수 있습니다.