- 텍스트 상자 요소: 노트 페이지 툴바에 '텍스트' 추가 — 키보드로 쓰는 글 상자를 배치 요소로 올린다 (배치 모드에서 이동·크기 조절, 선택 시 '글 수정'으로 내용·색 편집, 내보내기에도 그대로 렌더). DiaryPageItem에 text 필드 추가(kind .text, CloudKit 기본값 규칙 준수) - 페이지 순서 변경: ⋯ 메뉴 → '페이지 순서 변경' 시트에서 노트 페이지를 드래그로 재배열 (요약 페이지는 항상 첫 장) - 프리미엄 만료 후 열람 허용: 이미 쓴 일기가 있으면 잠금 화면 대신 달력을 열람 전용으로 연다 — 안내 배너 + 일기 있는 날짜만 활성, 진입만으로 엔트리를 만들지 않고, 기분/할일/필기/필터/캘린더 등 모든 편집 UI 비활성 (environment diaryReadOnly로 전파). 일기를 쓴 적 없는 미구매 사용자는 기존 잠금 안내 유지 검증(iPad): 시드된 노트 페이지에서 텍스트 상자 렌더·툴바 확인, -premium NO에서 배너/날짜 비활성/편집 UI 숨김 스크린샷 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
693 lines
26 KiB
Swift
693 lines
26 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
|
||
|
||
@State private var arranging = false
|
||
@State private var selectedItemID: UUID?
|
||
@State private var photoSelection: PhotosPickerItem?
|
||
/// 텍스트 상자 내용 편집 대상
|
||
@State private var editingTextItem: DiaryPageItem?
|
||
|
||
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) {
|
||
Toggle(isOn: $page.lined.animation()) {
|
||
Label("줄 노트", systemImage: "text.justify")
|
||
}
|
||
.toggleStyle(.button)
|
||
.onChange(of: page.lined) { save() }
|
||
|
||
if page.lined {
|
||
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 {
|
||
DiaryLinedPaper(spacing: page.lineSpacing)
|
||
.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
|
||
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)
|
||
}
|
||
|
||
/// 서브트리에서 펜슬 캔버스를 찾아 목표 래스터 배율을 지정한다
|
||
private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) {
|
||
if let canvas = view as? SharpPencilCanvasView {
|
||
canvas.targetContentsScale = scale
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
|
||
private static func apply(_ scale: CGFloat, view: UIView) {
|
||
if abs(view.contentScaleFactor - scale) > 0.01 {
|
||
view.contentScaleFactor = scale
|
||
}
|
||
apply(scale, layer: view.layer)
|
||
for subview in view.subviews {
|
||
apply(scale, view: subview)
|
||
}
|
||
}
|
||
|
||
private static func apply(_ scale: CGFloat, layer: CALayer) {
|
||
if abs(layer.contentsScale - scale) > 0.01 {
|
||
layer.contentsScale = scale
|
||
layer.setNeedsDisplay()
|
||
}
|
||
for sublayer in layer.sublayers ?? [] {
|
||
apply(scale, layer: sublayer)
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|