diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/Model/DisplayDayLog.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/Model/DisplayDayLog.swift index a5fa9ea..fd6bece 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/Model/DisplayDayLog.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/Model/DisplayDayLog.swift @@ -8,6 +8,7 @@ import Foundation // MARK: - DisplayDayLog Model +/// 하루 동안의 운동 기록을 표시하기 위한 모델 struct DisplayDayLog { let date: Date let locationName: String @@ -20,11 +21,14 @@ struct DisplayDayLog { let totalSteps: Int } - // MARK: - DisplayDayLog 초기화 추가 extension DisplayDayLog { + /// DayLog 모델 데이터를 기반으로 DisplayDayLog를 초기화하는 생성자 + /// - Parameter dayLog: 원본 DayLog 데이터 init(from dayLog: DayLog) { self.date = dayLog.date + + // 위치 문자열을 공백으로 분리 후 필요한 토큰만 사용 let splitted = dayLog.locationName.split(separator: " ").map { String($0) } if splitted.isEmpty { self.locationName = "" @@ -34,7 +38,7 @@ extension DisplayDayLog { // 첫 번째 토큰이 "시"로 끝나면 첫 번째 토큰만 사용 self.locationName = first } else { - // 그렇지 않으면, 두 번째 토큰까지 합쳐서 사용 (단, 두 번째 토큰이 없으면 첫 번째 토큰만 사용) + // 그렇지 않으면, 두 번째 토큰까지 합쳐서 사용 (두 번째 토큰이 없으면 첫 번째 토큰만 사용) if splitted.count >= 2 { self.locationName = "\(first) \(splitted[1])" } else { @@ -42,6 +46,8 @@ extension DisplayDayLog { } } } + + // 날씨 정보 및 온도, 제목, 난이도, 소요시간, 거리, 걸음수 설정 self.weather = Constants.WeatherCondition.from(dayLog.weather).description self.temperature = dayLog.temperature self.title = dayLog.title diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/Model/RecordDetail.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/Model/RecordDetail.swift index bea973b..abe9fc4 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/Model/RecordDetail.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/Model/RecordDetail.swift @@ -7,20 +7,24 @@ import Foundation +/// 각 기록 섹션의 세부 정보를 담는 모델 struct RecordDetail { let timeRange: String // 예: "06:12 - 06:18" let distance: String // 예: "1.81km" let steps: String // 예: "345" - let route: [Point] + let route: [Point] // 해당 섹션의 경로 좌표 배열 } extension RecordDetail { - /// Section 데이터를 기반으로 RecordDetail 생성 + /// Section 데이터를 기반으로 RecordDetail을 생성하는 생성자 + /// - Parameter section: 원본 Section 데이터 init(from section: Section) { - // route 배열을 timestamp 기준으로 정렬 + // Section의 route 배열을 timestamp 기준으로 오름차순 정렬 let sortedRoute = section.route.sorted { $0.timestamp < $1.timestamp } let startTime = sortedRoute.first?.timestamp let endTime = sortedRoute.last?.timestamp + + // 시작 시간과 종료 시간을 "HH:mm" 형식의 문자열로 변환하여 timeRange 생성 let timeRange: String if let start = startTime, let end = endTime { let formatter = DateFormatter() @@ -31,9 +35,9 @@ extension RecordDetail { } self.timeRange = timeRange + // 거리와 걸음수는 형식에 맞게 문자열로 변환 self.distance = String(format: "%.2fkm", section.distance) self.steps = "\(section.steps)" self.route = section.route } } - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogView.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogView.swift index e94c5d5..0f50cb4 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogView.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogView.swift @@ -10,7 +10,6 @@ import MapKit import SnapKit import Then - final class DetailLogView: UIView { // MARK: - UI Components 선언 @@ -26,33 +25,32 @@ final class DetailLogView: UIView { /// 지도 영역을 표시하는 MKMapView private let mapView = MKMapView().then { - // 추가 delegate 설정 및 커스터마이징 가능 + // 지도 영역에 둥근 모서리 효과 적용 및 클리핑 활성화 $0.layer.cornerRadius = DynamicSize.scaledSize(10) $0.clipsToBounds = true } - - /// 지도 위에 오버레이될 무빙트랙 버튼 let movingTrackButton = UIButton(type: .system).then { $0.configuration = UIButton.Configuration.plain() + // 버튼의 타이틀 설정 let attributedTitle = NSAttributedString.RLAttributedString(text: "무빙트랙", font: .Label2) $0.setAttributedTitle(attributedTitle, for: .normal) $0.backgroundColor = .Gray300 $0.layer.cornerRadius = DynamicSize.scaledSize(10) + // 버튼 아이콘 설정 (시스템 이미지 사용) let config = UIImage.SymbolConfiguration(pointSize: DynamicSize.scaledSize(16), weight: .regular) let icon = UIImage(systemName: RLIcon.play.name)?.withConfiguration(config) $0.setImage(icon, for: .normal) $0.tintColor = .Gray000 + // 이미지와 타이틀 간의 여백 설정 $0.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: DynamicSize.scaledSize(8), bottom: 0, trailing: DynamicSize.scaledSize(8)) $0.configuration?.imagePadding = DynamicSize.scaledSize(8) } - - /// 타이틀 라벨 private let titleLabel = RLLabel( text: "하트런", @@ -120,6 +118,7 @@ final class DetailLogView: UIView { font: .RLHeading1 ) + /// 소요시간 관련 정보를 담는 수직 스택뷰 private lazy var timeStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [timeTitleLabel, timeValueLabel]) stack.axis = .vertical @@ -144,6 +143,7 @@ final class DetailLogView: UIView { font: .RLHeading1 ) + /// 운동거리 관련 정보를 담는 수직 스택뷰 private lazy var distanceStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [distanceTitleLabel, distanceValueLabel]) stack.axis = .vertical @@ -168,6 +168,7 @@ final class DetailLogView: UIView { font: .RLHeading1 ) + /// 걸음수 관련 정보를 담는 수직 스택뷰 private lazy var stepsStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [stepsTitleLabel, stepsValueLabel]) stack.axis = .vertical @@ -176,6 +177,7 @@ final class DetailLogView: UIView { return stack }() + /// 소요시간, 운동거리, 걸음수 정보를 수평으로 나열하는 스택뷰 lazy var statsStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [timeStack, distanceStack, stepsStack]) stack.axis = .horizontal @@ -196,8 +198,8 @@ final class DetailLogView: UIView { // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) - setupUI() - setupLayout() + setupUI() // UI 요소들을 추가 + setupLayout() // SnapKit을 통한 레이아웃 설정 print("LogView initialized") } @@ -207,19 +209,22 @@ final class DetailLogView: UIView { } // MARK: - Setup UI + /// 하위 뷰들을 계층 구조에 추가하는 메서드 private func setupUI() { - // UI 요소 추가 backgroundColor = .Gray900 + // 스크롤뷰와 내부 콘텐츠 뷰 추가 addSubview(scrollView) scrollView.addSubview(contentView) + // 콘텐츠 뷰에 지도, 타이틀, 날씨, 구분선, 통계, 기록 상세 관련 뷰 추가 contentView.addSubviews(mapView, titleLabel, weatherStack, separatorView, statsStack, recordTitleLabel, recordDetailView) + // 지도 위에 무빙트랙 버튼 추가 mapView.addSubview(movingTrackButton) - } // MARK: - Setup Layout + /// SnapKit을 활용하여 각 UI 요소들의 레이아웃 제약 조건을 설정하는 메서드 private func setupLayout() { scrollView.snp.makeConstraints { make in make.edges.equalTo(self) @@ -230,43 +235,46 @@ final class DetailLogView: UIView { make.width.equalTo(self.safeAreaLayoutGuide.snp.width) } + // 지도 영역 설정: 상단 인셋과 좌우 인셋, 높이는 너비와 동일하게 설정 mapView.snp.makeConstraints { make in make.top.equalToSuperview().inset(DynamicSize.scaledSize(16)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(24)) make.height.equalTo(mapView.snp.width) } + // 무빙트랙 버튼 위치 설정: 지도 뷰의 우측 하단 movingTrackButton.snp.makeConstraints { make in make.bottom.trailing.equalToSuperview().inset(DynamicSize.scaledSize(8)) make.width.equalTo(DynamicSize.scaledSize(95)) make.height.equalTo(DynamicSize.scaledSize(40)) } - // 타이틀 라벨: 맵뷰 아래 + // 타이틀 라벨 배치: 지도 하단에 위치 titleLabel.snp.makeConstraints { make in make.top.equalTo(mapView.snp.bottom).offset(DynamicSize.scaledSize(24)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(24)) } - // 날씨 스택: 타이틀 아래 + // 날씨 스택 배치: 타이틀 라벨 아래에 위치 weatherStack.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(DynamicSize.scaledSize(8)) make.leading.equalToSuperview().inset(DynamicSize.scaledSize(24)) } - // 구분선:날씨 스택 아래 + // 구분선 배치: 날씨 스택 아래, 좌우 인셋과 높이 설정 separatorView.snp.makeConstraints { make in make.top.equalTo(weatherStack.snp.bottom).offset(DynamicSize.scaledSize(8)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(24)) make.height.equalTo(DynamicSize.scaledSize(1)) } - // 통계 스택: 구분선 아래 + // 통계 스택 배치: 구분선 아래에 위치 statsStack.snp.makeConstraints { make in make.top.equalTo(separatorView.snp.bottom).offset(DynamicSize.scaledSize(8)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(24)) } + // 각 정보 스택의 너비 비율 설정 timeStack.snp.makeConstraints { make in make.width.equalTo(statsStack.snp.width).multipliedBy(0.334) } @@ -277,17 +285,17 @@ final class DetailLogView: UIView { make.width.equalTo(statsStack.snp.width).multipliedBy(0.333) } - // 기록상세 + // 기록 상세 타이틀 배치 recordTitleLabel.snp.makeConstraints { make in make.top.equalTo(statsStack.snp.bottom).offset(DynamicSize.scaledSize(24)) make.leading.equalToSuperview().inset(DynamicSize.scaledSize(24)) } - // “기록 상세” 테이블뷰 + // “기록 상세” 테이블뷰 배치 recordDetailView.snp.makeConstraints { make in - make.top.equalTo(recordTitleLabel.snp.bottom).offset(DynamicSize.scaledSize(16)) + make.top.equalTo(recordTitleLabel.snp.bottom).offset(DynamicSize.scaledSize(8)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(16)) - make.bottom.equalToSuperview().offset(DynamicSize.scaledSize(-20)) + make.bottom.lessThanOrEqualToSuperview().offset(DynamicSize.scaledSize(-20)) } } @@ -338,9 +346,8 @@ extension DetailLogView { mapView.setRegion(region, animated: animated) } + /// 맵뷰에 추가된 모든 오버레이 제거 func removeAllMapOverlays() { mapView.removeOverlays(mapView.overlays) } } - - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogViewController.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogViewController.swift index 4249966..bb0c9a3 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogViewController.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/DetailLogViewController.swift @@ -49,18 +49,22 @@ final class DetailLogViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() print("디버그: viewDidLoad 호출됨, 시각: \(Date())") + + // 테이블뷰의 데이터소스와 delegate 설정 let tableView = detailLogView.recordDetailView.tableView tableView.dataSource = self tableView.delegate = self + setupUI() setupNavigationBar() bindGesture() bindViewModel() - // setupMapView() + // setupMapView() // 초기 맵뷰 설정은 뷰모델 바인딩 내에서 처리 } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + // 뷰모델을 통해 DayLog 데이터를 새로고침하고 UI 업데이트 viewModel.refreshDayLog() refreshUI() } @@ -70,15 +74,17 @@ final class DetailLogViewController: UIViewController { } // MARK: - Setup UI + /// 추가적인 UI 설정이 필요할 경우 여기에 구현 (현재는 기본 설정만 적용) private func setupUI() { - // UI 요소 추가 + // UI 요소 추가 시 필요한 설정을 여기에 작성 } // MARK: - Setup Navigation Bar + /// 네비게이션 바 스타일 및 오른쪽 메뉴 버튼 추가 설정 private func setupNavigationBar() { - // 네비게이션바 디테일 설정 - navigationController?.setupAppearance() // 스타일 설정 + navigationController?.setupAppearance() // 네비게이션 바 스타일 적용 navigationController?.navigationItem.backButtonTitle = "chevron.left" + navigationController? .addRightMenuButton(menuItems: [ ("수정하기", .init()), @@ -92,8 +98,9 @@ final class DetailLogViewController: UIViewController { } // MARK: - Setup Gesture + /// 무빙트랙 버튼 및 통계 스택의 제스처를 바인딩하는 메서드 private func bindGesture() { - // 제스처 추가 + // 무빙트랙 버튼 터치 시 시트형태의 화면 표시 detailLogView.movingTrackButton.controlPublisher(for: .touchUpInside) .sink { [weak self] _ in guard let self = self else { return } @@ -107,37 +114,33 @@ final class DetailLogViewController: UIViewController { sheet.detents = [customDetent] sheet.selectedDetentIdentifier = customDetent.identifier - // Grabber 제거 + // Grabber 비표시 설정 및 기타 시트 옵션 적용 sheet.prefersGrabberVisible = false - sheet.prefersScrollingExpandsWhenScrolledToEdge = false sheet.prefersEdgeAttachedInCompactHeight = true sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true sheet.preferredCornerRadius = DynamicSize.scaledSize(16) } - self.present(sheetVC, animated: true) } .store(in: &cancellables) - // 통계 스택 탭 시 전체 경로 줌아웃 처리 + // 통계 스택 탭 시 전체 경로를 보여주기 위해 맵 뷰를 줌 아웃 let statsTapGesture = UITapGestureRecognizer() detailLogView.statsStack.addGestureRecognizer(statsTapGesture) statsTapGesture.tapPublisher .sink { [weak self] _ in guard let self = self, let dayLog = self.currentDayLog else { return } - self.zoomToAllPoints(dayLog: dayLog) // 전체 경로로 줌아웃 + self.zoomToAllPoints(dayLog: dayLog) } .store(in: &cancellables) - } - // MARK: - Bind ViewModel + /// 뷰모델의 output 이벤트를 수신하여 UI를 업데이트하고 필요한 액션을 수행하는 메서드 private func bindViewModel() { - viewModel.output .receive(on: DispatchQueue.main) .sink { [weak self] output in @@ -146,28 +149,27 @@ final class DetailLogViewController: UIViewController { case .loadedDayLog(let dayLog): self.currentDayLog = dayLog - // 첫 지점의 timestamp 기준으로 각 섹션 정렬 (최신순: 내림차순) + // 각 섹션의 첫 timestamp 기준으로 내림차순 정렬 let sortedSections = dayLog.sections.sorted { lhsSection, rhsSection in let lhsStartTime = lhsSection.route.sorted { $0.timestamp < $1.timestamp } .first?.timestamp ?? Date.distantPast let rhsStartTime = rhsSection.route.sorted { $0.timestamp < $1.timestamp } .first?.timestamp ?? Date.distantPast - return lhsStartTime > rhsStartTime } self.recordDetails = sortedSections.map { RecordDetail(from: $0) } - + // DayLog 데이터를 기반으로 뷰 업데이트 self.detailLogView.configure(with: DisplayDayLog(from: dayLog)) - self.recordDetails = dayLog.sections.map { - RecordDetail(from: $0) - } + self.recordDetails = dayLog.sections.map { RecordDetail(from: $0) } self.detailLogView.recordDetailView.tableView.reloadData() self.setupMapView(with: dayLog) + case .edit: let editViewModel = EditLogInfoViewModel(date: viewModel.date) self.navigationController?.pushViewController(EditLogInfoViewController(viewModel: editViewModel), animated: true) + case .share: self.handleShare() @@ -182,6 +184,7 @@ final class DetailLogViewController: UIViewController { } // MARK: - Action Handlers (함수 분리) + /// 공유 액션 처리: 현재 DayLog의 트랙 이미지를 공유 private func handleShare() { guard let data = currentDayLog?.trackImage else { return } let shareItems: [Any] = [UIImage(data: data)!] @@ -189,6 +192,7 @@ final class DetailLogViewController: UIViewController { self.present(activityVC, animated: true) } + /// 삭제 액션 처리: 삭제 확인 Alert 후 삭제 처리 private func handleDelete(in targetVC: UIViewController, dateString: String) { let alert = UIAlertController( title: "기록 삭제하기", @@ -200,7 +204,7 @@ final class DetailLogViewController: UIViewController { Task { do { try await self.viewModel.deleteDayLog() - // 삭제 성공 후 이전 화면으로 돌아가거나 추가 작업 수행 + // 삭제 성공 후 이전 화면으로 이동 DispatchQueue.main.async { self.navigationController?.popViewController(animated: true) } @@ -225,11 +229,12 @@ final class DetailLogViewController: UIViewController { targetVC.present(alert, animated: true) } - + /// 네비게이션 타이틀을 날짜 정보로 업데이트 private func updateNavigationTitle(with date: Date) { self.title = date.formattedString(.monthDay) } + /// 현재 DayLog 데이터를 기반으로 전체 UI를 새로고침 private func refreshUI() { guard let dayLog = currentDayLog else { return } detailLogView.configure(with: DisplayDayLog(from: dayLog)) @@ -246,55 +251,48 @@ extension DetailLogViewController: UITableViewDataSource, UITableViewDelegate { return 1 } - // 헤더 행 1개 + 실제 데이터 수 + // 실제 데이터 행 수만 반환 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return recordDetails.count + 1 + return recordDetails.count + } + + // 섹션 헤더 뷰 반환 + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let header = tableView.dequeueReusableHeaderFooterView( + withIdentifier: RecordDetailHeaderView.identifier + ) as? RecordDetailHeaderView else { + return nil + } + return header } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - if indexPath.row == 0 { - // 헤더 셀 - guard let cell = tableView.dequeueReusableCell( - withIdentifier: RecordDetailViewCell.identifier, - for: indexPath - ) as? RecordDetailViewCell else { - return UITableViewCell() - } - cell.configureAsHeader() - //print("디버그: 헤더 셀 생성됨, 시각: \(Date())") - return cell + let record = recordDetails[indexPath.row] + guard let cell = tableView.dequeueReusableCell( + withIdentifier: RecordDetailViewCell.identifier, + for: indexPath + ) as? RecordDetailViewCell else { + return UITableViewCell() + } + // 선택된 셀은 폰트를 RLHeadline1, 그 외는 RLHeadline2로 설정 + if indexPath.row == selectedSectionIndex { + cell.configure(with: record, font: .RLHeadline1) } else { - let record = recordDetails[indexPath.row - 1] - guard let cell = tableView.dequeueReusableCell( - withIdentifier: RecordDetailViewCell.identifier, - for: indexPath - ) as? RecordDetailViewCell else { - return UITableViewCell() - } - // 선택된 셀이면 폰트를 RLHeadline1, 아니면 RLHeadline2로 설정 - if indexPath.row - 1 == selectedSectionIndex { - cell.configure(with: record, font: .RLHeadline1) - } else { - cell.configure(with: record, font: .RLHeadline2) - } - return cell + cell.configure(with: record, font: .RLHeadline2) } + return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // 헤더 셀은 무시 (indexPath.row == 0) - guard indexPath.row > 0 else { return } - - let newSelectionIndex = indexPath.row - 1 + let newSelectionIndex = indexPath.row let previousSelection = selectedSectionIndex if let previous = previousSelection, previous != newSelectionIndex { - // 다른 셀을 선택한 경우: 기존 선택 해제 후 전체 경로 줌 (줌 아웃) + // 다른 셀 선택 시: 이전 선택 해제 후 전체 경로 줌(줌 아웃) selectedSectionIndex = nil tableView.reloadData() - // 줌 아웃 전에 오버레이 업데이트 + // 줌 아웃 전 오버레이 업데이트 detailLogView.removeAllMapOverlays() for polyline in polylineOverlays { detailLogView.addMapOverlay(polyline) @@ -304,13 +302,13 @@ extension DetailLogViewController: UITableViewDataSource, UITableViewDelegate { zoomToAllPoints(dayLog: dayLog) } - // 약간의 딜레이 후 새 선택 셀 줌 (줌 인) + // 약간의 딜레이 후 새 선택 셀 줌(줌 인) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self = self else { return } self.selectedSectionIndex = newSelectionIndex tableView.reloadData() - // 줌 인 전에 오버레이 업데이트 + // 줌 인 전 오버레이 업데이트 self.detailLogView.removeAllMapOverlays() for polyline in self.polylineOverlays { self.detailLogView.addMapOverlay(polyline) @@ -323,11 +321,11 @@ extension DetailLogViewController: UITableViewDataSource, UITableViewDelegate { } } } else { - // 동일한 셀을 선택한 경우 즉시 줌 처리 + // 동일 셀 선택 시: 즉시 줌 처리 selectedSectionIndex = newSelectionIndex tableView.reloadData() - // 줌 처리 전에 오버레이 업데이트 + // 줌 처리 전 오버레이 업데이트 detailLogView.removeAllMapOverlays() for polyline in polylineOverlays { detailLogView.addMapOverlay(polyline) @@ -342,29 +340,27 @@ extension DetailLogViewController: UITableViewDataSource, UITableViewDelegate { } } - // MARK: - Setup MapView & 폴리라인 extension DetailLogViewController { - /// DayLog를 파라미터로 받아 맵뷰 초기설정 및 폴리라인 그리기 + /// DayLog를 기반으로 맵뷰 초기 설정 및 폴리라인 그리기 private func setupMapView(with dayLog: DayLog) { // 1) 맵뷰 델리게이트 설정 detailLogView.setMapViewDelegate(self) - // 2) 데이터 세팅 (DisplayDayLog 생성 대신, dayLog 데이터 활용) + // 2) DayLog 데이터를 기반으로 뷰 업데이트 detailLogView.configure(with: DisplayDayLog(from: dayLog)) // 3) 폴리라인 그리기 drawPolyline(from: dayLog) } - /// dummyDayLog의 모든 Section을 순회하여 폴리라인을 그리고, 적절히 확대 + /// 모든 섹션의 좌표를 순회하여 폴리라인을 그리고 전체 영역으로 줌 아웃 처리 private func drawPolyline(from dayLog: DayLog) { // 기존 오버레이 제거 detailLogView.removeAllMapOverlays() polylineOverlays.removeAll() - // 각 section 별로 폴리라인 생성 + // 각 섹션별 폴리라인 생성 for (index, section) in dayLog.sections.enumerated() { - // timestamp 기준으로 정렬한 후 좌표 배열 생성 let sortedCoordinates = section.route .sorted(by: { $0.timestamp < $1.timestamp }) .map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } @@ -377,18 +373,16 @@ extension DetailLogViewController { detailLogView.addMapOverlay(polyline) } - // 전체 영역이 보이도록 확대 + // 전체 경로가 보이도록 맵 뷰 줌 아웃 처리 zoomToAllPoints(dayLog: dayLog) - } - /// 모든 경로 점들을 순회하여 바운딩 박스(최소·최대 위도/경도) 구하기 + /// 모든 좌표를 순회하여 바운딩 박스를 계산하고, 전체 영역으로 줌 아웃 private func zoomToAllPoints(dayLog: DayLog) { - // 1) 모든 Point 추출 let allPoints = dayLog.sections.flatMap { $0.route } guard !allPoints.isEmpty else { return } - // 2) min/max lat, lon 구하기 + // 최소/최대 위도 및 경도 계산 var minLat = Double.greatestFiniteMagnitude var maxLat = -Double.greatestFiniteMagnitude var minLon = Double.greatestFiniteMagnitude @@ -401,25 +395,18 @@ extension DetailLogViewController { maxLon = max(maxLon, point.longitude) } - // 3) 중심좌표 = (minLat ~ maxLat)의 중앙, (minLon ~ maxLon)의 중앙 + // 중심 좌표 계산 let centerLat = (minLat + maxLat) / 2 let centerLon = (minLon + maxLon) / 2 let center = CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon) - // 4) 가장 멀리 떨어진 두 점 = (minLat, minLon) vs (maxLat, maxLon) 라고 가정 + // 두 모서리 좌표 사이의 거리 계산 (여유 1.2배 적용) let corner1 = CLLocation(latitude: minLat, longitude: minLon) let corner2 = CLLocation(latitude: maxLat, longitude: maxLon) - - // 5) 두 지점 사이의 거리(미터) var distance = corner1.distance(from: corner2) - // 거리에 여유를 주고 싶다면 1.2배 등 곱해주기 - if distance == 0 { - distance = 5000 - } else { - distance *= 1.2 - } + distance = (distance == 0) ? 5000 : distance * 1.2 - // 6) region 설정 + // MKCoordinateRegion 생성 후 맵 영역 설정 let region = MKCoordinateRegion( center: center, latitudinalMeters: distance, @@ -429,8 +416,8 @@ extension DetailLogViewController { detailLogView.setMapRegion(region, animated: true) } + /// 선택된 섹션의 좌표를 기반으로 지도 영역을 줌 인 private func zoomToRoute(route: [Point]) { - // 경로가 비어있으면 아무 작업도 하지 않음 guard !route.isEmpty else { return } var minLat = Double.greatestFiniteMagnitude @@ -438,7 +425,7 @@ extension DetailLogViewController { var minLon = Double.greatestFiniteMagnitude var maxLon = -Double.greatestFiniteMagnitude - // 각 좌표의 최소, 최대 위도/경도 계산 + // 각 좌표의 최소, 최대 위도 및 경도 계산 for point in route { minLat = min(minLat, point.latitude) maxLat = max(maxLat, point.latitude) @@ -451,23 +438,20 @@ extension DetailLogViewController { let centerLon = (minLon + maxLon) / 2 let center = CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon) - // 두 모서리 좌표 사이의 거리 계산 (여유를 위해 1.2배) + // 두 모서리 좌표 사이의 거리 계산 (여유 1.2배 적용) let corner1 = CLLocation(latitude: minLat, longitude: minLon) let corner2 = CLLocation(latitude: maxLat, longitude: maxLon) var distance = corner1.distance(from: corner2) distance = (distance == 0) ? 5000 : distance * 1.2 - // MKCoordinateRegion 생성 후 맵뷰 영역 설정 + // MKCoordinateRegion 생성 후 맵 영역 설정 let region = MKCoordinateRegion(center: center, latitudinalMeters: distance, longitudinalMeters: distance) detailLogView.setMapRegion(region, animated: true) } - } - - // MARK: - MKMapViewDelegate extension DetailLogViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { @@ -477,7 +461,7 @@ extension DetailLogViewController: MKMapViewDelegate { let renderer = MKPolylineRenderer(polyline: polyline) if let title = polyline.title, let index = Int(title), index == selectedSectionIndex { - renderer.strokeColor = .NormalGreen // 선택된 section이면 NormalGreen색으로 표시 + renderer.strokeColor = .NormalGreen // 선택된 섹션이면 NormalGreen 색상 적용 renderer.lineWidth = 6 } else { renderer.strokeColor = .LightGreen @@ -486,5 +470,3 @@ extension DetailLogViewController: MKMapViewDelegate { return renderer } } - - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetView.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetView.swift index ff1ec2c..3e94e56 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetView.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetView.swift @@ -13,6 +13,7 @@ import Then final class MovingTrackSheetView: UIView { // MARK: - UI Components 선언 + /// 날짜를 표시하는 라벨 private let dateLabel = RLLabel( text: "날짜", textColor: .Gray000, @@ -21,6 +22,7 @@ final class MovingTrackSheetView: UIView { font: .RLHeading1 ) + /// 서브타이틀을 표시하는 라벨 private let subtitleLabel = RLLabel( text: "이날의 동선을 영상으로 확인해 보세요!", textColor: .Gray100, @@ -29,23 +31,24 @@ final class MovingTrackSheetView: UIView { font: .RLBody1 ) + /// 닫기 버튼 (뷰를 종료하기 위한 버튼) let closeButton = UIButton(type: .system).then { $0.setImage(UIImage(systemName: "xmark"), for: .normal) $0.tintColor = .white } + /// 지도 영역을 표시하는 MKMapView let mapView = MKMapView().then { - // 추가 delegate 설정 및 커스터마이징 가능 + // 지도에 둥근 모서리 효과 적용 및 클리핑 활성화 $0.layer.cornerRadius = DynamicSize.scaledSize(16) $0.clipsToBounds = true } - // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) - setupUI() - setupLayout() + setupUI() // UI 요소 추가 + setupLayout() // SnapKit 레이아웃 제약조건 설정 } required init?(coder: NSCoder) { @@ -53,52 +56,51 @@ final class MovingTrackSheetView: UIView { } // MARK: - Setup UI + /// 하위 뷰들을 추가하고 기본 배경색을 설정하는 메서드 private func setupUI() { - // UI 요소 추가 backgroundColor = .Gray700 - self.addSubviews(dateLabel, subtitleLabel, closeButton, mapView) } // MARK: - Setup Layout + /// SnapKit을 사용하여 UI 요소들의 레이아웃을 설정하는 메서드 private func setupLayout() { - // 레이아웃 설정 - // 상단 날짜 라벨 + // 날짜 라벨: safeArea 상단에서 일정 간격 떨어져 중앙에 위치 dateLabel.snp.makeConstraints { make in make.top.equalTo(safeAreaLayoutGuide).offset(DynamicSize.scaledSize(48)) make.centerX.equalToSuperview() } - // 닫기 버튼 + // 닫기 버튼: 날짜 라벨의 수평 중앙에 맞추고, 오른쪽에 인셋 적용 closeButton.snp.makeConstraints { make in make.centerY.equalTo(dateLabel) make.trailing.equalToSuperview().inset(DynamicSize.scaledSize(32)) } - // 서브타이틀 + // 서브타이틀: 날짜 라벨 아래에 위치하며 중앙 정렬 subtitleLabel.snp.makeConstraints { make in make.top.equalTo(dateLabel.snp.bottom).offset(DynamicSize.scaledSize(4)) make.centerX.equalToSuperview() } - // 지도 + // 지도: 서브타이틀 아래에서 시작, 좌우 인셋 적용, 높이는 너비 비율에 따라 설정하고 safeArea 하단에 여백 적용 mapView.snp.makeConstraints { make in make.top.equalTo(subtitleLabel.snp.bottom).offset(DynamicSize.scaledSize(48)) make.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(24)) make.height.equalTo(mapView.snp.width).multipliedBy(498.0 / 392.0) make.bottom.equalTo(safeAreaLayoutGuide).offset(DynamicSize.scaledSize(-60)) } - } // MARK: - Configure + /// 추가적인 뷰 설정을 위한 메서드 (필요 시 구현) private func configure() { - // 뷰 설정 + // 추가 설정이 필요하면 여기에 작성 } } extension MovingTrackSheetView { - /// 날짜를 받아서 dateLabel에 반영 + /// 날짜를 받아 dateLabel에 반영하는 메서드 func configure(with date: Date) { let dateString = date.formattedString(.detailedFull) print("MovingTrackSheetView configure 호출됨, 날짜: \(dateString)") @@ -108,12 +110,12 @@ extension MovingTrackSheetView { extension MovingTrackSheetView { - // 메서드: MapOverlay 추가 + /// MapView에 오버레이를 추가하는 메서드 func addMapOverlay(_ overlay: MKOverlay) { mapView.addOverlay(overlay) } - // 메서드: 맵 영역 설정 + /// MapView의 영역을 설정하는 메서드 func setMapRegion(_ region: MKCoordinateRegion, animated: Bool) { mapView.setRegion(region, animated: animated) } diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetViewController.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetViewController.swift index f3483ec..025e4e2 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetViewController.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/MovingTrackSheetViewController.swift @@ -29,7 +29,6 @@ final class MovingTrackSheetViewController: UIViewController { private var currentCoordIndexInSection = 0 /// 카메라 이동을 위한 타이머 private var cameraTimer: Timer? - /// 진행 경로를 누적하는 배열 (현재 섹션 내) private var accumulatedProgressCoordinates: [CLLocationCoordinate2D] = [] @@ -44,7 +43,9 @@ final class MovingTrackSheetViewController: UIViewController { } // MARK: - Lifecycle - override func loadView() { view = sheetView } + override func loadView() { + view = sheetView + } override func viewDidLoad() { super.viewDidLoad() @@ -53,7 +54,6 @@ final class MovingTrackSheetViewController: UIViewController { setupData() bindViewModel() setupMapView() - prepareSectionsCoordinates() } @@ -64,57 +64,61 @@ final class MovingTrackSheetViewController: UIViewController { } // MARK: - Setup Navigation Bar + /// 네비게이션 바 설정 (추가 설정 필요 시 이곳에 구현) private func setupNavigationBar() { // 네비게이션바 디테일 설정 } // MARK: - Setup Gesture + /// 닫기 버튼에 대한 제스처를 바인딩하는 메서드 private func bindGesture() { - // 제스처 추가 sheetView.closeButton.controlPublisher(for: .touchUpInside) .sink { [weak self] _ in self?.dismiss(animated: true) } .store(in: &cancellables) - } // MARK: - Setup Data + /// 초기 데이터 로드를 위한 메서드 (필요 시 구현) private func setupData() { // 초기 데이터 로드 } // MARK: - Bind ViewModel + /// 뷰모델의 출력 이벤트를 구독하여 UI 업데이트 및 액션을 처리하는 메서드 private func bindViewModel() { - // viewModel.output.something - // .sink { [weak self] value in - // // View 업데이트 로직 - // } - // .store(in: &cancellables) + // 예시 코드 - 필요 시 구현 + // viewModel.output.something + // .sink { [weak self] value in + // // View 업데이트 로직 + // } + // .store(in: &cancellables) } // MARK: - Setup MapView + /// MapView의 설정 및 기본 옵션 적용, 뷰모델에서 전달된 날짜를 반영 private func setupMapView() { let mapView = sheetView.mapView - // 사용자 상호작용 모두 비활성화 + // 사용자 상호작용 비활성화 mapView.isUserInteractionEnabled = false - // 1) 건물 표시 (3D 데이터가 있는 지역에서만 보임) + // 1) 3D 건물 표시 활성화 (해당 지역에서만 적용) mapView.showsBuildings = true - // 2) 사용자가 핀치/드래그 제스처로 지도 기울이거나 회전할 수 있게 + // 2) 지도 기울이기 및 회전 가능 mapView.isPitchEnabled = true mapView.isRotateEnabled = true - // 3) 나머지 옵션 + // 3) 추가 옵션 적용: 나침반, 축척 표시 mapView.showsCompass = true mapView.showsScale = true - // (Delegate 설정) + // Delegate 설정 mapView.delegate = self - // 뷰모델에서 받아온 날짜를 전달 (예: dayLog.date) + // 뷰모델의 dayLogPublisher를 통해 날짜 전달 및 뷰 업데이트 viewModel.dayLogPublisher .receive(on: DispatchQueue.main) .sink { [weak self] dayLog in @@ -123,13 +127,13 @@ final class MovingTrackSheetViewController: UIViewController { .store(in: &cancellables) } - /// 각 섹션의 route를 그대로 sectionsCoordinates에 저장 + /// 각 섹션의 route 데이터를 sectionsCoordinates에 저장하고 애니메이션 시작 준비 private func prepareSectionsCoordinates() { viewModel.dayLogPublisher .receive(on: DispatchQueue.main) .sink { [weak self] dayLog in guard let self = self else { return } - // 섹션별 route를 timestamp 순으로 정렬한 뒤 좌표 배열로 변환 + // 각 섹션별 route를 timestamp 순으로 정렬 후 좌표 배열로 변환 self.sectionsCoordinates = dayLog.sections.map { section in section.route .sorted(by: { $0.timestamp < $1.timestamp }) @@ -139,20 +143,19 @@ final class MovingTrackSheetViewController: UIViewController { // 전체 경로 오버레이 그리기 self.drawAllSectionOverlays() - // 첫 섹션 카메라 애니메이션 시작 + // 첫 섹션에 대해 카메라 애니메이션 시작 self.startCameraAnimationForCurrentSection() } .store(in: &cancellables) } - /// 각 섹션별로 오버레이를 그려 전체 경로를 표현 (섹션 간 구분) + /// 각 섹션별 전체 경로 오버레이를 지도에 그리는 메서드 private func drawAllSectionOverlays() { let mapView = sheetView.mapView mapView.removeOverlays(mapView.overlays) for (index, sortedCoordinates) in sectionsCoordinates.enumerated() { guard sortedCoordinates.count >= 2 else { continue } - let polyline = MKPolyline(coordinates: sortedCoordinates, count: sortedCoordinates.count) polyline.title = "전체 경로-\(index)" sheetView.addMapOverlay(polyline) @@ -160,25 +163,25 @@ final class MovingTrackSheetViewController: UIViewController { } // MARK: - 3D 카메라 애니메이션 (섹션별) - - /// 현재 섹션의 애니메이션 시작 + /// 현재 섹션에 대해 카메라 애니메이션을 시작하는 메서드 private func startCameraAnimationForCurrentSection() { guard currentSectionIndex < sectionsCoordinates.count else { return } let sectionCoords = sectionsCoordinates[currentSectionIndex] guard sectionCoords.count > 1 else { return } + // 타이머 초기화 및 섹션 내 좌표 인덱스 재설정 cameraTimer?.invalidate() cameraTimer = nil currentCoordIndexInSection = 0 - // 누적 좌표 배열 초기화 및 첫 좌표 추가 + // 누적 좌표 배열 초기화 후 첫 좌표 추가 및 카메라 초기 설정 accumulatedProgressCoordinates = [] if let firstCoord = sectionCoords.first { accumulatedProgressCoordinates.append(firstCoord) - // 초기 카메라 설정: 현재 섹션 첫 좌표, 현재 카메라 heading 유지 setCameraToCoordinate(firstCoord, heading: sheetView.mapView.camera.heading) } + // 타이머 시작: 0.2초 간격으로 카메라 업데이트 cameraTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateCameraForSection), @@ -186,7 +189,7 @@ final class MovingTrackSheetViewController: UIViewController { repeats: true) } - /// 섹션 내에서 카메라를 업데이트하고 진행 구간을 개별 세그먼트로 추가 + /// 섹션 내에서 카메라 위치와 heading을 업데이트하고, 진행 구간 오버레이를 추가하는 메서드 @objc private func updateCameraForSection() { let sectionCoords = sectionsCoordinates[currentSectionIndex] guard currentCoordIndexInSection < sectionCoords.count - 1 else { @@ -197,17 +200,17 @@ final class MovingTrackSheetViewController: UIViewController { let currentCoord = sectionCoords[currentCoordIndexInSection] let nextCoord = sectionCoords[currentCoordIndexInSection + 1] - // 경로 진행 방향에 따른 heading 계산 + // 현재 좌표와 다음 좌표 간의 heading 계산 let rawHeading = calculateHeading(from: currentCoord, to: nextCoord) let targetHeading = rawHeading.truncatingRemainder(dividingBy: 360) + // 애니메이션을 통한 카메라 이동 UIView.animate(withDuration: 0.2) { self.setCameraToCoordinate(nextCoord, heading: targetHeading) } - // 누적 좌표 배열에 새로운 좌표 추가 + // 진행 좌표 누적 및 세그먼트 오버레이 추가 accumulatedProgressCoordinates.append(nextCoord) - // 바로 직전 좌표와 연결하는 세그먼트 오버레이 추가 (기존 오버레이는 제거하지 않음) if accumulatedProgressCoordinates.count >= 2 { let lastTwoCoords = Array(accumulatedProgressCoordinates.suffix(2)) let segmentPolyline = MKPolyline(coordinates: lastTwoCoords, count: lastTwoCoords.count) @@ -218,13 +221,11 @@ final class MovingTrackSheetViewController: UIViewController { currentCoordIndexInSection += 1 } - /// 현재 섹션이 끝났다면 다음 섹션으로 전환 (있을 경우) + /// 현재 섹션이 끝나면 다음 섹션으로 전환하는 메서드 (있을 경우) private func moveToNextSectionIfAvailable() { if currentSectionIndex < sectionsCoordinates.count - 1 { currentSectionIndex += 1 currentCoordIndexInSection = 0 - - // 새로운 섹션으로 넘어갈 때 누적 좌표 배열 초기화 accumulatedProgressCoordinates = [] let nextSectionCoords = sectionsCoordinates[currentSectionIndex] if let firstCoord = nextSectionCoords.first { @@ -238,7 +239,7 @@ final class MovingTrackSheetViewController: UIViewController { } } - /// 카메라 설정 함수 + /// 주어진 좌표와 heading으로 카메라를 설정하는 메서드 private func setCameraToCoordinate(_ coordinate: CLLocationCoordinate2D, heading: CLLocationDirection) { let camera = MKMapCamera( lookingAtCenter: coordinate, @@ -249,7 +250,7 @@ final class MovingTrackSheetViewController: UIViewController { sheetView.mapView.camera = camera } - /// 두 좌표 간 heading(방위각) 계산 + /// 두 좌표 간의 heading(방위각)을 계산하는 메서드 private func calculateHeading(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> CLLocationDirection { let fromLat = from.latitude.deg2rad let fromLon = from.longitude.deg2rad @@ -267,8 +268,11 @@ final class MovingTrackSheetViewController: UIViewController { // MARK: - MKMapViewDelegate extension MovingTrackSheetViewController: MKMapViewDelegate { + /// 오버레이 렌더러를 설정하여 진행 경로와 전체 경로를 구분하여 그린다. func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { - guard let polyline = overlay as? MKPolyline, let title = polyline.title else { return MKOverlayRenderer() } + guard let polyline = overlay as? MKPolyline, let title = polyline.title else { + return MKOverlayRenderer() + } let renderer = MKPolylineRenderer(polyline: polyline) if title == "진행 경로" { renderer.strokeColor = .NormalGreen @@ -280,4 +284,3 @@ extension MovingTrackSheetViewController: MKMapViewDelegate { return renderer } } - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailHeaderView.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailHeaderView.swift new file mode 100644 index 0000000..a06bb2a --- /dev/null +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailHeaderView.swift @@ -0,0 +1,74 @@ +// +// RecordDetailHeaderView.swift +// RunLog +// +// Created by 도민준 on 4/7/25. +// + +import UIKit +import SnapKit +import Then + +final class RecordDetailHeaderView: UITableViewHeaderFooterView { + + static let identifier = "RecordDetailHeaderView" + + // MARK: - UI Components 선언 + /// 시간대를 표시하는 라벨 (예: "시간대") + private let timeLabel = UILabel().then { + $0.font = .RLBody1 + $0.textColor = .Gray000 + $0.textAlignment = .left + $0.text = "시간대" + } + + /// 운동거리를 표시하는 라벨 (예: "운동거리") + private let distanceLabel = UILabel().then { + $0.font = .RLBody1 + $0.textColor = .Gray000 + $0.textAlignment = .left + $0.text = "운동거리" + } + + /// 걸음수를 표시하는 라벨 (예: "걸음수") + private let stepsLabel = UILabel().then { + $0.font = .RLBody1 + $0.textColor = .Gray000 + $0.textAlignment = .left + $0.text = "걸음수" + } + + /// 3개의 라벨을 수평으로 배치하는 스택뷰 (각 라벨의 크기와 간격을 균등하게 배분) + private lazy var horizontalStack = UIStackView(arrangedSubviews: [timeLabel, distanceLabel, stepsLabel]).then { + $0.axis = .horizontal + $0.alignment = .center + $0.distribution = .fillEqually + $0.spacing = DynamicSize.scaledSize(8) + } + + // MARK: - Init + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + setupUI() // UI 요소들을 뷰 계층 구조에 추가 + setupLayout() // SnapKit을 이용한 레이아웃 제약 조건 설정 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup UI + /// contentView의 배경색을 설정하고 수평 스택뷰를 추가하는 메서드 + private func setupUI() { + contentView.backgroundColor = .Gray900 + contentView.addSubview(horizontalStack) + } + + // MARK: - Setup Layout + /// horizontalStack의 위치와 여백을 SnapKit을 이용해 설정하는 메서드 + private func setupLayout() { + horizontalStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(DynamicSize.scaledSize(8)) + } + } +} diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailView.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailView.swift index 9cd6e1e..d1617cb 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailView.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailView.swift @@ -12,9 +12,12 @@ import Then final class RecordDetailView: UIView { // MARK: - UI Components 선언 + /// 테이블뷰: 기록 상세 데이터를 표시하기 위한 테이블뷰 lazy var tableView = UITableView(frame: .zero, style: .plain).then { // 셀 등록 $0.register(RecordDetailViewCell.self, forCellReuseIdentifier: RecordDetailViewCell.identifier) + // 헤더 뷰 등록 + $0.register(RecordDetailHeaderView.self, forHeaderFooterViewReuseIdentifier: RecordDetailHeaderView.identifier) // 테이블뷰 기본 설정 $0.isScrollEnabled = false @@ -23,16 +26,20 @@ final class RecordDetailView: UIView { $0.separatorColor = .Gray500 $0.rowHeight = UITableView.automaticDimension $0.estimatedRowHeight = DynamicSize.scaledSize(32) + if #available(iOS 15.0, *) { + $0.sectionHeaderTopPadding = 0 + } } - // KVO 관찰자 (contentSize 변경) + + /// KVO 관찰자: 테이블뷰 contentSize 변경을 관찰하기 위한 변수 private var contentSizeObservation: NSKeyValueObservation? // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) - setupUI() - setupLayout() - configure() + setupUI() // UI 요소 추가 + setupLayout() // 레이아웃 제약조건 설정 + configure() // 추가 설정 (KVO 관찰 시작) } required init?(coder: NSCoder) { @@ -40,55 +47,49 @@ final class RecordDetailView: UIView { } // MARK: - Setup UI + /// 배경색을 설정하고 테이블뷰를 서브뷰로 추가 private func setupUI() { - // UI 요소 추가 backgroundColor = .Gray900 addSubview(tableView) } // MARK: - Setup Layout + /// SnapKit을 사용해 테이블뷰의 레이아웃 제약조건을 설정 private func setupLayout() { - // 초기 제약조건은 임의 높이 (나중에 contentSize에 따라 업데이트됨 + // 테이블뷰의 가장자리와 고정된 초기 높이 설정 (이후 contentSize 변경에 따라 업데이트됨) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() - make.height.equalTo(DynamicSize.scaledSize(300)) // 초기 높이 (나중에 KVO로 업데이트됨) + make.height.equalTo(DynamicSize.scaledSize(50)) // 초기 높이 } + // 셀 구분선 인셋 설정 tableView.separatorInset = UIEdgeInsets(top: 0, left: DynamicSize.scaledSize(8), bottom: 0, right: DynamicSize.scaledSize(8)) - } // MARK: - Configure + /// 테이블뷰의 contentSize를 관찰하여 높이 제약조건을 업데이트하는 설정 수행 private func configure() { - // 뷰 설정 observeTableViewContentSize() } // MARK: - KVO: contentSize 관찰 및 높이 업데이트 + 디버그 출력 + /// 테이블뷰의 contentSize 변화를 감지하여 제약조건을 업데이트하고 레이아웃을 새로 고침 private func observeTableViewContentSize() { contentSizeObservation = tableView.observe(\.contentSize, options: [.new, .old]) { [weak self] tableView, change in guard let self = self, let newSize = change.newValue else { return } - //print("디버그: tableView의 contentSize가 \(change.oldValue ?? .zero)에서 \(newSize)로 변경됨, 시각: \(Date())") - // SnapKit을 통해 높이 제약조건 업데이트 self.tableView.snp.updateConstraints { make in make.height.equalTo(newSize.height) } - - // 레이아웃 업데이트 강제 실행 + // 레이아웃 강제 업데이트 self.setNeedsLayout() self.layoutIfNeeded() - - // 업데이트 후 실제 테이블뷰의 높이 출력 - //print("디버그: 업데이트 후 tableView의 frame.height: \(self.tableView.frame.height), 시각: \(Date())") + // 디버그 출력을 원할 경우 주석 해제하여 사용 가능 + // print("디버그: tableView의 contentSize 변경됨: \(change.oldValue ?? .zero) -> \(newSize), 시각: \(Date())") + // print("디버그: 업데이트 후 tableView의 frame.height: \(self.tableView.frame.height), 시각: \(Date())") } } deinit { contentSizeObservation?.invalidate() } - } - - - - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailViewCell.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailViewCell.swift index faf7dda..3e0fcb7 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailViewCell.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/View/RecordDetailViewCell.swift @@ -14,23 +14,28 @@ final class RecordDetailViewCell: UITableViewCell { static let identifier = "RecordDetailCell" // MARK: - UI Components 선언 + /// 시간 정보를 표시하는 라벨 private let timeLabel = UILabel().then { $0.font = .RLHeadline2 $0.textColor = .LightOrange $0.textAlignment = .left } + + /// 운동거리 정보를 표시하는 라벨 private let distanceLabel = UILabel().then { $0.font = .RLHeadline2 $0.textColor = .LightPink $0.textAlignment = .left } + + /// 걸음수 정보를 표시하는 라벨 private let stepsLabel = UILabel().then { $0.font = .RLHeadline2 $0.textColor = .LightBlue $0.textAlignment = .left } - // 수평 스택뷰로 3개 라벨 배치 + /// 3개 라벨을 수평으로 배치하는 스택뷰 private lazy var horizontalStack = UIStackView(arrangedSubviews: [timeLabel, distanceLabel, stepsLabel]).then { $0.axis = .horizontal $0.alignment = .leading @@ -41,8 +46,8 @@ final class RecordDetailViewCell: UITableViewCell { // MARK: - Init override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - setupUI() - setupLayout() + setupUI() // UI 요소들을 contentView에 추가 + setupLayout() // SnapKit 레이아웃 제약조건 설정 } required init?(coder: NSCoder) { @@ -50,23 +55,23 @@ final class RecordDetailViewCell: UITableViewCell { } // MARK: - Setup UI + /// contentView의 배경색을 설정하고, 수평 스택뷰를 추가하는 메서드 private func setupUI() { - // UI 요소 추가 contentView.backgroundColor = .Gray900 contentView.addSubview(horizontalStack) - // 선택 스타일 제거 + // 셀 선택 시 배경색 변화 없도록 설정 selectionStyle = .none } // MARK: - Setup Layout + /// 수평 스택뷰와 각 라벨의 제약조건을 SnapKit을 이용해 설정하는 메서드 private func setupLayout() { - // 레이아웃 설정 horizontalStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(DynamicSize.scaledSize(8)) } - // 라벨별로 width 비율 고정 (예: 0.4 : 0.3 : 0.3) + // 각 라벨의 너비를 전체 스택뷰 너비의 비율로 고정 (0.334, 0.333, 0.333) timeLabel.snp.makeConstraints { make in make.width.equalTo(horizontalStack.snp.width).multipliedBy(0.334) } @@ -79,26 +84,7 @@ final class RecordDetailViewCell: UITableViewCell { } // MARK: - Configure - func configureAsHeader() { - print("헤더 셀 구성 호출됨") - // 뷰 설정 - timeLabel.font = .RLBody1 - timeLabel.textColor = .Gray000 - timeLabel.textAlignment = .left - timeLabel.text = "시간대" - - distanceLabel.font = .RLBody1 - distanceLabel.textColor = .Gray000 - distanceLabel.textAlignment = .left - distanceLabel.text = "운동거리" - - stepsLabel.font = .RLBody1 - stepsLabel.textColor = .Gray000 - stepsLabel.textAlignment = .left - stepsLabel.text = "걸음수" - } - - /// 폰트를 외부에서 지정할 수 있도록 수정한 configure 메서드 + /// 레코드 데이터를 받아서 라벨에 설정하고, 폰트를 외부에서 지정할 수 있도록 한 메서드 func configure(with record: RecordDetail, font: UIFont) { timeLabel.font = font timeLabel.textColor = .LightOrange @@ -116,10 +102,8 @@ final class RecordDetailViewCell: UITableViewCell { stepsLabel.text = record.steps } - // 기존 메서드 유지 (디폴트로 headline2 적용) + /// 기본적으로 RLHeadline2 폰트를 적용하여 레코드 데이터를 설정하는 메서드 func configure(with record: RecordDetail) { configure(with: record, font: .RLHeadline2) } } - - diff --git a/RunLog/RunLog/Sources/Presentation/DetailLog/ViewModel/DetailLogViewModel.swift b/RunLog/RunLog/Sources/Presentation/DetailLog/ViewModel/DetailLogViewModel.swift index cf368fa..add7027 100644 --- a/RunLog/RunLog/Sources/Presentation/DetailLog/ViewModel/DetailLogViewModel.swift +++ b/RunLog/RunLog/Sources/Presentation/DetailLog/ViewModel/DetailLogViewModel.swift @@ -12,9 +12,9 @@ import Combine final class DetailLogViewModel { // MARK: - Properties + /// 선택된 날짜 let date: Date - // MARK: - Input & Output enum Input { case menuSelected(String) @@ -27,25 +27,28 @@ final class DetailLogViewModel { case delete } + /// 사용자 입력을 받기 위한 subject let input = PassthroughSubject() + /// 뷰모델 출력 이벤트를 전달하기 위한 subject (초기값은 nil) let output = CurrentValueSubject(nil) private var cancellables = Set() + /// DayLogUseCase 의존성 주입 (주입 도구를 통해 할당) @Dependency private var dayLogUseCase: DayLogUseCase // MARK: - DayLog Subject - // 외부에서 가져온 DayLog 데이터 저장 및 업데이트를 위해 subject 사용 + /// 외부에서 가져온 DayLog 데이터를 저장 및 업데이트하기 위한 subject let dayLogSubject = CurrentValueSubject(nil) - // 외부에서 DayLog를 구독할 수 있는 publisher 제공 + /// 외부에서 구독할 수 있는 DayLog publisher (nil 제외) var dayLogPublisher: AnyPublisher { dayLogSubject .compactMap { $0 } .eraseToAnyPublisher() } - // 기존: 전체 경로를 하나의 배열로 반환 + /// 전체 경로 좌표 배열을 publisher로 제공 (모든 섹션의 좌표를 합침) var allCoordinatesPublisher: AnyPublisher<[CLLocationCoordinate2D], Never> { dayLogPublisher .map { dayLog in @@ -58,7 +61,7 @@ final class DetailLogViewModel { .eraseToAnyPublisher() } - // 수정: 각 섹션별로 좌표 배열을 반환 + /// 각 섹션별 좌표 배열을 publisher로 제공 var coordinatesBySectionPublisher: AnyPublisher<[[CLLocationCoordinate2D]], Never> { dayLogPublisher .map { dayLog in @@ -79,6 +82,7 @@ final class DetailLogViewModel { } // MARK: - Bind (Input -> Output) + /// Input 이벤트를 처리하여 적절한 Output 이벤트를 발생시키는 메서드 private func bind() { input.receive(on: DispatchQueue.main) .sink { [weak self] event in @@ -101,11 +105,12 @@ final class DetailLogViewModel { } // MARK: - private Functions + /// 지정된 날짜에 해당하는 DayLog 데이터를 비동기로 불러오고, 섹션을 내림차순(최신순)으로 정렬하여 업데이트 private func loadTargetDayLog(date: Date) { Task { guard let dayLog = try await dayLogUseCase.getDayLogByDate(date) else { return } - // 각 Section의 첫 지점 timestamp를 기준으로 내림차순(최신순) 정렬 + // 각 Section의 첫 좌표 timestamp를 기준으로 내림차순 정렬 let sortedSections = dayLog.sections.sorted { lhs, rhs in let lhsStartTime = lhs.route.sorted { $0.timestamp < $1.timestamp } .first?.timestamp ?? Date.distantPast @@ -118,17 +123,19 @@ final class DetailLogViewModel { var sortedDayLog = dayLog sortedDayLog.sections = sortedSections + // 업데이트된 DayLog를 subject와 output으로 전달 dayLogSubject.send(sortedDayLog) output.send(.loadedDayLog(sortedDayLog)) } } - + /// 선택된 날짜의 DayLog 데이터를 삭제하는 메서드 (비동기) func deleteDayLog() async throws { - try await dayLogUseCase.deleteDayLogByDate(date) - } + try await dayLogUseCase.deleteDayLogByDate(date) + } + /// DayLog 데이터를 새로고침하는 메서드 func refreshDayLog() { - loadTargetDayLog(date: date) - } + loadTargetDayLog(date: date) + } }