From cdea084d3c9227328ea27634df6ae0d4cecd3e4e Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 10:08:04 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20Notice=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20+=20Meet.pinnedNotice=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 MeetDetail 응답이 pinnedNotice 중첩 객체를 포함하도록 변경됨에 따라 Domain/Data 레이어에 PinnedNotice 도메인 모델과 매핑을 추가한다. ## Domain (Modules/Domain/Sources/Entities/Notice) - NoticeType enum 신규 (CUSTOM, SYSTEM) - PinnedNotice struct 신규 (public init, version 포함) - Meet entity에 version, pinnedNotice 옵셔널 필드 추가 - 기존 호출부는 named arg + 기본값 nil로 자동 호환 ## Data (Modules/Data/Sources/Network/Response) - Notice/PinnedNoticeResponse.swift 신규 DTO + toDomain() 매핑 - MeetResponse에 version, pinnedNotice 필드 + toDomain() 매핑 갱신 ## Mock - MockFetchMeetDetailUseCase에 mockPinnedNotice 샘플 추가 (UI 개발용) ## 참고 - 기존 feat/#37-notice (d2571a1)에서 동일 모델 작업이 있었으나 Phase 6 모듈화 이전 경로(Mople/Domain, Mople/Data)였음. 이 PR에서 모듈 경로(Modules/Domain, Modules/Data)로 재작성 + public init 부여. - version 필드는 서버 응답 매칭용으로만 추가, 클라이언트 사용처 미정. Refs #37, #60 --- .../Network/Response/Meet/MeetResponse.swift | 8 +++-- .../Notice/PinnedNoticeResponse.swift | 34 ++++++++++++++++++ .../Domain/Sources/Entities/Meet/Meet.swift | 13 ++++++- .../Sources/Entities/Notice/NoticeType.swift | 14 ++++++++ .../Entities/Notice/PinnedNotice.swift | 35 +++++++++++++++++++ .../UseCases/Meet/Read/FetchMeetDetail.swift | 14 +++++++- 6 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift create mode 100644 Modules/Domain/Sources/Entities/Notice/NoticeType.swift create mode 100644 Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift diff --git a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift index 6c15d708..54e42463 100644 --- a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift +++ b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift @@ -10,24 +10,28 @@ import Domain struct MeetResponse: Decodable { let meetId: Int? + let version: Int? let meetName: String? let meetImage: String? let sinceDays: Int? let hostId: Int? let memberCount: Int? let lastPlanDay: String? + let pinnedNotice: PinnedNoticeResponse? } extension MeetResponse { func toDomain() -> Meet { let date = DateManager.parseServerFullDate(string: lastPlanDay) - + return .init(meetSummary: .init(id: meetId, name: meetName, imagePath: meetImage), sinceDays: sinceDays, creatorId: hostId, memberCount: memberCount, - firstPlanDate: date) + firstPlanDate: date, + version: version, + pinnedNotice: pinnedNotice?.toDomain()) } } diff --git a/Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift b/Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift new file mode 100644 index 00000000..1bd90ba5 --- /dev/null +++ b/Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift @@ -0,0 +1,34 @@ +// +// PinnedNoticeResponse.swift +// Data +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain + +// MeetDetail 응답에 nested로 포함되는 고정 공지 DTO +struct PinnedNoticeResponse: Decodable { + let noticeId: Int? + let version: Int? + let meetId: Int? + let type: String? + let content: String? + let pinned: Bool? + let createdAt: String? +} + +extension PinnedNoticeResponse { + func toDomain() -> PinnedNotice { + return .init( + noticeId: noticeId, + version: version, + meetId: meetId, + type: NoticeType(rawValue: type ?? ""), + content: content, + isPinned: pinned ?? false, + createdAt: DateManager.parseServerFullDate(string: createdAt) + ) + } +} diff --git a/Modules/Domain/Sources/Entities/Meet/Meet.swift b/Modules/Domain/Sources/Entities/Meet/Meet.swift index 08164c9c..0c0bd40f 100644 --- a/Modules/Domain/Sources/Entities/Meet/Meet.swift +++ b/Modules/Domain/Sources/Entities/Meet/Meet.swift @@ -14,13 +14,24 @@ public struct Meet { public var creatorId: Int? public let memberCount: Int? public let firstPlanDate: Date? + public let version: Int? + public let pinnedNotice: PinnedNotice? - public init(isCreator: Bool = false, meetSummary: MeetSummary? = nil, sinceDays: Int? = nil, creatorId: Int? = nil, memberCount: Int? = nil, firstPlanDate: Date? = nil) { + public init(isCreator: Bool = false, + meetSummary: MeetSummary? = nil, + sinceDays: Int? = nil, + creatorId: Int? = nil, + memberCount: Int? = nil, + firstPlanDate: Date? = nil, + version: Int? = nil, + pinnedNotice: PinnedNotice? = nil) { self.isCreator = isCreator self.meetSummary = meetSummary self.sinceDays = sinceDays self.creatorId = creatorId self.memberCount = memberCount self.firstPlanDate = firstPlanDate + self.version = version + self.pinnedNotice = pinnedNotice } } diff --git a/Modules/Domain/Sources/Entities/Notice/NoticeType.swift b/Modules/Domain/Sources/Entities/Notice/NoticeType.swift new file mode 100644 index 00000000..edc0c5e6 --- /dev/null +++ b/Modules/Domain/Sources/Entities/Notice/NoticeType.swift @@ -0,0 +1,14 @@ +// +// NoticeType.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +// 모임 공지 분류 — 모임장이 작성한 사용자 공지(custom)와 시스템이 자동 발행한 공지(system) 두 가지 +public enum NoticeType: String, Sendable { + case custom = "CUSTOM" + case system = "SYSTEM" +} diff --git a/Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift b/Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift new file mode 100644 index 00000000..8a94b8ee --- /dev/null +++ b/Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift @@ -0,0 +1,35 @@ +// +// PinnedNotice.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +// MeetDetail 응답에 포함되는 상단 고정 공지. nil이면 표시할 공지 없음. +public struct PinnedNotice { + public let noticeId: Int? + public let version: Int? + public let meetId: Int? + public let type: NoticeType? + public let content: String? + public let isPinned: Bool + public let createdAt: Date? + + public init(noticeId: Int? = nil, + version: Int? = nil, + meetId: Int? = nil, + type: NoticeType? = nil, + content: String? = nil, + isPinned: Bool = false, + createdAt: Date? = nil) { + self.noticeId = noticeId + self.version = version + self.meetId = meetId + self.type = type + self.content = content + self.isPinned = isPinned + self.createdAt = createdAt + } +} diff --git a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift index 5efb8ed2..45a9263b 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift @@ -31,13 +31,25 @@ public final class MockFetchMeetDetailUseCase: FetchMeetDetail { public func execute(meetId: Int) async throws -> Meet { print("✅ [Mock] 모임 상세 조회 - meetId: \(meetId)") + let mockPinnedNotice = PinnedNotice( + noticeId: 1, + version: 1, + meetId: meetId, + type: .custom, + content: "11/28일 모임 18:00 → 20:00 변경 되었습니다. 날씨이슈로 인해서", + isPinned: true, + createdAt: Date() + ) + let mockMeet = Meet( isCreator: true, meetSummary: MeetSummary(id: meetId, name: "테니스 동호회"), sinceDays: 120, creatorId: 1, memberCount: 8, - firstPlanDate: Calendar.current.date(byAdding: .day, value: 3, to: Date()) + firstPlanDate: Calendar.current.date(byAdding: .day, value: 3, to: Date()), + version: 1, + pinnedNotice: mockPinnedNotice ) try await Task.sleep(nanoseconds: 1_000_000_000) From f22ca77fc059281b3776e31e19ec910d899a3d06 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 10:12:12 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20NoticeFlowCoordinator=20+=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=ED=99=94=EB=A9=B4=203=EA=B0=9C=20placehol?= =?UTF-8?q?der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeetDetail에서 진입하는 모임 공지 Flow의 뼈대를 추가한다. 실제 화면은 후속 PR(공지 리스트/상세/작성)에서 구현하고, 이번 PR에선 placeholder VC 3개와 Coordinator 라우팅만 갖춘다. ## 신규 파일 - Presentation/MainScene/Sub/Notice/ - NoticeFlowCoordinator.swift - NoticeFlowEntry enum (.detail(noticeId), .list(meetId, isCreator)) - 진입점 분기: 확성기 버튼 → list / 미리보기 카드 → detail - 리스트 안에서 작성 화면 push (모임장 전용 노출은 본 구현 시 처리) - View/NoticeListViewController.swift (placeholder) - View/NoticeDetailViewController.swift (placeholder) - View/NoticeComposeViewController.swift (placeholder) - Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift - NoticeSceneDependencies 프로토콜 + 구현 - entry를 생성자에서 받아 makeNoticeFlowCoordinator()로 조립 ## 배선 - MeetDetailSceneDependencies에 makeNoticeFlowCoordinator(entry:) 추가 - MeetDetailCoordination에 presentNoticeListView, presentNoticeDetailView 추가 - MeetDetailSceneCoordinator가 NoticeSceneDIContainer를 통해 Flow 생성 후 modal present - ScreenName에 notice_list/notice_detail/notice_compose 추가 Refs #37, #60 --- .../Sub/MeetDetailSceneDIContainer.swift | 13 +++- .../Sub/NoticeSceneDIContainer.swift | 58 +++++++++++++++ .../ScreenTrack/ScreenName.swift | 5 ++ .../MeetDetailFlowCoordinator.swift | 24 +++++++ .../Sub/Notice/NoticeFlowCoordinator.swift | 71 +++++++++++++++++++ .../View/NoticeComposeViewController.swift | 46 ++++++++++++ .../View/NoticeDetailViewController.swift | 46 ++++++++++++ .../View/NoticeListViewController.swift | 49 +++++++++++++ 8 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift index 196df42c..3b9d0b05 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift @@ -27,9 +27,11 @@ protocol MeetDetailSceneDependencies { // MARK: - Flow func makePlanCreateFlowCoordinator(meet: MeetSummary, completion: ((Plan) -> Void)?) -> BaseCoordinator - + func makePostDetailFlowCoordinator(postId: Int, type: PostType) -> BaseCoordinator + + func makeNoticeFlowCoordinator(entry: NoticeFlowEntry) -> BaseCoordinator } final class MeetDetailSceneDIContainer: BaseContainer, MeetDetailSceneDependencies { @@ -261,4 +263,13 @@ extension MeetDetailSceneDIContainer { postId: postId) return planDetailDI.makePostDetailCoordinator() } + + // MARK: - 공지 + func makeNoticeFlowCoordinator(entry: NoticeFlowEntry) -> BaseCoordinator { + let noticeDI = NoticeSceneDIContainer(appNetworkService: appNetworkService, + commonFactory: commonViewFactory, + userSession: userSession, + entry: entry) + return noticeDI.makeNoticeFlowCoordinator() + } } diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift new file mode 100644 index 00000000..91e1a508 --- /dev/null +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift @@ -0,0 +1,58 @@ +// +// NoticeSceneDIContainer.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import Domain +import Data + +protocol NoticeSceneDependencies { + func makeNoticeListViewController(meetId: Int, + isCreator: Bool, + coordinator: NoticeFlowCoordination) -> UIViewController + func makeNoticeDetailViewController(noticeId: Int) -> UIViewController + func makeNoticeComposeViewController(meetId: Int) -> UIViewController +} + +final class NoticeSceneDIContainer: BaseContainer, NoticeSceneDependencies { + + private let entry: NoticeFlowEntry + + init(appNetworkService: AppNetworkService, + commonFactory: ViewDependencies, + userSession: UserSessionProvider, + entry: NoticeFlowEntry) { + self.entry = entry + super.init(appNetworkService: appNetworkService, + commonFactory: commonFactory, + userSession: userSession) + } + + func makeNoticeFlowCoordinator() -> NoticeFlowCoordinator { + return .init(dependencies: self, + entry: entry, + navigationController: AppNaviViewController()) + } +} + +// MARK: - View Factories +extension NoticeSceneDIContainer { + + func makeNoticeListViewController(meetId: Int, + isCreator: Bool, + coordinator: NoticeFlowCoordination) -> UIViewController { + // 작성 진입점 연결은 본 구현 시 NoticeListViewReactor에 coordinator를 주입하는 방식으로 확장. + return NoticeListViewController(meetId: meetId, isCreator: isCreator) + } + + func makeNoticeDetailViewController(noticeId: Int) -> UIViewController { + return NoticeDetailViewController(noticeId: noticeId) + } + + func makeNoticeComposeViewController(meetId: Int) -> UIViewController { + return NoticeComposeViewController(meetId: meetId) + } +} diff --git a/Mople/Infrastructure/ScreenTrack/ScreenName.swift b/Mople/Infrastructure/ScreenTrack/ScreenName.swift index 34a6cb93..a360e267 100644 --- a/Mople/Infrastructure/ScreenTrack/ScreenName.swift +++ b/Mople/Infrastructure/ScreenTrack/ScreenName.swift @@ -43,6 +43,11 @@ enum ScreenName: String { case notification case notification_setting + // MARK: - Notice (모임 공지) + case notice_list + case notice_detail + case notice_compose + // MARK: - Member case participant_list diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift index 170fcc77..fad3967f 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift @@ -23,6 +23,8 @@ protocol MeetDetailCoordination: AnyObject { func presentPlanDetailView(postId: Int, type: PostType) func pushMemberListView() + func presentNoticeListView(meetId: Int, isCreator: Bool) + func presentNoticeDetailView(noticeId: Int) func endFlow() } @@ -144,6 +146,28 @@ extension MeetDetailSceneCoordinator { } } +// MARK: - Notice Flow +extension MeetDetailSceneCoordinator { + + // 확성기 버튼 → 공지 리스트 (modal present) + func presentNoticeListView(meetId: Int, isCreator: Bool) { + let coordinator = dependencies.makeNoticeFlowCoordinator( + entry: .list(meetId: meetId, isCreator: isCreator) + ) + start(coordinator: coordinator) + self.present(coordinator.navigationController) + } + + // 공지 미리보기 카드 → 공지 상세 (modal present) + func presentNoticeDetailView(noticeId: Int) { + let coordinator = dependencies.makeNoticeFlowCoordinator( + entry: .detail(noticeId: noticeId) + ) + start(coordinator: coordinator) + self.present(coordinator.navigationController) + } +} + // MARK: - End Flow extension MeetDetailSceneCoordinator { func endFlow() { diff --git a/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift new file mode 100644 index 00000000..7696e4dd --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift @@ -0,0 +1,71 @@ +// +// NoticeFlowCoordinator.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import Domain + +// 모임 공지 Flow. +// MeetDetail에서 두 가지 진입점(미리보기 카드 → 상세, 확성기 → 리스트)을 가지며 +// 리스트 안에서 작성 화면(모임장 전용)으로 push. +enum NoticeFlowEntry { + case detail(noticeId: Int) + case list(meetId: Int, isCreator: Bool) +} + +protocol NoticeFlowCoordination: AnyObject { + func pushComposeView() + func endFlow() +} + +final class NoticeFlowCoordinator: BaseCoordinator, NoticeFlowCoordination { + + private let dependencies: NoticeSceneDependencies + private let entry: NoticeFlowEntry + + init(dependencies: NoticeSceneDependencies, + entry: NoticeFlowEntry, + navigationController: AppNaviViewController) { + self.dependencies = dependencies + self.entry = entry + super.init(navigationController: navigationController) + setDismissGestureCompletion() + } + + override func start() { + switch entry { + case .detail(let noticeId): + let vc = dependencies.makeNoticeDetailViewController(noticeId: noticeId) + self.pushWithTracking(vc, animated: false) + + case .list(let meetId, let isCreator): + let vc = dependencies.makeNoticeListViewController(meetId: meetId, + isCreator: isCreator, + coordinator: self) + self.pushWithTracking(vc, animated: false) + } + } +} + +// MARK: - Compose Flow (리스트 → 작성) +extension NoticeFlowCoordinator { + func pushComposeView() { + guard case .list(let meetId, _) = entry else { return } + let vc = dependencies.makeNoticeComposeViewController(meetId: meetId) + self.pushWithTracking(vc, animated: true) + } +} + +// MARK: - End Flow +extension NoticeFlowCoordinator { + func endFlow() { + self.navigationController.dismiss(animated: true) { [weak self] in + guard let self else { return } + self.clearUp() + self.parentCoordinator?.didFinish(coordinator: self) + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift new file mode 100644 index 00000000..e9010446 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift @@ -0,0 +1,46 @@ +// +// NoticeComposeViewController.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit + +// Phase 1 placeholder. 공지 리스트 화면 안의 작성 버튼에서만 진입. 모임장만 노출. +final class NoticeComposeViewController: TitleNaviViewController { + + private let meetId: Int + + init(meetId: Int) { + self.meetId = meetId + super.init(screenName: .notice_compose, + title: "공지 작성") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .bgPrimary + setupPlaceholder() + } + + private func setupPlaceholder() { + let label = UILabel() + label.text = "공지 작성 화면 (구현 예정)\nmeetId: \(meetId)" + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .text03 + label.font = FontStyle.Body1.medium + + view.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(20) + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift new file mode 100644 index 00000000..f63fadb7 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift @@ -0,0 +1,46 @@ +// +// NoticeDetailViewController.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit + +// Phase 1 placeholder. 미리보기 카드에서 진입. +final class NoticeDetailViewController: TitleNaviViewController { + + private let noticeId: Int + + init(noticeId: Int) { + self.noticeId = noticeId + super.init(screenName: .notice_detail, + title: "공지 상세") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .bgPrimary + setupPlaceholder() + } + + private func setupPlaceholder() { + let label = UILabel() + label.text = "공지 상세 화면 (구현 예정)\nnoticeId: \(noticeId)" + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .text03 + label.font = FontStyle.Body1.medium + + view.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(20) + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift new file mode 100644 index 00000000..bd0a4bec --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift @@ -0,0 +1,49 @@ +// +// NoticeListViewController.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit + +// Phase 1 placeholder. 본 화면은 후속 PR에서 구현. +// 확성기 버튼에서 진입. 작성 진입점은 isCreator일 때만 노출 예정. +final class NoticeListViewController: TitleNaviViewController { + + private let meetId: Int + private let isCreator: Bool + + init(meetId: Int, isCreator: Bool) { + self.meetId = meetId + self.isCreator = isCreator + super.init(screenName: .notice_list, + title: "공지사항") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .bgPrimary + setupPlaceholder() + } + + private func setupPlaceholder() { + let label = UILabel() + label.text = "공지 리스트 화면 (구현 예정)\nmeetId: \(meetId)\nisCreator: \(isCreator)" + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .text03 + label.font = FontStyle.Body1.medium + + view.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(20) + } + } +} From 8fb6f3f7c4f8367cc489bd1545125c31c4237472 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 10:18:35 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20MeetDetail=20UI=20=EB=A6=AC?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20+=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 디자인(2026-05-26 figma 4206:3733)에 맞춰 MeetDetail 화면을 정비한다. ## UI 변경 - 모임 이미지·이름을 본문 카드 → 커스텀 네비 중앙으로 이동 (MeetDetailNaviTitleView) - 모임 인원수(16명) 표시 제거 - 세그먼트(예정/지난 약속)를 카드 박스 → 알약(pill) 토글로 변경 (MeetDetailPillSegment) - 공지 미리보기 카드 추가 (MeetDetailNoticePreviewView) - pinnedNotice 존재 시만 노출, 본문 한 줄 ellipsis - 확성기 버튼 추가 (네비 우측, 햄버거 좌측 인접) - SF Symbol megaphone.fill placeholder (디자인 확정 시 PNG 교체 예정) - pinnedNotice 존재 시 파란 점 배지 노출 - 모임장 + 공지 없음 조건일 때 작성 유도 툴팁 노출 ## SubView 신규 (Sub/MeetDetail/View/SubView/) - MeetDetailNaviTitleView — 네비 중앙 모임 정보 (Kingfisher 썸네일 + 이름) - MeetDetailNoticePreviewView — 80px 공지 카드, 탭 이벤트 노출 - MeetDetailPillSegment — 200x48 알약 세그먼트, RxSwift 이벤트 ## Reactor - MeetDetailViewReactor.Action.Flow에 openNoticeList, openNoticeDetail 추가 - 확성기 탭 → presentNoticeListView(meetId, isCreator) - 미리보기 카드 탭 → presentNoticeDetailView(noticeId) ## 호환성 - 기존 thumbnailView/headerStackView/borderView 제거 — 본문은 공지 카드 + pill + pageController 3단 구성 - DefaultSegmentedControl은 다른 화면에서 계속 사용, MeetDetail만 PillSegment로 교체 - TitleNaviBar는 미수정 — naviBar.addSubview 패턴으로 커스텀 중앙뷰/확성기 배치 Refs #37, #60 --- .../View/MeetDetailViewController.swift | 263 +++++++++++------- .../View/MeetDetailViewReactor.swift | 14 +- .../SubView/MeetDetailNaviTitleView.swift | 69 +++++ .../SubView/MeetDetailNoticePreviewView.swift | 112 ++++++++ .../View/SubView/MeetDetailPillSegment.swift | 123 ++++++++ 5 files changed, 485 insertions(+), 96 deletions(-) create mode 100644 Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift create mode 100644 Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift create mode 100644 Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 8559e5ac..913a365a 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -13,63 +13,78 @@ import RxCocoa import ReactorKit final class MeetDetailViewController: TitleNaviViewController, View { - + // MARK: - Reactor typealias Reactor = MeetDetailViewReactor var disposeBag: DisposeBag = DisposeBag() // MARK: - Observables private let endFlow: PublishSubject = .init() - + // MARK: - UI Components + + // 본문 컨테이너 (네비바 아래 전체) private let contentView: UIView = { let view = UIView() view.backgroundColor = .bgSecondary return view }() - - private let borderView: UIView = { - let view = UIView() - view.backgroundColor = .appStroke - view.layer.makeCornes(radius: 16, corners: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]) - return view + + // 네비 중앙: 모임 썸네일 + 이름 + private let naviTitleView = MeetDetailNaviTitleView() + + // 네비 우측 확성기 버튼 (rightButton(햄버거) 왼쪽에 배치) + private let megaphoneButton: UIButton = { + let btn = UIButton() + let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) + btn.setImage(UIImage(systemName: "megaphone.fill", withConfiguration: config), for: .normal) + btn.tintColor = .text01 + return btn }() - - private let thumbnailView: MeetDetailThumbnail = { - let view = MeetDetailThumbnail(thumbnailSize: 56, - thumbnailRadius: 12) - view.setTitleLabel(font: FontStyle.Title2.semiBold, - color: .text01) - view.setSpacing(12) - return view + + // 확성기 배지 점 (공지 존재 시 노출) + private let megaphoneBadge: UIView = { + let dot = UIView() + dot.backgroundColor = .appPrimary + dot.layer.cornerRadius = 4 + dot.isHidden = true + return dot }() - - private let segment: DefaultSegmentedControl = { - let buttonTitles = [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] - let segControl = DefaultSegmentedControl(buttonTitles: buttonTitles) - return segControl + + // 작성 유도 툴팁 (모임장 + 공지 없음 조건) + private let composeTooltipView: UIView = { + let v = UIView() + v.backgroundColor = .bgPrimary + v.layer.cornerRadius = 8 + v.layer.makeShadow(opactity: 0.08, radius: 8, offset: .init(width: 0, height: 2)) + v.isHidden = true + return v }() - - private lazy var headerStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [thumbnailView, segment]) - stackView.axis = .vertical - stackView.distribution = .fill - stackView.alignment = .fill - stackView.spacing = 24 - stackView.backgroundColor = .bgPrimary - stackView.layer.makeShadow(opactity: 0.02, radius: 12, offset: .init(width: 0, height: 0)) - stackView.layer.makeCornes(radius: 16, corners: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = .init(top: 20, left: 20, bottom: 20, right: 20) - return stackView + + private let composeTooltipLabel: UILabel = { + let label = UILabel() + label.text = "공지를 작성해보세요." + label.font = FontStyle.Body2.medium + label.textColor = .text02 + return label }() - + + // 공지 미리보기 카드 + private let noticePreviewView = MeetDetailNoticePreviewView() + + // pill 세그먼트 + private let pillSegment = MeetDetailPillSegment( + titles: [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] + ) + + // 일정/리뷰 페이지 영역 private(set) var pageController: UIPageViewController = { let pageVC = UIPageViewController(transitionStyle: .scroll, - navigationOrientation: .horizontal) + navigationOrientation: .horizontal) return pageVC }() - + + // 일정 추가 FAB private let addPlanButton: BaseButton = { let btn = BaseButton() btn.setImage(image: .addButton) @@ -80,81 +95,116 @@ final class MeetDetailViewController: TitleNaviViewController, View { offset: .init(width: 0, height: 0)) return btn }() - + // MARK: - LifeCycle init(screenName: ScreenName, title: String?, reactor: MeetDetailViewReactor?) { super.init(screenName: screenName, - title: title) + title: nil) // 커스텀 중앙 뷰를 쓰므로 기본 title은 비워둔다 self.reactor = reactor } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() setupUI() } - + // MARK: - UI Setup private func setupUI() { - setLayout() setupNavi() + setLayout() } - + private func setLayout() { self.add(child: pageController) - self.view.addSubview(contentView) - self.view.addSubview(addPlanButton) - self.contentView.addSubview(borderView) - self.contentView.addSubview(headerStackView) - self.contentView.addSubview(pageController.view) - + view.addSubview(contentView) + view.addSubview(addPlanButton) + + contentView.addSubview(noticePreviewView) + contentView.addSubview(pillSegment) + contentView.addSubview(pageController.view) + contentView.snp.makeConstraints { make in make.top.equalTo(self.titleViewBottom) make.horizontalEdges.bottom.equalToSuperview() } - - headerStackView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.horizontalEdges.equalToSuperview() - } - - segment.snp.makeConstraints { make in - make.height.equalTo(56) + + // 공지 미리보기 카드 — 기본 hidden, pinnedNotice 존재 시만 표시 + noticePreviewView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.horizontalEdges.equalToSuperview().inset(20) + make.height.equalTo(80) } - - borderView.snp.makeConstraints { make in - make.top.equalTo(headerStackView) - make.horizontalEdges.equalToSuperview() - make.bottom.equalTo(headerStackView).offset(1) + noticePreviewView.isHidden = true + + pillSegment.snp.makeConstraints { make in + make.top.equalTo(noticePreviewView.snp.bottom).offset(16) + make.centerX.equalToSuperview() + make.width.equalTo(200) + make.height.equalTo(48) } - + pageController.view.snp.makeConstraints { make in - make.top.equalTo(borderView.snp.bottom) + make.top.equalTo(pillSegment.snp.bottom).offset(16) make.horizontalEdges.bottom.equalToSuperview() } - + addPlanButton.snp.makeConstraints { make in make.size.equalTo(54) make.trailing.equalToSuperview().inset(20) make.bottom.equalToSuperview().inset(24) } } - + private func setupNavi() { self.setBarItem(type: .left) self.setBarItem(type: .right, image: .list) + + // 중앙: 모임 이미지 + 이름 + self.naviBar.addSubview(naviTitleView) + naviTitleView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.centerX.equalToSuperview() + } + + // 확성기 (햄버거 rightButton 좌측에 배치: 20 padding + 40 rightButton + 8 gap = 68) + self.naviBar.addSubview(megaphoneButton) + megaphoneButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(68) + make.size.equalTo(40) + } + + // 배지 점 — 확성기 우상단 + megaphoneButton.addSubview(megaphoneBadge) + megaphoneBadge.snp.makeConstraints { make in + make.size.equalTo(8) + make.top.equalToSuperview().offset(8) + make.trailing.equalToSuperview().inset(8) + } + + // 작성 유도 툴팁 — 확성기 버튼 아래 + composeTooltipView.addSubview(composeTooltipLabel) + composeTooltipLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)) + } + view.addSubview(composeTooltipView) + composeTooltipView.snp.makeConstraints { make in + make.top.equalTo(megaphoneButton.snp.bottom).offset(4) + make.centerX.equalTo(megaphoneButton) + } } - + // MARK: - Gesture public func configureEdgeGesture() { guard let currentNavi = self.findCurrentNavigation(), let appNavi = currentNavi as? AppNaviViewController else { return } - + pageController.viewControllers?.forEach { guard let gestureVC = $0 as? EdgeGestureConfigurable else { return } gestureVC.configureEdgeGesture(appNavi.edgeGesture) @@ -169,7 +219,7 @@ extension MeetDetailViewController { inputBind(reactor) outputBind(reactor) } - + private func inputBind(_ reactor: Reactor) { setActionBind(reactor) setNotificationBind(reactor) @@ -182,44 +232,46 @@ extension MeetDetailViewController { }) .disposed(by: disposeBag) } - - + + private func setActionBind(_ reactor: Reactor) { self.naviBar.rightItemEvent .map { Reactor.Action.flow(.pushMeetSetupView) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.segment.rx.tapped + + self.pillSegment.selectedIndexChanged .map { index -> Reactor.Action in - let isFirst = index == 0 - return .flow(.switchPage(isFuture: isFirst)) + let isFuture = index == 0 + return .flow(.switchPage(isFuture: isFuture)) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.thumbnailView.rx.imageTap - .map { Reactor.Action.flow(.showMeetImage) } - .bind(to: reactor.action) - .disposed(by: disposeBag) - + self.addPlanButton.rx.tap .map { Reactor.Action.flow(.createPlan) } .bind(to: reactor.action) .disposed(by: disposeBag) - + self.naviBar.leftItemEvent .map { Reactor.Action.flow(.endFlow) } .bind(to: reactor.action) .disposed(by: disposeBag) - + self.endFlow .map { Reactor.Action.flow(.endFlow) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.thumbnailView.inviteButton.rx.tap - .map { Reactor.Action.flow(.memberList) } + + // 확성기 → 공지 리스트 진입 + self.megaphoneButton.rx.tap + .map { Reactor.Action.flow(.openNoticeList) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // 미리보기 카드 → 공지 상세 진입 + self.noticePreviewView.tapEvent + .map { Reactor.Action.flow(.openNoticeDetail) } .bind(to: reactor.action) .disposed(by: disposeBag) } @@ -229,7 +281,7 @@ extension MeetDetailViewController { .map { Reactor.Action.editMeet($0) } .bind(to: reactor.action) .disposed(by: disposeBag) - + NotificationManager.shared.addObservable(name: .midnightUpdate) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) @@ -241,10 +293,10 @@ extension MeetDetailViewController { .asDriver(onErrorJustReturn: nil) .compactMap({ $0 }) .drive(with: self, onNext: { vc, meet in - vc.thumbnailView.configure(with: .init(meet: meet)) + vc.applyMeet(meet) }) .disposed(by: disposeBag) - + reactor.pulse(\.$inviteUrl) .asDriver(onErrorJustReturn: nil) .compactMap { [weak self] url -> String? in @@ -256,7 +308,7 @@ extension MeetDetailViewController { vc.showActivityViewController(items: [url]) }) .disposed(by: disposeBag) - + Observable.merge(reactor.pulse(\.$meetInfoLoaded), reactor.pulse(\.$futurePlanLoaded), reactor.pulse(\.$pastPlanLoaded)) @@ -268,7 +320,7 @@ extension MeetDetailViewController { .map({ _ in true }) .drive(self.rx.isLoading) .disposed(by: disposeBag) - + Observable.combineLatest(reactor.pulse(\.$meetInfoLoaded), reactor.pulse(\.$futurePlanLoaded), reactor.pulse(\.$pastPlanLoaded)) @@ -282,7 +334,7 @@ extension MeetDetailViewController { .asDriver(onErrorJustReturn: false) .drive(self.rx.isLoading) .disposed(by: disposeBag) - + reactor.pulse(\.$error) .asDriver(onErrorJustReturn: nil) .compactMap { $0 } @@ -291,7 +343,28 @@ extension MeetDetailViewController { }) .disposed(by: disposeBag) } - + + // 모임 데이터 → UI 반영 (네비 중앙, 공지 카드, 확성기 배지, 작성 유도 툴팁) + private func applyMeet(_ meet: Meet) { + naviTitleView.configure(name: meet.meetSummary?.name, + imagePath: meet.meetSummary?.imagePath) + + let pinned = meet.pinnedNotice + let hasNotice = pinned != nil + + // 공지 미리보기 카드 + noticePreviewView.isHidden = !hasNotice + if let content = pinned?.content { + noticePreviewView.configure(content: content) + } + + // 확성기 파란 점 배지 + megaphoneBadge.isHidden = !hasNotice + + // 모임장 + 공지 없음 → 작성 유도 툴팁 + composeTooltipView.isHidden = !(meet.isCreator && !hasNotice) + } + // MARK: - 에러 핸들링 private func handleError(_ err: MeetDetailError) { switch err { @@ -316,7 +389,7 @@ extension MeetDetailViewController { let inviteComment = L10n.Meetdetail.inviteMessage return inviteComment + "\n" + url } - + private func showActivityViewController(items: [Any]) { let ac = UIActivityViewController(activityItems: items, applicationActivities: nil) self.present(ac, animated: true) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift index 0e93d710..fc11d9f3 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift @@ -31,6 +31,8 @@ final class MeetDetailViewReactor: Reactor, LifeCycleLoggable { case endFlow case showMeetImage case memberList + case openNoticeList + case openNoticeDetail } enum Loading { @@ -188,8 +190,18 @@ extension MeetDetailViewReactor { imagePath: imagePath) case .memberList: coordinator?.pushMemberListView() + case .openNoticeList: + // 확성기 버튼 → 공지 리스트 진입 + guard let meet = currentState.meet, + let meetId = meet.meetSummary?.id else { return .empty() } + coordinator?.presentNoticeListView(meetId: meetId, + isCreator: meet.isCreator) + case .openNoticeDetail: + // 미리보기 카드 → 공지 상세 진입 (pinnedNotice가 있을 때만) + guard let noticeId = currentState.meet?.pinnedNotice?.noticeId else { return .empty() } + coordinator?.presentNoticeDetailView(noticeId: noticeId) } - + return .empty() } } diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift new file mode 100644 index 00000000..a26a2bf3 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift @@ -0,0 +1,69 @@ +// +// MeetDetailNaviTitleView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import Kingfisher + +// 모임 상세 네비바 중앙 — 모임 썸네일(28×28) + 이름(Medium 14) 가로 스택. +// 기존 TitleNaviBar의 titleLabel을 가리는 형태로 addSubview한다. +final class MeetDetailNaviTitleView: UIView { + + private let thumbnailImageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.layer.cornerRadius = 6 + iv.layer.borderWidth = 1 + iv.layer.borderColor = UIColor.appStroke.cgColor + return iv + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body1.medium + label.textColor = .text01 + label.textAlignment = .left + return label + }() + + private lazy var stackView: UIStackView = { + let sv = UIStackView(arrangedSubviews: [thumbnailImageView, nameLabel]) + sv.axis = .horizontal + sv.alignment = .center + sv.spacing = 8 + return sv + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + thumbnailImageView.snp.makeConstraints { make in + make.size.equalTo(28) + } + } + + func configure(name: String?, imagePath: String?) { + nameLabel.text = name + if let path = imagePath, let url = URL(string: path) { + thumbnailImageView.kf.setImage(with: url, placeholder: UIImage(named: "defaultMeet")) + } else { + thumbnailImageView.image = UIImage(named: "defaultMeet") + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift new file mode 100644 index 00000000..2a5a1a49 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift @@ -0,0 +1,112 @@ +// +// MeetDetailNoticePreviewView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +// MeetDetail 본문 상단의 공지 미리보기 카드 (80px / r12 / bg white / shadow). +// pinnedNotice가 nil일 때는 숨김 처리(외부에서 isHidden 토글). +final class MeetDetailNoticePreviewView: UIView { + + // MARK: - Public + var tapEvent: ControlEvent { tapControl.rx.controlEvent(.touchUpInside) } + + // MARK: - UI Components + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .bgPrimary + view.layer.cornerRadius = 12 + view.layer.makeShadow(opactity: 0.02, radius: 12, offset: .init(width: 0, height: 0)) + return view + }() + + // 헤더(아이콘 + 라벨) + private let iconImageView: UIImageView = { + let iv = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + iv.image = UIImage(systemName: "megaphone.fill", withConfiguration: config) + iv.tintColor = .defaultRed1 + iv.contentMode = .scaleAspectFit + return iv + }() + + private let headerLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body2.semiBold + label.textColor = .text03 + label.text = "공지알림" + return label + }() + + private lazy var headerStack: UIStackView = { + let sv = UIStackView(arrangedSubviews: [iconImageView, headerLabel]) + sv.axis = .horizontal + sv.alignment = .center + sv.spacing = 4 + return sv + }() + + private let contentLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body1.medium + label.textColor = .text02 + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var verticalStack: UIStackView = { + let sv = UIStackView(arrangedSubviews: [headerStack, contentLabel]) + sv.axis = .vertical + sv.alignment = .leading + sv.spacing = 4 + return sv + }() + + // 카드 전체를 덮는 투명 버튼 — 탭 이벤트 단순화 + private let tapControl: UIControl = { + let c = UIControl() + c.backgroundColor = .clear + return c + }() + + // MARK: - LifeCycle + override init(frame: CGRect) { + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(containerView) + containerView.addSubview(verticalStack) + containerView.addSubview(tapControl) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + verticalStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + iconImageView.snp.makeConstraints { make in + make.size.equalTo(20) + } + tapControl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Configure + func configure(content: String?) { + contentLabel.text = content + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift new file mode 100644 index 00000000..200e7fbb --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift @@ -0,0 +1,123 @@ +// +// MeetDetailPillSegment.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +// 예정된 약속 / 지난 약속 알약(pill) 세그먼트. +// 전체 폭 200, 높이 48, 반지름 22의 둥근 알약 + 좌우 6 padding. +// 선택 시 appPrimary 배경 + 흰 글자 + 미세 그림자. +final class MeetDetailPillSegment: UIView { + + // MARK: - Public + private(set) var selectedIndex: Int = 0 + var selectedIndexChanged: Observable { selectedSubject.asObservable() } + + // MARK: - Private + private let selectedSubject = PublishSubject() + private let disposeBag = DisposeBag() + private let titles: [String] + private var buttons: [UIButton] = [] + + // 선택된 알약 — 버튼 frame에 맞춰 슬라이드 + private let selectedPill: UIView = { + let v = UIView() + v.backgroundColor = .appPrimary + v.layer.cornerRadius = 22 + v.layer.makeShadow(opactity: 0.12, radius: 8, offset: .init(width: 0, height: 0)) + return v + }() + + private lazy var stackView: UIStackView = { + let sv = UIStackView(arrangedSubviews: buttons) + sv.axis = .horizontal + sv.distribution = .fillEqually + sv.alignment = .fill + sv.spacing = 8 + return sv + }() + + private let containerView: UIView = { + let v = UIView() + v.backgroundColor = UIColor.bgPrimary.withAlphaComponent(0.6) + v.layer.cornerRadius = 24 + return v + }() + + init(titles: [String], defaultIndex: Int = 0) { + self.titles = titles + self.selectedIndex = defaultIndex + super.init(frame: .zero) + makeButtons() + setupUI() + bind() + DispatchQueue.main.async { [weak self] in + self?.updatePillPosition(animated: false) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func makeButtons() { + buttons = titles.enumerated().map { index, title in + let btn = UIButton() + btn.setTitle(title, for: .normal) + btn.titleLabel?.font = FontStyle.Body1.semiBold + btn.setTitleColor(.text03, for: .normal) + btn.setTitleColor(.primaryText, for: .selected) + btn.tag = index + btn.isSelected = (index == selectedIndex) + return btn + } + } + + private func setupUI() { + addSubview(containerView) + containerView.addSubview(selectedPill) + containerView.addSubview(stackView) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(6) + } + } + + private func bind() { + for button in buttons { + button.rx.controlEvent(.touchUpInside) + .map { button.tag } + .subscribe(with: self, onNext: { owner, idx in + owner.select(index: idx) + }) + .disposed(by: disposeBag) + } + } + + private func select(index: Int) { + guard index != selectedIndex else { return } + selectedIndex = index + buttons.enumerated().forEach { $0.element.isSelected = ($0.offset == index) } + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { [weak self] in + self?.updatePillPosition(animated: true) + self?.layoutIfNeeded() + } + selectedSubject.onNext(index) + } + + private func updatePillPosition(animated: Bool) { + guard let target = buttons[safe: selectedIndex] else { return } + selectedPill.snp.remakeConstraints { make in + make.edges.equalTo(target) + } + } +} From bdd027bdd53bcbbdc7f7c894f9450bd89bcebd4a Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 10:34:06 +0900 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=203=EA=B0=9C=EB=A5=BC=20SwiftUI=20+=20UIHost?= =?UTF-8?q?ingController=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md "신규 화면은 SwiftUI + async/await" 컨벤션에 맞춰 placeholder UIKit VC 3개를 SwiftUI View로 갈아엎고, UIHostingController로 래핑한다. ## 변경 - 삭제 (UIKit placeholder): - NoticeListViewController.swift - NoticeDetailViewController.swift - NoticeComposeViewController.swift - 신규 (SwiftUI placeholder): - NoticeListView.swift — meetId, isCreator, onComposeTap closure - NoticeDetailView.swift — noticeId - NoticeComposeView.swift — meetId - NoticeSceneDIContainer: - factory 메서드를 UIHostingController(rootView:) 반환으로 변경 - 작성 진입은 onComposeTap closure에서 coordinator.pushComposeView() 호출 - @MainActor는 구현체에만 (TransferMeet 패턴), protocol 시그니처는 nonisolated 유지 → Coordinator.start() (BaseCoordinator override) 같은 nonisolated 컨텍스트에서도 protocol 호출 가능 ## 네비 - 각 SwiftUI View는 .customNavigationBar(title:) modifier로 기존 UIKit 디자인의 네비바 모양 유지 Refs #37, #60 --- .../Sub/NoticeSceneDIContainer.swift | 22 +++++++-- .../Sub/Notice/View/NoticeComposeView.swift | 28 +++++++++++ .../View/NoticeComposeViewController.swift | 46 ----------------- .../Sub/Notice/View/NoticeDetailView.swift | 28 +++++++++++ .../View/NoticeDetailViewController.swift | 46 ----------------- .../Sub/Notice/View/NoticeListView.swift | 48 ++++++++++++++++++ .../View/NoticeListViewController.swift | 49 ------------------- 7 files changed, 121 insertions(+), 146 deletions(-) create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift delete mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift delete mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift delete mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift index 91e1a508..c7a36f2b 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift @@ -6,6 +6,7 @@ // import UIKit +import SwiftUI import Domain import Data @@ -38,21 +39,32 @@ final class NoticeSceneDIContainer: BaseContainer, NoticeSceneDependencies { } } -// MARK: - View Factories +// MARK: - View Factories (SwiftUI + UIHostingController) extension NoticeSceneDIContainer { + @MainActor func makeNoticeListViewController(meetId: Int, isCreator: Bool, coordinator: NoticeFlowCoordination) -> UIViewController { - // 작성 진입점 연결은 본 구현 시 NoticeListViewReactor에 coordinator를 주입하는 방식으로 확장. - return NoticeListViewController(meetId: meetId, isCreator: isCreator) + let view = NoticeListView( + meetId: meetId, + isCreator: isCreator, + onComposeTap: { [weak coordinator] in + coordinator?.pushComposeView() + } + ) + return UIHostingController(rootView: view) } + @MainActor func makeNoticeDetailViewController(noticeId: Int) -> UIViewController { - return NoticeDetailViewController(noticeId: noticeId) + let view = NoticeDetailView(noticeId: noticeId) + return UIHostingController(rootView: view) } + @MainActor func makeNoticeComposeViewController(meetId: Int) -> UIViewController { - return NoticeComposeViewController(meetId: meetId) + let view = NoticeComposeView(meetId: meetId) + return UIHostingController(rootView: view) } } diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift new file mode 100644 index 00000000..76bf5be1 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift @@ -0,0 +1,28 @@ +// +// NoticeComposeView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 공지 리스트 화면의 작성 버튼에서만 진입. 모임장만. +struct NoticeComposeView: View { + + let meetId: Int + + var body: some View { + VStack(spacing: 12) { + Text("공지 작성 화면 (구현 예정)") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text01)) + Text("meetId: \(meetId)") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지 작성") + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift deleted file mode 100644 index e9010446..00000000 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewController.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// NoticeComposeViewController.swift -// Mople -// -// Created by CatSlave on 5/26/26. -// - -import UIKit -import SnapKit - -// Phase 1 placeholder. 공지 리스트 화면 안의 작성 버튼에서만 진입. 모임장만 노출. -final class NoticeComposeViewController: TitleNaviViewController { - - private let meetId: Int - - init(meetId: Int) { - self.meetId = meetId - super.init(screenName: .notice_compose, - title: "공지 작성") - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .bgPrimary - setupPlaceholder() - } - - private func setupPlaceholder() { - let label = UILabel() - label.text = "공지 작성 화면 (구현 예정)\nmeetId: \(meetId)" - label.numberOfLines = 0 - label.textAlignment = .center - label.textColor = .text03 - label.font = FontStyle.Body1.medium - - view.addSubview(label) - label.snp.makeConstraints { make in - make.center.equalToSuperview() - make.leading.trailing.equalToSuperview().inset(20) - } - } -} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift new file mode 100644 index 00000000..a0454403 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -0,0 +1,28 @@ +// +// NoticeDetailView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 미리보기 카드에서 진입. +struct NoticeDetailView: View { + + let noticeId: Int + + var body: some View { + VStack(spacing: 12) { + Text("공지 상세 화면 (구현 예정)") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text01)) + Text("noticeId: \(noticeId)") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지 상세") + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift deleted file mode 100644 index f63fadb7..00000000 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewController.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// NoticeDetailViewController.swift -// Mople -// -// Created by CatSlave on 5/26/26. -// - -import UIKit -import SnapKit - -// Phase 1 placeholder. 미리보기 카드에서 진입. -final class NoticeDetailViewController: TitleNaviViewController { - - private let noticeId: Int - - init(noticeId: Int) { - self.noticeId = noticeId - super.init(screenName: .notice_detail, - title: "공지 상세") - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .bgPrimary - setupPlaceholder() - } - - private func setupPlaceholder() { - let label = UILabel() - label.text = "공지 상세 화면 (구현 예정)\nnoticeId: \(noticeId)" - label.numberOfLines = 0 - label.textAlignment = .center - label.textColor = .text03 - label.font = FontStyle.Body1.medium - - view.addSubview(label) - label.snp.makeConstraints { make in - make.center.equalToSuperview() - make.leading.trailing.equalToSuperview().inset(20) - } - } -} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift new file mode 100644 index 00000000..9b7edef5 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -0,0 +1,48 @@ +// +// NoticeListView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 본 화면은 후속 PR에서 SwiftUI + @Observable + async/await 으로 구현. +// 확성기 버튼에서 진입. 작성 진입점은 isCreator일 때만 노출 예정. +struct NoticeListView: View { + + let meetId: Int + let isCreator: Bool + let onComposeTap: () -> Void + + var body: some View { + VStack(spacing: 12) { + Text("공지 리스트 화면 (구현 예정)") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text01)) + Text("meetId: \(meetId)") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + Text("isCreator: \(String(isCreator))") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + + if isCreator { + Button(action: onComposeTap) { + Text("공지 작성하기") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .primaryText)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color(uiColor: .appPrimary)) + .cornerRadius(8) + } + .padding(.horizontal, 20) + .padding(.top, 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지사항") + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift deleted file mode 100644 index bd0a4bec..00000000 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewController.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// NoticeListViewController.swift -// Mople -// -// Created by CatSlave on 5/26/26. -// - -import UIKit -import SnapKit - -// Phase 1 placeholder. 본 화면은 후속 PR에서 구현. -// 확성기 버튼에서 진입. 작성 진입점은 isCreator일 때만 노출 예정. -final class NoticeListViewController: TitleNaviViewController { - - private let meetId: Int - private let isCreator: Bool - - init(meetId: Int, isCreator: Bool) { - self.meetId = meetId - self.isCreator = isCreator - super.init(screenName: .notice_list, - title: "공지사항") - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .bgPrimary - setupPlaceholder() - } - - private func setupPlaceholder() { - let label = UILabel() - label.text = "공지 리스트 화면 (구현 예정)\nmeetId: \(meetId)\nisCreator: \(isCreator)" - label.numberOfLines = 0 - label.textAlignment = .center - label.textColor = .text03 - label.font = FontStyle.Body1.medium - - view.addSubview(label) - label.snp.makeConstraints { make in - make.center.equalToSuperview() - make.leading.trailing.equalToSuperview().inset(20) - } - } -} From f20d867c008e0c5064d427e967d4f3bd38efaed4 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 10:38:40 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(experimental):=20MeetDetail=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C=20=EA=B3=B5=EC=A7=80+pi?= =?UTF-8?q?ll=20=ED=97=A4=EB=8D=94=20sticky/hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자식 PlanList/ReviewList의 contentOffset.y를 부모에 전달해 헤더(공지 미리보기 + pill 세그먼트)를 헤더 높이만큼 위로 슬라이드해 사라졌다가 다시 나타나도록 한다. ## 동작 - 자식 tableView 위로 스크롤 → offset.y 증가 → 헤더 transform.y = -clamp(0, headerHeight) - 헤더 + pageController.view를 같은 transform으로 묶어 같이 슬라이드 - 헤더가 완전히 사라지면 pageController.view가 contentView 상단까지 차지 - 위로 당기면 헤더 다시 나타남 ## 변경 - MeetPlanListViewController, MeetReviewListViewController: - var onScrollChange: ((CGFloat) -> Void)? 노출 - 기존 scrollViewDidScroll(_:)에 onScrollChange?(contentOffset.y) 추가 (페이지네이션 트리거는 그대로 유지) - MeetDetailViewController: - 공지 카드 + pill을 headerContainer(UIStackView)로 묶어 transform 단위화 - 공지 hidden 시 stackView가 자동 collapse → 헤더 높이도 함께 줄어듦 - applyHide(_:) → headerContainer + pageController.view 동시 translateY - attachChildScrollObservers(_:) 메서드 노출 - MeetDetailFlowCoordinator.setPageViews()에서 자식 생성 직후 wire-up ## 알려진 한계 - 페이지 전환 시 두 자식의 contentOffset이 다르면 헤더 상태가 시각적으로 점프할 수 있음 - 디자인 의도와 차이가 있을 수 있으니, 별로면 이 커밋만 revert 가능 Refs #37, #60 --- .../MeetDetailFlowCoordinator.swift | 5 + .../MeetPlanListViewController.swift | 6 ++ .../MeetReviewListViewController.swift | 6 ++ .../View/MeetDetailViewController.swift | 95 +++++++++++++++---- 4 files changed, 96 insertions(+), 16 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift index fad3967f..7bf89c71 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift @@ -53,6 +53,11 @@ final class MeetDetailSceneCoordinator: BaseCoordinator, MeetDetailCoordination reviewListVC = dependencies.makeMeetReviewListViewController() detailMeetVC?.pageController.setViewControllers([planListVC!], direction: .forward, animated: false) detailMeetVC?.configureEdgeGesture() + + // sticky 헤더 — 자식 스크롤을 부모로 전달 + if let plan = planListVC, let review = reviewListVC { + detailMeetVC?.attachChildScrollObservers(plan, review) + } } } diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift index e19afae7..8315af30 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift @@ -28,6 +28,9 @@ final class MeetPlanListViewController: BaseViewController, View { private var hasAppeared: Bool = false private var isVisibleView: Bool = false private var isSetEdgeGesture: Bool = false + + // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 + var onScrollChange: ((CGFloat) -> Void)? // MARK: - UI Components private let countView: CountView = { @@ -283,6 +286,9 @@ extension MeetPlanListViewController: UIScrollViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 부모 헤더 sticky/hide 처리용 offset 전달 + onScrollChange?(scrollView.contentOffset.y) + guard scrollView.isBottom(threshold: 50), reactor?.page?.hasNext == true else { return } nextPage.onNext(()) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift index 3b5eee4e..1e009d51 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift @@ -25,6 +25,9 @@ final class MeetReviewListViewController: BaseViewController, View { // MARK: - Variables private var hasAppeared: Bool = false private var isSetEdgeGesture: Bool = false + + // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 + var onScrollChange: ((CGFloat) -> Void)? // MARK: - UI Components private lazy var countView: CountView = { @@ -192,6 +195,9 @@ extension MeetReviewListViewController: UIScrollViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 부모 헤더 sticky/hide 처리용 offset 전달 + onScrollChange?(scrollView.contentOffset.y) + guard scrollView.isBottom(threshold: 50), reactor?.page?.hasNext == true else { return } nextPage.onNext(()) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 913a365a..b0123cb1 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -77,6 +77,41 @@ final class MeetDetailViewController: TitleNaviViewController, View { titles: [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] ) + // 공지 카드를 20pt 좌우 인셋으로 감싸는 wrapper (UIStackView가 width를 full로 강제하므로 필요) + private let noticePreviewContainer = UIView() + + // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드해 사라지고 다시 나타남. + // 공지가 없을 때는 noticePreviewContainer가 isHidden 처리되어 UIStackView가 자동 collapse. + private lazy var headerContainer: UIStackView = { + let pillWrap = UIView() + pillWrap.addSubview(pillSegment) + pillSegment.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.bottom.equalToSuperview() + make.width.equalTo(200) + make.height.equalTo(48) + } + + noticePreviewContainer.addSubview(noticePreviewView) + noticePreviewView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.horizontalEdges.equalToSuperview().inset(20) + make.height.equalTo(80) + } + + let sv = UIStackView(arrangedSubviews: [noticePreviewContainer, pillWrap]) + sv.axis = .vertical + sv.alignment = .fill + sv.spacing = 16 + sv.isLayoutMarginsRelativeArrangement = true + sv.layoutMargins = UIEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) + return sv + }() + + // 자식 스크롤 시 헤더가 사라질 수 있는 최대 거리 (= 헤더 전체 높이) + private var headerMaxHide: CGFloat { headerContainer.bounds.height } + private var currentHideAmount: CGFloat = 0 + // 일정/리뷰 페이지 영역 private(set) var pageController: UIPageViewController = { let pageVC = UIPageViewController(transitionStyle: .scroll, @@ -125,8 +160,7 @@ final class MeetDetailViewController: TitleNaviViewController, View { view.addSubview(contentView) view.addSubview(addPlanButton) - contentView.addSubview(noticePreviewView) - contentView.addSubview(pillSegment) + contentView.addSubview(headerContainer) contentView.addSubview(pageController.view) contentView.snp.makeConstraints { make in @@ -134,23 +168,16 @@ final class MeetDetailViewController: TitleNaviViewController, View { make.horizontalEdges.bottom.equalToSuperview() } - // 공지 미리보기 카드 — 기본 hidden, pinnedNotice 존재 시만 표시 - noticePreviewView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.horizontalEdges.equalToSuperview().inset(20) - make.height.equalTo(80) - } - noticePreviewView.isHidden = true + // 헤더 (공지 미리보기 + pill 세그먼트) — 기본은 공지 hidden 상태로 시작 + noticePreviewContainer.isHidden = true - pillSegment.snp.makeConstraints { make in - make.top.equalTo(noticePreviewView.snp.bottom).offset(16) - make.centerX.equalToSuperview() - make.width.equalTo(200) - make.height.equalTo(48) + headerContainer.snp.makeConstraints { make in + make.top.equalToSuperview() + make.horizontalEdges.equalToSuperview() } pageController.view.snp.makeConstraints { make in - make.top.equalTo(pillSegment.snp.bottom).offset(16) + make.top.equalTo(headerContainer.snp.bottom) make.horizontalEdges.bottom.equalToSuperview() } @@ -352,12 +379,16 @@ extension MeetDetailViewController { let pinned = meet.pinnedNotice let hasNotice = pinned != nil - // 공지 미리보기 카드 + // 공지 미리보기 카드 (UIStackView가 isHidden 자동 collapse → 헤더 높이도 함께 변동) + noticePreviewContainer.isHidden = !hasNotice noticePreviewView.isHidden = !hasNotice if let content = pinned?.content { noticePreviewView.configure(content: content) } + // 헤더 높이가 바뀔 수 있으므로 sticky 상태도 재계산 + applyHide(currentHideAmount) + // 확성기 파란 점 배지 megaphoneBadge.isHidden = !hasNotice @@ -365,6 +396,38 @@ extension MeetDetailViewController { composeTooltipView.isHidden = !(meet.isCreator && !hasNotice) } + // MARK: - Sticky Header + // Coordinator가 page child VC들을 생성한 직후 wire-up. 두 자식 모두 같은 콜백으로 묶어 + // 헤더 transform이 한 곳에서만 변하도록 한다. + func attachChildScrollObservers(_ children: UIViewController...) { + for child in children { + if let plan = child as? MeetPlanListViewController { + plan.onScrollChange = { [weak self] offset in + self?.handleChildScroll(offset) + } + } else if let review = child as? MeetReviewListViewController { + review.onScrollChange = { [weak self] offset in + self?.handleChildScroll(offset) + } + } + } + } + + // 자식 VC(MeetPlanListVC / MeetReviewListVC)가 emit하는 contentOffset.y를 받아 + // 헤더가 위로 슬라이드되며 사라지고, 아래로 당기면 다시 나타나는 동작을 만든다. + private func handleChildScroll(_ offset: CGFloat) { + let clamped = max(0, min(headerMaxHide, offset)) + guard abs(clamped - currentHideAmount) > 0.5 else { return } + currentHideAmount = clamped + applyHide(clamped) + } + + private func applyHide(_ amount: CGFloat) { + let translate = CGAffineTransform(translationX: 0, y: -amount) + headerContainer.transform = translate + pageController.view.transform = translate + } + // MARK: - 에러 핸들링 private func handleError(_ err: MeetDetailError) { switch err { From db352dff60e7f30b0a09b45d7e79046863dca0dd Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 12:45:30 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20sticky=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20overlay=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 구현은 헤더와 pageController.view에 같은 transform을 걸어 둘 다 위로 슬라이드 → 페이지 컨텐츠가 위로 따라 올라가며 아래에 공백이 생기고, tableView 자체는 스크롤되지 않고 헤더와 동기로 움직이는 듯한 느낌이 됨. 원하는 동작: - pageController.view는 contentView 전체에 고정 (height 불변) - tableView가 정상적으로 스크롤되면서, 그 진행에 맞춰 헤더만 위로 사라짐 ## 새 구조 - pageController.view: contentView.edges 전체로 펴서 height 고정 - headerContainer: 그 위에 overlay (zPosition = 1) - 자식 tableView contentInset.top = headerHeight, 초기 contentOffset.y = -headerHeight → 처음엔 헤더 아래에서 컨텐츠 시작, 사용자가 위로 swipe하면 정상 스크롤 - 부모는 scroll offset 받아서 hide = clamp(0, h, offset + h) 계산 → 헤더만 transform.y 적용 (pageController.view는 건들지 않음) ## 추가 - MeetPlanListViewController / MeetReviewListViewController: - setTopContentInset(_:) 메서드 — contentInset.top + 초기 contentOffset 설정 - MeetDetailViewController: - viewDidLayoutSubviews에서 headerHeight 변동 감지 → 자식들에게 inset 전파 - PassThroughStackView 도입 — 헤더 overlay 영역에서 자기 영역 hit는 통과시켜 그 아래 tableView swipe가 정상 동작 - planChild/reviewChild weak ref 보관 ## 제한 - 페이지 전환 시 두 자식의 contentOffset 동기화는 아직 미구현 — 어색하면 후속 폴리시 Refs #37, #60 --- .../MeetPlanListViewController.swift | 11 +++ .../MeetReviewListViewController.swift | 11 +++ .../View/MeetDetailViewController.swift | 76 ++++++++++++++----- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift index 8315af30..c14176c1 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift @@ -31,6 +31,17 @@ final class MeetPlanListViewController: BaseViewController, View { // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 var onScrollChange: ((CGFloat) -> Void)? + + // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + private var topInsetApplied: Bool = false + func setTopContentInset(_ inset: CGFloat) { + tableView.contentInset.top = inset + tableView.verticalScrollIndicatorInsets.top = inset + if !topInsetApplied { + topInsetApplied = true + tableView.contentOffset = CGPoint(x: 0, y: -inset) + } + } // MARK: - UI Components private let countView: CountView = { diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift index 1e009d51..3edb069b 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift @@ -28,6 +28,17 @@ final class MeetReviewListViewController: BaseViewController, View { // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 var onScrollChange: ((CGFloat) -> Void)? + + // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + private var topInsetApplied: Bool = false + func setTopContentInset(_ inset: CGFloat) { + tableView.contentInset.top = inset + tableView.verticalScrollIndicatorInsets.top = inset + if !topInsetApplied { + topInsetApplied = true + tableView.contentOffset = CGPoint(x: 0, y: -inset) + } + } // MARK: - UI Components private lazy var countView: CountView = { diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index b0123cb1..23cb1e5c 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -82,7 +82,9 @@ final class MeetDetailViewController: TitleNaviViewController, View { // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드해 사라지고 다시 나타남. // 공지가 없을 때는 noticePreviewContainer가 isHidden 처리되어 UIStackView가 자동 collapse. - private lazy var headerContainer: UIStackView = { + // PassThroughStackView로 만들어서 자체 영역의 hit는 통과시키고, 자식 view(공지 카드/pill)만 터치를 받게 한다. + // → 헤더 overlay 영역에서도 swipe가 그 아래 tableView로 전달됨. + private lazy var headerContainer: PassThroughStackView = { let pillWrap = UIView() pillWrap.addSubview(pillSegment) pillSegment.snp.makeConstraints { make in @@ -99,7 +101,7 @@ final class MeetDetailViewController: TitleNaviViewController, View { make.height.equalTo(80) } - let sv = UIStackView(arrangedSubviews: [noticePreviewContainer, pillWrap]) + let sv = PassThroughStackView(arrangedSubviews: [noticePreviewContainer, pillWrap]) sv.axis = .vertical sv.alignment = .fill sv.spacing = 16 @@ -108,9 +110,14 @@ final class MeetDetailViewController: TitleNaviViewController, View { return sv }() - // 자식 스크롤 시 헤더가 사라질 수 있는 최대 거리 (= 헤더 전체 높이) - private var headerMaxHide: CGFloat { headerContainer.bounds.height } + // 자식 스크롤에 따라 헤더 transform 조정 + // 헤더는 contentView 상단 overlay로 떠 있고, 자식 tableView는 contentInset.top = headerHeight로 + // 헤더 아래에서 시작한다. 위로 swipe하면 tableView의 contentOffset.y가 -headerHeight → 0 으로 + // 이동하며, 같은 양만큼 헤더가 transform y로 위로 슬라이드해 사라진다. + private var lastPropagatedHeaderHeight: CGFloat = -1 private var currentHideAmount: CGFloat = 0 + private weak var planChild: MeetPlanListViewController? + private weak var reviewChild: MeetReviewListViewController? // 일정/리뷰 페이지 영역 private(set) var pageController: UIPageViewController = { @@ -149,6 +156,11 @@ final class MeetDetailViewController: TitleNaviViewController, View { setupUI() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + propagateHeaderInsetIfNeeded() + } + // MARK: - UI Setup private func setupUI() { setupNavi() @@ -160,8 +172,10 @@ final class MeetDetailViewController: TitleNaviViewController, View { view.addSubview(contentView) view.addSubview(addPlanButton) - contentView.addSubview(headerContainer) + // pageController.view는 contentView 전체를 채운다 (height 고정). + // 헤더는 그 위에 overlay로 떠서 transform.y로만 슬라이드 → 페이지 컨텐츠 영역은 일정. contentView.addSubview(pageController.view) + contentView.addSubview(headerContainer) contentView.snp.makeConstraints { make in make.top.equalTo(self.titleViewBottom) @@ -175,10 +189,10 @@ final class MeetDetailViewController: TitleNaviViewController, View { make.top.equalToSuperview() make.horizontalEdges.equalToSuperview() } + headerContainer.layer.zPosition = 1 pageController.view.snp.makeConstraints { make in - make.top.equalTo(headerContainer.snp.bottom) - make.horizontalEdges.bottom.equalToSuperview() + make.edges.equalToSuperview() } addPlanButton.snp.makeConstraints { make in @@ -397,35 +411,51 @@ extension MeetDetailViewController { } // MARK: - Sticky Header - // Coordinator가 page child VC들을 생성한 직후 wire-up. 두 자식 모두 같은 콜백으로 묶어 - // 헤더 transform이 한 곳에서만 변하도록 한다. + // Coordinator가 page child VC들을 생성한 직후 wire-up. + // 두 자식 모두 같은 콜백으로 묶어 헤더 transform이 한 곳에서만 변하도록 한다. + // 그리고 layout 사이클 후 setTopContentInset(headerHeight)로 자식 tableView의 상단을 비워둔다. func attachChildScrollObservers(_ children: UIViewController...) { for child in children { if let plan = child as? MeetPlanListViewController { + planChild = plan plan.onScrollChange = { [weak self] offset in self?.handleChildScroll(offset) } } else if let review = child as? MeetReviewListViewController { + reviewChild = review review.onScrollChange = { [weak self] offset in self?.handleChildScroll(offset) } } } + // 다음 layout 사이클에서 inset이 전파되도록 트리거 + view.setNeedsLayout() } - // 자식 VC(MeetPlanListVC / MeetReviewListVC)가 emit하는 contentOffset.y를 받아 - // 헤더가 위로 슬라이드되며 사라지고, 아래로 당기면 다시 나타나는 동작을 만든다. + // 헤더 높이가 변동되면(공지 유무 변화 등) 자식 tableView에 새 inset 전파 + private func propagateHeaderInsetIfNeeded() { + let h = headerContainer.bounds.height + guard h > 0, + abs(h - lastPropagatedHeaderHeight) > 0.5 else { return } + lastPropagatedHeaderHeight = h + planChild?.setTopContentInset(h) + reviewChild?.setTopContentInset(h) + } + + // 자식 VC가 emit하는 contentOffset.y에 따라 헤더가 슬라이드되며 사라지고 다시 나타난다. + // contentOffset.y == -headerHeight → 헤더 완전 노출 (hide 0) + // contentOffset.y == 0 → 헤더 완전 사라짐 (hide headerHeight) private func handleChildScroll(_ offset: CGFloat) { - let clamped = max(0, min(headerMaxHide, offset)) - guard abs(clamped - currentHideAmount) > 0.5 else { return } - currentHideAmount = clamped - applyHide(clamped) + let h = lastPropagatedHeaderHeight + guard h > 0 else { return } + let hide = max(0, min(h, offset + h)) + guard abs(hide - currentHideAmount) > 0.5 else { return } + currentHideAmount = hide + applyHide(hide) } private func applyHide(_ amount: CGFloat) { - let translate = CGAffineTransform(translationX: 0, y: -amount) - headerContainer.transform = translate - pageController.view.transform = translate + headerContainer.transform = CGAffineTransform(translationX: 0, y: -amount) } // MARK: - 에러 핸들링 @@ -458,3 +488,13 @@ extension MeetDetailViewController { self.present(ac, animated: true) } } + +// MARK: - PassThroughStackView +// 자기 영역의 hit는 통과시키고 자식 view들의 hit만 잡는 UIStackView. +// 헤더 overlay 영역에서도 그 아래 tableView의 swipe가 동작하도록 한다. +final class PassThroughStackView: UIStackView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit === self ? nil : hit + } +} From d55babf8f6d78c9c954b3331201b436d09ce6735 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 12:56:30 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=20height=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=20=EC=8B=9C=20=EC=9E=90=EC=8B=9D=20contentOf?= =?UTF-8?q?fset=20=EB=8F=99=EA=B8=B0=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 동작: 첫 layout 시 공지 hidden 상태로 자식 contentInset.top = 80, contentOffset.y = -80 설정. 그 후 applyMeet으로 공지 visible 되면서 inset = 176으로 갱신되지만 contentOffset.y는 -80 그대로 유지됨. 결과: 사용자 swipe 80px 동안 hide 공식이 0 → 176으로 진행되어 약 2.2배 빠른 비율로 헤더가 사라짐 → 공지는 빠르게 사라지고 pill만 마지막에 남아있는 듯 보임. 수정: setTopContentInset에서 inset 변동량(delta)을 자식 contentOffset.y에도 같이 적용해 swipe 거리와 hide 거리가 항상 1:1 비율로 매핑되게 한다. Refs #37, #60 --- .../MeetPlanListViewController.swift | 10 +++++++++- .../MeetReviewListViewController.swift | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift index c14176c1..ccd1f79e 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift @@ -33,14 +33,22 @@ final class MeetPlanListViewController: BaseViewController, View { var onScrollChange: ((CGFloat) -> Void)? // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + // 헤더 height가 변동(공지 hidden ↔ visible)되면, 자식 contentOffset도 같은 delta만큼 따라 이동시켜 + // swipe 거리와 hide 거리가 항상 1:1로 매핑되게 한다. private var topInsetApplied: Bool = false func setTopContentInset(_ inset: CGFloat) { + let oldInset = tableView.contentInset.top tableView.contentInset.top = inset tableView.verticalScrollIndicatorInsets.top = inset if !topInsetApplied { topInsetApplied = true - tableView.contentOffset = CGPoint(x: 0, y: -inset) + tableView.setContentOffset(CGPoint(x: 0, y: -inset), animated: false) + return } + let delta = inset - oldInset + guard abs(delta) > 0.5 else { return } + let oldOffset = tableView.contentOffset.y + tableView.setContentOffset(CGPoint(x: 0, y: oldOffset - delta), animated: false) } // MARK: - UI Components diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift index 3edb069b..f3c1857a 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift @@ -30,14 +30,22 @@ final class MeetReviewListViewController: BaseViewController, View { var onScrollChange: ((CGFloat) -> Void)? // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + // 헤더 height가 변동(공지 hidden ↔ visible)되면, 자식 contentOffset도 같은 delta만큼 따라 이동시켜 + // swipe 거리와 hide 거리가 항상 1:1로 매핑되게 한다. private var topInsetApplied: Bool = false func setTopContentInset(_ inset: CGFloat) { + let oldInset = tableView.contentInset.top tableView.contentInset.top = inset tableView.verticalScrollIndicatorInsets.top = inset if !topInsetApplied { topInsetApplied = true - tableView.contentOffset = CGPoint(x: 0, y: -inset) + tableView.setContentOffset(CGPoint(x: 0, y: -inset), animated: false) + return } + let delta = inset - oldInset + guard abs(delta) > 0.5 else { return } + let oldOffset = tableView.contentOffset.y + tableView.setContentOffset(CGPoint(x: 0, y: oldOffset - delta), animated: false) } // MARK: - UI Components From 542facd1102ceb6892330877a860db552e64ff79 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 13:00:00 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=20height=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=20=EC=8B=9C=20layoutIfNeeded=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자식 attach 시점 또는 noticePreviewContainer.isHidden 변동 직후 headerContainer.bounds.height가 아직 stale(0 또는 이전 값)일 수 있어 잘못된 inset이 propagate되던 문제 보완. ## 변경 - attachChildScrollObservers: setNeedsLayout 대신 view.layoutIfNeeded() 강제 호출 후 propagate - propagateHeaderInsetIfNeeded: 측정 직전 headerContainer.layoutIfNeeded() 호출 Refs #37, #60 --- .../MeetDetail/View/MeetDetailViewController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 23cb1e5c..996bf26c 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -428,12 +428,17 @@ extension MeetDetailViewController { } } } - // 다음 layout 사이클에서 inset이 전파되도록 트리거 - view.setNeedsLayout() + // 즉시 layout을 강제해서 attach 직후에도 인셋 전파가 일어나도록 한다. + // setNeedsLayout만으로는 다음 runloop으로 미뤄지고, 그 사이 child가 그려지면 + // contentInset 없이 잘못된 위치에서 첫 표시될 수 있다. + view.layoutIfNeeded() + propagateHeaderInsetIfNeeded() } - // 헤더 높이가 변동되면(공지 유무 변화 등) 자식 tableView에 새 inset 전파 + // 헤더 높이가 변동되면(공지 유무 변화 등) 자식 tableView에 새 inset 전파. + // bounds.height는 마지막 layout 결과만 반영하므로 측정 직전에 layoutIfNeeded로 강제 갱신. private func propagateHeaderInsetIfNeeded() { + headerContainer.layoutIfNeeded() let h = headerContainer.bounds.height guard h > 0, abs(h - lastPropagatedHeaderHeight) > 0.5 else { return } From 0840bef15b77526c6eb4bfc2fc851ef2c9e93f2c Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 13:05:56 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20pill=20=EC=84=B8=EA=B7=B8?= =?UTF-8?q?=EB=A8=BC=ED=8A=B8=EB=A5=BC=20sticky=20overlay=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20(=ED=8A=B8=EC=9C=84=ED=84=B0=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 동작: 공지 + pill 둘 다 전체 헤더 height만큼 사라짐 원하는 동작: 공지만 위로 사라지고 pill은 sticky 위치에서 멈춤 pill 알파 0.6 배경 + tableView 셀이 pill 뒤로 깔리는 figma 디자인 의도 ('pill이 tableView랑 겹쳐있어'에 매칭)에 맞춰 동작 조정. ## 변경 - pillWrap을 stored property로 노출 — frame.minY 측정용 - 측정 기준을 headerHeight 전체 → pillWrap.frame.minY로 변경 - 공지 visible: 16 + 80 + 16 = 112 (transform max) - 공지 hidden : 16 = 16 (transform max) - handleChildScroll: hide max = noticeArea (pill이 sticky 위치 도달 시 멈춤) - setTopContentInset: 자식 contentInset.top = noticeArea - 첫 셀이 pill의 minY부터 시작 → pill 뒤로 깔리는 시각 ## 동작 1. 초기: 공지(y=16~96) + pill(y=112~160), 첫 셀은 inset.top=112 위치부터 2. swipe → contentOffset.y -112 → 0 (1:1) - 공지 transform.y = -hide → 위로 사라짐 - pill transform.y = -hide → 112에서 점차 16(sticky 위치)으로 이동 3. hide가 noticeArea(=112) 도달 → 공지 완전 사라짐, pill은 nav 바로 아래 16pt 고정 4. 추가 swipe (contentOffset.y > 0) → 헤더 transform 그대로, tableView 정상 스크롤 Refs #37, #60 --- .../View/MeetDetailViewController.swift | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 996bf26c..921f07e2 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -80,12 +80,16 @@ final class MeetDetailViewController: TitleNaviViewController, View { // 공지 카드를 20pt 좌우 인셋으로 감싸는 wrapper (UIStackView가 width를 full로 강제하므로 필요) private let noticePreviewContainer = UIView() - // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드해 사라지고 다시 나타남. - // 공지가 없을 때는 noticePreviewContainer가 isHidden 처리되어 UIStackView가 자동 collapse. + // pill을 감싸는 wrapper. frame.minY를 측정해서 "공지 영역(transform 대상)"의 크기를 알아낸다. + private let pillWrap = UIView() + + // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드. + // transform max는 pillWrap.frame.minY로 제한 — pill이 sticky 위치(navi 아래 16pt)에 도달하면 멈춘다. + // 공지가 없을 때는 noticePreviewContainer가 isHidden 처리되어 UIStackView가 자동 collapse, + // pillWrap.frame.minY도 같이 줄어들어 hide max가 자동 조정된다. // PassThroughStackView로 만들어서 자체 영역의 hit는 통과시키고, 자식 view(공지 카드/pill)만 터치를 받게 한다. // → 헤더 overlay 영역에서도 swipe가 그 아래 tableView로 전달됨. private lazy var headerContainer: PassThroughStackView = { - let pillWrap = UIView() pillWrap.addSubview(pillSegment) pillSegment.snp.makeConstraints { make in make.centerX.equalToSuperview() @@ -110,11 +114,12 @@ final class MeetDetailViewController: TitleNaviViewController, View { return sv }() - // 자식 스크롤에 따라 헤더 transform 조정 - // 헤더는 contentView 상단 overlay로 떠 있고, 자식 tableView는 contentInset.top = headerHeight로 - // 헤더 아래에서 시작한다. 위로 swipe하면 tableView의 contentOffset.y가 -headerHeight → 0 으로 - // 이동하며, 같은 양만큼 헤더가 transform y로 위로 슬라이드해 사라진다. - private var lastPropagatedHeaderHeight: CGFloat = -1 + // 자식 스크롤에 따라 헤더 transform 조정. pill은 sticky이므로 transform의 max는 + // "pill 시작 위치(=공지 영역) = pillWrap.frame.minY"로 제한한다. + // 헤더 전체에 같은 transform이 걸리지만, max로 인해 pill이 sticky 위치(navi 아래 16pt)에서 멈춘다. + // 자식 tableView contentInset.top = pillWrap.frame.minY → 첫 셀이 pill의 minY부터 시작해 + // pill 알파 0.6 배경 뒤로 깔리는 디자인. + private var lastPropagatedNoticeArea: CGFloat = -1 private var currentHideAmount: CGFloat = 0 private weak var planChild: MeetPlanListViewController? private weak var reviewChild: MeetReviewListViewController? @@ -435,25 +440,30 @@ extension MeetDetailViewController { propagateHeaderInsetIfNeeded() } - // 헤더 높이가 변동되면(공지 유무 변화 등) 자식 tableView에 새 inset 전파. - // bounds.height는 마지막 layout 결과만 반영하므로 측정 직전에 layoutIfNeeded로 강제 갱신. + // 공지 영역(transform 대상)이 변동되면(공지 hidden ↔ visible 등) 자식 tableView에 새 inset 전파. + // bounds 측정 직전에 layoutIfNeeded로 강제 갱신. private func propagateHeaderInsetIfNeeded() { headerContainer.layoutIfNeeded() - let h = headerContainer.bounds.height - guard h > 0, - abs(h - lastPropagatedHeaderHeight) > 0.5 else { return } - lastPropagatedHeaderHeight = h - planChild?.setTopContentInset(h) - reviewChild?.setTopContentInset(h) + // pillWrap.frame.minY = stackView 좌표에서 pill이 시작되는 위치 + // 공지 visible: 16(layoutMargin.top) + 80(noticeContainer) + 16(spacing) = 112 + // 공지 hidden : 16(layoutMargin.top) = 16 + let area = pillWrap.frame.minY + guard area > 0, + abs(area - lastPropagatedNoticeArea) > 0.5 else { return } + lastPropagatedNoticeArea = area + planChild?.setTopContentInset(area) + reviewChild?.setTopContentInset(area) } // 자식 VC가 emit하는 contentOffset.y에 따라 헤더가 슬라이드되며 사라지고 다시 나타난다. - // contentOffset.y == -headerHeight → 헤더 완전 노출 (hide 0) - // contentOffset.y == 0 → 헤더 완전 사라짐 (hide headerHeight) + // pill은 sticky이므로 hide max = noticeArea (pill이 sticky 위치에 도달하면 멈춤). + // contentOffset.y == -noticeArea → 헤더 완전 노출 (hide 0) + // contentOffset.y == 0 → 공지 완전 사라짐, pill sticky 위치 (hide noticeArea) + // contentOffset.y > 0 → tableView 정상 스크롤, 헤더 transform 그대로 유지 private func handleChildScroll(_ offset: CGFloat) { - let h = lastPropagatedHeaderHeight - guard h > 0 else { return } - let hide = max(0, min(h, offset + h)) + let area = lastPropagatedNoticeArea + guard area > 0 else { return } + let hide = max(0, min(area, offset + area)) guard abs(hide - currentHideAmount) > 0.5 else { return } currentHideAmount = hide applyHide(hide) From e6c8a6a63d41fdf045f39f4656d5a84b4d5180ab Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 13:22:15 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=EC=85=80=EC=9D=B4=20=EC=95=BD?= =?UTF-8?q?=EC=86=8D=20=ED=83=AD=20=EC=95=84=EB=9E=98=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=ED=95=98=EB=8F=84=EB=A1=9D=20inset=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전: contentInset.top = pillWrap.minY (= 약속 탭 시작 위치) → 첫 셀이 약속 탭 minY부터 시작 → 약속 탭과 시각적으로 겹침 → tableView의 tableHeaderView('예정된 약속 15개')가 약속 탭과 같은 y에 보임 수정: inset과 hideMax를 분리해 셀이 약속 탭 끝(maxY) + spacing 아래에서 시작. - inset = pillWrap.maxY + 16 (= 약속 탭 아래 셀 시작 위치, 보통 176) - hideMax = pillWrap.minY - 16 (= 약속 탭이 sticky 위치까지 이동할 거리, 보통 96) handleChildScroll 공식 변경: - swipeDistance = max(0, offset + inset) - hide = min(hideMax, swipeDistance) - swipe 0~hideMax 동안 헤더 transform → 약속 탭이 sticky 도달 - swipe hideMax+ 동안 hide clamp, tableView만 정상 스크롤 추가 안전망: viewDidAppear에서도 propagateHeaderInsetIfNeeded 호출. 첫 layout 사이클이 stale인 케이스를 viewDidAppear 시점에 재측정해 회복. Refs #37, #60 --- .../View/MeetDetailViewController.swift | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 921f07e2..a51bc8c8 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -114,12 +114,13 @@ final class MeetDetailViewController: TitleNaviViewController, View { return sv }() - // 자식 스크롤에 따라 헤더 transform 조정. pill은 sticky이므로 transform의 max는 - // "pill 시작 위치(=공지 영역) = pillWrap.frame.minY"로 제한한다. - // 헤더 전체에 같은 transform이 걸리지만, max로 인해 pill이 sticky 위치(navi 아래 16pt)에서 멈춘다. - // 자식 tableView contentInset.top = pillWrap.frame.minY → 첫 셀이 pill의 minY부터 시작해 - // pill 알파 0.6 배경 뒤로 깔리는 디자인. - private var lastPropagatedNoticeArea: CGFloat = -1 + // 자식 스크롤에 따라 헤더 transform 조정. 약속 탭은 sticky로 navi 아래 16pt에서 멈춘다. + // - inset.top = 약속 탭 maxY + spacing → 첫 셀이 약속 탭 아래에서 시작 (겹치지 않음) + // - hideMax = 약속 탭 sticky 도달까지 필요한 transform 거리 (= 약속 탭 minY - 16) + // inset != hideMax 이므로 1:1 매핑은 아니지만, swipe 시작과 동시에 transform이 진행되고 + // sticky 도달 후 추가 swipe에서는 tableView만 정상 스크롤된다. + private var lastPropagatedInset: CGFloat = -1 + private var lastPropagatedHideMax: CGFloat = -1 private var currentHideAmount: CGFloat = 0 private weak var planChild: MeetPlanListViewController? private weak var reviewChild: MeetReviewListViewController? @@ -166,6 +167,12 @@ final class MeetDetailViewController: TitleNaviViewController, View { propagateHeaderInsetIfNeeded() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // 첫 layout 사이클에서 stale일 수 있는 height를 viewDidAppear 시점에 한 번 더 측정 + propagateHeaderInsetIfNeeded() + } + // MARK: - UI Setup private func setupUI() { setupNavi() @@ -440,30 +447,35 @@ extension MeetDetailViewController { propagateHeaderInsetIfNeeded() } - // 공지 영역(transform 대상)이 변동되면(공지 hidden ↔ visible 등) 자식 tableView에 새 inset 전파. - // bounds 측정 직전에 layoutIfNeeded로 강제 갱신. + // 헤더 layout 변동 시(공지 hidden ↔ visible, 첫 표시 등) 자식 tableView에 inset 전파. + // 두 값을 계산해 보관: + // inset = 약속 탭 maxY + spacing (= pillWrap.frame.maxY + 16) + // → 자식 tableView contentInset.top. 셀이 약속 탭 아래에서 시작. + // hideMax = 약속 탭이 sticky 위치(navi 아래 16pt)까지 이동해야 하는 transform 거리 + // = pillWrap.frame.minY - 16 private func propagateHeaderInsetIfNeeded() { headerContainer.layoutIfNeeded() - // pillWrap.frame.minY = stackView 좌표에서 pill이 시작되는 위치 - // 공지 visible: 16(layoutMargin.top) + 80(noticeContainer) + 16(spacing) = 112 - // 공지 hidden : 16(layoutMargin.top) = 16 - let area = pillWrap.frame.minY - guard area > 0, - abs(area - lastPropagatedNoticeArea) > 0.5 else { return } - lastPropagatedNoticeArea = area - planChild?.setTopContentInset(area) - reviewChild?.setTopContentInset(area) + let inset = pillWrap.frame.maxY + 16 + let hideMax = max(0, pillWrap.frame.minY - 16) + guard inset > 0, + abs(inset - lastPropagatedInset) > 0.5 else { return } + lastPropagatedInset = inset + lastPropagatedHideMax = hideMax + planChild?.setTopContentInset(inset) + reviewChild?.setTopContentInset(inset) } // 자식 VC가 emit하는 contentOffset.y에 따라 헤더가 슬라이드되며 사라지고 다시 나타난다. - // pill은 sticky이므로 hide max = noticeArea (pill이 sticky 위치에 도달하면 멈춤). - // contentOffset.y == -noticeArea → 헤더 완전 노출 (hide 0) - // contentOffset.y == 0 → 공지 완전 사라짐, pill sticky 위치 (hide noticeArea) - // contentOffset.y > 0 → tableView 정상 스크롤, 헤더 transform 그대로 유지 + // swipeDistance = offset + inset (사용자가 위로 swipe한 누적 거리, 0 이상) + // hide = min(hideMax, swipeDistance) + // → swipe 0 ~ hideMax: 공지 사라지면서 약속 탭이 sticky 위치 도달 + // → swipe hideMax+: sticky 유지, tableView만 자체 스크롤 (약속 탭 transform 더 안 됨) private func handleChildScroll(_ offset: CGFloat) { - let area = lastPropagatedNoticeArea - guard area > 0 else { return } - let hide = max(0, min(area, offset + area)) + let inset = lastPropagatedInset + let hideMax = lastPropagatedHideMax + guard inset > 0 else { return } + let swipeDistance = max(0, offset + inset) + let hide = min(hideMax, swipeDistance) guard abs(hide - currentHideAmount) > 0.5 else { return } currentHideAmount = hide applyHide(hide) From c4c69fff44fcc0829216d40ca87b229d4249353e Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 13:26:58 +0900 Subject: [PATCH 11/16] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B0=80=EB=B3=80=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=85=EC=8B=9C=EC=A0=81=20height=20constraint?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전: noticePreviewContainer.isHidden 토글 (UIStackView 자동 collapse) → 첫 layout 사이클 시점에 stale measure 가능성 있음 수정: 명시적 height constraint(0 ↔ 80) + view.layoutIfNeeded() + propagate → 가변 값(공지 카드 height)이 한 곳에서만 명시적으로 제어됨 → layout 갱신이 즉시 반영되어 stale 측정 회피 ## 변경 - noticePreviewContainer.clipsToBounds = true (height 0일 때 시각적 클립) - noticePreviewContainer에 명시적 height constraint(기본 0) 추가, Constraint 저장 - noticePreviewView를 wrapper edges가 아닌 top+horizontal+height 80 고정으로 묶음 (wrapper height와 분리 — wrapper가 0일 때도 layout 충돌 없음) - applyMeet 변경: - isHidden 처리 제거 - noticeHeightConstraint.update(offset: hasNotice ? 80 : 0) - view.layoutIfNeeded() 즉시 호출 - propagateHeaderInsetIfNeeded() 직접 호출 Refs #37, #60 --- .../View/MeetDetailViewController.swift | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index a51bc8c8..1d2cab3c 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -77,18 +77,22 @@ final class MeetDetailViewController: TitleNaviViewController, View { titles: [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] ) - // 공지 카드를 20pt 좌우 인셋으로 감싸는 wrapper (UIStackView가 width를 full로 강제하므로 필요) - private let noticePreviewContainer = UIView() + // 공지 카드 wrapper. height constraint를 명시적으로 0/80 토글해서 가변 처리. + // isHidden / 자동 collapse 대신 명시적 height로 layout이 항상 명확하다. + // clipsToBounds = true로 height 0일 때 안쪽 컨텐츠를 시각적으로 클립. + private let noticePreviewContainer: UIView = { + let v = UIView() + v.clipsToBounds = true + return v + }() + private var noticeHeightConstraint: Constraint? - // pill을 감싸는 wrapper. frame.minY를 측정해서 "공지 영역(transform 대상)"의 크기를 알아낸다. + // pill을 감싸는 wrapper. frame.maxY/minY를 측정해서 inset과 hideMax를 계산. private let pillWrap = UIView() // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드. - // transform max는 pillWrap.frame.minY로 제한 — pill이 sticky 위치(navi 아래 16pt)에 도달하면 멈춘다. - // 공지가 없을 때는 noticePreviewContainer가 isHidden 처리되어 UIStackView가 자동 collapse, - // pillWrap.frame.minY도 같이 줄어들어 hide max가 자동 조정된다. - // PassThroughStackView로 만들어서 자체 영역의 hit는 통과시키고, 자식 view(공지 카드/pill)만 터치를 받게 한다. - // → 헤더 overlay 영역에서도 swipe가 그 아래 tableView로 전달됨. + // 공지 height가 0(기본)이면 wrapper도 0, 80이면 80 — 그에 따라 pillWrap.minY/maxY가 자동 변동. + // PassThroughStackView로 만들어서 자체 영역의 hit는 통과시키고, 자식 view만 터치를 받게 한다. private lazy var headerContainer: PassThroughStackView = { pillWrap.addSubview(pillSegment) pillSegment.snp.makeConstraints { make in @@ -100,10 +104,14 @@ final class MeetDetailViewController: TitleNaviViewController, View { noticePreviewContainer.addSubview(noticePreviewView) noticePreviewView.snp.makeConstraints { make in - make.top.bottom.equalToSuperview() - make.horizontalEdges.equalToSuperview().inset(20) + make.top.equalToSuperview() + make.leading.equalToSuperview().offset(20) + make.trailing.equalToSuperview().offset(-20) make.height.equalTo(80) } + noticePreviewContainer.snp.makeConstraints { make in + self.noticeHeightConstraint = make.height.equalTo(0).constraint + } let sv = PassThroughStackView(arrangedSubviews: [noticePreviewContainer, pillWrap]) sv.axis = .vertical @@ -194,9 +202,7 @@ final class MeetDetailViewController: TitleNaviViewController, View { make.horizontalEdges.bottom.equalToSuperview() } - // 헤더 (공지 미리보기 + pill 세그먼트) — 기본은 공지 hidden 상태로 시작 - noticePreviewContainer.isHidden = true - + // 헤더 (공지 미리보기 + pill 세그먼트) — 공지 height는 기본 0 (constraint로 처리) headerContainer.snp.makeConstraints { make in make.top.equalToSuperview() make.horizontalEdges.equalToSuperview() @@ -405,13 +411,17 @@ extension MeetDetailViewController { let pinned = meet.pinnedNotice let hasNotice = pinned != nil - // 공지 미리보기 카드 (UIStackView가 isHidden 자동 collapse → 헤더 높이도 함께 변동) - noticePreviewContainer.isHidden = !hasNotice - noticePreviewView.isHidden = !hasNotice if let content = pinned?.content { noticePreviewView.configure(content: content) } + // 공지 카드 height 명시적 토글 — 가변은 이 한 값만, 0 ↔ 80 + // layoutIfNeeded()로 즉시 layout 반영 → propagate 시점에 정확한 pillWrap frame 측정 보장 + let targetHeight: CGFloat = hasNotice ? 80 : 0 + noticeHeightConstraint?.update(offset: targetHeight) + view.layoutIfNeeded() + propagateHeaderInsetIfNeeded() + // 헤더 높이가 바뀔 수 있으므로 sticky 상태도 재계산 applyHide(currentHideAmount) From 98de8d9aa892f0f6b585402183245b220d62b321 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 13:46:28 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=8B=9C=20=ED=97=A4=EB=8D=94=20transform?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20(=EC=9E=90=EC=8B=9D=20viewWill?= =?UTF-8?q?Appear)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 버그 1. 예정된 약속 탭에서 아래로 스크롤 (헤더 transform = -hideMax) 2. 지난 약속 탭 전환 → 지난 약속 VC.offset = -inset (초기) 3. 헤더 transform은 -hideMax 그대로 stale → 약속 탭 sticky 상태인데 공지 자리가 비어 보임 4. 지난 약속 탭 살짝 스크롤하면 scrollViewDidScroll → handleChildScroll → 정상화 5. 다시 예정된 탭 복귀 → 헤더 transform은 0 (지난 탭 마지막 상태)으로 stale → 헤더 정상 노출인데 셀들은 이미 위로 스크롤된 채 ## 원인 - 두 자식 VC의 contentOffset은 독립적 - 부모 헤더 transform은 마지막 scrollViewDidScroll에 의존 - 페이지 전환만으로는 scroll 이벤트 발생 X → handleChildScroll 호출 안 됨 - 결과: 새로 보이는 자식의 offset과 부모 transform이 불일치 ## 수정 자식 viewWillAppear에서 자기 contentOffset.y를 onScrollChange?로 즉시 emit. 부모가 새 자식의 offset 기반으로 헤더 transform을 재계산해 sync 회복. - MeetPlanListViewController.viewWillAppear: onScrollChange?(offset) 추가 - MeetReviewListViewController: viewWillAppear 오버라이드 신규, onScrollChange?(offset) 호출 Refs #37, #60 --- .../MeetPlanListViewController.swift | 3 +++ .../MeetReviewListViewController.swift | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift index ccd1f79e..e48caff8 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift @@ -96,6 +96,9 @@ final class MeetPlanListViewController: BaseViewController, View { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) isVisibleView = true + // 페이지 전환으로 다시 표시될 때 부모(MeetDetail)의 헤더 transform이 이전 자식의 상태로 + // stale일 수 있다. 자기 contentOffset.y를 즉시 알려서 헤더 transform을 재계산하게 한다. + onScrollChange?(tableView.contentOffset.y) } override func viewWillDisappear(_ animated: Bool) { diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift index f3c1857a..f34ce5c5 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift @@ -89,7 +89,14 @@ final class MeetReviewListViewController: BaseViewController, View { super.viewDidLoad() setupUI() } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // 페이지 전환으로 다시 표시될 때 부모(MeetDetail)의 헤더 transform이 이전 자식의 상태로 + // stale일 수 있다. 자기 contentOffset.y를 즉시 알려서 헤더 transform을 재계산하게 한다. + onScrollChange?(tableView.contentOffset.y) + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() setHeaderView() From d5ebf23dda405842c5679626ad92877189613e17 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 15:40:27 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=EC=9E=91=EC=84=B1=20=EC=9C=A0?= =?UTF-8?q?=EB=8F=84=20=EB=A7=90=ED=92=8D=EC=84=A0=20+=20sticky=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94/=EA=B3=B5=EC=A7=80=20=EA=B0=80=EB=B3=80=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A7=88=EB=AC=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TooltipBalloonView (신규) CAShapeLayer + UIBezierPath로 본체 + tail을 단일 path로 그려 그림자도 모양 그대로 따라가게 한다. - 본체: figma 사양 145×40, padding 16/10, corner 8, bg white, text Body1.SemiBold text02 - tail: 6×12 위쪽 삼각형. tailCenterX 프로퍼티로 위치 동적 조정 - 그림자: 같은 path를 layer.shadowPath에 재사용 ## composeTooltipView 적용 - TooltipBalloonView로 교체 (기존 단순 UIView + UILabel 제거) - 위치 조건: 1) 말풍선 trailing = 확성기 trailing 2) tail centerX = 확성기 centerX (viewDidLayoutSubviews에서 동적 계산) - view 계층 가장 앞으로 가져오고 zPosition 2로 contentView/addPlanButton보다 위에 노출 ## 공지 카드 가변 처리 보강 - noticePreviewContainer.isHidden 기본 true → mock 도착 전에도 약속 탭이 navi 바로 아래 sticky 위치(layoutMargin.top 16)에서 시작 - applyMeet에서 isHidden + height(0/80) 동시 토글 → UIStackView가 isHidden 자식 + 인접 spacing을 자동 collapse → 공지 없을 때 stackView 내부 spacing 16이 함께 빠져 잔여 공간 없음 ## MeetPlanList / MeetReviewList sticky 동기화 - viewWillAppear에서 onScrollChange?(contentOffset.y) 호출 → 페이지 전환 시 새 자식의 offset을 부모에 즉시 emit → 헤더 transform이 이전 자식 상태로 stale되는 문제 해결 ## Mock 토글 - MockFetchMeetDetailUseCase: meetId 짝수 → 공지 있음, 홀수 → 공지 없음 두 케이스 (공지 카드 노출 / 모임장 작성 유도 말풍선 노출) 모두 시뮬레이터에서 확인 가능 Refs #37, #60 --- .../UseCases/Meet/Read/FetchMeetDetail.swift | 6 +- .../View/MeetDetailViewController.swift | 58 +++++----- .../View/SubView/MeetDetailPillSegment.swift | 5 +- .../View/SubView/TooltipBalloonView.swift | 102 ++++++++++++++++++ 4 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift diff --git a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift index 45a9263b..5edca626 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift @@ -31,7 +31,9 @@ public final class MockFetchMeetDetailUseCase: FetchMeetDetail { public func execute(meetId: Int) async throws -> Meet { print("✅ [Mock] 모임 상세 조회 - meetId: \(meetId)") - let mockPinnedNotice = PinnedNotice( + // meetId 짝수: 공지 있음 / 홀수: 공지 없음 → 모임장 작성 유도 툴팁 노출 케이스 둘 다 확인 가능 + let hasNotice = meetId % 2 == 0 + let mockPinnedNotice: PinnedNotice? = hasNotice ? PinnedNotice( noticeId: 1, version: 1, meetId: meetId, @@ -39,7 +41,7 @@ public final class MockFetchMeetDetailUseCase: FetchMeetDetail { content: "11/28일 모임 18:00 → 20:00 변경 되었습니다. 날씨이슈로 인해서", isPinned: true, createdAt: Date() - ) + ) : nil let mockMeet = Meet( isCreator: true, diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 1d2cab3c..a3bacab4 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -51,24 +51,13 @@ final class MeetDetailViewController: TitleNaviViewController, View { return dot }() - // 작성 유도 툴팁 (모임장 + 공지 없음 조건) - private let composeTooltipView: UIView = { - let v = UIView() - v.backgroundColor = .bgPrimary - v.layer.cornerRadius = 8 - v.layer.makeShadow(opactity: 0.08, radius: 8, offset: .init(width: 0, height: 2)) + // 작성 유도 툴팁 (모임장 + 공지 없음 조건). 위쪽에 삼각형 꼬리가 달린 말풍선. + private let composeTooltipView: TooltipBalloonView = { + let v = TooltipBalloonView(text: "공지를 작성해보세요.") v.isHidden = true return v }() - private let composeTooltipLabel: UILabel = { - let label = UILabel() - label.text = "공지를 작성해보세요." - label.font = FontStyle.Body2.medium - label.textColor = .text02 - return label - }() - // 공지 미리보기 카드 private let noticePreviewView = MeetDetailNoticePreviewView() @@ -77,12 +66,13 @@ final class MeetDetailViewController: TitleNaviViewController, View { titles: [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] ) - // 공지 카드 wrapper. height constraint를 명시적으로 0/80 토글해서 가변 처리. - // isHidden / 자동 collapse 대신 명시적 height로 layout이 항상 명확하다. - // clipsToBounds = true로 height 0일 때 안쪽 컨텐츠를 시각적으로 클립. + // 공지 카드 wrapper. height constraint와 isHidden을 함께 토글해서 가변 처리. + // 기본은 isHidden = true / height = 0 → mock 도착 전에도 약속 탭이 sticky 위치(navi 아래 16)에 있음. + // pinnedNotice 도착 시 applyMeet에서 isHidden = false / height = 80으로 동시 전환. private let noticePreviewContainer: UIView = { let v = UIView() v.clipsToBounds = true + v.isHidden = true return v }() private var noticeHeightConstraint: Constraint? @@ -173,6 +163,18 @@ final class MeetDetailViewController: TitleNaviViewController, View { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() propagateHeaderInsetIfNeeded() + alignTooltipTailToMegaphone() + } + + // tooltip의 trailing이 화면 우측 inset에 고정되어 있으므로, tail의 가로 위치를 + // megaphoneButton의 centerX와 매칭시켜야 확성기를 정확히 가리킨다. + private func alignTooltipTailToMegaphone() { + guard composeTooltipView.bounds.width > 0 else { return } + let megaphoneCenter = megaphoneButton.convert( + CGPoint(x: megaphoneButton.bounds.midX, y: 0), + to: composeTooltipView + ) + composeTooltipView.tailCenterX = megaphoneCenter.x } override func viewDidAppear(_ animated: Bool) { @@ -185,6 +187,10 @@ final class MeetDetailViewController: TitleNaviViewController, View { private func setupUI() { setupNavi() setLayout() + // setupNavi에서 add한 composeTooltipView가 setLayout에서 나중에 add된 contentView/addPlanButton에 + // 가려지지 않도록 가장 앞으로 가져온다. + view.bringSubviewToFront(composeTooltipView) + composeTooltipView.layer.zPosition = 2 } private func setLayout() { @@ -247,15 +253,14 @@ final class MeetDetailViewController: TitleNaviViewController, View { make.trailing.equalToSuperview().inset(8) } - // 작성 유도 툴팁 — 확성기 버튼 아래 - composeTooltipView.addSubview(composeTooltipLabel) - composeTooltipLabel.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)) - } + // 작성 유도 툴팁 — 확성기 버튼 아래. + // 조건 1: 말풍선의 trailing = 확성기 trailing (오른쪽 끝이 확성기와 정렬) + // 조건 2: tail의 centerX = 확성기의 centerX (꼬리가 확성기 중앙을 가리킴) + // → tail은 viewDidLayoutSubviews의 alignTooltipTailToMegaphone()에서 동적 설정 view.addSubview(composeTooltipView) composeTooltipView.snp.makeConstraints { make in make.top.equalTo(megaphoneButton.snp.bottom).offset(4) - make.centerX.equalTo(megaphoneButton) + make.trailing.equalTo(megaphoneButton.snp.trailing) } } @@ -415,9 +420,12 @@ extension MeetDetailViewController { noticePreviewView.configure(content: content) } - // 공지 카드 height 명시적 토글 — 가변은 이 한 값만, 0 ↔ 80 - // layoutIfNeeded()로 즉시 layout 반영 → propagate 시점에 정확한 pillWrap frame 측정 보장 + // 공지 카드 토글: + // isHidden: UIStackView가 자동 collapse — 자식 + 인접 spacing이 함께 빠짐 + // → 약속 탭이 navi 바로 아래(layoutMargin.top 16만)에 위치 + // height : 명시적 안전망 (isHidden 처리 외 상황에서도 layout 명확) let targetHeight: CGFloat = hasNotice ? 80 : 0 + noticePreviewContainer.isHidden = !hasNotice noticeHeightConstraint?.update(offset: targetHeight) view.layoutIfNeeded() propagateHeaderInsetIfNeeded() diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift index 200e7fbb..81d8e272 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift @@ -25,11 +25,12 @@ final class MeetDetailPillSegment: UIView { private let titles: [String] private var buttons: [UIButton] = [] - // 선택된 알약 — 버튼 frame에 맞춰 슬라이드 + // 선택된 알약 — 버튼 frame에 맞춰 슬라이드. + // button height = container(48) - padding(6+6) = 36 → cornerRadius 18 (capsule) private let selectedPill: UIView = { let v = UIView() v.backgroundColor = .appPrimary - v.layer.cornerRadius = 22 + v.layer.cornerRadius = 18 v.layer.makeShadow(opactity: 0.12, radius: 8, offset: .init(width: 0, height: 0)) return v }() diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift new file mode 100644 index 00000000..0936b412 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift @@ -0,0 +1,102 @@ +// +// TooltipBalloonView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit + +// 본체: figma 사양 (145×40, padding 16/10, corner 8, bg white, text Body1.SemiBold text02) +// tail: 위쪽 작은 삼각형 (figma엔 없지만 디자인 시안에 따라 어디를 가리키는지 명확히 하기 위해 추가) +// 본체 + tail을 하나의 UIBezierPath로 합쳐 CAShapeLayer로 그린다. +// 동일 path를 layer.shadowPath에 재사용해 그림자도 모양 그대로 따라가게. +final class TooltipBalloonView: UIView { + + // MARK: - Style + private let tailHeight: CGFloat = 6 + private let tailWidth: CGFloat = 12 + private let cornerRadius: CGFloat = 8 + private let horizontalPadding: CGFloat = 16 + private let verticalPadding: CGFloat = 10 + + // tail 가로 위치 (말풍선 좌표 기준). nil이면 중앙. + var tailCenterX: CGFloat? { + didSet { setNeedsLayout() } + } + + // MARK: - Subviews + private let shapeLayer = CAShapeLayer() + + private let label: UILabel = { + let lb = UILabel() + lb.font = FontStyle.Body1.semiBold + lb.textColor = .text02 + lb.numberOfLines = 0 + return lb + }() + + // MARK: - Init + init(text: String) { + super.init(frame: .zero) + label.text = text + setupLayer() + setupLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayer() { + shapeLayer.fillColor = UIColor.bgPrimary.cgColor + layer.insertSublayer(shapeLayer, at: 0) + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.08 + layer.shadowRadius = 8 + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.masksToBounds = false + } + + private func setupLabel() { + addSubview(label) + label.snp.makeConstraints { make in + // 본체 영역(tail 아래)에 padding 적용 + make.top.equalToSuperview().offset(tailHeight + verticalPadding) + make.bottom.equalToSuperview().offset(-verticalPadding) + make.leading.equalToSuperview().offset(horizontalPadding) + make.trailing.equalToSuperview().offset(-horizontalPadding) + } + } + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + updateShapePath() + } + + private func updateShapePath() { + let w = bounds.width + let h = bounds.height + guard w > 0, h > tailHeight else { return } + + // 본체: tail 아래 영역의 둥근 사각형 + let bodyRect = CGRect(x: 0, y: tailHeight, width: w, height: h - tailHeight) + let path = UIBezierPath(roundedRect: bodyRect, cornerRadius: cornerRadius) + + // tail: 위쪽 정점, 밑변은 본체 상단에 닿음 + let cx = tailCenterX ?? (w / 2) + let tail = UIBezierPath() + tail.move(to: CGPoint(x: cx - tailWidth / 2, y: tailHeight)) + tail.addLine(to: CGPoint(x: cx, y: 0)) + tail.addLine(to: CGPoint(x: cx + tailWidth / 2, y: tailHeight)) + tail.close() + + path.append(tail) + + shapeLayer.path = path.cgPath + layer.shadowPath = path.cgPath + } +} From 345498340c8f022f07f9199e9828f31e9d70689c Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 15:43:58 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat(experimental):=20PageController=20sw?= =?UTF-8?q?ipe=EC=99=80=20pill=20=EC=9D=B8=ED=84=B0=EB=9E=99=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=ED=8A=B8=EB=9E=98=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageController 좌우 swipe에 selectedPill x 위치를 progress 비율로 보간 이동. 임계점/속도 처리는 UIPageViewController 자체 로직 활용. ## 변경 - MeetDetailPillSegment 확장: - setInteractiveProgress(_:) — -1.0~+1.0 progress 받아 transform.x 보간 - commitInteractiveTransition(to:) — transition 확정 시 selectedIndex 갱신, constraint 새 버튼 remake, transform reset. selectedIndexChanged emit X (루프 방지) - MeetDetailViewController: - UIPageViewControllerDataSource 채택 — plan ↔ review 양방향 swipe 활성화 - UIPageViewControllerDelegate 채택 — didFinishAnimating에서 pill commit - PageController 내부 scrollView KVO (contentOffset)로 progress 추출 - attachChildScrollObservers 끝에서 setupInteractivePageSwipe 호출 ## 동작 - 사용자 swipe 시작: contentOffset.x 변동 → KVO → progress 계산 → pill transform.x - swipe 끝: UIPageViewController가 자체적으로 임계점/속도로 transition 확정 or 복귀 - 성공 → didFinishAnimating(completed: true) → commit(to: newIndex) - 실패 → didFinishAnimating(completed: false) → transform reset only ## 안전망 직전 커밋 d5ebf23이 안정 체크포인트. 이 시험이 별로면: git revert HEAD 또는 git reset --hard d5ebf23 Refs #37, #60 --- .../View/MeetDetailViewController.swift | 72 +++++++++++++++++++ .../View/SubView/MeetDetailPillSegment.swift | 46 ++++++++++++ 2 files changed, 118 insertions(+) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index a3bacab4..b28102c4 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -112,6 +112,10 @@ final class MeetDetailViewController: TitleNaviViewController, View { return sv }() + // PageController 내부 scrollView contentOffset KVO observer. + // 사용자 swipe progress를 selectedPill 보간 이동에 사용. + private var pageScrollObservation: NSKeyValueObservation? + // 자식 스크롤에 따라 헤더 transform 조정. 약속 탭은 sticky로 navi 아래 16pt에서 멈춘다. // - inset.top = 약속 탭 maxY + spacing → 첫 셀이 약속 탭 아래에서 시작 (겹치지 않음) // - hideMax = 약속 탭 sticky 도달까지 필요한 transform 거리 (= 약속 탭 minY - 16) @@ -463,6 +467,37 @@ extension MeetDetailViewController { // contentInset 없이 잘못된 위치에서 첫 표시될 수 있다. view.layoutIfNeeded() propagateHeaderInsetIfNeeded() + + // 인터랙티브 swipe ↔ pill 트래킹 setup + setupInteractivePageSwipe() + } + + // PageController 좌우 swipe와 pill 세그먼트 selectedPill을 동기화한다. + // - dataSource로 양방향 swipe 활성화 (plan ↔ review) + // - delegate로 transition 완료 후 selectedIndex 동기화 + // - 내부 scrollView contentOffset KVO로 progress 추출 → pill에 보간 이동 적용 + private func setupInteractivePageSwipe() { + pageController.dataSource = self + pageController.delegate = self + + for sv in pageController.view.subviews { + guard let scrollView = sv as? UIScrollView else { continue } + pageScrollObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] sv, _ in + self?.handlePageInteractiveScroll(sv) + } + break + } + } + + // UIPageViewController scrollView 동작: + // 정상 상태 contentOffset.x = bounds.width (가운데 페이지가 visible) + // 왼→오 swipe: x < bounds.width (이전 페이지 방향) + // 오→왼 swipe: x > bounds.width (다음 페이지 방향) + private func handlePageInteractiveScroll(_ sv: UIScrollView) { + let pageWidth = sv.bounds.width + guard pageWidth > 0 else { return } + let progress = (sv.contentOffset.x - pageWidth) / pageWidth + pillSegment.setInteractiveProgress(progress) } // 헤더 layout 변동 시(공지 hidden ↔ visible, 첫 표시 등) 자식 tableView에 inset 전파. @@ -543,3 +578,40 @@ final class PassThroughStackView: UIStackView { return hit === self ? nil : hit } } + +// MARK: - UIPageViewController DataSource / Delegate (인터랙티브 swipe) +extension MeetDetailViewController: UIPageViewControllerDataSource { + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + // review의 이전 = plan + if viewController === reviewChild { return planChild } + return nil + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + // plan의 다음 = review + if viewController === planChild { return reviewChild } + return nil + } +} + +extension MeetDetailViewController: UIPageViewControllerDelegate { + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + // 전환 실패(복귀): transform reset만 + guard completed else { + pillSegment.commitInteractiveTransition(to: pillSegment.selectedIndex) + return + } + // 전환 성공: 현재 visible VC로 selectedIndex 확정 + guard let currentVC = pageViewController.viewControllers?.first else { return } + let newIndex: Int + if currentVC === planChild { newIndex = 0 } + else if currentVC === reviewChild { newIndex = 1 } + else { return } + pillSegment.commitInteractiveTransition(to: newIndex) + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift index 81d8e272..4764069a 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift @@ -122,3 +122,49 @@ final class MeetDetailPillSegment: UIView { } } } + +// MARK: - Interactive Transition (PageController swipe와 연동) +extension MeetDetailPillSegment { + + // PageController가 emit하는 -1.0 ~ +1.0 progress를 받아 selectedPill x 위치를 보간. + // progress > 0 → 다음 인덱스 방향, progress < 0 → 이전 인덱스 방향. + // transform.x로만 이동시켜서 constraint 변경 없이 가볍게 처리한다. + func setInteractiveProgress(_ progress: CGFloat) { + let clamped = max(-1, min(1, progress)) + let baseIndex = selectedIndex + + let targetIndex: Int + if clamped > 0 && baseIndex < buttons.count - 1 { + targetIndex = baseIndex + 1 + } else if clamped < 0 && baseIndex > 0 { + targetIndex = baseIndex - 1 + } else { + // 끝 페이지에서 그 이상 방향 swipe — bounce. transform 0. + selectedPill.transform = .identity + return + } + + guard let baseBtn = buttons[safe: baseIndex], + let targetBtn = buttons[safe: targetIndex] else { return } + + let deltaX = (targetBtn.frame.minX - baseBtn.frame.minX) * abs(clamped) + selectedPill.transform = CGAffineTransform(translationX: deltaX, y: 0) + } + + // PageController 전환 결과 확정 시 호출. + // - 새 인덱스로 전환 성공: selectedIndex 갱신 + constraint 새 버튼으로 remake + transform reset + // - 복귀(전환 실패): transform만 reset + // selectedIndexChanged stream은 emit하지 않는다 — 외부 PageController swipe로 인한 전환이라 + // 부모로 다시 통지하면 루프 위험. + func commitInteractiveTransition(to newIndex: Int) { + guard newIndex != selectedIndex, + newIndex >= 0, newIndex < buttons.count else { + selectedPill.transform = .identity + return + } + selectedIndex = newIndex + buttons.enumerated().forEach { $0.element.isSelected = ($0.offset == newIndex) } + selectedPill.transform = .identity + updatePillPosition(animated: false) + } +} From b7f5ff4ca661b361e6d5d71b207f3e7dcd29eb77 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 16:46:17 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20PinnedNotice=20=E2=86=92=20No?= =?UTF-8?q?tice=20rename=20+=20NoticeRepo=20=ED=94=84=EB=A1=9C=ED=86=A0?= =?UTF-8?q?=EC=BD=9C=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공지 entity를 일반화. MeetDetail의 pinnedNotice, 공지 리스트 셀, 공지 상세 모두 같은 서버 응답 구조(NoticeClientResponse)라 단일 entity로 통일. ## Domain - Entities/Notice/PinnedNotice.swift → Notice.swift (rename + 의미 일반화) - Meet.pinnedNotice 타입: PinnedNotice? → Notice? - Interfaces/Repositories/Notice/NoticeRepo.swift 신규 - 공지 CRUD: fetchNoticeList, createNotice, updateNotice, deleteNotice - 고정 토글: pinNotice, unpinNotice - 공지 댓글: fetchNoticeCommentList, createNoticeComment - 댓글 수정/삭제는 일반 CommentRepo의 editComment/deleteComment 재사용 (서버가 /comment/{commentId} 공통 endpoint) ## Data - Network/Response/Notice/PinnedNoticeResponse.swift → NoticeResponse.swift - MeetResponse.pinnedNotice 타입 NoticeResponse? ## 호출부 - FetchMeetDetail mock의 PinnedNotice → Notice ## 후속 (이 PR에서 이어서) - DTO 추가 (NoticeCreateRequest, NoticeCommentResponse 등) - UseCase + Repository 구현 - UI (NoticeListVC, NoticeDetailVC, NoticeComposeVC) UIKit + ReactorKit - 기존 SwiftUI placeholder 제거 Refs #37, #60 --- .../Network/Response/Meet/MeetResponse.swift | 2 +- ...iceResponse.swift => NoticeResponse.swift} | 12 ++--- .../Domain/Sources/Entities/Meet/Meet.swift | 4 +- .../{PinnedNotice.swift => Notice.swift} | 7 +-- .../Repositories/Notice/NoticeRepo.swift | 44 +++++++++++++++++++ .../UseCases/Meet/Read/FetchMeetDetail.swift | 2 +- 6 files changed, 59 insertions(+), 12 deletions(-) rename Modules/Data/Sources/Network/Response/Notice/{PinnedNoticeResponse.swift => NoticeResponse.swift} (66%) rename Modules/Domain/Sources/Entities/Notice/{PinnedNotice.swift => Notice.swift} (78%) create mode 100644 Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift diff --git a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift index 54e42463..d6901ec9 100644 --- a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift +++ b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift @@ -17,7 +17,7 @@ struct MeetResponse: Decodable { let hostId: Int? let memberCount: Int? let lastPlanDay: String? - let pinnedNotice: PinnedNoticeResponse? + let pinnedNotice: NoticeResponse? } extension MeetResponse { diff --git a/Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift similarity index 66% rename from Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift rename to Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift index 1bd90ba5..95d92b8f 100644 --- a/Modules/Data/Sources/Network/Response/Notice/PinnedNoticeResponse.swift +++ b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift @@ -1,15 +1,17 @@ // -// PinnedNoticeResponse.swift +// NoticeResponse.swift // Data // // Created by CatSlave on 5/26/26. // +// 서버 NoticeClientResponse — 공지 객체 단일 표현. +// MeetDetail 응답의 pinnedNotice 중첩 + 공지 리스트/상세 응답 모두 같은 구조. +// import Foundation import Domain -// MeetDetail 응답에 nested로 포함되는 고정 공지 DTO -struct PinnedNoticeResponse: Decodable { +struct NoticeResponse: Decodable { let noticeId: Int? let version: Int? let meetId: Int? @@ -19,8 +21,8 @@ struct PinnedNoticeResponse: Decodable { let createdAt: String? } -extension PinnedNoticeResponse { - func toDomain() -> PinnedNotice { +extension NoticeResponse { + func toDomain() -> Notice { return .init( noticeId: noticeId, version: version, diff --git a/Modules/Domain/Sources/Entities/Meet/Meet.swift b/Modules/Domain/Sources/Entities/Meet/Meet.swift index 0c0bd40f..67487163 100644 --- a/Modules/Domain/Sources/Entities/Meet/Meet.swift +++ b/Modules/Domain/Sources/Entities/Meet/Meet.swift @@ -15,7 +15,7 @@ public struct Meet { public let memberCount: Int? public let firstPlanDate: Date? public let version: Int? - public let pinnedNotice: PinnedNotice? + public let pinnedNotice: Notice? public init(isCreator: Bool = false, meetSummary: MeetSummary? = nil, @@ -24,7 +24,7 @@ public struct Meet { memberCount: Int? = nil, firstPlanDate: Date? = nil, version: Int? = nil, - pinnedNotice: PinnedNotice? = nil) { + pinnedNotice: Notice? = nil) { self.isCreator = isCreator self.meetSummary = meetSummary self.sinceDays = sinceDays diff --git a/Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift b/Modules/Domain/Sources/Entities/Notice/Notice.swift similarity index 78% rename from Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift rename to Modules/Domain/Sources/Entities/Notice/Notice.swift index 8a94b8ee..bbe5228c 100644 --- a/Modules/Domain/Sources/Entities/Notice/PinnedNotice.swift +++ b/Modules/Domain/Sources/Entities/Notice/Notice.swift @@ -1,5 +1,5 @@ // -// PinnedNotice.swift +// Notice.swift // Domain // // Created by CatSlave on 5/26/26. @@ -7,8 +7,9 @@ import Foundation -// MeetDetail 응답에 포함되는 상단 고정 공지. nil이면 표시할 공지 없음. -public struct PinnedNotice { +// 모임 공지. MeetDetail 응답의 pinnedNotice, Notice 리스트 응답의 셀, 공지 상세 모두 같은 구조. +// isPinned 플래그로 고정 여부 표현. +public struct Notice: Hashable { public let noticeId: Int? public let version: Int? public let meetId: Int? diff --git a/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift new file mode 100644 index 00000000..5ab9b6e4 --- /dev/null +++ b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift @@ -0,0 +1,44 @@ +// +// NoticeRepo.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// +// 모임 공지(Notice) + 공지 댓글 API 추상화. +// - 공지 CRUD/고정 토글: /notice/* +// - 공지 댓글 조회/생성: /comment/notice/{noticeId} +// - 공지 댓글 수정/삭제: 일반 CommentRepo.editComment / deleteComment 재사용 (URL 공통) +// + +import Foundation + +public protocol NoticeRepo { + + // MARK: - 공지 CRUD + func fetchNoticeList(meetId: Int, + size: Int?, + cursor: String?) async throws -> Page + + func createNotice(meetId: Int, content: String) async throws -> Notice + + func updateNotice(noticeId: Int, + meetId: Int, + content: String) async throws -> Notice + + func deleteNotice(noticeId: Int) async throws + + // MARK: - 고정 토글 + func pinNotice(noticeId: Int) async throws -> Notice + func unpinNotice(noticeId: Int) async throws -> Notice + + // MARK: - 공지 댓글 + // 게시글 댓글과 별도 endpoint이지만 응답 구조는 동일(Comment)이라 같은 entity로 매핑. + // 댓글 수정/삭제는 CommentRepo의 editComment / deleteComment 재사용. + func fetchNoticeCommentList(noticeId: Int, + size: Int?, + cursor: String?) async throws -> Page + + func createNoticeComment(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment +} diff --git a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift index 5edca626..3d856876 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift @@ -33,7 +33,7 @@ public final class MockFetchMeetDetailUseCase: FetchMeetDetail { // meetId 짝수: 공지 있음 / 홀수: 공지 없음 → 모임장 작성 유도 툴팁 노출 케이스 둘 다 확인 가능 let hasNotice = meetId % 2 == 0 - let mockPinnedNotice: PinnedNotice? = hasNotice ? PinnedNotice( + let mockPinnedNotice: Notice? = hasNotice ? Notice( noticeId: 1, version: 1, meetId: meetId, From bf250d9af0e32850acc1dda96ba50086e358af18 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 16:52:41 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20PageController=20paging=20gesture?= =?UTF-8?q?=EC=99=80=20edge=20gesture=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: PageController dataSource 활성화 후(인터랙티브 swipe 추가) 좌측 edge에서 우측으로 swipe해도 modal dismiss(meet flow 나가기)가 동작 안 함. 원인: PageController 내부 scrollView의 paging pan gesture가 edge gesture보다 먼저 인식되어 페이지 전환만 시도(이전 페이지 없으니 bounce). 수정: 자식 tableView에 이미 적용된 패턴(panGestureRecognizer.require(toFail:))을 PageController 내부 scrollView에도 적용. - configureEdgeGesture()에서 pageController.view.subviews 순회해서 scrollView 찾음 - scrollView.panGestureRecognizer.require(toFail: appNavi.edgeGesture) 결과: - 좌측 edge 영역 swipe → edge gesture 인식 → modal dismiss ✓ - 중앙/우측 영역 swipe → PageController paging + pill 인터랙티브 트래킹 ✓ Refs #37, #60 --- .../Sub/MeetDetail/View/MeetDetailViewController.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index b28102c4..65e9f6f8 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -277,6 +277,15 @@ final class MeetDetailViewController: TitleNaviViewController, View { guard let gestureVC = $0 as? EdgeGestureConfigurable else { return } gestureVC.configureEdgeGesture(appNavi.edgeGesture) } + + // PageController의 paging pan gesture도 edge gesture가 먼저 인식되도록 양보. + // dataSource 활성화 이후 좌측 edge swipe로 modal dismiss가 안 되는 충돌을 해결. + for sv in pageController.view.subviews { + if let scrollView = sv as? UIScrollView { + scrollView.panGestureRecognizer.require(toFail: appNavi.edgeGesture) + break + } + } } }