mycode/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
songyc macbook 5aae30983d feat(diary): 도형·텍스트 상자 편집 강화 — 크기 손잡이·자유 비율·회전·스타일 시트
"정해진 모양·크기 그대로만 나온다"는 실사용 보고: 핀치(균등 스케일)만 있어
가로세로 비율·회전·색·굵기를 바꿀 수 없었고 맥(마우스)은 크기 조절 수단 자체가
없었다. 일반 노트 앱 수준의 기본 편집을 채워 넣는다.

- DiaryPageItem 스타일 필드 8개 추가 (전부 기본값 — CloudKit·구버전 데이터 안전):
  heightRatio(자유 비율)·lineWidth·filled·cornerRadius·fontSize·textBold·
  textAlignRaw·textBorder. 0/음수/빈 값 = 기존 하드코딩과 동일한 룩
- 선택 시 오른쪽 아래 크기 손잡이: 도형·텍스트는 가로세로 따로, 사진은 원본
  비율 균등. rotationEffect 안쪽 overlay라 회전 상태에서도 로컬 좌표로 정확
- 두 손가락 돌리기 회전 제스처 + 시트의 회전 슬라이더(마우스·정밀 대체)
- '도형 수정' 시트: 색·선 굵기·채우기(사각/원)·모서리 둥글기(사각)·회전
- '글 수정' 시트 확장: 글자 크기·굵게·정렬(왼쪽/가운데/오른쪽)·테두리(+굵기)·회전
- 내보내기는 DiaryItemView 재사용이라 자동 일치, 시드가 새 필드 전부 렌더
- 검증 인자 -diaryShapeEdit 추가, 도움말 갱신 + en/ja 번역
- 검증: Debug/Store 빌드, 아이패드 심 ko/en/ja 시트 렌더, 기존 스토어 위
  경량 마이그레이션(덮어 설치) 정상

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

1084 lines
44 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?
/// (····)
@State private var editingShapeItem: DiaryPageItem?
#if DEBUG
/// -diaryShapeEdit ( 1)
@State private var debugShapeEditDone = false
#endif
/// . (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)
}
.sheet(item: $editingShapeItem) { item in
DiaryShapeEditSheet(item: item, onCommit: save)
}
.onAppear { debugOpenShapeEditIfNeeded() }
.onChange(of: isActive) { debugOpenShapeEditIfNeeded() }
}
/// ·: -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
}
// 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")
}
} 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 }
// :
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
@State private var rotationDelta: Angle = .zero
/// ( )
@State private var resizeDelta: CGSize = .zero
private var isSelected: Bool { selectedID == 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 {
max(24, baseWidth * pinchScale + resizeDelta.width)
}
private var height: CGFloat {
// ·
if item.kind == .photo, baseWidth > 0 {
return baseHeight * (width / baseWidth)
}
return max(24, baseHeight * pinchScale + resizeDelta.height)
}
var body: some View {
content
.frame(width: width, height: height)
.overlay {
if arranging && isSelected {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(AppTheme.green, lineWidth: 2)
}
}
// rotationEffect translation
// ( ) /
.overlay(alignment: .bottomTrailing) {
if arranging && isSelected {
resizeHandle
}
}
.rotationEffect(.degrees(item.rotationDegrees) + rotationDelta)
.position(
x: pageSize.width * item.centerX + dragTranslation.width,
y: pageSize.height * item.centerY + dragTranslation.height
)
.allowsHitTesting(arranging)
.onTapGesture { selectedID = item.uuid }
.gesture(arranging ? moveResizeRotate : nil)
}
/// · , .
/// () .
private var resizeHandle: some View {
ZStack {
Circle().fill(AppTheme.green)
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 26, height: 26)
.padding(6)
.contentShape(Rectangle())
.offset(x: 16, y: 16)
.accessibilityLabel(Text("크기 조절"))
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
selectedID = item.uuid
resizeDelta = value.translation
}
.onEnded { value in
let newWidth = max(24, baseWidth + value.translation.width)
item.widthRatio = min(max(newWidth / pageSize.width, 0.04), 0.95)
if item.kind != .photo {
let newHeight = max(24, baseHeight + value.translation.height)
item.heightRatio = min(max(newHeight / pageSize.width, 0.02), 1.25)
}
resizeDelta = .zero
onCommit()
}
)
}
@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)
}
return Text(item.text.isEmpty ? String(localized: "텍스트") : item.text)
.font(.system(size: size, weight: item.textBold ? .semibold : .medium))
.multilineTextAlignment(textAlign)
.foregroundStyle(Color(hex: item.colorHex).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(Color(hex: item.colorHex).opacity(0.85), 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
item.widthRatio = min(max(item.widthRatio * value, 0.08), 0.95)
//
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
@FocusState private var focused: Bool
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 })
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
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)
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 {
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])
.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()
}
}
}