fix(ipad): main-tab scroll oscillation — replace lazy adaptive grids

- 아이패드 모음 탭에서 맨 아래로 스크롤하면 화면이 위아래로 무한 진동하며 터치가 안 먹는 실기기 버그 — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-19 08:41:31 +09:00
parent ddd46a4084
commit 43be9c0aa9
2 changed files with 91 additions and 15 deletions

View File

@ -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 시 메모 시트(건너뛰기 가능)

View File

@ -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..<rowEnd {
let size = subviews[i].sizeThatFits(ProposedViewSize(width: itemWidth, height: nil))
rowHeight = max(rowHeight, size.height)
}
heights.append(rowHeight)
index = rowEnd
}
return (itemWidth, count, heights)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> 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..<rowEnd {
// , ( GridItem alignment: .top )
subviews[i].place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(width: layout.itemWidth, height: nil)
)
x += layout.itemWidth + spacing
}
y += layout.heights[row] + spacing
row += 1
index = rowEnd
}
}
}
// MARK: -
struct ActionButtonCell: View {