Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat/NST-51] #25 Schedule picker 컴포넌트 작성 #29

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// SchedulePicker.swift
// Noostak_iOS
//
// Created by 오연서 on 1/10/25.
//

import UIKit

final class SchedulePicker: UICollectionView {
enum Mode {
case editMode
case readMode
}

private let layout = SchedulePickerLayout()
private let timeHeaders: [String]
private let dateHeaders: [String]
private let mode: Mode
private var selectedCells: Set<IndexPath> = [] // edit Mode
private var cellAvailability: [IndexPath: Int] = [:] // read Mode

init(timeHeaders: [String], dateHeaders: [String], mode: Mode) {
self.timeHeaders = timeHeaders
self.dateHeaders = dateHeaders
self.mode = mode
super.init(frame: .zero, collectionViewLayout: layout)
self.layout.configure(totalRows: timeHeaders.count + 1, totalColumns: dateHeaders.count + 1)
self.register(SchedulePickerCell.self, forCellWithReuseIdentifier: SchedulePickerCell.identifier)
self.cellAvailability = calculateCellAvailability(totalRows: timeHeaders.count + 1,
totalColumns: dateHeaders.count + 1,
//Fix: mockMemberStartTime 변경 필요
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 TODO or FIX 사항은 해결된건가요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아뇹 ㅋㅋ 이부분은 화면 만들면서 다시 바꿔볼 예정입니다 ..

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아뇨 지금 해주세요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTimes: mockMemberStartTime)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캬 야무지네요 다만 init 아래의 내용은 팀 코드 컨벤션대로 따로 셋업 함수에서 처리해주세요!


required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func addSelectedCell(at indexPath: IndexPath) {
guard mode == .editMode else { return }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 처리 뭐죠 너 오연서 아니지

if let cell = cellForItem(at: indexPath) as? SchedulePickerCell {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에서 guard문으로 체크하는 김에 같이 작성해서
editMode && SchedulePickerCell
조건일때만 아래로 넘기겠다의 문맥이면 더 좋을 것 같네요!

cell.isSelectedCell.toggle()
if cell.isSelectedCell {
selectedCells.insert(indexPath)
} else {
selectedCells.remove(indexPath)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 절차형같아요 반응형으로 리팩토링하는 고민을 해보는건 어떨까요?

현재 코드

  1. CV에서 Cell의 내용을 토글
  2. CV에서 Cell의 내용을 참조해서 CV의 내용을 수정

반응형이라면

  1. CV와 Cell의 데이터가 바인딩
  2. cell or cv에서 변경되면 나머지도 같이 변경

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모르게쒀요... 이부분 말고도 전체적인 흐름을 반응형으로 바꾸기 위해서 selectedCells와 cellAvailability를 behaviorRelay로 변경하는게 나을까요
반응형으로 리팩토링해야한다면 변경해야하는 부분은 이 토글부분 말고는 안보이기는 하는데 . . .

}
}

func configureCellBackground(_ cell: SchedulePickerCell, for indexPath: IndexPath, participants: Int) {
guard mode == .readMode else { return }
let count = cellAvailability[indexPath, default: 0]
let ratio = Float(count) / Float(participants)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 ratio는 컬러 관련 기능명세와 동일한 내용인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동일합니당
여기서 participants는 일정을 선택한 참여자를 뜻합니다


switch ratio {
case 0.01...0.2:
cell.backgroundColor = .appBlue50
case 0.2...0.4:
cell.backgroundColor = .appBlue200
case 0.4...0.6:
cell.backgroundColor = .appBlue400
case 0.6...0.8:
cell.backgroundColor = .appBlue700
case 0.8...1:
cell.backgroundColor = .appBlue800
default:
cell.backgroundColor = .clear
}
}
}

/// .ReadMode 색상 반환 로직
extension SchedulePicker {
///각 시간에 대한 가능 인원 계산
private func calculateCellAvailability(totalRows: Int, totalColumns: Int, startTimes: [String]) -> [IndexPath: Int] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

접근제어자 진짜 뭐냐 너 오연서 아니지
별개로 컴포넌트 내부에서만 사용하는 함수들을 모아놓은거라면 private 통일 해주시면 좋을 것 같아요!

var cellAvailability: [IndexPath: Int] = [:]
let dateTimeMapping = createDateTimeMapping(totalRows: totalRows, totalColumns: totalColumns)
for startTime in startTimes {
if let indexPath = dateTimeMapping[startTime] {
cellAvailability[indexPath, default: 0] += 1
}
}
return cellAvailability
}

/// 시각 - cell 매핑
private func createDateTimeMapping(totalRows: Int, totalColumns: Int) -> [String: IndexPath] {
var mapping: [String: IndexPath] = [:]
//FIX: mockDateList 변경 필요
let dates = mockDateList.map { String($0.prefix(10)) }

for row in 1..<totalRows {
for column in 1..<totalColumns {
let time = self.timeHeaders[row - 1] // 시간
guard column - 1 < dates.count else { continue }
let date = dates[column - 1] // 날짜
let combinedKey = "\(date)T\(time):00:00"
let indexPath = IndexPath(item: (row * totalColumns) + column, section: 0)
mapping[combinedKey] = indexPath
}
}
return mapping
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캬 머리싸맨 흔적이 보여서 좋네오
시간나면 해당 로직을 어떻게 더 간단하고 효율족으로 바꿀 수 있을까 생각해보세요!
로직 개발은 코테 연장선입니다-

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실버 탈출기 😅😅


func reloadCellBackgrounds() {
self.cellAvailability = calculateCellAvailability(totalRows: self.timeHeaders.count + 1,
totalColumns: self.dateHeaders.count + 1,
startTimes: mockMemberStartTime)
self.reloadData()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기에 접근제어가 빠진 이유가 있나요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editmode <-> readmode 전환되는 로직이 있어서 뺐어용

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// SchedulePickerCell.swift
// Noostak_iOS
//
// Created by 오연서 on 1/10/25.
//

import UIKit

final class SchedulePickerCell: UICollectionViewCell {

static let identifier = "SchedulePickerCell"

private var textLabel = UILabel()
var isSelectedCell: Bool = false {
didSet {
backgroundColor = isSelectedCell ? .appBlue400 : .appWhite
}
}
Comment on lines +15 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rx 말고 didSet 사용한 이유가 있을까요?
전 일단 해당 코드에 동의해요! :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굳이 rx로 안해도될거같아서 .... didSet을 사용했는데 딱히 이유는 없어요 😅😅
동의하신 이유를 알려주시죠 ㅋ.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅋㅋ 똑같습니다 굳이 rx로 안해도 돼서라고 생각해요!
내-외부에서 구독해야하는 스트림과 일련의 비즈니스 로직이 존재하는 것도 아니고,
오히려 Rx를 위해 Cell 별 DisposeBag과 RxCocoa를 통한 subscribe 로직이 필요할테니까요.
Cell이 특히나 많이 생성될 여지가 있는 부분에서 didSet으로 역할을 간소화시킨건 좋다고 생각해요.


override init(frame: CGRect) {
super.init(frame: frame)
setupCell()
setUpHierarchy()
setUpUI()
setUpLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func setUpHierarchy() {
self.addSubview(textLabel)
}

private func setUpUI() {
textLabel.do {
$0.font = .PretendardStyle.c4_r.font
$0.numberOfLines = 0
$0.textAlignment = .center
$0.textColor = .appGray600
}
}

private func setUpLayout() {
textLabel.snp.makeConstraints {
$0.center.equalToSuperview()
}
}

private func setupCell() {
self.backgroundColor = .appWhite
self.layer.borderWidth = 0.5
self.layer.borderColor = UIColor.appGray200.cgColor
}

func configureHeader(for indexPath: IndexPath, dateHeaders: [String], timeHeaders: [String]) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드의 역할이 너무 많은것 같아요! 세부적으로 기능을 쪼개면 더 읽기 편하고 명료한 코드가 될 것 같습니다.
추가로 if 케이스 처리가 여러개라면 Switch를 통해 가독성을 높이는 방법도 고려해보세요!

let totalRows = timeHeaders.count + 1
let totalColumns = dateHeaders.count + 1
let row = indexPath.item / totalColumns
let column = indexPath.item % totalColumns

let isTopLeft = indexPath.item == 0
let isTopRight = indexPath.item == totalColumns - 1
let isBottomLeft = indexPath.item == (totalRows - 1) * totalColumns
let isBottomRight = indexPath.item == totalRows * totalColumns - 1

/// dateHeader, timeHeader text binding
if row == 0, column > 0 {
self.textLabel.text = dateHeaders[column - 1]
} else if column == 0, row > 0 {
self.textLabel.text = "\(timeHeaders[row - 1])시"
} else {
self.textLabel.text = ""
}

/// 테이블 모서리 둥글게
if isTopLeft || isTopRight || isBottomLeft || isBottomRight {
self.layer.cornerRadius = 10
self.layer.masksToBounds = true
self.layer.borderColor = UIColor.appGray200.cgColor
if isTopLeft {
self.layer.maskedCorners = [.layerMinXMinYCorner]
} else if isTopRight {
self.layer.maskedCorners = [.layerMaxXMinYCorner]
} else if isBottomLeft {
self.layer.maskedCorners = [.layerMinXMaxYCorner]
} else if isBottomRight {
self.layer.maskedCorners = [.layerMaxXMaxYCorner]
}
} else {
self.layer.cornerRadius = 0
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// SchedulePickerLayout.swift
// Noostak_iOS
//
// Created by 오연서 on 1/10/25.
//

import UIKit

final class SchedulePickerLayout: UICollectionViewFlowLayout {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

야무지네여 야무져 다만 해당 레이아웃은 SchedulePicker에만 해당되는 레이아웃이니 SchedulePicker extension으로 선언해서 프로퍼티임을 강조하는게 어떨까요?

private let fixedFirstColumnWidth: CGFloat = 42
private let fixedFirstRowHeight: CGFloat = 36

private var totalRows: Int = 0
private var totalColumns: Int = 0
private let minimumSpacing: CGFloat = 0

func configure(totalRows: Int = 0, totalColumns: Int = 0) {
self.totalRows = totalRows
self.totalColumns = totalColumns
invalidateLayout()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 코드는 어떤 코드인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레이아웃이 말을 안들어서 추가해놨었는데 빼도 될거같음다

}

override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
let remainingWidth = collectionView.bounds.width - fixedFirstColumnWidth - CGFloat(totalColumns - 1) * minimumSpacing
let dynamicColumnWidth = remainingWidth / CGFloat(totalColumns - 1)
let dynamicRowHeight = 32.0

itemSize = CGSize(width: dynamicColumnWidth, height: dynamicRowHeight)
minimumLineSpacing = 0
minimumInteritemSpacing = 0
sectionInset = .zero
}

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
attributes?.forEach { layoutAttribute in
let indexPath = layoutAttribute.indexPath
let column = indexPath.item % totalColumns
let row = indexPath.item / totalColumns

// 첫 번째 열의 너비 고정, 열 간 간격 조정
if column == 0 {
layoutAttribute.frame.size.width = fixedFirstColumnWidth
layoutAttribute.frame.origin.x = 0
} else { // 두 번째 열 이후
let previousColumnRight = fixedFirstColumnWidth + CGFloat(column - 1) * (itemSize.width + minimumInteritemSpacing)
layoutAttribute.frame.origin.x = previousColumnRight
}

// 첫 번째 행의 높이 고정, 행 간 간격 조정
if indexPath.item < totalColumns {
layoutAttribute.frame.size.height = fixedFirstRowHeight
layoutAttribute.frame.origin.y = 0
} else { // 두 번째 행 이후
let previousRowBottom = fixedFirstRowHeight + CGFloat(row - 1) * (itemSize.height + minimumLineSpacing)
layoutAttribute.frame.origin.y = previousRowBottom
}
}
return attributes
}
}
48 changes: 48 additions & 0 deletions Noostak_iOS/Noostak_iOS/Global/Utils/NSTDateUtility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,18 @@ public final class NSTDateUtility {

public extension NSTDateUtility {
enum NSTDateFormatter {
case yyyyMMddTHHmmss
case yyyyMMddHHmmss
case yyyyMMdd
case yyyyMM
case EE
case HH
case MMddEE

var format: String {
switch self {
case .yyyyMMddTHHmmss:
return "yyyy-MM-dd'T'HH:mm:ss"
case .yyyyMMddHHmmss:
return "yyyy-MM-dd HH:mm:ss"
case .yyyyMMdd:
Expand All @@ -62,6 +67,10 @@ public extension NSTDateUtility {
return "yyyy-MM"
case .EE:
return "EE"
case .HH:
return "HH"
case .MMddEE:
return "EE\nMM/dd"
}
}
}
Expand All @@ -77,3 +86,42 @@ public extension NSTDateUtility {
}
}
}

func dateList(_ dateStrings: [String]) -> [String] {
let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식
let displayFormatter = NSTDateUtility(format: .MMddEE) // 출력 형식

return dateStrings.compactMap { dateString in
switch formatter.date(from: dateString) {
case .success(let date):
return displayFormatter.string(from: date)
case .failure(let error):
print("Failed to parse date \(dateString): \(error.localizedDescription)")
return nil
}
}
}

func timeList(_ startTime: String, _ endTime: String) -> [String] {
let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식
var result: [String] = []

switch (formatter.date(from: startTime), formatter.date(from: endTime)) {
case (.success(let start), .success(let end)):
let calendar = Calendar.current
var current = start

while current <= end {
result.append(NSTDateUtility(format: .HH).string(from: current)) // 출력 형식
if let nextHour = calendar.date(byAdding: .hour, value: 1, to: current) {
current = nextHour
} else {
break
}
}
default:
print("Failed to parse start or end time.")
return []
}
return result
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 함수들은 NSTDateUtility 내용에 포함되면서 self로도 사용 가능하니 NSTDateUtility extension으로 두는게 어떨까요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NSTDateUtility 파일 내에 extension으로 따로 빼라는 말인거죠?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다!