전체 재분석에서 확정한 4건 + 양식 개선 2건:
1) 목표 자동 판정 기준 시점 (핵심 수정)
기존에는 판정이 "실행되는 시점의 현재 주기" 기준이라, 종료일
며칠 뒤에 앱을 열면 목표 기간 밖의 데이터로 달성/미달성이
갈렸다. 이제 자동 판정(evaluateIfEnded)은 **종료일이 속한
논리적 하루의 마지막 순간**(judgmentReference) 기준 — 늦게
실행해도, 종료일을 과거로 고쳐도 종료일까지의 기록으로 판정.
(하루 시작 시간 설정으로 기준이 미래가 되는 경우 now로 상한)
수동 종료는 종료일 없는 목표 전용이므로 기존대로 "지금" 기준.
목표 편집 시 종료일 변경 처리: 진행 중 목표는 저장 즉시 판정,
완료된 목표의 종료일을 바꾸면 '진행 중'으로 재개 후 재판정
(미래/없음 → 진행 중 복귀, 다른 과거 날짜 → 그 시점 기준 재판정).
검증: -endGoalYesterday로 어제 종료 → 종료일 기준 '달성' 판정 확인.
2) 워치 컴플리케이션 지역화 2건
- 다짐 달성률 rectangular의 기간 라벨("하루/주간/월간")이 String
파라미터라 미추출 → en/ja에서 한국어 노출 → String(localized:)
- 빈 상태 문구("목표 없음"/"다짐 없음") 동일 문제 → 추출 + 워치
앱/워치 위젯 카탈로그에 en/ja 번역 추가 (타깃별 stringsdata로
sync — 카탈로그 오염 없음, 전 카탈로그 missing 0)
3) 배포 체크리스트: CloudKit 프로덕션 스키마 배포 항목을 CLAUDE.md
§1에 추가 (DiaryTemplate 등 새 레코드 타입은 출시 전 대시보드
Deploy Schema to Production 필수)
4) 일기 양식 렌더 개선
- 첫 렌더 비동기화: 저장된 썸네일을 플레이스홀더로 즉시 깔고
본 렌더(2×)를 백그라운드로 — 복잡한 벡터 PDF도 페이지 넘김이
걸리지 않음
- 비활성 페이지 고해상도 강등: 화면 밖 페이지는 래스터를 2×로
상한(allowsHighResolution) — TabView가 이웃 페이지를 유지해도
양식 여러 장의 메모리 사용을 절약, 활성 복귀 시 원래 배율 복원
검증: 양식 페이지 2× 프로그램 줌에서 점 노트 선명(회귀 없음),
시드 필기 페이지 획 유지 확인.
빌드: Debug·Store·워치 스킴 모두 성공. 도움말(목표 판정 문구
갱신 — 종료일 기준·재판정 규칙 안내)과 CLAUDE.md §4.3 갱신,
문구 수정 stale 1건 삭제 및 ko/en/ja 번역 완료.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
722 lines
28 KiB
Swift
722 lines
28 KiB
Swift
//
|
||
// DiaryNotePage.swift
|
||
// Haru_Danim
|
||
//
|
||
// 일기 노트 페이지 — 애플 펜슬 자유 필기 캔버스 + 사진/도형 배치.
|
||
//
|
||
// 좌표계: 페이지는 논리 크기 768×1024pt로 고정하고 화면 크기에 맞춰 scaleEffect로 축소/확대한다.
|
||
// → 필기(PKDrawing 절대 좌표)와 배치 요소(비율 좌표)가 기기·창 크기와 무관하게 항상 같은
|
||
// 자리에 놓이고, 내보내기(같은 768pt 기준 렌더)와도 픽셀 단위로 일치한다.
|
||
//
|
||
// 모드: 기본은 필기 모드(캔버스가 터치를 받음), '배치 모드'를 켜면 캔버스 히트테스트를 끄고
|
||
// 사진/도형을 드래그·핀치로 옮기고 크기를 바꿀 수 있다 (선택 시 삭제 버튼 표시).
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
import PencilKit
|
||
import PhotosUI
|
||
|
||
/// 페이지 논리 크기 (내보내기 렌더와 공유)
|
||
enum DiaryPageMetrics {
|
||
static let size = CGSize(width: 768, height: 1024)
|
||
static let lineSpacing: CGFloat = 44
|
||
static let lineTopInset: CGFloat = 72
|
||
}
|
||
|
||
struct DiaryNotePageView: View {
|
||
@Bindable var page: DiaryPage
|
||
/// 현재 보이는 페이지일 때만 캔버스가 퍼스트 리스폰더(펜슬 도구 팔레트)를 가진다
|
||
let isActive: Bool
|
||
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.diaryReadOnly) private var readOnly
|
||
@Query private var allTemplates: [DiaryTemplate]
|
||
|
||
@State private var arranging = false
|
||
@State private var selectedItemID: UUID?
|
||
@State private var photoSelection: PhotosPickerItem?
|
||
/// 텍스트 상자 내용 편집 대상
|
||
@State private var editingTextItem: DiaryPageItem?
|
||
|
||
/// 이 페이지의 배경 양식 (삭제됐거나 아직 동기화 전이면 nil → 빈 캔버스 폴백)
|
||
private var template: DiaryTemplate? {
|
||
guard !page.templateID.isEmpty else { return nil }
|
||
return allTemplates.first { $0.uuid.uuidString == page.templateID }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 10) {
|
||
if !readOnly {
|
||
pageToolbar
|
||
}
|
||
// 두 손가락 핀치로 화면에 꽉 차게 확대(최대 4배)하고, 확대 중엔 두 손가락으로 이동.
|
||
// 한 손가락/펜슬은 그대로 필기·배치에 쓰인다. 배치 모드에서는 요소 핀치와
|
||
// 충돌하지 않도록 페이지 줌을 잠근다.
|
||
DiaryZoomContainer(pageSize: DiaryPageMetrics.size, zoomEnabled: !arranging) {
|
||
pageContent
|
||
.frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height)
|
||
}
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 34)
|
||
.onChange(of: photoSelection) {
|
||
guard let photoSelection else { return }
|
||
Task {
|
||
if let data = try? await photoSelection.loadTransferable(type: Data.self),
|
||
let image = UIImage(data: data) {
|
||
addPhoto(image)
|
||
}
|
||
self.photoSelection = nil
|
||
}
|
||
}
|
||
.sheet(item: $editingTextItem) { item in
|
||
DiaryTextEditSheet(item: item, onCommit: save)
|
||
}
|
||
}
|
||
|
||
// MARK: 페이지 도구 줄
|
||
|
||
private var pageToolbar: some View {
|
||
HStack(spacing: 12) {
|
||
// 양식이 깔린 페이지에서는 줄 노트가 양식에 가려지므로 토글을 숨긴다 (상호 배타)
|
||
if template == nil {
|
||
Toggle(isOn: $page.lined.animation()) {
|
||
Label("줄 노트", systemImage: "text.justify")
|
||
}
|
||
.toggleStyle(.button)
|
||
.onChange(of: page.lined) { save() }
|
||
}
|
||
|
||
if page.lined, template == nil {
|
||
Menu {
|
||
Picker("줄 간격", selection: Binding(
|
||
get: { page.lineSpacing },
|
||
set: { page.lineSpacing = $0; save() }
|
||
)) {
|
||
Text("좁게").tag(34.0)
|
||
Text("보통").tag(44.0)
|
||
Text("넓게").tag(56.0)
|
||
Text("아주 넓게").tag(70.0)
|
||
}
|
||
} label: {
|
||
Label("줄 간격", systemImage: "arrow.up.and.down.text.horizontal")
|
||
}
|
||
}
|
||
|
||
PhotosPicker(selection: $photoSelection, matching: .images) {
|
||
Label("사진", systemImage: "photo.badge.plus")
|
||
}
|
||
|
||
Menu {
|
||
shapeButton(.rectangle, label: String(localized: "사각형"), symbol: "rectangle")
|
||
shapeButton(.ellipse, label: String(localized: "원"), symbol: "circle")
|
||
shapeButton(.arrow, label: String(localized: "화살표"), symbol: "arrow.right")
|
||
shapeButton(.line, label: String(localized: "선"), symbol: "minus")
|
||
} label: {
|
||
Label("도형", systemImage: "square.on.circle")
|
||
}
|
||
|
||
Button {
|
||
addText()
|
||
} label: {
|
||
Label("텍스트", systemImage: "textformat")
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if arranging, let selectedItemID,
|
||
let item = page.items.first(where: { $0.uuid == selectedItemID }) {
|
||
if item.kind == .text {
|
||
Button {
|
||
editingTextItem = item
|
||
} label: {
|
||
Label("글 수정", systemImage: "square.and.pencil")
|
||
}
|
||
}
|
||
Button(role: .destructive) {
|
||
deleteItem(item)
|
||
} label: {
|
||
Label("삭제", systemImage: "trash")
|
||
}
|
||
}
|
||
|
||
Toggle(isOn: $arranging.animation()) {
|
||
Label("배치 모드", systemImage: "hand.draw")
|
||
}
|
||
.toggleStyle(.button)
|
||
.onChange(of: arranging) {
|
||
if !arranging { selectedItemID = nil }
|
||
}
|
||
}
|
||
.font(.subheadline)
|
||
.tint(AppTheme.green)
|
||
.padding(.horizontal, 4)
|
||
.frame(maxWidth: DiaryPageMetrics.size.width)
|
||
}
|
||
|
||
private func shapeButton(_ kind: DiaryItemKind, label: String, symbol: String) -> some View {
|
||
Button {
|
||
addShape(kind)
|
||
} label: {
|
||
Label(label, systemImage: symbol)
|
||
}
|
||
}
|
||
|
||
// MARK: 페이지 본체 (768×1024 고정 좌표)
|
||
|
||
private var pageContent: some View {
|
||
ZStack {
|
||
// 종이
|
||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||
.fill(AppTheme.surface)
|
||
.shadow(color: .black.opacity(0.10), radius: 14, y: 6)
|
||
if page.lined, template == nil {
|
||
DiaryLinedPaper(spacing: page.lineSpacing)
|
||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||
}
|
||
|
||
// 배경 양식 (PDF/이미지) — 필기·배치 요소 아래에 깔린다
|
||
if let template {
|
||
DiaryTemplateBackground(template: template, pageIndex: page.templatePageIndex,
|
||
isActive: isActive)
|
||
.allowsHitTesting(false)
|
||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||
}
|
||
|
||
// 배치 요소 (사진/도형) — 필기 아래에 깔린다
|
||
ForEach(page.items, id: \.uuid) { item in
|
||
DiaryItemView(
|
||
item: item,
|
||
pageSize: DiaryPageMetrics.size,
|
||
arranging: arranging,
|
||
selectedID: $selectedItemID,
|
||
onCommit: save
|
||
)
|
||
}
|
||
|
||
// 필기 캔버스 — 항상 최상단. 배치 모드에서는 터치를 요소들에게 넘기고,
|
||
// 열람 전용(프리미엄 만료)에서는 필기를 받지 않는다.
|
||
DiaryPencilCanvas(
|
||
drawingData: page.drawingData,
|
||
isActive: isActive && !arranging && !readOnly
|
||
) { data in
|
||
page.drawingData = data
|
||
save()
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||
.allowsHitTesting(!arranging && !readOnly)
|
||
|
||
if arranging {
|
||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||
.strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5]))
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if arranging { selectedItemID = nil }
|
||
}
|
||
}
|
||
|
||
// MARK: 요소 추가/삭제
|
||
|
||
private func addPhoto(_ image: UIImage) {
|
||
let item = DiaryPageItem(kind: .photo)
|
||
item.imageData = image.diaryCompressed()
|
||
item.page = page
|
||
context.insert(item)
|
||
arranging = true
|
||
selectedItemID = item.uuid
|
||
save()
|
||
}
|
||
|
||
private func addShape(_ kind: DiaryItemKind) {
|
||
let item = DiaryPageItem(kind: kind)
|
||
item.colorHex = AppTheme.tagPresets.randomElement() ?? "#2F6B4F"
|
||
item.page = page
|
||
context.insert(item)
|
||
arranging = true
|
||
selectedItemID = item.uuid
|
||
save()
|
||
}
|
||
|
||
/// 텍스트 상자 추가 — 만들자마자 내용 입력 시트를 띄운다
|
||
private func addText() {
|
||
let item = DiaryPageItem(kind: .text)
|
||
item.colorHex = "#3A3A3A"
|
||
item.widthRatio = 0.45
|
||
item.page = page
|
||
context.insert(item)
|
||
arranging = true
|
||
selectedItemID = item.uuid
|
||
save()
|
||
editingTextItem = item
|
||
}
|
||
|
||
private func deleteItem(_ item: DiaryPageItem) {
|
||
context.delete(item)
|
||
selectedItemID = nil
|
||
save()
|
||
}
|
||
|
||
private func save() {
|
||
page.entry?.updatedAt = .now
|
||
try? context.save()
|
||
}
|
||
}
|
||
|
||
// MARK: - 줄 노트 배경
|
||
|
||
struct DiaryLinedPaper: View {
|
||
/// 줄 간격 (페이지별 설정, 기본 44pt)
|
||
var spacing: Double = DiaryPageMetrics.lineSpacing
|
||
|
||
var body: some View {
|
||
Canvas { canvasContext, size in
|
||
let step = max(CGFloat(spacing), 20)
|
||
var y = DiaryPageMetrics.lineTopInset
|
||
while y < size.height - 24 {
|
||
var path = Path()
|
||
path.move(to: CGPoint(x: 32, y: y))
|
||
path.addLine(to: CGPoint(x: size.width - 32, y: y))
|
||
canvasContext.stroke(path, with: .color(.primary.opacity(0.10)), lineWidth: 1)
|
||
y += step
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 핀치줌 컨테이너 (UIScrollView 기반)
|
||
|
||
/// 노트 페이지를 감싸는 확대/축소 스크롤 뷰.
|
||
/// - 처음엔 페이지가 화면에 딱 맞게(fit) 표시되고, 두 손가락 핀치로 최대 4배까지 확대
|
||
/// - 확대 상태에서 두 손가락 드래그로 이동 (한 손가락·펜슬은 안쪽 필기 캔버스가 그대로 받는다)
|
||
/// - 배치 모드에서는 zoomEnabled=false로 페이지 줌을 잠가 요소 핀치와 충돌하지 않게 한다
|
||
private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
|
||
let pageSize: CGSize
|
||
let zoomEnabled: Bool
|
||
@ViewBuilder let content: () -> Content
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator()
|
||
}
|
||
|
||
func makeUIView(context: Context) -> DiaryZoomScrollView {
|
||
let scroll = DiaryZoomScrollView()
|
||
scroll.pageSize = pageSize
|
||
scroll.delegate = context.coordinator
|
||
scroll.showsVerticalScrollIndicator = false
|
||
scroll.showsHorizontalScrollIndicator = false
|
||
scroll.contentInsetAdjustmentBehavior = .never
|
||
scroll.scrollsToTop = false
|
||
scroll.bounces = false
|
||
scroll.bouncesZoom = true
|
||
scroll.backgroundColor = .clear
|
||
// 이동/확대는 손가락 전용 — 펜슬 터치는 스크롤이 가로채지 않고 캔버스가 받아 필기한다.
|
||
// 손가락 한 개로도 (확대 상태에서) 페이지를 상하좌우로 이동할 수 있다.
|
||
scroll.panGestureRecognizer.allowedTouchTypes = [
|
||
NSNumber(value: UITouch.TouchType.direct.rawValue)
|
||
]
|
||
scroll.panGestureRecognizer.minimumNumberOfTouches = 1
|
||
scroll.pinchGestureRecognizer?.allowedTouchTypes = [
|
||
NSNumber(value: UITouch.TouchType.direct.rawValue)
|
||
]
|
||
|
||
let hosting = UIHostingController(rootView: content())
|
||
hosting.view.backgroundColor = .clear
|
||
hosting.safeAreaRegions = []
|
||
hosting.view.frame = CGRect(origin: .zero, size: pageSize)
|
||
scroll.addSubview(hosting.view)
|
||
scroll.contentSize = pageSize
|
||
scroll.zoomedView = hosting.view
|
||
context.coordinator.hosting = hosting
|
||
#if DEBUG
|
||
// 검증용: -diaryZoomScale 2 → 2.5초 뒤 fit 대비 N배로 프로그램 줌
|
||
// (핀치 없이 확대 시 필기 획 유지·선명도를 스크린샷으로 확인)
|
||
let zoomFactor = UserDefaults.standard.double(forKey: "diaryZoomScale")
|
||
if zoomFactor > 0 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak scroll] in
|
||
guard let scroll else { return }
|
||
scroll.setZoomScale(scroll.minimumZoomScale * zoomFactor, animated: false)
|
||
scroll.delegate?.scrollViewDidEndZooming?(
|
||
scroll, with: scroll.zoomedView, atScale: scroll.zoomScale
|
||
)
|
||
}
|
||
}
|
||
#endif
|
||
return scroll
|
||
}
|
||
|
||
func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) {
|
||
context.coordinator.hosting?.rootView = content()
|
||
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
|
||
// 배치 모드에서는 한 손가락 드래그를 요소 이동에 양보 (페이지 이동은 두 손가락)
|
||
scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2
|
||
}
|
||
|
||
final class Coordinator: NSObject, UIScrollViewDelegate {
|
||
var hosting: UIHostingController<Content>?
|
||
|
||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||
(scrollView as? DiaryZoomScrollView)?.zoomedView
|
||
}
|
||
|
||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
|
||
}
|
||
|
||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||
// 핀치가 끝난 배율에 맞춰 캔버스 래스터 해상도를 다시 맞춘다 (확대 시 픽셀 번짐 방지)
|
||
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 레이아웃(회전·창 크기 변경)에 맞춰 fit 배율을 다시 계산하고 콘텐츠를 가운데 정렬하는 스크롤 뷰
|
||
final class DiaryZoomScrollView: UIScrollView {
|
||
var pageSize: CGSize = DiaryPageMetrics.size
|
||
weak var zoomedView: UIView?
|
||
private var didSetInitialZoom = false
|
||
private var lastCanvasScale: CGFloat = 0
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||
let fit = min(bounds.width / pageSize.width, bounds.height / pageSize.height)
|
||
let wasAtMinimum = zoomScale <= minimumZoomScale + 0.0001
|
||
if abs(minimumZoomScale - fit) > 0.0001 {
|
||
minimumZoomScale = fit
|
||
maximumZoomScale = max(fit * 4, 1)
|
||
// 처음이거나 fit 상태로 보고 있었다면 새 크기에 맞춰 다시 fit
|
||
if !didSetInitialZoom || wasAtMinimum || zoomScale < fit {
|
||
zoomScale = fit
|
||
didSetInitialZoom = true
|
||
}
|
||
} else if !didSetInitialZoom {
|
||
zoomScale = fit
|
||
didSetInitialZoom = true
|
||
}
|
||
centerZoomedView()
|
||
updateCanvasSharpness()
|
||
}
|
||
|
||
/// 콘텐츠가 화면보다 작을 때 가운데 정렬
|
||
func centerZoomedView() {
|
||
guard let zoomedView else { return }
|
||
let dx = max((bounds.width - contentSize.width) / 2, 0)
|
||
let dy = max((bounds.height - contentSize.height) / 2, 0)
|
||
zoomedView.center = CGPoint(x: contentSize.width / 2 + dx,
|
||
y: contentSize.height / 2 + dy)
|
||
}
|
||
|
||
/// 펜슬 캔버스의 래스터 해상도를 현재 줌 배율에 정확히 맞춘다.
|
||
/// 페이지는 논리 768×1024pt 고정 + 스케일 변환이라, 캔버스 래스터가 화면 픽셀과
|
||
/// 1:1로 일치(contentsScale = 기기 스케일 × 줌)해야 가장 선명하다 — 남거나(다운샘플 소프트닝)
|
||
/// 모자라면(업스케일 픽셀) 흐려 보인다. 상한은 메모리 보호용 (기기 스케일 × 3).
|
||
/// 실제 적용·유지는 SharpPencilCanvasView가 담당한다 (획을 그릴 때마다 재적용).
|
||
func updateCanvasSharpness() {
|
||
guard let zoomedView else { return }
|
||
let display = max(traitCollection.displayScale, 1)
|
||
let target = display * min(max(zoomScale, 0.7), 3)
|
||
guard abs(target - lastCanvasScale) > 0.01 else { return }
|
||
lastCanvasScale = target
|
||
Self.assignCanvasScale(target, in: zoomedView)
|
||
}
|
||
|
||
/// 서브트리에서 펜슬 캔버스·양식 배경을 찾아 목표 래스터 배율을 지정한다
|
||
/// (PDF 양식도 줌에 맞춰 다시 래스터해 확대 시 흐려지지 않게 — 캔버스와 같은 규칙)
|
||
private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) {
|
||
if let canvas = view as? SharpPencilCanvasView {
|
||
canvas.targetContentsScale = scale
|
||
} else if let templateView = view as? DiaryTemplateImageView {
|
||
templateView.targetContentsScale = scale
|
||
}
|
||
for subview in view.subviews {
|
||
assignCanvasScale(scale, in: subview)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 배치 요소 (사진/도형)
|
||
|
||
struct DiaryItemView: View {
|
||
@Bindable var item: DiaryPageItem
|
||
let pageSize: CGSize
|
||
let arranging: Bool
|
||
@Binding var selectedID: UUID?
|
||
var onCommit: () -> Void = {}
|
||
|
||
@State private var dragTranslation: CGSize = .zero
|
||
@State private var pinchScale: CGFloat = 1
|
||
|
||
private var isSelected: Bool { selectedID == item.uuid }
|
||
|
||
private var width: CGFloat {
|
||
pageSize.width * item.widthRatio * pinchScale
|
||
}
|
||
|
||
private var height: CGFloat {
|
||
if item.kind == .photo,
|
||
let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 {
|
||
return width * image.size.height / image.size.width
|
||
}
|
||
return width * item.kind.defaultAspect
|
||
}
|
||
|
||
var body: some View {
|
||
content
|
||
.frame(width: width, height: height)
|
||
.overlay {
|
||
if arranging && isSelected {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.strokeBorder(AppTheme.green, lineWidth: 2)
|
||
}
|
||
}
|
||
.rotationEffect(.degrees(item.rotationDegrees))
|
||
.position(
|
||
x: pageSize.width * item.centerX + dragTranslation.width,
|
||
y: pageSize.height * item.centerY + dragTranslation.height
|
||
)
|
||
.allowsHitTesting(arranging)
|
||
.onTapGesture { selectedID = item.uuid }
|
||
.gesture(arranging ? moveAndResize : nil)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
switch item.kind {
|
||
case .photo:
|
||
if let data = item.imageData, let image = UIImage(data: data) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.shadow(color: .black.opacity(0.12), radius: 5, y: 2)
|
||
} else {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(Color.gray.opacity(0.2))
|
||
}
|
||
case .rectangle:
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.strokeBorder(Color(hex: item.colorHex), lineWidth: 5)
|
||
case .ellipse:
|
||
Ellipse()
|
||
.strokeBorder(Color(hex: item.colorHex), lineWidth: 5)
|
||
case .arrow:
|
||
DiaryArrowShape()
|
||
.stroke(Color(hex: item.colorHex),
|
||
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
|
||
case .line:
|
||
DiaryLineShape()
|
||
.stroke(Color(hex: item.colorHex),
|
||
style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||
case .text:
|
||
Text(item.text.isEmpty ? String(localized: "텍스트") : item.text)
|
||
.font(.system(size: 24, weight: .medium))
|
||
.foregroundStyle(Color(hex: item.colorHex).opacity(item.text.isEmpty ? 0.4 : 1))
|
||
.minimumScaleFactor(0.3)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
}
|
||
}
|
||
|
||
/// 드래그(이동) + 핀치(크기) 동시 제스처. 끝났을 때만 모델에 반영한다.
|
||
private var moveAndResize: some Gesture {
|
||
let drag = DragGesture()
|
||
.onChanged { value in
|
||
selectedID = item.uuid
|
||
dragTranslation = value.translation
|
||
}
|
||
.onEnded { value in
|
||
item.centerX = min(max(item.centerX + value.translation.width / pageSize.width, 0.02), 0.98)
|
||
item.centerY = min(max(item.centerY + value.translation.height / pageSize.height, 0.02), 0.98)
|
||
dragTranslation = .zero
|
||
onCommit()
|
||
}
|
||
let pinch = MagnificationGesture()
|
||
.onChanged { value in
|
||
selectedID = item.uuid
|
||
pinchScale = value
|
||
}
|
||
.onEnded { value in
|
||
item.widthRatio = min(max(item.widthRatio * value, 0.08), 0.95)
|
||
pinchScale = 1
|
||
onCommit()
|
||
}
|
||
return drag.simultaneously(with: pinch)
|
||
}
|
||
}
|
||
|
||
// MARK: - 텍스트 상자 내용 편집 시트
|
||
|
||
private struct DiaryTextEditSheet: View {
|
||
@Bindable var item: DiaryPageItem
|
||
var onCommit: () -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@FocusState private var focused: Bool
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
TextField("내용을 입력하세요", text: $item.text, axis: .vertical)
|
||
.lineLimit(3...8)
|
||
.padding(10)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.focused($focused)
|
||
ColorPicker("글자 색", selection: Binding(
|
||
get: { Color(hex: item.colorHex) },
|
||
set: { item.colorHex = $0.hexString }
|
||
), supportsOpacity: false)
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.background(AppTheme.background)
|
||
.navigationTitle("텍스트 상자")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("완료") {
|
||
onCommit()
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.height(300)])
|
||
.onAppear { focused = true }
|
||
}
|
||
}
|
||
|
||
struct DiaryArrowShape: Shape {
|
||
func path(in rect: CGRect) -> Path {
|
||
var path = Path()
|
||
let midY = rect.midY
|
||
path.move(to: CGPoint(x: rect.minX, y: midY))
|
||
path.addLine(to: CGPoint(x: rect.maxX, y: midY))
|
||
let head = min(rect.width * 0.22, rect.height * 0.9)
|
||
path.move(to: CGPoint(x: rect.maxX - head, y: midY - head * 0.6))
|
||
path.addLine(to: CGPoint(x: rect.maxX, y: midY))
|
||
path.addLine(to: CGPoint(x: rect.maxX - head, y: midY + head * 0.6))
|
||
return path
|
||
}
|
||
}
|
||
|
||
struct DiaryLineShape: Shape {
|
||
func path(in rect: CGRect) -> Path {
|
||
var path = Path()
|
||
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
|
||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||
return path
|
||
}
|
||
}
|
||
|
||
// MARK: - PencilKit 캔버스
|
||
|
||
/// 래스터 해상도를 스스로 유지하는 PKCanvasView.
|
||
/// 획을 그리거나 내부 레이아웃이 갱신될 때 PencilKit이 렌더 레이어를 새로 만들며
|
||
/// contentsScale이 기본값으로 돌아가는 것을 매번 다시 맞춘다.
|
||
final class SharpPencilCanvasView: PKCanvasView {
|
||
/// 바깥 줌 스크롤이 지정하는 목표 래스터 배율 (기기 스케일 × 현재 줌)
|
||
var targetContentsScale: CGFloat = 0 {
|
||
didSet {
|
||
if abs(targetContentsScale - oldValue) > 0.01 { applySharpness() }
|
||
}
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
applySharpness()
|
||
}
|
||
|
||
func applySharpness() {
|
||
guard targetContentsScale > 0 else { return }
|
||
Self.apply(targetContentsScale, view: self)
|
||
}
|
||
|
||
/// ⚠️ 뷰의 contentScaleFactor만 조정한다 — 예전처럼 서브'레이어'의 contentsScale을
|
||
/// 직접 바꾸고 setNeedsDisplay()를 호출하면, PencilKit이 렌더러로 직접 contents를
|
||
/// 채우는 획 타일 레이어의 픽셀이 지워진 채 다시 그려지지 않아 확대할 때마다
|
||
/// 필기가 사라지는 버그가 된다 (그리는 동안엔 라이브 렌더로 보였다가 획을 떼면
|
||
/// 다시 지워짐). 뷰 수준 배율만 지정하면 PencilKit이 자기 타이밍에 새 배율로
|
||
/// 다시 렌더링하므로 획이 유지되면서 선명해진다.
|
||
private static func apply(_ scale: CGFloat, view: UIView) {
|
||
if abs(view.contentScaleFactor - scale) > 0.01 {
|
||
view.contentScaleFactor = scale
|
||
}
|
||
for subview in view.subviews {
|
||
apply(scale, view: subview)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct DiaryPencilCanvas: UIViewRepresentable {
|
||
let drawingData: Data
|
||
/// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만
|
||
let isActive: Bool
|
||
let onChange: (Data) -> Void
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(onChange: onChange)
|
||
}
|
||
|
||
func makeUIView(context: Context) -> SharpPencilCanvasView {
|
||
let canvas = SharpPencilCanvasView()
|
||
canvas.backgroundColor = .clear
|
||
canvas.isOpaque = false
|
||
#if targetEnvironment(simulator)
|
||
// 시뮬레이터에는 펜슬이 없으므로 마우스(손가락) 입력 허용
|
||
canvas.drawingPolicy = .anyInput
|
||
#else
|
||
// 실기기: 필기는 펜슬 전용 — 손가락은 페이지 이동/확대에만 쓰인다
|
||
canvas.drawingPolicy = .pencilOnly
|
||
#endif
|
||
canvas.delegate = context.coordinator
|
||
if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty {
|
||
canvas.drawing = drawing
|
||
}
|
||
context.coordinator.canvas = canvas
|
||
return canvas
|
||
}
|
||
|
||
func updateUIView(_ canvas: SharpPencilCanvasView, context: Context) {
|
||
context.coordinator.setActive(isActive)
|
||
}
|
||
|
||
@MainActor
|
||
final class Coordinator: NSObject, PKCanvasViewDelegate {
|
||
let onChange: (Data) -> Void
|
||
weak var canvas: SharpPencilCanvasView?
|
||
private let toolPicker = PKToolPicker()
|
||
private var observing = false
|
||
|
||
init(onChange: @escaping (Data) -> Void) {
|
||
self.onChange = onChange
|
||
}
|
||
|
||
func setActive(_ active: Bool) {
|
||
guard let canvas else { return }
|
||
if active {
|
||
if !observing {
|
||
toolPicker.addObserver(canvas)
|
||
observing = true
|
||
}
|
||
toolPicker.setVisible(true, forFirstResponder: canvas)
|
||
// 창에 붙기 전엔 실패하므로 다음 런루프에서 재시도
|
||
DispatchQueue.main.async {
|
||
if canvas.window != nil {
|
||
canvas.becomeFirstResponder()
|
||
}
|
||
}
|
||
} else if canvas.isFirstResponder {
|
||
canvas.resignFirstResponder()
|
||
}
|
||
}
|
||
|
||
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
|
||
onChange(canvasView.drawing.dataRepresentation())
|
||
// 새 획의 렌더 레이어가 기본 해상도로 생겼을 수 있어 다시 맞춘다
|
||
(canvasView as? SharpPencilCanvasView)?.applySharpness()
|
||
}
|
||
}
|
||
}
|