-
Notifications
You must be signed in to change notification settings - Fork 0
[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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rx 말고 didSet 사용한 이유가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 굳이 rx로 안해도될거같아서 .... didSet을 사용했는데 딱히 이유는 없어요 😅😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ㅋㅋ 똑같습니다 굳이 rx로 안해도 돼서라고 생각해요! |
||
|
||
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]) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드의 역할이 너무 많은것 같아요! 세부적으로 기능을 쪼개면 더 읽기 편하고 명료한 코드가 될 것 같습니다. |
||
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 | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 ratio는 컬러 관련 기능명세와 동일한 내용인가요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
동일합니당
여기서 participants는 일정을 선택한 참여자를 뜻합니다