Skip to content

Commit

Permalink
Merge pull request #29 from Noostak/feat/NST-51/schedulePicker
Browse files Browse the repository at this point in the history
[Feat/NST-51] #25 Schedule picker 컴포넌트 작성
  • Loading branch information
oyslucy authored Jan 30, 2025
2 parents cd73681 + e751186 commit d841f39
Show file tree
Hide file tree
Showing 4 changed files with 332 additions and 0 deletions.
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
}
}

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]) {
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
}
}

0 comments on commit d841f39

Please sign in to comment.