Skip to content

feat: MeetDetail UI 리디자인 + 공지 Flow 진입점 연결#62

Open
Dongju3079 wants to merge 16 commits into
developfrom
feat/#60-meetdetail-redesign
Open

feat: MeetDetail UI 리디자인 + 공지 Flow 진입점 연결#62
Dongju3079 wants to merge 16 commits into
developfrom
feat/#60-meetdetail-redesign

Conversation

@Dongju3079

Copy link
Copy Markdown
Collaborator

Summary

모임 상세 화면 UI 리디자인과 공지(Notice) Flow의 진입점을 연결.

  • MeetDetail UI 리디자인: 모임 이미지·이름을 본문 카드 → 커스텀 네비 중앙으로 이동, 알약(pill) 토글, 확성기 + 배지, 작성 유도 말풍선
  • Sticky 헤더 (트위터 스타일): 공지만 transform으로 사라지고 약속 탭은 navi 아래 sticky
  • PageController swipe ↔ pill 인터랙티브 트래킹 (실험)
  • Notice 도메인 정비: PinnedNoticeNotice rename + NoticeRepo 프로토콜 신설 + Meet.pinnedNotice/Meet.version 필드 추가
  • 공지 Flow 진입점: NoticeFlowCoordinator + 화면 3개(상세/리스트/작성) placeholder 연결

Base

develop에서 분기. 공지 본 구현은 위에 쌓인 #61에 분리되어 있어, #61이 이 브랜치에 먼저 머지된 뒤 같이 develop으로 들어오는 흐름.

Test plan

  • 모임 상세 진입 — 새 네비 레이아웃 / pill 세그먼트 / 작성 유도 말풍선(공지 없음 + 모임장 시)
  • 스크롤 — 공지 카드는 사라지고 약속 pill은 navi 아래 sticky
  • 확성기 버튼 → 공지 리스트(placeholder 동작 / feat: 공지 리스트/상세/작성 본 구현 #61 머지 후엔 본 구현)
  • 공지 미리보기 카드 → 공지 상세(placeholder / 본 구현)
  • PageController 좌우 swipe ↔ pill 인터랙티브 트래킹
  • 다크/라이트 모드 양쪽 정상

Closes #60

Dongju3079 added 16 commits May 26, 2026 10:08
서버 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
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
새 디자인(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
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
자식 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
이전 구현은 헤더와 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
이전 동작: 첫 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
자식 attach 시점 또는 noticePreviewContainer.isHidden 변동 직후
headerContainer.bounds.height가 아직 stale(0 또는 이전 값)일 수 있어
잘못된 inset이 propagate되던 문제 보완.

## 변경
- attachChildScrollObservers: setNeedsLayout 대신 view.layoutIfNeeded() 강제 호출 후 propagate
- propagateHeaderInsetIfNeeded: 측정 직전 headerContainer.layoutIfNeeded() 호출

Refs #37, #60
이전 동작: 공지 + 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
이전: 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
이전: 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
## 버그
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
## 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
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
공지 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
문제: 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant