mycode/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
songyc macbook 0a55b45515 feat(1.5-p6): 소킹 중 추가 5건 — 아이폰 일기 탭·일기 건강 카드·타임테이블 수면/운동 실구간·수면 표시 구간(요일별)·맥 필터 안내, 빌드 4
- 아이폰 일기 개방: AppTab.phoneCases·radialOrder, '그리기' 토글(손가락)·가로 도구줄, 일기 잠금 설정 전 기기
- 건강 카드: DiarySection.health(목록 끝), DiaryHealthCard(지표 선택 공용화·타일 상속·맥 숨김), 인쇄 요약 동승
- 타임테이블 실구간: HealthIntervals 캐시(App Group 400일, CloudKit 금지), injectingHealthBlocks(수면 인디고·운동 주황), 필터 '건강 데이터' 토글+맥 안내
- 수면 표시 구간: SleepWindowPrefs(전체+요일별), 평문 sleep 키=유효 구간 값, 백필 서명 매핑 포함, 새 수면 다짐 기본값 시드
- 실측 수정: 일기 목표 카드 건강 다짐 값 단위, ja 건강 칩 말줄임
- 검증 61/20/27/10 ALL PASS·3종 빌드·카탈로그 0/0·시각 QA 10여 장(26.5/18.5/맥 분기)
- 마케팅: 06-bubble 재촬영, 10-week→10-diary 교체, 아이패드 03 재촬영(3언어), whats-new/설명 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 21:56:57 +09:00

1465 lines
62 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DiaryNotePage.swift
// Haru_Danim
//
// + / .
//
// : 768×1024pt scaleEffect /.
// (PKDrawing ) ( ) ·
// , ( 768pt ) .
//
// : ( ), ' '
// / · ( ).
//
import SwiftUI
import SwiftData
import PencilKit
import PhotosUI
/// ( ).
/// PDF (DiaryTemplateRenderer) .
nonisolated enum DiaryPageMetrics {
static let size = CGSize(width: 768, height: 1024)
static let lineSpacing: CGFloat = 44
static let lineTopInset: CGFloat = 72
/// ·
static let coordSpace = "diaryPage"
}
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?
/// (····)
@State private var editingShapeItem: DiaryPageItem?
/// ()
@State private var inlineEditingTextID: UUID?
#if DEBUG
/// -diaryShapeEdit·-diaryTextEdit ( 1)
@State private var debugShapeEditDone = false
@State private var debugTextEditDone = false
#endif
/// . ()·(, 1.5 ) ·
/// '' (§6.7).
/// true( ) .
@State private var drawMode: Bool = {
#if DEBUG
// ·: -diaryDrawMode YES ·
if UserDefaults.standard.bool(forKey: "diaryDrawMode") { return true }
#endif
return !(DeviceLayout.isMac || DeviceLayout.isPhone)
}()
/// ( 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)
}
.sheet(item: $editingShapeItem) { item in
DiaryShapeEditSheet(item: item, onCommit: save)
}
// ( · ·
// · ·) +
.onChange(of: inlineEditingTextID) { oldValue, _ in
if let oldValue { finalizeInlineText(oldValue) }
}
// ( )
.onChange(of: selectedItemID) {
if let editing = inlineEditingTextID, selectedItemID != editing {
inlineEditingTextID = nil
}
}
.onAppear {
debugOpenShapeEditIfNeeded()
debugOpenTextEditIfNeeded()
}
.onChange(of: isActive) {
debugOpenShapeEditIfNeeded()
debugOpenTextEditIfNeeded()
}
}
/// ·: -diaryShapeEdit YES ' '
private func debugOpenShapeEditIfNeeded() {
#if DEBUG
guard !debugShapeEditDone, isActive,
UserDefaults.standard.bool(forKey: "diaryShapeEdit"),
let shape = page.items.first(where: { $0.kind != .photo && $0.kind != .text })
else { return }
debugShapeEditDone = true
arranging = true
selectedItemID = shape.uuid
editingShapeItem = shape
#endif
}
/// ·: -diaryTextEdit YES
private func debugOpenTextEditIfNeeded() {
#if DEBUG
guard !debugTextEditDone, isActive,
UserDefaults.standard.bool(forKey: "diaryTextEdit"),
let textItem = page.items.first(where: { $0.kind == .text })
else { return }
debugTextEditDone = true
arranging = true
selectedItemID = textItem.uuid
inlineEditingTextID = textItem.uuid
#endif
}
// MARK:
/// : ( ).
/// : '' , '' .
/// xLarge +
/// ( ).
/// (1.5 ) ''
@ViewBuilder
private var pageToolbar: some View {
Group {
if DeviceLayout.isMac || DeviceLayout.isPhone {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
textAddButton
photoPickerButton
shapeMenu
linedControls
drawModeToggle
selectionButtons
arrangeToggle
}
.padding(.horizontal, 4)
}
} else {
HStack(spacing: 12) {
linedControls
photoPickerButton
shapeMenu
textAddButton
Spacer()
selectionButtons
arrangeToggle
}
.padding(.horizontal, 4)
}
}
.font(.subheadline)
.lineLimit(1)
.tint(AppTheme.green)
.frame(maxWidth: DiaryPageMetrics.size.width)
}
/// + ( )
@ViewBuilder
private var linedControls: some View {
if template == nil {
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")
}
}
}
}
private var photoPickerButton: some View {
PhotosPicker(selection: $photoSelection, matching: .images) {
Label("사진", systemImage: "photo.badge.plus")
}
}
private var shapeMenu: some View {
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")
}
}
private var textAddButton: some View {
Button {
addText()
} label: {
Label("텍스트", systemImage: "textformat")
}
}
/// · /
private var drawModeToggle: some View {
Toggle(isOn: $drawMode.animation()) {
Label("그리기", systemImage: "pencil.tip")
}
.toggleStyle(.button)
.onChange(of: drawMode) {
if drawMode { arranging = false }
}
}
@ViewBuilder
private var selectionButtons: some View {
if arranging, let selectedItemID,
let item = page.items.first(where: { $0.uuid == selectedItemID }) {
if item.kind == .text {
Button {
endInlineTextEditing()
editingTextItem = item
} label: {
Label("스타일", systemImage: "textformat")
}
} else if item.kind != .photo {
Button {
editingShapeItem = item
} label: {
Label("도형 수정", systemImage: "slider.horizontal.3")
}
}
Button(role: .destructive) {
deleteItem(item)
} label: {
Label("삭제", systemImage: "trash")
}
}
}
private var arrangeToggle: some View {
Toggle(isOn: $arranging.animation()) {
Label("배치 모드", systemImage: "hand.draw")
}
.toggleStyle(.button)
.onChange(of: arranging) {
if !arranging {
selectedItemID = nil
endInlineTextEditing()
}
// ·: /
if arranging && (DeviceLayout.isMac || DeviceLayout.isPhone) { drawMode = false }
}
}
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,
inlineEditingID: $inlineEditingTextID,
onCommit: save
)
}
// . ,
// ( ) .
// drawMode · ( true , §6.7)
DiaryPencilCanvas(
drawingData: page.drawingData,
isActive: isActive && !arranging && !readOnly && drawMode,
fixedLightInk: template != nil
) { data in
page.drawingData = data
save()
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.allowsHitTesting(!arranging && !readOnly && drawMode)
if arranging {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5]))
}
}
.contentShape(Rectangle())
.coordinateSpace(name: DiaryPageMetrics.coordSpace)
.onTapGesture {
if arranging {
selectedItemID = nil
endInlineTextEditing()
}
}
}
/// · onChange(of: inlineEditingTextID) finalize
/// ( DiaryItemView )
private func endInlineTextEditing() {
guard inlineEditingTextID != nil else { return }
inlineEditingTextID = nil
}
/// : (
/// ). , '' .
private func finalizeInlineText(_ id: UUID) {
if let item = page.items.first(where: { $0.uuid == id }),
item.kind == .text,
item.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
editingTextItem?.uuid != id {
context.delete(item)
if selectedItemID == id { selectedItemID = nil }
}
save()
}
// 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()
inlineEditingTextID = item.uuid
}
private func deleteItem(_ item: DiaryPageItem) {
context.delete(item)
selectedItemID = nil
// ( uuid
// finalize )
if inlineEditingTextID == item.uuid { inlineEditingTextID = 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() -> DiaryZoomCoordinator {
DiaryZoomCoordinator()
}
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: AnyView(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 = AnyView(content())
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
// ( )
scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2
}
}
/// · + AnyView (1.4 18 ):
/// DiaryZoomContainer<Content> UIHostingController<Content> ,
/// 18 Release(-O wholemodule) swift-frontend deinit
/// (SILPerformanceInliner
/// isCallerAndCalleeLayoutConstraintsCompatible 26 , ).
/// rootView AnyView .
private final class DiaryZoomCoordinator: NSObject, UIScrollViewDelegate {
var hosting: UIHostingController<AnyView>?
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 inlineEditingID: Binding<UUID?> = .constant(nil)
var onCommit: () -> Void = {}
@State private var dragTranslation: CGSize = .zero
@State private var pinchScale: CGFloat = 1
@State private var rotationDelta: Angle = .zero
/// (pt )
@State private var liveSize: CGSize?
/// ( · )
@State private var liveCenterShift: CGSize = .zero
/// (·)
@State private var liveRotation: Double?
/// · (· )
@State private var isSnapped = false
@FocusState private var inlineFocused: Bool
private var isSelected: Bool { selectedID == item.uuid }
private var isInlineEditing: Bool { inlineEditingID.wrappedValue == item.uuid }
private var baseWidth: CGFloat { pageSize.width * item.widthRatio }
private var baseHeight: CGFloat {
if item.kind == .photo,
let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 {
return baseWidth * image.size.height / image.size.width
}
let ratio = item.heightRatio > 0 ? item.heightRatio : item.widthRatio * item.kind.defaultAspect
return pageSize.width * ratio
}
private var width: CGFloat {
if let liveSize { return liveSize.width }
return max(24, baseWidth * pinchScale)
}
private var height: CGFloat {
if let liveSize { return liveSize.height }
// ·
if item.kind == .photo, baseWidth > 0 {
return baseHeight * (width / baseWidth)
}
return max(24, baseHeight * pinchScale)
}
var body: some View {
content
.frame(width: width, height: height)
.overlay {
if arranging && isSelected {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isSnapped ? AppTheme.yellow : AppTheme.green, lineWidth: 2)
}
}
// rotationEffect .
// (.named) .
.overlay {
if arranging && isSelected && !isInlineEditing {
handles
}
}
.rotationEffect(.degrees(liveRotation ?? item.rotationDegrees) + rotationDelta)
.position(
x: pageSize.width * item.centerX + dragTranslation.width + liveCenterShift.width,
y: pageSize.height * item.centerY + dragTranslation.height + liveCenterShift.height
)
.allowsHitTesting(arranging)
.onTapGesture {
// : ()
if item.kind == .text, isSelected {
inlineEditingID.wrappedValue = item.uuid
}
selectedID = item.uuid
}
.gesture(arranging && !isInlineEditing ? moveResizeRotate : nil)
}
// MARK: ()
/// = 4( , ) /
/// = 4( , ) /
/// · = (·) / · = 1
@ViewBuilder
private var handles: some View {
switch item.kind {
case .rectangle:
handleDot()
.position(x: 0, y: 0)
.gesture(sizeHandleDrag(sx: -1, sy: -1))
handleDot()
.position(x: width, y: 0)
.gesture(sizeHandleDrag(sx: 1, sy: -1))
handleDot()
.position(x: 0, y: height)
.gesture(sizeHandleDrag(sx: -1, sy: 1))
handleDot()
.position(x: width, y: height)
.gesture(sizeHandleDrag(sx: 1, sy: 1))
case .ellipse:
handleDot()
.position(x: width / 2, y: 0)
.gesture(sizeHandleDrag(sx: 0, sy: -1))
handleDot()
.position(x: width / 2, y: height)
.gesture(sizeHandleDrag(sx: 0, sy: 1))
handleDot()
.position(x: 0, y: height / 2)
.gesture(sizeHandleDrag(sx: -1, sy: 0))
handleDot()
.position(x: width, y: height / 2)
.gesture(sizeHandleDrag(sx: 1, sy: 0))
case .arrow, .line:
handleDot()
.position(x: 0, y: height / 2)
.gesture(endpointDrag(isEnd: false))
handleDot()
.position(x: width, y: height / 2)
.gesture(endpointDrag(isEnd: true))
case .photo, .text:
handleDot()
.position(x: width, y: height)
.gesture(sizeHandleDrag(sx: 1, sy: 1))
}
}
private func handleDot() -> some View {
ZStack {
Circle()
.fill(isSnapped ? AppTheme.yellow : AppTheme.green)
Circle()
.fill(.white)
.frame(width: 8, height: 8)
}
.frame(width: 22, height: 22)
.padding(9)
.contentShape(Circle())
.accessibilityLabel(Text("크기 조절"))
}
/// ( )
private func rotatedVector(_ v: CGSize, degrees: Double) -> CGSize {
let r = degrees * .pi / 180
return CGSize(width: v.width * cos(r) - v.height * sin(r),
height: v.width * sin(r) + v.height * cos(r))
}
/// · .
/// sx/sy = (0 )
private func sizeHandleDrag(sx: CGFloat, sy: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1, coordinateSpace: .named(DiaryPageMetrics.coordSpace))
.onChanged { value in
selectedID = item.uuid
applySizeHandle(sx: sx, sy: sy, translation: value.translation, commit: false)
}
.onEnded { value in
applySizeHandle(sx: sx, sy: sy, translation: value.translation, commit: true)
}
}
private func applySizeHandle(sx: CGFloat, sy: CGFloat, translation: CGSize, commit: Bool) {
// ( )
let local = rotatedVector(translation, degrees: -item.rotationDegrees)
var w = baseWidth
var h = baseHeight
if sx != 0 { w = max(24, baseWidth + sx * local.width) }
if item.kind == .photo {
h = baseWidth > 0 ? w * (baseHeight / baseWidth) : baseHeight
} else if sy != 0 {
h = max(24, baseHeight + sy * local.height)
}
// · +
var snapped = false
if item.kind == .rectangle, sx != 0, sy != 0, abs(w - h) <= 10 {
let side = (w + h) / 2
w = side
h = side
snapped = true
}
if item.kind == .ellipse, abs(w - h) <= 10 {
if sx != 0 { w = h } else { h = w }
snapped = true
}
if snapped != isSnapped {
isSnapped = snapped
if snapped {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
// : ( )
let localShift = CGSize(width: sx * (w - baseWidth) / 2,
height: (item.kind == .photo ? 1 : sy) * (h - baseHeight) / 2)
let pageShift = rotatedVector(localShift, degrees: item.rotationDegrees)
if commit {
item.widthRatio = min(max(w / pageSize.width, 0.04), 0.95)
if item.kind != .photo {
item.heightRatio = min(max(h / pageSize.width, 0.02), 1.25)
}
item.centerX = min(max(item.centerX + pageShift.width / pageSize.width, 0.02), 0.98)
item.centerY = min(max(item.centerY + pageShift.height / pageSize.height, 0.02), 0.98)
liveSize = nil
liveCenterShift = .zero
isSnapped = false
onCommit()
} else {
liveSize = CGSize(width: w, height: h)
liveCenterShift = pageShift
}
}
/// ·
private func endpointDrag(isEnd: Bool) -> some Gesture {
DragGesture(minimumDistance: 1, coordinateSpace: .named(DiaryPageMetrics.coordSpace))
.onChanged { value in
selectedID = item.uuid
applyEndpoint(isEnd: isEnd, translation: value.translation, commit: false)
}
.onEnded { value in
applyEndpoint(isEnd: isEnd, translation: value.translation, commit: true)
}
}
private func applyEndpoint(isEnd: Bool, translation: CGSize, commit: Bool) {
let center = CGPoint(x: pageSize.width * item.centerX, y: pageSize.height * item.centerY)
let half = rotatedVector(CGSize(width: baseWidth / 2, height: 0), degrees: item.rotationDegrees)
let endPoint = CGPoint(x: center.x + half.width, y: center.y + half.height)
let startPoint = CGPoint(x: center.x - half.width, y: center.y - half.height)
let fixed = isEnd ? startPoint : endPoint
let movingFrom = isEnd ? endPoint : startPoint
let moving = CGPoint(x: movingFrom.x + translation.width, y: movingFrom.y + translation.height)
// ( )
let dx = isEnd ? moving.x - fixed.x : fixed.x - moving.x
let dy = isEnd ? moving.y - fixed.y : fixed.y - moving.y
let length = max(24, hypot(dx, dy))
let angle = Double(atan2(dy, dx)) * 180 / .pi
let newCenter = CGPoint(x: (fixed.x + moving.x) / 2, y: (fixed.y + moving.y) / 2)
if commit {
item.widthRatio = min(max(length / pageSize.width, 0.04), 1.6)
item.rotationDegrees = angle
item.centerX = min(max(newCenter.x / pageSize.width, 0.02), 0.98)
item.centerY = min(max(newCenter.y / pageSize.height, 0.02), 0.98)
liveSize = nil
liveRotation = nil
liveCenterShift = .zero
onCommit()
} else {
liveSize = CGSize(width: length, height: baseHeight)
liveRotation = angle
liveCenterShift = CGSize(width: newCenter.x - center.x, height: newCenter.y - center.y)
}
}
@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:
let radius = item.cornerRadius >= 0 ? item.cornerRadius : 10
let rect = RoundedRectangle(cornerRadius: radius, style: .continuous)
if item.filled {
rect.fill(Color(hex: item.colorHex))
} else {
rect.strokeBorder(Color(hex: item.colorHex), lineWidth: strokeWidth)
}
case .ellipse:
if item.filled {
Ellipse().fill(Color(hex: item.colorHex))
} else {
Ellipse().strokeBorder(Color(hex: item.colorHex), lineWidth: strokeWidth)
}
case .arrow:
DiaryArrowShape()
.stroke(Color(hex: item.colorHex),
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round, lineJoin: .round))
case .line:
DiaryLineShape()
.stroke(Color(hex: item.colorHex),
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
case .text:
textContent
}
}
/// 0() ( )
private var strokeWidth: CGFloat {
item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth
}
private var textContent: some View {
let size = item.fontSize > 0 ? item.fontSize : 24
let (textAlign, frameAlign): (TextAlignment, Alignment) = switch item.textAlignRaw {
case "center": (.center, .top)
case "trailing": (.trailing, .topTrailing)
default: (.leading, .topLeading)
}
let font = Font.system(size: size, weight: item.textBold ? .semibold : .medium)
let textColor = Color(hex: item.colorHex)
let borderColor = item.borderColorHex.isEmpty
? textColor.opacity(0.85)
: Color(hex: item.borderColorHex)
return ZStack {
if isInlineEditing {
// () Text ·
// UITextView ( 5· 8)
TextEditor(text: $item.text)
.font(font)
.foregroundStyle(textColor)
.multilineTextAlignment(textAlign)
.scrollContentBackground(.hidden)
.padding(.horizontal, item.textBorder ? 3 : -5)
.padding(.vertical, item.textBorder ? 0 : -8)
.focused($inlineFocused)
.onAppear { inlineFocused = true }
.onChange(of: inlineFocused) {
// ( )
if !inlineFocused && isInlineEditing {
inlineEditingID.wrappedValue = nil
onCommit()
}
}
} else {
Text(item.text.isEmpty ? String(localized: "텍스트") : item.text)
.font(font)
.multilineTextAlignment(textAlign)
.foregroundStyle(textColor.opacity(item.text.isEmpty ? 0.4 : 1))
.minimumScaleFactor(0.5)
.padding(item.textBorder ? 8 : 0)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlign)
}
}
.overlay {
if item.textBorder {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(borderColor, lineWidth: strokeWidth)
}
}
}
/// () + ( ) + () .
/// . ·' '/' ' .
private var moveResizeRotate: 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
// · 1.6
// 0.95
let maxWidthRatio: Double = (item.kind == .arrow || item.kind == .line) ? 1.6 : 0.95
item.widthRatio = min(max(item.widthRatio * value, 0.08), maxWidthRatio)
//
if item.kind != .photo, item.heightRatio > 0 {
item.heightRatio = min(max(item.heightRatio * value, 0.02), 1.25)
}
pinchScale = 1
onCommit()
}
let rotate = RotationGesture()
.onChanged { value in
selectedID = item.uuid
rotationDelta = value
}
.onEnded { value in
item.rotationDegrees += value.degrees
rotationDelta = .zero
onCommit()
}
return drag.simultaneously(with: pinch).simultaneously(with: rotate)
}
}
// MARK: -
/// (· ).
/// .
private struct DiaryValueSliderRow: View {
let title: LocalizedStringKey
@Binding var value: Double
let range: ClosedRange<Double>
let step: Double
var unit: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
Spacer()
Text(verbatim: "\(Int(value))\(unit)")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Slider(value: $value, in: range, step: step) {
Text(title)
}
}
}
}
/// · . ·
private struct DiaryRotationRow: View {
@Bindable var item: DiaryPageItem
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("회전")
Spacer()
Text(verbatim: "\(Int(item.rotationDegrees))°")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Slider(value: $item.rotationDegrees, in: -180...180, step: 5) {
Text("회전")
}
}
}
}
// MARK: -
private struct DiaryShapeEditSheet: View {
@Bindable var item: DiaryPageItem
var onCommit: () -> Void
@Environment(\.dismiss) private var dismiss
private var isClosedShape: Bool { item.kind == .rectangle || item.kind == .ellipse }
private var lineWidthBinding: Binding<Double> {
Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth },
set: { item.lineWidth = $0 })
}
private var cornerBinding: Binding<Double> {
Binding(get: { item.cornerRadius >= 0 ? item.cornerRadius : 10 },
set: { item.cornerRadius = $0 })
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ColorPicker("도형 색", selection: Binding(
get: { Color(hex: item.colorHex) },
set: { item.colorHex = $0.hexString }
), supportsOpacity: false)
if isClosedShape {
Toggle("채우기", isOn: $item.filled)
}
if !(isClosedShape && item.filled) {
DiaryValueSliderRow(title: "선 굵기", value: lineWidthBinding,
range: 1...14, step: 1, unit: "pt")
}
if item.kind == .rectangle {
DiaryValueSliderRow(title: "모서리 둥글기", value: cornerBinding,
range: 0...40, step: 2, unit: "pt")
}
DiaryRotationRow(item: item)
}
.padding()
}
.background(AppTheme.background)
.navigationTitle("도형 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") {
onCommit()
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
}
}
// MARK: -
/// ,
private struct DiaryTextEditSheet: View {
@Bindable var item: DiaryPageItem
var onCommit: () -> Void
@Environment(\.dismiss) private var dismiss
private var fontBinding: Binding<Double> {
Binding(get: { item.fontSize > 0 ? item.fontSize : 24 },
set: { item.fontSize = $0 })
}
private var borderWidthBinding: Binding<Double> {
Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth },
set: { item.lineWidth = $0 })
}
/// =
private var borderColorBinding: Binding<Color> {
Binding(get: { Color(hex: item.borderColorHex.isEmpty ? item.colorHex : item.borderColorHex) },
set: { item.borderColorHex = $0.hexString })
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ColorPicker("글자 색", selection: Binding(
get: { Color(hex: item.colorHex) },
set: { item.colorHex = $0.hexString }
), supportsOpacity: false)
DiaryValueSliderRow(title: "글자 크기", value: fontBinding,
range: 12...64, step: 1, unit: "pt")
Toggle("굵게", isOn: $item.textBold)
HStack {
Text("정렬")
Spacer()
Picker("정렬", selection: $item.textAlignRaw) {
Text("왼쪽").tag("")
Text("가운데").tag("center")
Text("오른쪽").tag("trailing")
}
.pickerStyle(.segmented)
.frame(maxWidth: 280)
}
Toggle("테두리", isOn: $item.textBorder)
if item.textBorder {
ColorPicker("테두리 색", selection: borderColorBinding, supportsOpacity: false)
DiaryValueSliderRow(title: "테두리 굵기", value: borderWidthBinding,
range: 1...8, step: 1, unit: "pt")
}
DiaryRotationRow(item: item)
}
.padding()
}
.background(AppTheme.background)
.navigationTitle("텍스트 스타일")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") {
onCommit()
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
}
}
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
/// (PDF·) .
/// PencilKit / ,
/// ( PDF )
/// (· ).
/// + WYSIWYG .
let fixedLightInk: 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
canvas.overrideUserInterfaceStyle = fixedLightInk ? .light : .unspecified
#if targetEnvironment(simulator)
// ()
canvas.drawingPolicy = .anyInput
#else
// : / .
// (·)·(, 1.5 ) anyInput
// '' (drawMode) (§6.7)
canvas.drawingPolicy = (DeviceLayout.isMac || DeviceLayout.isPhone) ? .anyInput : .pencilOnly
#endif
canvas.delegate = context.coordinator
if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty {
canvas.drawing = drawing
}
context.coordinator.canvas = canvas
#if DEBUG
// (-diaryStrokeTest): 3
// , 6.5 Documents/diary-stroke-test.txt
// strokes( ) delivered( ) (§14)
if isActive, UserDefaults.standard.bool(forKey: "diaryStrokeTest") {
let coordinator = context.coordinator
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak canvas] in
guard let canvas else { return }
var drawing = canvas.drawing
let points = stride(from: 0.0, through: 1.0, by: 0.05).map { t in
PKStrokePoint(
location: CGPoint(x: 80 + 600 * t, y: 150 + 700 * t),
timeOffset: t, size: CGSize(width: 6, height: 6),
opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2
)
}
drawing.strokes.append(PKStroke(
ink: PKInk(.pen, color: .systemRed),
path: PKStrokePath(controlPoints: points, creationDate: .now)
))
canvas.drawing = drawing
coordinator.canvasViewDrawingDidChange(canvas)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 6.5) { [weak canvas] in
guard let canvas else { return }
let lines = [
"strokes=\(canvas.drawing.strokes.count)",
"delivered=\(coordinator.debugDeliveredCount)",
]
let url = URL.documentsDirectory.appending(path: "diary-stroke-test.txt")
try? lines.joined(separator: "\n")
.write(to: url, atomically: true, encoding: .utf8)
}
}
#endif
return canvas
}
func updateUIView(_ canvas: SharpPencilCanvasView, context: Context) {
//
let style: UIUserInterfaceStyle = fixedLightInk ? .light : .unspecified
if canvas.overrideUserInterfaceStyle != style {
canvas.overrideUserInterfaceStyle = style
}
context.coordinator.setFixedLightInk(fixedLightInk)
context.coordinator.setActive(isActive)
}
static func dismantleUIView(_ uiView: SharpPencilCanvasView, coordinator: Coordinator) {
// ( )
coordinator.flush()
}
@MainActor
final class Coordinator: NSObject, PKCanvasViewDelegate {
let onChange: (Data) -> Void
weak var canvas: SharpPencilCanvasView?
private let toolPicker = PKToolPicker()
private var observing = false
// (1.4 ):
// (dataRepresentation) externalStorage
// (
// ). PKDrawing( )
// , 1.2 1 .
// : ( · · )·
// (dismantleUIView)· (willResignActive).
private var pendingDrawing: PKDrawing?
private var saveTask: Task<Void, Never>?
///
private var deliverGeneration = 0
private var resignObserver: NSObjectProtocol?
#if DEBUG
/// (-diaryStrokeTest): onChange( )
var debugDeliveredCount = 0
#endif
init(onChange: @escaping (Data) -> Void) {
self.onChange = onChange
super.init()
resignObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.flush() }
}
}
deinit {
if let resignObserver {
NotificationCenter.default.removeObserver(resignObserver)
}
}
/// · ( )
func flush() {
saveTask?.cancel()
saveTask = nil
guard let drawing = pendingDrawing else { return }
pendingDrawing = nil
deliverGeneration += 1
#if DEBUG
debugDeliveredCount += 1
#endif
onChange(drawing.dataRepresentation())
}
private func scheduleSave() {
saveTask?.cancel()
saveTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(1.2))
guard !Task.isCancelled else { return }
self?.deliverPending()
}
}
private func deliverPending() {
guard let drawing = pendingDrawing else { return }
pendingDrawing = nil
deliverGeneration += 1
let generation = deliverGeneration
Task { @MainActor [weak self] in
//
let data = await Task.detached(priority: .userInitiated) {
drawing.dataRepresentation()
}.value
guard let self, generation == self.deliverGeneration else { return }
#if DEBUG
self.debugDeliveredCount += 1
#endif
self.onChange(data)
}
}
/// ·
/// ''
/// ( )
func setFixedLightInk(_ fixed: Bool) {
let style: UIUserInterfaceStyle = fixed ? .light : .unspecified
if toolPicker.colorUserInterfaceStyle != style {
toolPicker.colorUserInterfaceStyle = style
}
}
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 {
// ( · · )
flush()
if canvas.isFirstResponder {
canvas.resignFirstResponder()
}
}
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
pendingDrawing = canvasView.drawing
scheduleSave()
//
(canvasView as? SharpPencilCanvasView)?.applySharpness()
}
}
}