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:
parent
ddd46a4084
commit
43be9c0aa9
@ -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 시 메모 시트(건너뛰기 가능)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user