docs(banner): 배너 컴포넌트 계획을 문서화한다
This commit is contained in:
330
docs/plan-task/20260527_배너컴포넌트.md
Normal file
330
docs/plan-task/20260527_배너컴포넌트.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# 배너 컴포넌트 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Figma `24:5525`와 PRD `docs/prd/20260527_배너컴포넌트_prd.md` 기준으로 자동 전환, 무한 순환, 수동 스와이프, 카운터 오버레이, XML 미리보기를 제공하는 재사용 가능한 Android XML Views 배너 컴포넌트를 추가한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 `BannerViewPager` 래핑 없이 `kr.co.vividnext.sodalive.v2.widget.banner` 하위에 순수 Kotlin 상태/계산 contract와 Android custom view를 분리한다. 배너 UI는 `RecyclerView + PagerSnapHelper` 기반으로 구현하고, 이미지 로딩과 터치 후 이동은 컴포넌트 내부에서 결정하지 않고 호출부 callback 또는 노출된 `ImageView` 바인딩으로 위임한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Android XML Views, Kotlin custom View, RecyclerView, PagerSnapHelper, Handler/Runnable 또는 lifecycle-aware timer, ViewBinding/resource merge, JUnit4 local unit test, Robolectric view test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
- 배너 item은 `1:1` 비율이며 width/height는 `screenWidth - 40dp`로 계산한다.
|
||||||
|
- 현재 배너는 가운데 정렬하고 화면 좌우 `20dp` 기준 여백을 유지한다.
|
||||||
|
- 배너가 2개 이상이면 item 간격 `8dp`와 좌우 이전/다음 배너 일부 노출을 제공한다.
|
||||||
|
- 배너 목록 0개는 숨김, 1개는 단일 이미지, 2개 이상은 카운터/스와이프/자동 전환을 적용한다.
|
||||||
|
- 자동 전환 주기는 5초, 전환 애니메이션 시간은 350ms다.
|
||||||
|
- 마지막 배너 다음은 첫 번째 배너, 첫 번째 배너 이전은 마지막 배너로 이어지는 무한 순환을 제공한다.
|
||||||
|
- 사용자가 직접 스와이프하면 자동 전환 타이머를 초기화한다.
|
||||||
|
- 터치 액션은 호출부 콜백으로 위임한다.
|
||||||
|
- XML layout editor에서 샘플 이미지/placeholder와 샘플 카운터를 미리 볼 수 있어야 한다.
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerItem.kt`
|
||||||
|
- 배너 컴포넌트 전용 UI 모델을 정의한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculator.kt`
|
||||||
|
- 화면 폭, 좌우 여백 `20dp`, item 간격 `8dp` 기준으로 item size와 peek/padding 값을 계산한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatter.kt`
|
||||||
|
- `01 / 20` 형식의 현재 index/전체 count 문자열을 만든다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerState.kt`
|
||||||
|
- item count별 표시 모드, 현재 index 보정, 다음/이전 index 계산을 담당한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt`
|
||||||
|
- RecyclerView item을 생성하고 이미지 view와 클릭 callback을 연결한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
|
||||||
|
- XML custom view 루트로 RecyclerView, counter, timer, lifecycle start/stop을 조합한다.
|
||||||
|
- Create: `app/src/main/res/layout/view_banner.xml`
|
||||||
|
- `BannerView` 루트, 내부 RecyclerView, 우상단 counter overlay를 정의한다.
|
||||||
|
- Create: `app/src/main/res/layout/item_banner.xml`
|
||||||
|
- `1:1` 이미지 item layout을 정의한다.
|
||||||
|
- Create: `app/src/main/res/drawable/bg_banner_counter.xml`
|
||||||
|
- 반투명 검정 capsule counter 배경을 정의한다.
|
||||||
|
- Create if needed: `app/src/main/res/drawable/bg_banner_preview_placeholder.xml`
|
||||||
|
- XML 미리보기용 placeholder 배경을 정의한다. 기존 적절한 placeholder drawable이 있으면 새 파일 대신 재사용한다.
|
||||||
|
- Modify: `app/src/main/res/values/attrs.xml`
|
||||||
|
- 없으면 생성하고, XML preview용 `bannerPreviewItemCount`, `bannerPreviewCurrentIndex`, `bannerPreviewImage` 속성을 정의한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculatorTest.kt`
|
||||||
|
- `screenWidth - 40dp`, `1:1`, `8dp` 간격 계산을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatterTest.kt`
|
||||||
|
- 두 자리 counter 형식을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerStateTest.kt`
|
||||||
|
- 0/1/2개 이상 표시 모드와 무한 순환 index 계산을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
|
||||||
|
- Robolectric으로 visibility, counter, adapter click, preview 속성 반영을 검증한다.
|
||||||
|
- Modify: `docs/agent-guides/build-test-style.md`
|
||||||
|
- 신규 배너 테스트 단일 실행 예시를 추가한다.
|
||||||
|
- Modify: `docs/plan-task/20260527_배너컴포넌트.md`
|
||||||
|
- 구현 중 체크박스와 검증 기록을 누적한다.
|
||||||
|
|
||||||
|
## 구현 계획
|
||||||
|
|
||||||
|
### Phase 1: 기준 확인 및 구현 경계 확정
|
||||||
|
|
||||||
|
- [x] **Task 1.1: PRD와 Figma 기준 재확인**
|
||||||
|
- 확인 파일: `docs/prd/20260527_배너컴포넌트_prd.md`
|
||||||
|
- Figma 기준: `24:5525`
|
||||||
|
- 확인 항목: `1:1` item 비율, `screenWidth - 40dp`, 좌우 `20dp`, item 간격 `8dp`, 좌우 peek, radius `14dp`, counter 위치 `top/right 14dp`, counter text `01 / 20`.
|
||||||
|
- 검증: PRD의 Goals, Visual Requirements, Behavior Requirements, Metrics가 계획의 phase/task에 모두 매핑되는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 기존 v2 custom view와 테스트 패턴 확인**
|
||||||
|
- 확인 파일:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt`
|
||||||
|
- `app/src/main/res/values/colors.xml`
|
||||||
|
- `app/src/main/res/values/dimens.xml`
|
||||||
|
- `app/src/main/res/values/typography.xml`
|
||||||
|
- 검증: `@JvmOverloads` custom view, `ImageView` 노출, Robolectric inflate test, 기존 token 이름을 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.3: RecyclerView 기반 배너 구현 경계 확정**
|
||||||
|
- 구현 원칙: 기존 `BannerViewPager`는 래핑하거나 확장하지 않는다.
|
||||||
|
- 구현 방식: `RecyclerView` horizontal layout + `PagerSnapHelper` + adapter virtual position 또는 index remap으로 무한 순환을 구현한다.
|
||||||
|
- 검증: `app/build.gradle`에 `androidx.recyclerview:recyclerview` 의존성이 있으므로 신규 pager 의존성을 추가하지 않는다.
|
||||||
|
|
||||||
|
### Phase 2: 순수 Kotlin contract와 단위 테스트
|
||||||
|
|
||||||
|
- [ ] **Task 2.1: `BannerItem` 모델 추가**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerItem.kt`
|
||||||
|
- 요구사항: `bannerId: String?`, `imageUrl: String`만 포함한다.
|
||||||
|
- 검증: 서버 DTO, 딥링크 타입, 외부 URL 타입을 모델에 포함하지 않는다.
|
||||||
|
|
||||||
|
- [ ] **Task 2.2: `BannerLayoutCalculatorTest` 작성**
|
||||||
|
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculatorTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- 화면 폭 `402dp`이면 item width/height는 `362dp`.
|
||||||
|
- 화면 폭 `360dp`이면 item width/height는 `320dp`.
|
||||||
|
- item spacing은 항상 `8dp`.
|
||||||
|
- horizontal side inset 기준값은 `20dp`.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest"`
|
||||||
|
- 기대: 구현 전 실패, 구현 후 성공.
|
||||||
|
|
||||||
|
- [ ] **Task 2.3: `BannerLayoutCalculator` 구현**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculator.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- `screenWidthPx`, `density` 또는 dp 변환 가능한 입력을 받아 item width/height를 계산한다.
|
||||||
|
- side inset은 `20dp`, item spacing은 `8dp`로 고정한다.
|
||||||
|
- item height는 item width와 동일하다.
|
||||||
|
- 검증: `BannerLayoutCalculatorTest`가 통과한다.
|
||||||
|
|
||||||
|
- [ ] **Task 2.4: `BannerCounterFormatterTest` 작성**
|
||||||
|
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatterTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- current index `0`, count `20`이면 `01 / 20`.
|
||||||
|
- current index `9`, count `20`이면 `10 / 20`.
|
||||||
|
- count `1`이어도 formatter 자체는 `01 / 01`을 만들 수 있지만 view에서는 1개일 때 counter를 숨긴다.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest"`
|
||||||
|
- 기대: 구현 전 실패, 구현 후 성공.
|
||||||
|
|
||||||
|
- [ ] **Task 2.5: `BannerCounterFormatter` 구현**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatter.kt`
|
||||||
|
- 요구사항: 현재 index는 0-based 입력을 받고 표시값은 1-based 두 자리 문자열로 변환한다.
|
||||||
|
- 검증: `BannerCounterFormatterTest`가 통과한다.
|
||||||
|
|
||||||
|
- [ ] **Task 2.6: `BannerStateTest` 작성**
|
||||||
|
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerStateTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- count 0은 `Hidden` 모드다.
|
||||||
|
- count 1은 `Single` 모드이며 swipe/auto/counter 대상이 아니다.
|
||||||
|
- count 2 이상은 `Carousel` 모드다.
|
||||||
|
- 마지막 index의 next는 0이다.
|
||||||
|
- 첫 번째 index의 previous는 마지막 index다.
|
||||||
|
- 목록 갱신으로 current index가 범위를 벗어나면 0으로 보정한다.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest"`
|
||||||
|
- 기대: 구현 전 실패, 구현 후 성공.
|
||||||
|
|
||||||
|
- [ ] **Task 2.7: `BannerState` 구현**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerState.kt`
|
||||||
|
- 요구사항: 표시 모드, 현재 index 보정, next/previous 계산을 Android view 의존성 없이 구현한다.
|
||||||
|
- 검증: `BannerStateTest`가 통과한다.
|
||||||
|
|
||||||
|
### Phase 3: XML 리소스와 디자인 타임 미리보기
|
||||||
|
|
||||||
|
- [ ] **Task 3.1: counter 배경 drawable 추가**
|
||||||
|
- 생성 파일: `app/src/main/res/drawable/bg_banner_counter.xml`
|
||||||
|
- 요구사항:
|
||||||
|
- solid color는 `#B3000000` 또는 동등한 `rgba(0,0,0,0.7)` 표현을 사용한다.
|
||||||
|
- corners radius는 capsule 형태로 충분히 큰 값을 사용한다.
|
||||||
|
- 검증: resource merge 시 drawable이 정상 참조된다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.2: preview placeholder drawable 추가 또는 재사용 결정**
|
||||||
|
- 생성 후보: `app/src/main/res/drawable/bg_banner_preview_placeholder.xml`
|
||||||
|
- 요구사항:
|
||||||
|
- XML layout editor에서 이미지 데이터 없이도 배너 영역이 보인다.
|
||||||
|
- 기존 적절한 placeholder drawable이 있으면 새 drawable을 만들지 않고 해당 리소스를 사용한다.
|
||||||
|
- 검증: `item_banner.xml`의 `tools:src` 또는 `android:background`에서 참조 가능하다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.3: preview attrs 추가**
|
||||||
|
- 생성 또는 수정 파일: `app/src/main/res/values/attrs.xml`
|
||||||
|
- 추가 속성:
|
||||||
|
- `bannerPreviewItemCount` format `integer`
|
||||||
|
- `bannerPreviewCurrentIndex` format `integer`
|
||||||
|
- `bannerPreviewImage` format `reference`
|
||||||
|
- 검증: `BannerView`에서 `context.obtainStyledAttributes(attrs, R.styleable.BannerView)`로 읽을 수 있다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.4: `item_banner.xml` 추가**
|
||||||
|
- 생성 파일: `app/src/main/res/layout/item_banner.xml`
|
||||||
|
- 요구사항:
|
||||||
|
- root는 `FrameLayout`.
|
||||||
|
- 내부 `ImageView`는 `match_parent`, `centerCrop`, `contentDescription="@null"`.
|
||||||
|
- root와 image는 `14dp` radius clip 대상이 될 수 있어야 한다.
|
||||||
|
- 검증: XML preview에서 정사각형 item 내부 이미지 영역이 확인된다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.5: `view_banner.xml` 추가**
|
||||||
|
- 생성 파일: `app/src/main/res/layout/view_banner.xml`
|
||||||
|
- 요구사항:
|
||||||
|
- root는 `kr.co.vividnext.sodalive.v2.widget.banner.BannerView`.
|
||||||
|
- 내부 `RecyclerView`는 horizontal carousel 용도로 배치하고 `clipToPadding=false`, `clipChildren=false`를 적용한다.
|
||||||
|
- counter container는 우상단 `14dp` margin, `bg_banner_counter`, padding horizontal `6dp`, vertical `4dp`.
|
||||||
|
- counter는 현재 index, `/`, total count를 분리된 TextView 또는 span으로 표현해 현재 index white, 나머지 `gray_400`을 적용한다.
|
||||||
|
- `tools:` 속성으로 샘플 counter와 preview image가 보이게 한다.
|
||||||
|
- 검증: XML preview에서 배너 형태, radius, counter 위치가 확인된다.
|
||||||
|
|
||||||
|
### Phase 4: Adapter와 View 구현
|
||||||
|
|
||||||
|
- [ ] **Task 4.1: `BannerAdapter` 구현**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- `RecyclerView.Adapter`로 구현한다.
|
||||||
|
- item count 2개 이상에서는 virtual position을 실제 item index로 remap해 무한 순환에 필요한 충분한 scroll range를 제공한다.
|
||||||
|
- item count 0/1개에서는 실제 count만 반환한다.
|
||||||
|
- `ImageView`는 호출부가 이미지 로딩을 연결할 수 있도록 bind callback으로 전달한다.
|
||||||
|
- item click은 현재 `BannerItem`을 호출부 callback으로 전달한다.
|
||||||
|
- 검증: adapter 단위 또는 view test에서 click callback이 현재 item을 전달한다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.2: `BannerView` 기본 구조 구현**
|
||||||
|
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- `@JvmOverloads constructor(context, attrs, defStyleAttr)` 패턴을 사용한다.
|
||||||
|
- `view_banner.xml`을 inflate하고 내부 RecyclerView/counter view를 찾는다.
|
||||||
|
- `setItems(items: List<BannerItem>)` API를 제공한다.
|
||||||
|
- `setOnBannerClickListener(listener: ((BannerItem) -> Unit)?)` API를 제공한다.
|
||||||
|
- `setOnBindBannerImage(listener: ((ImageView, BannerItem) -> Unit)?)` 또는 동등한 이미지 바인딩 API를 제공한다.
|
||||||
|
- 검증: 빈 목록, 단일 목록, 복수 목록을 설정해 visibility와 counter 상태가 바뀐다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.3: layout size와 peek 적용**
|
||||||
|
- 수정 파일: `BannerView.kt`, `BannerAdapter.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- view width를 기준으로 item size를 `width - 40dp`로 계산한다.
|
||||||
|
- item layout params는 width/height 동일 값으로 설정한다.
|
||||||
|
- RecyclerView horizontal padding 또는 item decoration을 조합해 현재 item 가운데 정렬, item 간격 `8dp`, 좌우 peek 노출을 적용한다.
|
||||||
|
- 단일 item은 peek 없이 가운데 정렬한다.
|
||||||
|
- 검증: `BannerLayoutCalculatorTest`와 `BannerViewTest`에서 item size, spacing, counter visibility를 확인한다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.4: counter 표시 구현**
|
||||||
|
- 수정 파일: `BannerView.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- 0개/1개에서는 counter를 숨긴다.
|
||||||
|
- 2개 이상에서는 `BannerCounterFormatter` 결과를 표시한다.
|
||||||
|
- 현재 index는 white, slash와 total count는 `gray_400`을 적용한다.
|
||||||
|
- scroll settle 후 실제 item index에 맞춰 counter를 갱신한다.
|
||||||
|
- 검증: 빠른 scroll 후에도 counter index가 현재 item과 일치한다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.5: radius clipping 구현**
|
||||||
|
- 수정 파일: `BannerView.kt` 또는 item view holder
|
||||||
|
- 요구사항:
|
||||||
|
- 배너 image/root에 `radius_14` outline provider를 적용한다.
|
||||||
|
- Android XML preview와 runtime 모두에서 corner radius가 적용된다.
|
||||||
|
- 검증: Robolectric에서는 outline provider 설정 여부를 확인하고, 수동 확인에서는 radius clipping을 확인한다.
|
||||||
|
|
||||||
|
### Phase 5: 자동 전환, 수동 스와이프, lifecycle
|
||||||
|
|
||||||
|
- [ ] **Task 5.1: 자동 전환 timer 구현**
|
||||||
|
- 수정 파일: `BannerView.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- 배너 2개 이상일 때만 5초 timer를 시작한다.
|
||||||
|
- timer tick 시 다음 배너 방향으로 이동한다.
|
||||||
|
- 전환 애니메이션 시간은 350ms를 기준으로 한다.
|
||||||
|
- 검증: 테스트 가능한 timer wrapper를 두거나 Robolectric scheduler로 자동 전환 호출을 검증한다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.2: 무한 순환 이동 구현**
|
||||||
|
- 수정 파일: `BannerView.kt`, `BannerAdapter.kt`, `BannerState.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- 마지막 배너 다음 방향은 첫 번째 배너로 이어진다.
|
||||||
|
- 첫 번째 배너 이전 방향은 마지막 배너로 이어진다.
|
||||||
|
- virtual position을 사용하는 경우 실제 index와 counter index가 항상 일치한다.
|
||||||
|
- 검증: `BannerStateTest`와 `BannerViewTest`에서 next/previous remap을 확인한다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.3: 수동 스와이프 timer reset 구현**
|
||||||
|
- 수정 파일: `BannerView.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- RecyclerView scroll state가 사용자 drag로 시작되면 자동 전환 예약을 취소한다.
|
||||||
|
- scroll settle 후 현재 시점부터 5초 timer를 다시 예약한다.
|
||||||
|
- 검증: 수동 scroll 이벤트 시 timer reset 메서드가 호출되는지 view test에서 확인한다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.4: attach/detach lifecycle 정리**
|
||||||
|
- 수정 파일: `BannerView.kt`
|
||||||
|
- 요구사항:
|
||||||
|
- `onAttachedToWindow()`에서 조건에 맞으면 timer를 시작한다.
|
||||||
|
- `onDetachedFromWindow()`에서 timer callback을 제거한다.
|
||||||
|
- item count가 0/1개로 바뀌면 timer를 중지한다.
|
||||||
|
- 검증: detach 후 pending callback이 남지 않도록 테스트하거나 코드 리뷰 체크리스트에 명시한다.
|
||||||
|
|
||||||
|
### Phase 6: 상태별 view test와 회귀 방지
|
||||||
|
|
||||||
|
- [ ] **Task 6.1: `BannerViewTest` 기본 상태 테스트 작성**
|
||||||
|
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- empty items 설정 시 root visibility는 `GONE`.
|
||||||
|
- one item 설정 시 root visibility는 `VISIBLE`, counter는 `GONE`.
|
||||||
|
- two items 설정 시 counter는 `VISIBLE`.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
|
||||||
|
|
||||||
|
- [ ] **Task 6.2: `BannerViewTest` preview 속성 테스트 작성**
|
||||||
|
- 수정 파일: `BannerViewTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- edit mode 또는 preview 속성 처리 경로에서 sample count/current index가 counter에 반영된다.
|
||||||
|
- preview image 속성이 있으면 placeholder/image view에 적용된다.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
|
||||||
|
|
||||||
|
- [ ] **Task 6.3: `BannerViewTest` click callback 테스트 작성**
|
||||||
|
- 수정 파일: `BannerViewTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- 첫 번째 배너 클릭 시 첫 번째 `BannerItem`이 callback으로 전달된다.
|
||||||
|
- click listener가 null이어도 클릭으로 crash가 발생하지 않는다.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
|
||||||
|
|
||||||
|
- [ ] **Task 6.4: `BannerViewTest` layout 계산 연결 테스트 작성**
|
||||||
|
- 수정 파일: `BannerViewTest.kt`
|
||||||
|
- 테스트 항목:
|
||||||
|
- 측정된 width 기준 item layout params가 정사각형으로 설정된다.
|
||||||
|
- 복수 item일 때 item decoration 또는 padding이 `8dp` spacing과 좌우 peek 조건을 반영한다.
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
|
||||||
|
|
||||||
|
### Phase 7: 문서, 검증, 마무리
|
||||||
|
|
||||||
|
- [ ] **Task 7.1: 테스트 실행 예시 문서 갱신**
|
||||||
|
- 수정 파일: `docs/agent-guides/build-test-style.md`
|
||||||
|
- 추가 내용: 배너 컴포넌트 단일 테스트 실행 예시.
|
||||||
|
- 예시 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`
|
||||||
|
- 검증: 기존 문서 형식과 중복되지 않게 최소 변경한다.
|
||||||
|
|
||||||
|
- [ ] **Task 7.2: 단위 테스트 실행**
|
||||||
|
- 실행:
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest"`
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest"`
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest"`
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
|
||||||
|
- 기대 결과: 모두 `BUILD SUCCESSFUL`.
|
||||||
|
|
||||||
|
- [ ] **Task 7.3: 리소스/빌드 검증**
|
||||||
|
- 실행:
|
||||||
|
- `./gradlew :app:assembleDebug`
|
||||||
|
- 필요 시 `./gradlew :app:ktlintCheck`
|
||||||
|
- 기대 결과: resource merge, Kotlin compile, ktlint가 성공한다.
|
||||||
|
|
||||||
|
- [ ] **Task 7.4: 수동 확인 항목 기록**
|
||||||
|
- 확인 항목:
|
||||||
|
- XML layout editor에서 배너 preview, radius, counter 위치가 보인다.
|
||||||
|
- 1개 배너는 정사각형 단일 이미지로 가운데 정렬된다.
|
||||||
|
- 2개 이상 배너는 좌우 이전/다음 배너 일부가 보인다.
|
||||||
|
- 자동 전환은 5초 주기로 동작한다.
|
||||||
|
- 수동 스와이프 후 자동 전환 timer가 다시 5초부터 시작한다.
|
||||||
|
- 마지막/첫 번째 경계에서 무한 순환이 자연스럽다.
|
||||||
|
- 기록 위치: 이 문서 하단 `검증 기록` 섹션.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-05-27 Phase 1 완료: PRD `docs/prd/20260527_배너컴포넌트_prd.md`의 Goals, Visual Requirements, Behavior Requirements, Metrics가 계획의 Phase 2~7 task로 매핑됨을 확인했다. Figma 기준 `24:5525`에서 계획에 명시된 `1:1`, `screenWidth - 40dp`, 좌우 `20dp`, item 간격 `8dp`, 좌우 peek, `radius_14`, counter `top/right 14dp`, `01 / 20` 형식과 불일치하는 항목은 발견하지 못했다.
|
||||||
|
- 2026-05-27 Phase 1 패턴 확인: `AudioContentCardView`의 `@JvmOverloads` custom view, `thumbnailView(): ImageView` 노출, `radius_14` outline clipping 패턴을 확인했다. `FeedAdapter`의 `RecyclerView.Adapter`, click callback, image binding callback 패턴과 `FeedViewTest`의 Robolectric XML inflate test 패턴을 확인했다.
|
||||||
|
- 2026-05-27 Phase 1 리소스/의존성 확인: `colors.xml`에 `white`, `gray_400`, `dimens.xml`에 `spacing_8`, `spacing_14`, `spacing_20`, `radius_14`, `typography.xml`에 `Typography.Body5` medium 14sp 토큰이 있음을 확인했다. `attrs.xml`은 현재 없어 Phase 3에서 생성 대상이다.
|
||||||
|
- 2026-05-27 Phase 1 구현 경계 확정: `app/build.gradle`에 `androidx.recyclerview:recyclerview:1.4.0`, JUnit4, Robolectric 의존성이 이미 있으므로 신규 pager 의존성은 추가하지 않는다. 기존 `com.github.zhpanvip:bannerviewpager:3.5.7` 의존성은 존재하지만 이번 컴포넌트는 PRD/계획대로 래핑하거나 확장하지 않는다.
|
||||||
164
docs/prd/20260527_배너컴포넌트_prd.md
Normal file
164
docs/prd/20260527_배너컴포넌트_prd.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# PRD: 배너 컴포넌트
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
Figma `24:5525` 디자인을 기준으로 이미지 배너를 자동 전환, 수동 스와이프, 카운터 오버레이, 터치 콜백과 함께 제공하는 Android XML Views 기반 재사용 컴포넌트를 개발한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 여러 화면에서 이미지 배너를 사용할 때 자동 전환 주기, 전환 시간, 카운터 표기, 무한 순환 동작이 화면별로 달라질 수 있다.
|
||||||
|
- 배너 터치 동작은 화면마다 달라질 수 있으므로 UI 컴포넌트가 이동 목적지나 액션 타입을 직접 알면 재사용성이 낮아진다.
|
||||||
|
- XML 레이아웃에서 배너를 배치할 때 런타임 데이터가 없어도 레이아웃 에디터에서 크기와 기본 형태를 미리 확인할 수 있어야 한다.
|
||||||
|
- 기존 배너 구현은 특정 화면과 외부 라이브러리 사용 방식에 묶여 있어 신규 `v2` 공용 컴포넌트 계약으로 바로 재사용하기 어렵다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- `kr.co.vividnext.sodalive.v2.widget.banner` 하위에 신규 공용 배너 컴포넌트를 정의한다.
|
||||||
|
- Figma `24:5525` 기준의 이미지 카드, `14dp` radius, 우상단 `01 / 20` 형태의 카운터 오버레이를 제공한다.
|
||||||
|
- 배너 아이템은 `1:1` 비율로 표시하고, 아이템 width는 화면 width에서 좌우 여백 `20dp`씩을 제외한 값으로 계산한다.
|
||||||
|
- 배너가 여러 개일 때 아이템 사이 간격은 `8dp`이며, 현재 배너 좌우 빈 공간에는 이전/다음 배너가 약간 보여야 한다.
|
||||||
|
- 배너 목록이 2개 이상이면 5초마다 자동으로 다음 배너로 전환한다.
|
||||||
|
- 배너 전환 애니메이션 시간은 350ms를 기준으로 한다.
|
||||||
|
- 마지막 배너에서 동일한 방향으로 스와이프하면 첫 번째 배너가 보이는 무한 순환 동작을 제공한다.
|
||||||
|
- 사용자가 직접 스와이프하면 자동 전환 타이머를 초기화한다.
|
||||||
|
- 호출부는 배너 아이템별 터치 액션을 콜백으로 연결할 수 있어야 한다.
|
||||||
|
- XML에서 배치할 때 디자인 타임 미리보기가 가능해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위에서는 서버 API, DTO, 딥링크 스키마, 이벤트 추적 스키마를 정의하지 않는다.
|
||||||
|
- 기존 화면의 배너를 신규 컴포넌트로 일괄 교체하지 않는다.
|
||||||
|
- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다.
|
||||||
|
- Yandex 광고 배너와 같은 광고 SDK 배너는 이번 컴포넌트 범위에 포함하지 않는다.
|
||||||
|
- 기존 `com.zhpan.bannerview.BannerViewPager` 래핑 가능성은 검토하지 않는다.
|
||||||
|
- Figma에 없는 제목, 설명, CTA 버튼, 페이지 dot indicator, skeleton loading, shimmer, 별도 pressed animation은 추가하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 앱 내 배너를 확인하고 터치하는 사용자.
|
||||||
|
- XML 레이아웃에서 재사용 가능한 배너 UI를 배치하고 데이터와 클릭 동작을 연결하는 Android 개발자.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 여러 개의 배너를 자동 전환과 스와이프로 자연스럽게 탐색하고 싶다.
|
||||||
|
- 사용자는 마지막 배너 이후에도 같은 방향 스와이프로 첫 번째 배너를 이어서 볼 수 있기를 기대한다.
|
||||||
|
- 사용자는 현재 보고 있는 배너가 전체 중 몇 번째인지 우상단 카운터로 확인하고 싶다.
|
||||||
|
- 개발자는 화면별 이동 정책을 컴포넌트 밖에서 콜백으로 연결하고 싶다.
|
||||||
|
- 개발자는 XML 작성 시 실제 데이터가 없어도 배너의 크기와 형태를 미리 확인하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Banner Component
|
||||||
|
Figma `24:5525` 기준의 이미지 배너를 XML + Kotlin custom view 기반 공용 컴포넌트로 제공한다.
|
||||||
|
|
||||||
|
#### Figma Reference
|
||||||
|
- Banner: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-5525&m=dev
|
||||||
|
|
||||||
|
#### Visual Requirements
|
||||||
|
- 기본 배너 비율은 `1:1` 정사각형이다.
|
||||||
|
- 기본 배너 item width는 `screenWidth - 40dp`로 계산한다.
|
||||||
|
- 배너 item height는 계산된 item width와 동일하게 적용한다.
|
||||||
|
- 현재 배너 item은 부모 영역의 가로 가운데에 정렬한다.
|
||||||
|
- 현재 배너 item은 화면 왼쪽에서 `20dp`, 오른쪽에서 `20dp` 떨어진 위치를 기준으로 표시한다.
|
||||||
|
- 배너가 여러 개이면 item 사이 간격은 `8dp`다.
|
||||||
|
- 배너가 여러 개이면 현재 배너 좌우 빈 공간에 이전/다음 배너 일부가 보여야 한다.
|
||||||
|
- 배너 이미지는 전체 영역을 채우며 `centerCrop` 기준으로 표시한다.
|
||||||
|
- 배너 root와 이미지 영역은 `radius_14` 또는 `14dp` radius를 적용한다.
|
||||||
|
- 카운터는 배너 우상단에 배치한다.
|
||||||
|
- 카운터 위치는 상단 `14dp`, 오른쪽 `14dp`를 기준으로 한다.
|
||||||
|
- 카운터 배경은 `rgba(0,0,0,0.7)`에 가까운 반투명 검정, capsule radius를 적용한다.
|
||||||
|
- 카운터 내부 padding은 horizontal `6dp`, vertical `4dp`를 기준으로 한다.
|
||||||
|
- 카운터 텍스트는 Pretendard Medium, `14sp`, line-height `1.45`를 기준으로 한다.
|
||||||
|
- 현재 index는 white, 구분자 `/`와 전체 count는 `gray_400` 계열 색상을 사용한다.
|
||||||
|
- 현재 index와 전체 count는 두 자리 형식으로 표시한다. 예: `01 / 20`.
|
||||||
|
|
||||||
|
#### Behavior Requirements
|
||||||
|
- 배너 목록이 0개이면 컴포넌트 영역은 표시하지 않는다.
|
||||||
|
- 배너 목록이 1개이면 자동 전환, 수동 스와이프, 카운터를 사용하지 않고 단일 이미지만 표시한다.
|
||||||
|
- 배너 목록이 1개이면 좌우 peek 영역 없이 단일 배너를 가운데 정렬한다.
|
||||||
|
- 배너 목록이 2개 이상이면 카운터를 표시하고 자동 전환을 시작한다.
|
||||||
|
- 배너 목록이 2개 이상이면 이전/다음 배너가 좌우에 일부 보이는 상태를 유지한다.
|
||||||
|
- 자동 전환 주기는 5초다.
|
||||||
|
- 전환 애니메이션 시간은 350ms다.
|
||||||
|
- 자동 전환 방향은 다음 배너 방향으로 고정한다.
|
||||||
|
- 마지막 배너 자동 전환 후에는 첫 번째 배너가 표시되어야 한다.
|
||||||
|
- 마지막 배너에서 사용자가 다음 방향으로 스와이프하면 첫 번째 배너가 표시되어야 한다.
|
||||||
|
- 첫 번째 배너에서 사용자가 이전 방향으로 스와이프하면 마지막 배너가 표시되어야 한다.
|
||||||
|
- 사용자가 직접 스와이프하면 현재 시점부터 자동 전환 타이머를 다시 5초로 초기화한다.
|
||||||
|
- 컴포넌트가 화면에서 detach되거나 lifecycle이 정지되면 자동 전환 타이머가 중지되어야 한다.
|
||||||
|
- 컴포넌트가 다시 표시되고 배너 목록이 2개 이상이면 자동 전환 타이머를 재개할 수 있어야 한다.
|
||||||
|
|
||||||
|
#### Data Contract Requirements
|
||||||
|
- 배너 컴포넌트 전용 UI 모델을 정의한다.
|
||||||
|
- 최소 데이터는 다음 정보를 포함한다.
|
||||||
|
- `bannerId`: 호출부에서 식별에 사용할 수 있는 선택적 id.
|
||||||
|
- `imageUrl`: 배너 이미지 URL.
|
||||||
|
- 서버 응답 DTO, 딥링크 타입, 콘텐츠 상세 타입, 외부 URL 타입은 배너 컴포넌트 모델에 포함하지 않는다.
|
||||||
|
- 배너 터치 시 컴포넌트는 현재 배너 UI 모델을 호출부 콜백으로 전달한다.
|
||||||
|
- 이미지 로딩 실패, placeholder, retry 정책은 호출 화면 또는 이미지 로딩 계층 정책을 따른다.
|
||||||
|
|
||||||
|
#### XML Preview Requirements
|
||||||
|
- XML layout editor에서 배너 컴포넌트의 기본 형태를 미리 볼 수 있어야 한다.
|
||||||
|
- 디자인 타임에는 샘플 이미지 또는 placeholder 배경과 샘플 카운터가 표시되어야 한다.
|
||||||
|
- `tools:` 속성 또는 커스텀 preview 속성으로 샘플 count와 current index를 확인할 수 있어야 한다.
|
||||||
|
- 런타임 데이터가 없어도 XML 미리보기에서 배너 높이, radius, 카운터 위치가 확인되어야 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `imageUrl`이 비어 있으면 호출부 이미지 로딩 정책에 따라 placeholder 또는 빈 이미지로 표시한다.
|
||||||
|
- 배너 목록 갱신 시 현재 index가 새 목록 범위를 벗어나면 첫 번째 배너로 보정한다.
|
||||||
|
- 배너 목록 갱신 후 0개가 되면 컴포넌트 영역을 숨기고 자동 전환 타이머를 중지한다.
|
||||||
|
- 배너 목록 갱신 후 1개가 되면 첫 번째 이미지만 표시하고 자동 전환 타이머를 중지한다.
|
||||||
|
- 배너 목록 갱신 후 2개 이상이 되면 첫 번째 배너 기준으로 카운터와 자동 전환을 다시 시작한다.
|
||||||
|
- 빠른 연속 스와이프 중에도 카운터는 실제 표시 중인 배너 index와 일치해야 한다.
|
||||||
|
- 터치 콜백이 등록되지 않은 경우 배너 터치가 앱을 크래시시키지 않아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- 배너는 이미지 중심 컴포넌트이며 텍스트 정보는 카운터만 표시한다.
|
||||||
|
- 배너 radius와 카운터 위치는 Figma와 일관되게 유지한다.
|
||||||
|
- 배너는 화면 좌우 `20dp` 여백을 기준으로 가운데 정렬되고, 정사각형 비율을 유지해야 한다.
|
||||||
|
- 여러 배너를 표시할 때는 `8dp` 간격과 좌우 peek 노출로 다음/이전 콘텐츠가 있음을 사용자가 알 수 있어야 한다.
|
||||||
|
- 카운터는 이미지 위에서 읽히되 배너 콘텐츠를 과도하게 가리지 않아야 한다.
|
||||||
|
- 자동 전환은 사용자의 수동 스와이프보다 우선하지 않아야 하며, 사용자가 조작한 직후에는 타이머가 초기화되어야 한다.
|
||||||
|
- 무한 순환은 사용자가 끝에 도달했다는 끊김 없이 자연스럽게 동작해야 한다.
|
||||||
|
- 배너 터치 가능 여부는 호출부 콜백 등록 여부와 화면 정책을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 현재 프로젝트는 Android XML Views + Kotlin custom View + ViewBinding/resource 기반이므로 XML layout과 Kotlin custom view 패턴을 우선한다.
|
||||||
|
- 신규 Kotlin 코드는 `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner` 하위 패키지에 작성한다.
|
||||||
|
- 색상, spacing, radius, typography는 기존 `colors.xml`, `dimens.xml`, `typography.xml` token을 우선 재사용한다.
|
||||||
|
- 기존 `BannerViewPager` 래핑 없이 신규 컴포넌트 자체 구현으로 진행한다.
|
||||||
|
- 이미지 로딩 라이브러리를 컴포넌트 내부에 고정하지 않고, 호출부가 이미지 로딩을 연결할 수 있는 `ImageView` 접근 또는 바인딩 계약을 제공한다.
|
||||||
|
- 자동 전환 타이머는 view lifecycle에 맞춰 누수 없이 시작/중지되어야 한다.
|
||||||
|
- 기존 화면 파일은 요청 없이 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- Figma `24:5525` 기준의 `14dp` radius와 우상단 카운터가 구현된다.
|
||||||
|
- 배너 item은 `1:1` 비율이며 width와 height가 `screenWidth - 40dp`로 계산된다.
|
||||||
|
- 현재 배너는 가운데 정렬되고 화면 좌우에 각각 `20dp` 기준 여백을 가진다.
|
||||||
|
- 배너 목록 2개 이상에서는 item 간격 `8dp`와 좌우 이전/다음 배너 일부 노출이 적용된다.
|
||||||
|
- 배너 목록 0개에서는 컴포넌트 영역이 숨겨진다.
|
||||||
|
- 배너 목록 1개에서는 단일 이미지만 표시되고 자동 전환, 스와이프, 카운터가 비활성화된다.
|
||||||
|
- 배너 목록 2개 이상에서는 5초 자동 전환과 350ms 전환 애니메이션이 동작한다.
|
||||||
|
- 마지막 배너 다음에는 첫 번째 배너가 표시되고, 첫 번째 배너 이전에는 마지막 배너가 표시된다.
|
||||||
|
- 수동 스와이프 후 자동 전환 타이머가 5초로 초기화된다.
|
||||||
|
- 배너 터치 시 호출부 콜백으로 현재 배너 아이템이 전달된다.
|
||||||
|
- XML layout editor에서 배너 기본 형태와 샘플 카운터를 미리 볼 수 있다.
|
||||||
|
- 관련 unit test 또는 Robolectric/view 로직 테스트와 Android resource merge/build가 성공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 사용자 요청이 “PRD 문서 작성”이므로 이번 작업에서는 구현 파일과 계획/TASK 문서를 만들지 않는다.
|
||||||
|
- 실제 이미지 placeholder 리소스는 구현 계획 단계에서 기존 리소스 재사용 여부를 확인해 결정한다.
|
||||||
Reference in New Issue
Block a user