Skip to content

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

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

Merged
merged 3 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all 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,116 @@
//
// 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)
setupLayout()
setupFoundation()
}

private func setupLayout() {
layout.configure(totalRows: timeHeaders.count + 1, totalColumns: dateHeaders.count + 1)
}

private func setupFoundation() {
self.register(SchedulePickerCell.self, forCellWithReuseIdentifier: SchedulePickerCell.identifier)
}

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

// MARK: Public Methods
func addSelectedCell(at indexPath: IndexPath) {
guard mode == .editMode,
let cell = cellForItem(at: indexPath) as? SchedulePickerCell
else { return }
cell.isSelectedCell.toggle()
if cell.isSelectedCell {
selectedCells.insert(indexPath)
} else {
selectedCells.remove(indexPath)
}
}

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는 일정을 선택한 참여자를 뜻합니다

cell.backgroundColor = calculateBackgroundColor(for: ratio)
}

func updateCellAvailability(with dateList: [String], startTimes: [String]) {
guard mode == .readMode else { return }
self.cellAvailability = calculateCellAvailability(totalRows: self.timeHeaders.count + 1,
totalColumns: self.dateHeaders.count + 1,
dateList: dateList,
startTimes: startTimes)
reloadData()
}
}

// MARK: Internal Logics
extension SchedulePicker {
///각 시간에 대한 가능 인원 계산
private func calculateCellAvailability(totalRows: Int, totalColumns: Int, dateList: [String], startTimes: [String]) -> [IndexPath: Int] {
var cellAvailability: [IndexPath: Int] = [:]
let dateTimeMapping = createDateTimeMapping(totalRows: totalRows, totalColumns: totalColumns, dateList: dateList)
for startTime in startTimes {
if let indexPath = dateTimeMapping[startTime] {
cellAvailability[indexPath, default: 0] += 1
}
}
return cellAvailability
}

/// 시각 - cell 매핑
private func createDateTimeMapping(totalRows: Int, totalColumns: Int, dateList: [String]) -> [String: IndexPath] {
var mapping: [String: IndexPath] = [:]
let dates = dateList.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
}

private func calculateBackgroundColor(for ratio: Float) -> UIColor {
switch ratio {
case 0.01...0.2: return .appBlue50
case 0.2...0.4: return .appBlue200
case 0.4...0.6: return .appBlue400
case 0.6...0.8: return .appBlue700
case 0.8...1: return .appBlue800
default: return .clear
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// 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 totalColumns = dateHeaders.count + 1
let row = indexPath.item / totalColumns
let column = indexPath.item % totalColumns

/// 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 = ""
}
}

func configureTableRoundness(for indexPath: IndexPath, dateHeaders: [String], timeHeaders: [String]) {
let totalRows = timeHeaders.count + 1
let totalColumns = dateHeaders.count + 1

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

if isTopLeft || isTopRight || isBottomLeft || isBottomRight {
self.layer.cornerRadius = 10
self.layer.masksToBounds = true
switch true {
case isTopLeft:
self.layer.maskedCorners = [.layerMinXMinYCorner]
case isTopRight:
self.layer.maskedCorners = [.layerMaxXMinYCorner]
case isBottomLeft:
self.layer.maskedCorners = [.layerMinXMaxYCorner]
case isBottomRight:
self.layer.maskedCorners = [.layerMaxXMaxYCorner]
default:
break
}
} else {
self.layer.cornerRadius = 0
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// SchedulePickerLayout.swift
// Noostak_iOS
//
// Created by 오연서 on 1/10/25.
//

import UIKit

extension SchedulePicker {
final class SchedulePickerLayout: UICollectionViewFlowLayout {
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
}

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
}
}
}
50 changes: 50 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,44 @@ public extension NSTDateUtility {
}
}
}

extension NSTDateUtility {
static 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
}
}
}

static 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
}
}