From 43be9c0aa93ec9495c1f961e68b47115a0394291 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sun, 19 Jul 2026 08:41:31 +0900 Subject: [PATCH] =?UTF-8?q?fix(ipad):=20main-tab=20scroll=20oscillation=20?= =?UTF-8?q?=E2=80=94=20replace=20lazy=20adaptive=20grids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 아이패드 모음 탭에서 맨 아래로 스크롤하면 화면이 위아래로 무한 진동하며 터치가 안 먹는 실기기 버그 — lazy 컨테이너의 화면 밖 높이 "추정"이 높이 제각각인 목표 카드의 실제 높이와 어긋나 콘텐츠 높이가 출렁이고, 1초 타이머 갱신이 매초 이를 걷어차 진동이 지속되는 레이아웃 피드백 루프로 분석 - 수정: iPad 전용 비-lazy `AdaptiveColumnsLayout`(커스텀 Layout) 신설 — LazyVGrid(.adaptive)와 동일한 열 계산(목표 340~520·행동 150~220, 행 내 위 정렬·왼쪽 정렬)으로 시각 결과는 그대로 두고 전 항목 즉시 배치로 높이 추정을 원천 제거. 항목 수가 적어 성능 부담 없음 - 목표 카드 영역 + 행동 그리드 둘 다 적용(통일, 사용자 지시). iPhone 경로(고정 열 LazyVGrid·페이징)는 무변경 - 검증: iPad 모음(카드·그리드·행 배치)·배치 편집 모드·iPhone 모음 시뮬 스크린샷 — 기존과 동일 렌더. 진동 재현은 스크롤 제스처 자동화 불가로 실기기 확인 필요(같은 절차: 측정 여러 개 켠 상태로 맨 아래 스크롤) - 새 문자열 없음(카탈로그 클린), 도움말 무변경. CLAUDE.md §6.1에 원인·재발 방지(다시 lazy로 되돌리지 말 것) 기재. Debug/Store 빌드 성공 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp --- myApp/HaruDanim/CLAUDE.md | 2 +- myApp/HaruDanim/IOS/Views/MainView.swift | 104 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 8fe0e27..f3f04fe 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -175,7 +175,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f - 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined) - "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼 - 행동 버튼 그리드: 꼬리표 색 그라데이션 배경 + 아이콘 + 이름 + 오늘 누적(시간형) 또는 오늘 횟수(횟수형, 숫자 전환 애니메이션). 측정 중이면 노란 테두리 + record 점. 탭 = 시작/종료 토글 또는 +1 (스프링 눌림 + 햅틱) -- 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 adaptive +- 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 화면 폭 자동 채움 — **iPad의 목표 카드·행동 그리드는 LazyVGrid가 아니라 비-lazy `AdaptiveColumnsLayout`**(MainView 내 커스텀 Layout, LazyVGrid(.adaptive)와 같은 열 계산). lazy 높이 추정이 높이 제각각인 목표 카드 + 1초 타이머 갱신과 결합해 맨 아래 스크롤 시 화면이 무한 진동(터치 불능)하는 실기기 버그의 수정 — iPhone(고정 열·페이징 높이 고정)은 LazyVGrid 유지. iPad 모음 그리드를 다시 lazy로 되돌리지 말 것 - 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / 행동 설정 수정 / 삭제 - 배치 편집: 지글 애니메이션 + 드래그 재정렬 → `local.actionOrder` 저장 (CloudKit 모델에 쓰지 않음) - "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능) diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index a2cb01f..1e32bd3 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -155,12 +155,9 @@ struct MainView: View { @ViewBuilder private var goalArea: some View { if DeviceLayout.isPad { - // 넓은 화면: 페이징 대신 카드를 여러 장 나란히 배치 (폭도 카드 단위로 제한) - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 340, maximum: 520), spacing: 10, alignment: .top)], - alignment: .leading, - spacing: 10 - ) { + // 넓은 화면: 페이징 대신 카드를 여러 장 나란히 배치 (폭도 카드 단위로 제한). + // 비-lazy 레이아웃인 이유는 AdaptiveColumnsLayout 주석 참고 (스크롤 진동 버그) + AdaptiveColumnsLayout(minWidth: 340, maxWidth: 520, spacing: 10) { ForEach(pinnedGoals) { goal in GoalSummaryCard(goal: goal) } @@ -266,24 +263,34 @@ struct MainView: View { // MARK: 그리드 - /// iPhone: 설정한 한 줄 개수 고정 / iPad·Mac: 버튼 크기 기준으로 화면 폭에 맞춰 자동 채움 + /// iPhone: 설정한 한 줄 개수 고정 (iPad는 AdaptiveColumnsLayout이 자동 채움) private var gridLayout: [GridItem] { - if DeviceLayout.isPad { - return [GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 12)] - } - return Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns) + Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns) } + /// iPad는 목표 카드 영역과 같은 비-lazy 적응형 레이아웃 (통일 + 스크롤 진동 방지) private var grid: some View { - LazyVGrid(columns: gridLayout, spacing: 12) { - ForEach(orderedActions) { action in - cell(for: action) + Group { + if DeviceLayout.isPad { + AdaptiveColumnsLayout(minWidth: 150, maxWidth: 220, spacing: 12) { + gridCells + } + } else { + LazyVGrid(columns: gridLayout, spacing: 12) { + gridCells + } } } .animation(.default, value: orderedActions.map(\.persistentModelID)) .animation(.default, value: gridColumns) } + private var gridCells: some View { + ForEach(orderedActions) { action in + cell(for: action) + } + } + @ViewBuilder private func cell(for action: Action) -> some View { let base = ActionButtonCell( @@ -860,6 +867,75 @@ struct RunningSessionRow: View { } } +// MARK: - iPad 적응형 그리드 (비-lazy) + +/// iPad 모음 탭 전용: `LazyVGrid(.adaptive)` 대체. +/// lazy 컨테이너는 화면 밖 항목의 높이를 "추정"하는데, 높이가 제각각인 목표 카드가 +/// 화면 밖으로 나가면 실제 높이와 추정이 어긋나 ScrollView 콘텐츠 높이가 출렁이고, +/// 1초 타이머 갱신이 이를 계속 걷어차 스크롤이 무한 진동하는 실기기 버그가 있었다 +/// (아이폰은 고정 열+측정 높이 고정이라 무관). 항목 수가 적으므로 전부 즉시 배치해 +/// 높이 추정을 원천 제거한다. 열 계산은 LazyVGrid(.adaptive)와 동일 규칙. +struct AdaptiveColumnsLayout: Layout { + var minWidth: CGFloat + var maxWidth: CGFloat + var spacing: CGFloat + + private func columns(for width: CGFloat) -> (count: Int, itemWidth: CGFloat) { + guard width > minWidth else { return (1, max(width, 1)) } + let count = max(1, Int((width + spacing) / (minWidth + spacing))) + let itemWidth = min((width - spacing * CGFloat(count - 1)) / CGFloat(count), maxWidth) + return (count, itemWidth) + } + + /// 항목들을 행 단위로 잘라 (행 시작 인덱스, 행 높이)를 계산 + private func rows(width: CGFloat, subviews: Subviews) -> (itemWidth: CGFloat, count: Int, heights: [CGFloat]) { + let (count, itemWidth) = columns(for: width) + var heights: [CGFloat] = [] + var index = 0 + while index < subviews.count { + let rowEnd = min(index + count, subviews.count) + var rowHeight: CGFloat = 0 + for i in index.. CGSize { + let width = proposal.width ?? minWidth + let layout = rows(width: width, subviews: subviews) + let height = layout.heights.reduce(0, +) + spacing * CGFloat(max(layout.heights.count - 1, 0)) + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let layout = rows(width: bounds.width, subviews: subviews) + var y = bounds.minY + var index = 0 + var row = 0 + while index < subviews.count { + let rowEnd = min(index + layout.count, subviews.count) + var x = bounds.minX + for i in index..