Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. Walkthrough감정 데이터 관리 시스템을 재설계합니다. API 엔드포인트의 baseURL을 버전별로 분리하고, 감정 관련 저장소와 Use Case를 추가합니다. 기록 데이터 구조를 변경하여 primaryEmotion과 detailEmotions으로 구분하고, 페이지를 선택적으로 처리합니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant NoteViewController
participant NoteViewModel
participant EmotionRegistrationView
participant FetchDetailEmotionsUseCase
participant EmotionRepository
participant DetailEmotionBottomSheet
User->>NoteViewController: 감정 선택 클릭
NoteViewController->>EmotionRegistrationView: 감정 행 선택 감지
EmotionRegistrationView->>NoteViewController: onEmotionSelected 콜백
NoteViewController->>NoteViewModel: didSelectEmotion(primaryEmotion) 액션
NoteViewController->>NoteViewModel: fetchDetailEmotions(emotion) 액션
NoteViewModel->>NoteViewModel: isLoadingEmotions = true
NoteViewController->>NoteViewController: setLoadingEmotions(true)
NoteViewModel->>FetchDetailEmotionsUseCase: execute(primaryEmotion)
FetchDetailEmotionsUseCase->>EmotionRepository: getDetailEmotions(primaryEmotion)
EmotionRepository-->>FetchDetailEmotionsUseCase: [DetailEmotion] 반환
FetchDetailEmotionsUseCase-->>NoteViewModel: [DetailEmotion] 발행
NoteViewModel->>NoteViewModel: fetchDetailEmotionsSuccessed 액션
NoteViewModel->>NoteViewModel: isLoadingEmotions = false, detailEmotions 저장
NoteViewController->>NoteViewController: setLoadingEmotions(false)
NoteViewController->>NoteViewController: 세부 감정 바텀시트 표시
DetailEmotionBottomSheet->>User: 세부 감정 선택 UI 표시
User->>DetailEmotionBottomSheet: 세부 감정 선택 후 확인
DetailEmotionBottomSheet->>NoteViewController: confirmAction 콜백
NoteViewController->>EmotionRegistrationView: setDetailEmotions([DetailEmotion])
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 Fix all issues with AI agents
In `@src/Projects/BKData/Sources/Constant/APIConfig.swift`:
- Around line 19-22: 현재 baseURLv2 클로저는 baseURL.replacingOccurrences(of:
"/api/v1", with: "/api/v2") 만 호출하므로 치환이 실패하면 동일한 V1 URL을 반환합니다; baseURLv2 초기화
로직(심볼: baseURLv2, 참조: baseURL, replacingOccurrences)을 수정해 치환 결과가 실제로 "/api/v2"를
포함하는지 검사하는 assertion 또는 guard를 추가하고(예: 치환 전후 비교 또는 contains 검사), 실패 시 명확한 오류/로그
또는 런타임 실패를 발생시키도록 변경해 잘못된 환경설정이 조기에 드러나게 하세요.
In `@src/Projects/BKData/Sources/DTO/Response/EmotionResponseDTO.swift`:
- Around line 46-53: Remove the unused duplicate type PrimaryEmotionResponseDTO
and consolidate uses to the existing PrimaryEmotionDTO: delete the
PrimaryEmotionResponseDTO definition, update any references (if any) to use
PrimaryEmotionDTO, and ensure call sites like InsertRecordResponseDTO and
DetailRecordResponseDTO continue to call primaryEmotion.toDomain() ?? .other;
keep the PrimaryEmotionDTO struct (code, displayName, toDomain()) as the
canonical type.
In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift`:
- Line 55: Remove the duplicate SwiftLint disable for the empty_count rule in
BKChip.swift (the second `// swiftlint:disable empty_count`), leaving the single
disable at the top (already at line 23) or replace the duplicate with a targeted
`// swiftlint:disable:next empty_count` placed immediately before the specific
statement that needs it; ensure only one blanket disable remains to avoid
SwiftLint's blanket_disable_command warning and that any local exemptions use
the :next form.
- Around line 71-83: The titleLabel trailing constraint to superview created in
setupLayout() causes conflicts when count changes from 0 to non-zero; modify
setupLayout() to store that trailing NSLayoutConstraint (or SnapKit Constraint)
as a property (e.g., titleTrailingToSuperviewConstraint) and do not permanently
activate it, then in the count's didSet toggle constraints: if count == 0
activate the titleTrailingToSuperviewConstraint and deactivate the
titleLabel.trailing-to-countLabel constraint (or hide/disable countLabel
constraints), else deactivate titleTrailingToSuperviewConstraint and activate
the constraints that pin titleLabel to countLabel (or the existing countLabel
constraints); after toggling call setNeedsLayout()/layoutIfNeeded() or
updateConstraintsIfNeeded() to apply changes. Ensure you reference
setupLayout(), count's didSet, titleLabel, countLabel, and the saved
titleTrailingToSuperviewConstraint when making the change.
In
`@src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion`+UI.swift:
- Around line 121-141: The from(seedName:) method in PrimaryEmotion omits
handling the "other"/"기타" seed name so Seeds named "기타" return nil; update
PrimaryEmotion.from(seedName:) to include a case for "other" and "기타" that
returns .other (so PrimaryEmotion.from(seedName:) and by extension from(seed:)
behave consistently with EmotionSeed.from(seedName:)). Ensure the switch in
from(seedName:) adds: case "other", "기타": return .other.
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift`:
- Around line 370-399: The code uses PrimaryEmotion.hashValue in rowView.tag and
then finds the emotion by matching tags in emotionRowTapped, which is fragile;
instead assign a stable identifier (e.g. the PrimaryEmotion rawValue or its
index) to the view tag or better yet avoid tag lookups entirely and locate the
tapped emotion via the emotionRows dictionary or by storing the PrimaryEmotion
on the view (e.g. a property on the row view). Update the places referencing
hashValue (where rowView.tag is set and in emotionRowTapped where
PrimaryEmotion.allCases.first(where:) is used) to use the chosen stable
identifier or direct mapping so tapping reliably resolves the correct
PrimaryEmotion.
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`:
- Around line 115-119: 핵심 문제: fetchDetailEmotions 핸들러가 실패 시 self?.lastEffect를
갱신하지 않아 retryTapped로 재시도 불가능하거나 잘못된 이펙트가 재시도된다; 수정법은 fetchDetailEmotions 케이스
처리부에서 실행 전에 self?.lastEffect에 해당 이펙트(예: .fetchDetailEmotions(primaryEmotion))를
저장하거나, 대신 에러를 일반 재시도 다이얼로그를 트리거하는 Action.errorOccured로 보내지 말고 감정 전용 실패
액션(Action.fetchDetailEmotionsFailed 등)을 반환하도록 변경하여 재시도 흐름과 사용자 다이얼로그 동작을 분리하는
것(함수명/심볼: fetchDetailEmotionsUseCase.execute,
Action.fetchDetailEmotionsSuccessed, Action.errorOccured, retryTapped,
lastEffect).
- Around line 89-95: The failure path for fetchDetailEmotions leaves
isLoadingEmotions true and retries don't replay the emotion effect; update the
errorOccured handler to also set newState.isLoadingEmotions = false whenever an
error occurs and/or add specific error handling for emotion failures, and modify
the handle(.fetchDetailEmotions(primaryEmotion)) logic to record the lastEffect
(or store the specific fetchDetailEmotions effect) so the isRetrying flow can
re-dispatch the exact effect when retry is triggered (adjust usage of
lastEffect/isRetrying to include the fetchDetailEmotions effect).
In
`@src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift`:
- Around line 267-299: Track the previously computed intrinsic height and only
call invalidateIntrinsicContentSize when the computed tag height actually
changes beyond a small threshold: add a private stored property (e.g.,
previousIntrinsicHeight: CGFloat = 0), compute newHeight in layoutTags(),
compare newHeight to previousIntrinsicHeight (use a small epsilon like 0.5), and
if different update previousIntrinsicHeight and call
invalidateIntrinsicContentSize(); keep calls confined to
layoutTags()/layoutSubviews but remove the direct comparison against
bounds.height to avoid the recursive layout loop. Ensure you reference
layoutSubviews, layoutTags, invalidateIntrinsicContentSize, and the new
previousIntrinsicHeight symbol when making the change.
🧹 Nitpick comments (23)
src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift (1)
83-87:page가 nil일 때"-p"표시가 의도된 디자인인지 확인 필요페이지가 없을 때
"-p"를 표시하면 사용자에게 데이터 누락처럼 보일 수 있습니다. 빈 문자열이나 페이지 라벨 자체를 숨기는 방식도 고려해볼 수 있습니다. 의도된 디자인이라면 무시해주세요.대안: 페이지 없을 때 라벨 숨기기
if let page { pageLabel.setText(text: "\(page)p") + pageLabel.isHidden = false } else { - pageLabel.setText(text: "-p") + pageLabel.isHidden = true }src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift (2)
30-36:sentenceLabelRow에 단일 요소만 포함 — 스택뷰가 필요한지 확인 필요.
sentenceLabelRow는sentenceLabel하나만 포함하고 있어, 다른 label row(pageLabelRow,memoLabelRow)처럼 badge가 없습니다. 향후 badge 추가 가능성을 고려한 일관성 유지라면 이해되지만, 현재로선 불필요한 래핑입니다. 의도적인 구조라면 무시해주세요.Also applies to: 130-132
234-252:pageField에 숫자가 아닌 값이 입력될 경우Int(trimmedPage)가nil을 반환할 수 있습니다.
UITextFieldDelegate에서 숫자만 허용하도록 검증하고 있지만,setText등 프로그래밍 방식으로 텍스트가 설정되면 delegate를 우회할 수 있습니다.Int(trimmedPage)가nil을 반환하면 page가nil로 처리되어 사용자가 입력한 값이 무시됩니다. 현재 흐름상 큰 문제는 아니지만, 방어적으로 처리하려면 파싱 실패 시nil반환 또는 에러 처리를 고려해보세요.🛡️ 방어적 처리 제안
let trimmedPage = pageField.text.trimmingCharacters(in: .whitespacesAndNewlines) - let page: Int? = trimmedPage.isEmpty ? nil : Int(trimmedPage) + let page: Int? + if trimmedPage.isEmpty { + page = nil + } else { + guard let parsed = Int(trimmedPage) else { return nil } + page = parsed + }src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Models/EmotionSeed.swift (2)
65-73:from(emotion:)반환 타입이 옵셔널이지만 모든 케이스를 처리하고 있습니다.현재
Emotion의 모든 케이스(.joy,.sad,.insight,.warmth,.other)가 매핑되어 있어 실제로nil이 반환되는 경우는 없습니다. 향후Emotion에 새로운 케이스가 추가될 경우를 대비한 의도라면 이해되지만, 관련 snippet의PrimaryEmotion.from(emotion:)은 non-optional을 반환하고 있어 접근 방식이 다릅니다.의도된 설계라면 무시해도 좋습니다.
15-33:.etc케이스의 이미지 플레이스홀더 상태 확인 필요
image에서BKImage.Graphics.sad,circleImage에서BKImage.Graphics.insightCircle을 사용하고 있습니다. BKImage 에셋을 확인한 결과, 4가지 주요 감정(warm,joy,insight,sad)에 대한 전용 이미지는 모두 정의되어 있으나,.etc케이스용 전용 에셋(etcCircle,etcGraphic등)은 존재하지 않습니다.다만
cardImage속성에서는 전용BKImage.Graphics.etcCard를 사용하고 있어, 설계 에셋 완성 상태가 속성별로 다른 것으로 보입니다. 카드 에셋은 완성되었으나 일반 이미지/서클 에셋은 아직 대기 중인 상황인지, 아니면 모두 함께 완성 예정인지 확인 부탁드립니다.src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/SeedReportView.swift (1)
129-136: 옵셔널 반환에 대한guard let처리가 적절합니다.
EmotionSeed.from(emotion:)의 반환 타입이 옵셔널로 변경된 것에 맞춰 안전하게 처리하고 있습니다. 다만 매핑 실패 시 조용히return하면 UI가 비어 보일 수 있는데, 이 경우 기본 상태 표시나 로그가 필요한지 검토해 보시면 좋겠습니다.src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift (1)
146-151: 사용자 입력이 무시되는 상태에서 UI가 여전히 활성화되어 있습니다.
registrationForm()이nil을 반환하지만,appreciationTextView는 여전히 편집 가능하고inputChangedPublisher도 이벤트를 발생시킵니다. Phase 4에서 제거 예정이라면 현재 단계에서라도 텍스트뷰를 비활성화하거나 뷰 자체를 숨기는 것이 사용자 혼란을 줄일 수 있습니다.Phase 4에서의 제거가 곧 이루어질 예정이라면 현재 상태로 유지해도 무방합니다.
src/Projects/BKData/Sources/API/RecordAPI.swift (1)
11-11: enum case 이름.patch와 실제 HTTP 메서드.put의 불일치V2 API에서 PUT을 사용한다면, enum case 이름도
.update또는.put으로 변경하는 것이 코드 가독성에 도움됩니다. 현재는 주석으로 보완하고 있지만, 호출부에서.patch를 보고 PATCH 요청이라고 오해할 수 있습니다.Also applies to: 43-44
src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailView.swift (1)
21-23: 정렬 안정성(stability) 저하: 동일 페이지 값에 대한 보조 정렬 기준 제거이전 구현에서는 페이지가 동일할 때
createdAt을 보조 정렬 기준으로 사용했지만, 현재 구현에서는 페이지 값만으로 비교합니다. 페이지가 같거나 둘 다nil인 경우 항목 순서가 비결정적(non-deterministic)이 될 수 있습니다.♻️ 보조 정렬 기준 추가 제안
case .pageDescending: - return { ($0.page ?? 0) > ($1.page ?? 0) } + return { + let lhs = $0.page ?? 0 + let rhs = $1.page ?? 0 + if lhs != rhs { return lhs > rhs } + return $0.createdAt > $1.createdAt + }src/Projects/BKDesign/Sources/Components/Chip/BKRemovableChip.swift (2)
34-39:init에서self.title = title할당 시didSet이 호출되지 않습니다.Swift에서 같은 클래스의
init내 프로퍼티 할당은didSet을 트리거하지 않습니다. 현재setupViews()의 Line 58에서titleLabel.setText(text: title)로 보완하고 있어 동작에는 문제가 없지만, 향후 리팩토링 시 혼란의 소지가 있습니다.
52-56: 하드코딩된 hex 색상#E3F8E9은 디자인 시스템과 일관성이 떨어집니다.프로젝트 내 다른 컴포넌트들은
bkBaseColor(),bkContentColor()등 시맨틱 컬러를 사용하고 있습니다. 가능하다면 디자인 시스템 토큰으로 교체하는 것을 권장합니다.src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift (1)
25-33:description프로퍼티가 Swift 기본 출력 동작을 덮어씁니다.
description은CustomStringConvertible프로토콜의 관례적 프로퍼티명입니다.PrimaryEmotion인스턴스를print()하거나 문자열 보간에 사용할 때, 케이스명 대신 한글 설명이 출력되어 디버깅 시 혼란을 줄 수 있습니다.emotionDescription등 다른 이름을 고려해 보세요.src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift (1)
160-176:scan기반 로딩 완료 감지 로직에 대한 참고.
scan을 사용해isLoadingEmotions의true → false전환을 감지하는 패턴은 기능적으로 올바르지만, 가독성이 다소 떨어집니다. 튜플 인덱스(prev.1,current.0,current.1)가 의미를 파악하기 어렵게 만듭니다.참고: 이 파이프라인은
NoteViewModel의errorOccured에서isLoadingEmotions = false가 처리되어야 에러 시에도 로딩 상태가 올바르게 해제됩니다 (ViewModel 리뷰 참조).♻️ 가독성 개선을 위한 named tuple 사용 제안
viewModel.statePublisher .map { ($0.isLoadingEmotions, $0.detailEmotions) } - .scan((false, false, [DetailEmotion]())) { prev, current in - // (wasLoading, isLoading, detailEmotions) - (prev.1, current.0, current.1) + .scan((wasLoading: false, isLoading: false, detailEmotions: [DetailEmotion]())) { prev, current in + (wasLoading: prev.isLoading, isLoading: current.0, detailEmotions: current.1) } - .filter { wasLoading, isLoading, _ in wasLoading && !isLoading } - .map { $0.2 } + .filter { $0.wasLoading && !$0.isLoading } + .map { $0.detailEmotions }src/Projects/BKPresentation/Sources/Common/Extension/BKBottomSheetViewController+.swift (2)
360-454:layoutSubviews와intrinsicContentSize에서 행 그룹핑 로직이 중복됩니다.칩 크기 계산 → 행 그룹핑 알고리즘이
layoutSubviews(Lines 366-389)와intrinsicContentSize(Lines 423-444)에서 거의 동일하게 반복됩니다. 공통 로직을 private 헬퍼로 추출하면 유지보수성이 개선됩니다.또한, Line 417에서
layoutSubviews내에서invalidateIntrinsicContentSize()를 호출하는 것은 부모 뷰가 intrinsic size 변경에 반응하여 bounds를 변경할 경우 레이아웃 루프를 유발할 수 있습니다. 이전 크기와 비교 후 변경 시에만 invalidate하는 것이 더 안전합니다.♻️ 행 그룹핑 로직 추출 및 안전한 invalidation 제안
private final class CenteredFlowLayoutView: UIView { private var chipViews: [UIView] = [] private let horizontalSpacing: CGFloat = 8 private let verticalSpacing: CGFloat = 8 + private var lastComputedHeight: CGFloat = 0 + + private func computeRows(maxWidth: CGFloat) -> (rows: [[Int]], chipSizes: [CGSize]) { + let chipSizes = chipViews.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) } + var rows: [[Int]] = [] + var currentRow: [Int] = [] + var currentRowWidth: CGFloat = 0 + + for (index, chipSize) in chipSizes.enumerated() { + let chipWidth = chipSize.width + let neededWidth = currentRow.isEmpty ? chipWidth : horizontalSpacing + chipWidth + if currentRowWidth + neededWidth > maxWidth, !currentRow.isEmpty { + rows.append(currentRow) + currentRow = [index] + currentRowWidth = chipWidth + } else { + currentRow.append(index) + currentRowWidth += neededWidth + } + } + if !currentRow.isEmpty { rows.append(currentRow) } + return (rows, chipSizes) + }그리고
layoutSubviews와intrinsicContentSize에서computeRows(maxWidth:)를 사용하세요:override func layoutSubviews() { super.layoutSubviews() let maxWidth = bounds.width guard maxWidth > 0 else { return } - let chipSizes = chipViews.map { ... } - // ... duplicated grouping logic ... + let (rows, chipSizes) = computeRows(maxWidth: maxWidth) // ... layout code using rows and chipSizes ... - invalidateIntrinsicContentSize() + let newHeight = /* computed total height */ + if abs(newHeight - lastComputedHeight) > 0.5 { + lastComputedHeight = newHeight + invalidateIntrinsicContentSize() + } }
421-421: 매직 넘버40사용.
UIScreen.main.bounds.width - 40에서40은 의도가 불분명합니다. 양쪽 20pt 마진을 의미하는 것으로 보이는데, 이름이 있는 상수나BKInset/BKSpacing값으로 대체하면 의도가 더 명확해집니다.src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion+UI.swift (2)
95-116:toLegacyEmotion이 도메인의toEmotion()과 중복됩니다.
PrimaryEmotion.swift(도메인)에 이미 동일한 매핑을 수행하는toEmotion() -> Emotion메서드가 존재합니다.toLegacyEmotion은 이 메서드와 완전히 동일한 로직입니다. 또한init?(from legacyEmotion:)은 모든 케이스를 처리하므로 실패할 수 없는 failable initializer입니다.♻️ 중복 제거 및 failable init 개선 제안
- /// 기존 Emotion에서 변환 - init?(from legacyEmotion: Emotion) { + /// 기존 Emotion에서 변환 + init(from legacyEmotion: Emotion) { switch legacyEmotion { case .warmth: self = .warmth case .joy: self = .joy case .sad: self = .sadness case .insight: self = .insight case .other: self = .other } } - /// 기존 Emotion으로 변환 (하위 호환성) - var toLegacyEmotion: Emotion { - switch self { - case .warmth: return .warmth - case .joy: return .joy - case .sadness: return .sad - case .insight: return .insight - case .other: return .other - } - } + /// 기존 Emotion으로 변환 (하위 호환성) + var toLegacyEmotion: Emotion { + toEmotion() + }
81-90: 하드코딩된 hex 색상 값 확인.
shareBackgroundColor에서 hex 값이 직접 사용되고 있습니다. 프로젝트의 다른 색상들은BKSemanticColor등 디자인 시스템을 통해 관리되는데, 이 부분만 하드코딩되어 있어 다크모드 등에서 일관성 문제가 있을 수 있습니다. 인스타 스토리 공유 전용 색상이라 의도적일 수 있으나, 향후 관리 차원에서 참고해주세요.src/Projects/BKData/Sources/Repository/DefaultEmotionRepository.swift (2)
24-28:.map내 사이드 이펙트(캐시 저장)는.handleEvents가 더 적절합니다.Combine의
.map은 순수 변환 용도이므로, 캐시 저장과 같은 부수 효과는.handleEvents(receiveOutput:)에서 처리하는 것이 의미적으로 더 명확합니다.♻️ handleEvents 사용 제안
.mapError { $0.toDomainError() } .debugError(logger: AppLogger.network) - .map { [weak self] response in - let emotions = response.emotions.compactMap { $0.toDomain() } - self?.cacheQueue.sync { - self?.cachedEmotions = emotions - } - return emotions - } + .map { response in + response.emotions.compactMap { $0.toDomain() } + } + .handleEvents(receiveOutput: { [weak self] emotions in + self?.cacheQueue.sync { + self?.cachedEmotions = emotions + } + })
8-47: 캐시 무효화(invalidation) 전략 부재.현재 캐시가 한번 설정되면 앱 생명주기 동안 절대 만료되지 않습니다. 감정 목록이 서버에서 변경되더라도 반영되지 않을 수 있습니다. DI에서 싱글톤으로 등록된다면 더욱 문제가 될 수 있습니다.
향후 필요 시 TTL 기반 만료나 강제 리프레시 메서드 추가를 고려해주세요.
src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift (3)
301-329:intrinsicContentSize에서 fallback 너비UIScreen.main.bounds.width - 100은 매직 넘버입니다.이 값은 실제 부모 뷰의 레이아웃과 무관하게 임의로 설정되어, 다양한 화면 크기나 레이아웃 변경 시 정확하지 않을 수 있습니다.
EmotionRegistrationView.swift의ChipFlowLayoutView에서도 유사한 패턴(UIScreen.main.bounds.width - 32 - 32)이 사용됩니다.
343-344:badgeCornerRadius상수가 선언되었지만 사용되지 않습니다.Line 155에서
emotionBadge.bounds.height / 2를 직접 사용하고 있어,badgeCornerRadius상수는 데드 코드입니다.♻️ 미사용 상수 제거
static let emotionInfoSpacing: CGFloat = 4 static let emotionIconSize: CGFloat = 40 - static let badgeCornerRadius: CGFloat = 12 static let minSpacingToDate: CGFloat = 16
237-330:TagFlowView와EmotionRegistrationView의ChipFlowLayoutView가 거의 동일한 로직을 가집니다.두 클래스 모두 flow layout 계산(가로 배치 → 줄바꿈)과
intrinsicContentSize오버라이드를 동일한 패턴으로 구현하고 있습니다. 공통 베이스 클래스 또는 유틸리티로 통합하면 유지보수성이 향상됩니다.src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift (1)
97-103:cornerRadius = 30하드코딩은 아이콘 크기(60)에 종속됩니다.아이콘 크기가 변경되면 cornerRadius도 같이 수정해야 합니다. 상수나 크기 기반 계산으로 연결하면 유지보수가 용이합니다.
♻️ 크기 기반 계산 제안
+ private static let iconSize: CGFloat = 60 + private let iconImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true - imageView.layer.cornerRadius = 30 + imageView.layer.cornerRadius = iconSize / 2 return imageView }()
| static let baseURLv2: String = { | ||
| // V1 URL에서 v2로 변경 | ||
| return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2") | ||
| }() |
There was a problem hiding this comment.
문자열 치환 방식이 취약합니다 — baseURL에 /api/v1이 없으면 V2 URL이 V1과 동일해집니다.
replacingOccurrences는 패턴이 없을 경우 원본 문자열을 그대로 반환합니다. 환경 설정 오류나 URL 형식 변경 시 V2 엔드포인트가 V1 API를 호출하게 되어 런타임에 발견하기 어려운 버그가 발생할 수 있습니다.
치환 결과를 검증하는 assertion을 추가하는 것을 권장합니다.
🛡️ 방어적 검증 추가 제안
static let baseURLv2: String = {
- // V1 URL에서 v2로 변경
- return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2")
+ let v2URL = baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2")
+ assert(v2URL != baseURL, "baseURL에 '/api/v1' 경로가 포함되어 있지 않습니다. BASE_API_URL 환경 설정을 확인하세요.")
+ return v2URL
}()🤖 Prompt for AI Agents
In `@src/Projects/BKData/Sources/Constant/APIConfig.swift` around lines 19 - 22,
현재 baseURLv2 클로저는 baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2") 만
호출하므로 치환이 실패하면 동일한 V1 URL을 반환합니다; baseURLv2 초기화 로직(심볼: baseURLv2, 참조: baseURL,
replacingOccurrences)을 수정해 치환 결과가 실제로 "/api/v2"를 포함하는지 검사하는 assertion 또는 guard를
추가하고(예: 치환 전후 비교 또는 contains 검사), 실패 시 명확한 오류/로그 또는 런타임 실패를 발생시키도록 변경해 잘못된 환경설정이
조기에 드러나게 하세요.
| updateAppearance() | ||
| } | ||
|
|
||
| // swiftlint:disable empty_count |
There was a problem hiding this comment.
중복된 swiftlint:disable empty_count 제거 필요
Line 23에서 이미 동일한 규칙이 비활성화되어 있습니다. SwiftLint에서도 blanket_disable_command 경고를 발생시키고 있습니다. 하나만 유지하거나, // swiftlint:disable:next empty_count로 필요한 곳에서만 개별 적용하세요.
🧰 Tools
🪛 SwiftLint (0.63.2)
[Warning] 55-55: The disabled 'empty_count' rule was already disabled
(blanket_disable_command)
[Warning] 55-55: Use 'next', 'this' or 'previous' instead to disable the 'empty_count' rule once, or re-enable it as soon as possible`
(blanket_disable_command)
🤖 Prompt for AI Agents
In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift` at line 55,
Remove the duplicate SwiftLint disable for the empty_count rule in BKChip.swift
(the second `// swiftlint:disable empty_count`), leaving the single disable at
the top (already at line 23) or replace the duplicate with a targeted `//
swiftlint:disable:next empty_count` placed immediately before the specific
statement that needs it; ensure only one blanket disable remains to avoid
SwiftLint's blanket_disable_command warning and that any local exemptions use
the :next form.
| private func setupLayout() { | ||
| titleLabel.snp.makeConstraints { | ||
| $0.leading.top.bottom.equalToSuperview() | ||
| if count == 0 { | ||
| $0.trailing.equalToSuperview() | ||
| } | ||
| } | ||
|
|
||
| countLabel.snp.makeConstraints { | ||
| $0.leading.equalTo(titleLabel.snp.trailing).offset(BKSpacing.spacing1) | ||
| $0.trailing.top.bottom.equalToSuperview() | ||
| $0.trailing.equalToSuperview() | ||
| $0.centerY.equalTo(titleLabel) | ||
| } |
There was a problem hiding this comment.
count가 0에서 non-zero로 변경될 때 Auto Layout 충돌 가능성
setupLayout()은 초기화 시점에 count == 0이면 titleLabel에 trailing.equalToSuperview() 제약을 추가합니다 (Line 75). 이후 count가 0이 아닌 값으로 변경되면, countLabel이 보이게 되지만 titleLabel의 trailing 제약은 여전히 superview에 고정되어 있어, countLabel의 leading/trailing 제약과 충돌합니다.
countLabel.isHidden만으로는 제약 충돌을 해결할 수 없습니다 — hidden된 뷰도 Auto Layout에 참여합니다.
🛠️ 제안: 제약 조건을 동적으로 관리
+ private var titleTrailingToSuperview: Constraint?
+
private func setupLayout() {
titleLabel.snp.makeConstraints {
$0.leading.top.bottom.equalToSuperview()
- if count == 0 {
- $0.trailing.equalToSuperview()
- }
+ titleTrailingToSuperview = $0.trailing.equalToSuperview().constraint
}
+ titleTrailingToSuperview?.isActive = (count == 0)
countLabel.snp.makeConstraints {그리고 count의 didSet에서:
public var count: Int = 0 {
didSet {
countLabel.setText(text: "\(count)")
countLabel.isHidden = (count == 0)
+ titleTrailingToSuperview?.isActive = (count == 0)
}
}🤖 Prompt for AI Agents
In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift` around lines 71 -
83, The titleLabel trailing constraint to superview created in setupLayout()
causes conflicts when count changes from 0 to non-zero; modify setupLayout() to
store that trailing NSLayoutConstraint (or SnapKit Constraint) as a property
(e.g., titleTrailingToSuperviewConstraint) and do not permanently activate it,
then in the count's didSet toggle constraints: if count == 0 activate the
titleTrailingToSuperviewConstraint and deactivate the
titleLabel.trailing-to-countLabel constraint (or hide/disable countLabel
constraints), else deactivate titleTrailingToSuperviewConstraint and activate
the constraints that pin titleLabel to countLabel (or the existing countLabel
constraints); after toggling call setNeedsLayout()/layoutIfNeeded() or
updateConstraintsIfNeeded() to apply changes. Ensure you reference
setupLayout(), count's didSet, titleLabel, countLabel, and the saved
titleTrailingToSuperviewConstraint when making the change.
| public extension PrimaryEmotion { | ||
| /// Seed 이름에서 변환 | ||
| static func from(seedName: String) -> Self? { | ||
| switch seedName.lowercased() { | ||
| case "warmth", "따뜻함": return .warmth | ||
| case "joy", "즐거움": return .joy | ||
| case "sad", "sadness", "슬픔": return .sadness | ||
| case "insight", "깨달음": return .insight | ||
| default: return nil | ||
| } | ||
| } | ||
|
|
||
| /// Seed 엔티티에서 변환 | ||
| static func from(seed: Seed) -> Self? { | ||
| return from(seedName: seed.name) | ||
| } | ||
|
|
||
| /// 기본 4가지 감정 (other 제외) | ||
| static var displayCases: [PrimaryEmotion] { | ||
| return [.warmth, .joy, .sadness, .insight] | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Seed 데이터에서 "etc"나 "기타"/"other" 이름이 사용되는지 확인
echo "=== Seed usage with etc/기타/other ==="
rg -n '"etc"|"기타"|"other"' --type swift -C3
echo ""
echo "=== PrimaryEmotion.from(seed) 호출처 확인 ==="
rg -n 'PrimaryEmotion\.from\(seed' --type swift -C3Repository: YAPP-Github/Reed-iOS
Length of output: 2768
🏁 Script executed:
# EmotionSeed와 PrimaryEmotion의 from(seedName:) 구현 확인
echo "=== EmotionSeed.from(seedName:) 구현 확인 ==="
rg -n 'extension.*EmotionSeed|Seed.*extension' --type swift -A30 | head -80
echo ""
echo "=== PrimaryEmotion 정의 및 cases 확인 ==="
rg -n 'enum PrimaryEmotion' --type swift -A15Repository: YAPP-Github/Reed-iOS
Length of output: 1562
🏁 Script executed:
# PrimaryEmotion.displayCases 사용처 확인
echo "=== PrimaryEmotion.displayCases 사용처 ==="
rg -n 'displayCases' --type swift -C3
echo ""
echo "=== Seed entity 구조 확인 ==="
fd '.*Seed.*\.swift' | head -20 | xargs rg -l 'class Seed|struct Seed'Repository: YAPP-Github/Reed-iOS
Length of output: 1199
🏁 Script executed:
# Seed entity 정의와 name 값 확인
echo "=== Seed entity 정의 ==="
cat src/Projects/BKDomain/Sources/Entity/Seed.swift
echo ""
echo "=== PrimaryEmotion.from 모든 호출처 ==="
rg -n '\.from\(' src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion+UI.swift -C2
echo ""
echo "=== 다른 곳에서 PrimaryEmotion 변환 로직 ==="
rg -n 'PrimaryEmotion' src/Projects/BKPresentation --type swift | grep -E 'from|map|convert' | head -20Repository: YAPP-Github/Reed-iOS
Length of output: 453
🏁 Script executed:
# Seed -> PrimaryEmotion 변환이 실제로 일어나는 곳
echo "=== Seed to PrimaryEmotion conversion ==="
rg -n 'EmotionSeed|PrimaryEmotion' src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Models/EmotionSeed.swift -C5
echo ""
echo "=== PrimaryEmotion.from(seed:) 메서드 사용 확인 ==="
rg -n 'from.*seed|from.*Seed' --type swift -C3 | grep -A3 -B3 'PrimaryEmotion'Repository: YAPP-Github/Reed-iOS
Length of output: 2648
from(seedName:) 메서드에서 "other"/"기타" 케이스 누락.
PrimaryEmotion 열거형에 .other 케이스(displayName: "기타")가 존재하지만, from(seedName:) 메서드에서는 처리하지 않아 "기타" 이름의 Seed가 전달될 경우 nil을 반환합니다. from(seed:) 메서드가 from(seedName: seed.name)을 호출하므로, API에서 name 값이 "기타"인 Seed가 오면 변환에 실패합니다. EmotionSeed.from(seedName:)에서 "etc", "기타" → .etc 매핑을 제공하는 것과 일관성 있게, 다음 케이스를 추가하세요:
case "other", "기타": return .other🤖 Prompt for AI Agents
In `@src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion`+UI.swift
around lines 121 - 141, The from(seedName:) method in PrimaryEmotion omits
handling the "other"/"기타" seed name so Seeds named "기타" return nil; update
PrimaryEmotion.from(seedName:) to include a case for "other" and "기타" that
returns .other (so PrimaryEmotion.from(seedName:) and by extension from(seed:)
behave consistently with EmotionSeed.from(seedName:)). Ensure the switch in
from(seedName:) adds: case "other", "기타": return .other.
| let tapGesture = UITapGestureRecognizer(target: self, action: #selector(emotionRowTapped(_:))) | ||
| rowView.addGestureRecognizer(tapGesture) | ||
| rowView.tag = emotion.hashValue | ||
|
|
||
| switch emotion { | ||
| case .warmth: | ||
| imageView.image = BKImage.Graphics.warmEmotion | ||
| case .joy: | ||
| imageView.image = BKImage.Graphics.joyEmotion | ||
| case .sad: | ||
| imageView.image = BKImage.Graphics.sadEmotion | ||
| case .insight: | ||
| imageView.image = BKImage.Graphics.insightEmotion | ||
| default: | ||
| imageView.image = BKImage.Graphics.warmEmotion | ||
| } | ||
| // Set up chip removal callback | ||
| rowView.onDetailEmotionRemoved = { [weak self] detailEmotion in | ||
| self?.removeDetailEmotion(detailEmotion) | ||
| } | ||
|
|
||
| imageView.layer.cornerRadius = 12 | ||
| imageView.clipsToBounds = true | ||
| imageView.contentMode = .scaleAspectFit | ||
| emotionRows[emotion] = rowView | ||
| emotionListStack.addArrangedSubview(rowView) | ||
| } | ||
| } | ||
|
|
||
| wrapperView.addSubview(imageView) | ||
| imageView.snp.makeConstraints { | ||
| $0.edges.equalToSuperview() | ||
| $0.height.equalTo(imageView.snp.width).multipliedBy(1.325) | ||
| private func removeDetailEmotion(_ detailEmotion: DetailEmotion) { | ||
| selectedDetailEmotions.removeAll { $0 == detailEmotion } | ||
| if let selectedPrimaryEmotion, let rowView = emotionRows[selectedPrimaryEmotion] { | ||
| rowView.selectedDetailEmotions = selectedDetailEmotions | ||
| } | ||
| inputChangedSubject.send(()) | ||
| } | ||
|
|
||
| wrapperView.isUserInteractionEnabled = true | ||
| let gesture = UITapGestureRecognizer(target: self, action: #selector(emotionTapped(_:))) | ||
| wrapperView.addGestureRecognizer(gesture) | ||
| wrapperView.tag = emotion.hashValue | ||
| @objc private func emotionRowTapped(_ sender: UITapGestureRecognizer) { | ||
| // 로딩 중에는 탭 무시 | ||
| guard !isLoadingEmotions else { return } | ||
|
|
||
| return wrapperView | ||
| } | ||
|
|
||
| @objc func emotionTapped(_ sender: UITapGestureRecognizer) { | ||
| guard let tappedView = sender.view, | ||
| let emotion = Emotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else { | ||
| let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else { | ||
| return | ||
| } |
There was a problem hiding this comment.
hashValue를 뷰 tag로 사용하는 것은 안정적이지 않습니다.
hashValue는 프로그램 실행 간 값이 달라질 수 있고, 이론적으로 해시 충돌이 발생할 수 있습니다. 5개 케이스에서 실제 충돌 가능성은 매우 낮지만, 올바른 식별 방법이 아닙니다.
🐛 안정적인 식별 방식으로 변경 제안
PrimaryEmotion이 CaseIterable이라면 index를 사용하거나, rawValue가 String인 경우 별도 매핑을 사용할 수 있습니다:
- rowView.tag = emotion.hashValue
+ if let index = PrimaryEmotion.allCases.firstIndex(of: emotion) {
+ rowView.tag = index
+ }
// ...
- let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
+ let emotion = PrimaryEmotion.allCases[safe: tappedView.tag] else {또는 tag 대신 emotionRows 딕셔너리에서 직접 조회하는 방식이 더 안전합니다:
`@objc` private func emotionRowTapped(_ sender: UITapGestureRecognizer) {
guard !isLoadingEmotions else { return }
- guard let tappedView = sender.view,
- let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
+ guard let tappedView = sender.view,
+ let emotion = emotionRows.first(where: { $0.value === tappedView })?.key else {
return
}🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift`
around lines 370 - 399, The code uses PrimaryEmotion.hashValue in rowView.tag
and then finds the emotion by matching tags in emotionRowTapped, which is
fragile; instead assign a stable identifier (e.g. the PrimaryEmotion rawValue or
its index) to the view tag or better yet avoid tag lookups entirely and locate
the tapped emotion via the emotionRows dictionary or by storing the
PrimaryEmotion on the view (e.g. a property on the row view). Update the places
referencing hashValue (where rowView.tag is set and in emotionRowTapped where
PrimaryEmotion.allCases.first(where:) is used) to use the chosen stable
identifier or direct mapping so tapping reliably resolves the correct
PrimaryEmotion.
| case .fetchDetailEmotions(let primaryEmotion): | ||
| newState.isLoadingEmotions = true | ||
| effects.append(.fetchDetailEmotions(primaryEmotion)) | ||
|
|
||
| case .fetchDetailEmotionsSuccessed(let detailEmotions): | ||
| newState.isLoadingEmotions = false | ||
| newState.detailEmotions = detailEmotions |
There was a problem hiding this comment.
fetchDetailEmotions 실패 시 isLoadingEmotions가 false로 리셋되지 않습니다.
fetchDetailEmotions 실패 → errorOccured 액션이 실행되지만, errorOccured 핸들러(Line 72-79)는 isLoading만 false로 설정하고 isLoadingEmotions는 건드리지 않습니다. 결과적으로 감정 로딩 실패 시 로딩 상태가 영구적으로 유지됩니다.
또한, handle(.fetchDetailEmotions) (Line 115-119)에서 lastEffect를 저장하지 않기 때문에, 에러 발생 시 isRetrying 플로우의 재시도 버튼이 실제로 감정 fetch를 다시 실행하지 못합니다. 감정 fetch 에러는 별도로 처리하거나, errorOccured에서 isLoadingEmotions를 리셋해야 합니다.
🐛 제안: errorOccured에서 isLoadingEmotions 리셋 추가
case .errorOccured(let error):
newState.isLoading = false
+ newState.isLoadingEmotions = false
if newState.isRetrying == false {
newState.isRetrying = true
} else {감정 fetch에 대한 별도 에러 처리가 필요하다면, 전용 에러 액션 도입도 고려해보세요.
🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`
around lines 89 - 95, The failure path for fetchDetailEmotions leaves
isLoadingEmotions true and retries don't replay the emotion effect; update the
errorOccured handler to also set newState.isLoadingEmotions = false whenever an
error occurs and/or add specific error handling for emotion failures, and modify
the handle(.fetchDetailEmotions(primaryEmotion)) logic to record the lastEffect
(or store the specific fetchDetailEmotions effect) so the isRetrying flow can
re-dispatch the exact effect when retry is triggered (adjust usage of
lastEffect/isRetrying to include the fetchDetailEmotions effect).
| case .fetchDetailEmotions(let primaryEmotion): | ||
| return fetchDetailEmotionsUseCase.execute(for: primaryEmotion) | ||
| .map { Action.fetchDetailEmotionsSuccessed($0) } | ||
| .catch { Just(Action.errorOccured($0)) } | ||
| .eraseToAnyPublisher() |
There was a problem hiding this comment.
감정 fetch 실패 시 lastEffect 미저장으로 재시도 불가.
submit 핸들러(Line 109-110)와 달리 fetchDetailEmotions 핸들러에서는 self?.lastEffect를 저장하지 않습니다. 에러가 errorOccured를 통해 재시도 알림을 트리거하더라도, retryTapped 시 lastEffect가 감정 fetch가 아니라 이전 submit effect이거나 nil이 됩니다.
사용자가 감정을 다시 탭하면 되므로 의도적일 수 있지만, 그 경우 errorOccured 대신 감정 전용 에러 처리를 사용하여 재시도 다이얼로그가 표시되지 않도록 하는 것이 좋습니다.
🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`
around lines 115 - 119, 핵심 문제: fetchDetailEmotions 핸들러가 실패 시 self?.lastEffect를
갱신하지 않아 retryTapped로 재시도 불가능하거나 잘못된 이펙트가 재시도된다; 수정법은 fetchDetailEmotions 케이스
처리부에서 실행 전에 self?.lastEffect에 해당 이펙트(예: .fetchDetailEmotions(primaryEmotion))를
저장하거나, 대신 에러를 일반 재시도 다이얼로그를 트리거하는 Action.errorOccured로 보내지 말고 감정 전용 실패
액션(Action.fetchDetailEmotionsFailed 등)을 반환하도록 변경하여 재시도 흐름과 사용자 다이얼로그 동작을 분리하는
것(함수명/심볼: fetchDetailEmotionsUseCase.execute,
Action.fetchDetailEmotionsSuccessed, Action.errorOccured, retryTapped,
lastEffect).
| override func layoutSubviews() { | ||
| super.layoutSubviews() | ||
| layoutTags() | ||
| } | ||
|
|
||
| private func layoutTags() { | ||
| var x: CGFloat = 0 | ||
| var y: CGFloat = 0 | ||
| var lineHeight: CGFloat = 0 | ||
|
|
||
| for label in tagLabels { | ||
| label.sizeToFit() | ||
| let labelWidth = label.bounds.width | ||
| let labelHeight = label.bounds.height | ||
|
|
||
| // 현재 줄에 맞지 않으면 다음 줄로 | ||
| if x + labelWidth > bounds.width && x > 0 { | ||
| x = 0 | ||
| y += lineHeight + verticalSpacing | ||
| lineHeight = 0 | ||
| } | ||
|
|
||
| label.frame = CGRect(x: x, y: y, width: labelWidth, height: labelHeight) | ||
| x += labelWidth + horizontalSpacing | ||
| lineHeight = max(lineHeight, labelHeight) | ||
| } | ||
|
|
||
| // 높이가 변경되면 intrinsicContentSize 업데이트 | ||
| let newHeight = y + lineHeight | ||
| if abs(newHeight - bounds.height) > 1 { | ||
| invalidateIntrinsicContentSize() | ||
| } | ||
| } |
There was a problem hiding this comment.
layoutSubviews 내에서 invalidateIntrinsicContentSize 호출은 재귀적 레이아웃 사이클을 유발할 수 있습니다.
layoutSubviews() → layoutTags() → invalidateIntrinsicContentSize() → setNeedsLayout() → layoutSubviews() 순환이 발생할 수 있습니다. abs(newHeight - bounds.height) > 1 가드가 있지만, 부동소수점 반올림으로 인해 1px 이상 차이가 반복적으로 발생하면 레이아웃 루프에 빠질 수 있습니다.
🛡️ 안전한 패턴으로 변경 제안
private func layoutTags() {
+ let previousHeight = _calculatedHeight
var x: CGFloat = 0
var y: CGFloat = 0
var lineHeight: CGFloat = 0
for label in tagLabels {
// ... existing layout code ...
}
- // 높이가 변경되면 intrinsicContentSize 업데이트
- let newHeight = y + lineHeight
- if abs(newHeight - bounds.height) > 1 {
- invalidateIntrinsicContentSize()
- }
+ _calculatedHeight = y + lineHeight
+ if abs(_calculatedHeight - previousHeight) > 0.5 {
+ invalidateIntrinsicContentSize()
+ }
}
+
+ private var _calculatedHeight: CGFloat = 0이전 높이를 별도로 추적하면 bounds.height와의 비교 대신 실제 계산 결과의 변화만 감지하여 불필요한 사이클을 방지할 수 있습니다.
🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift`
around lines 267 - 299, Track the previously computed intrinsic height and only
call invalidateIntrinsicContentSize when the computed tag height actually
changes beyond a small threshold: add a private stored property (e.g.,
previousIntrinsicHeight: CGFloat = 0), compute newHeight in layoutTags(),
compare newHeight to previousIntrinsicHeight (use a small epsilon like 0.5), and
if different update previousIntrinsicHeight and call
invalidateIntrinsicContentSize(); keep calls confined to
layoutTags()/layoutSubviews but remove the direct comparison against
bounds.height to avoid the recursive layout loop. Ensure you reference
layoutSubviews, layoutTags, invalidateIntrinsicContentSize, and the new
previousIntrinsicHeight symbol when making the change.
0712714 to
2d863c9
Compare
🔗 관련 이슈
📘 작업 유형
📙 작업 내역
✅ PR 체크리스트
💬 추가 설명 or 리뷰 포인트 (선택)
Summary by CodeRabbit
릴리스 노트
New Features
UI/UX 개선