mycode/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
songyc macbook 7475a963bd fix(diary): 양식 페이지 펜 색 라이트 고정 — 다크/라이트 잉크 반전으로 필기가 안 보이던 문제 해소
PencilKit은 획 색을 화면 모드에 맞춰 자동 반전하는데, 양식(PDF·사진)은 항상
밝은 배경이라 다크 모드 아이패드에서 쓴 획이 라이트 모드 맥의 흰 양식 위에서
흰색으로 반전돼 보이지 않았다(동기화 문제로 오인된 실사용 보고).

- 양식 페이지는 캔버스 overrideUserInterfaceStyle + PKToolPicker
  colorUserInterfaceStyle을 .light로 고정 (해석·렌더를 같은 기준으로 묶는 WYSIWYG)
- 양식 없는 페이지는 기존 동적 반전 유지 (다크 배경 종이에선 그게 맞음)
- 내보내기 PKDrawing.image도 라이트 트레잇 고정 — 호출 시점 트레잇 의존이라
  다크에서 내보내면 획이 반전 렌더될 수 있던 잠재 결함 제거
- 도움말 '나만의 양식(속지)' 문구 갱신 + en/ja 번역

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
2026-08-03 11:40:36 +09:00

816 lines
33 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
}
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?
/// . (Designed for iPad) ·
/// '' ( UX, §6.7).
/// true( ) .
@State private var drawMode: Bool = {
#if DEBUG
// ·: -diaryDrawMode YES
if UserDefaults.standard.bool(forKey: "diaryDrawMode") { return true }
#endif
return !DeviceLayout.isMac
}()
/// ( 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:
/// : ( ).
/// : '' , '' .
/// xLarge +
/// ( ).
@ViewBuilder
private var pageToolbar: some View {
Group {
if DeviceLayout.isMac {
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 {
editingTextItem = item
} label: {
Label("글 수정", systemImage: "square.and.pencil")
}
}
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 }
// :
if arranging && DeviceLayout.isMac { 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,
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())
.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
/// (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
// : / .
// (Designed for iPad) ·
// '' (drawMode) (§6.7)
canvas.drawingPolicy = DeviceLayout.isMac ? .anyInput : .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) {
//
let style: UIUserInterfaceStyle = fixedLightInk ? .light : .unspecified
if canvas.overrideUserInterfaceStyle != style {
canvas.overrideUserInterfaceStyle = style
}
context.coordinator.setFixedLightInk(fixedLightInk)
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 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 if canvas.isFirstResponder {
canvas.resignFirstResponder()
}
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
onChange(canvasView.drawing.dataRepresentation())
//
(canvasView as? SharpPencilCanvasView)?.applySharpness()
}
}
}