diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index eb2bbb7..da8d1ea 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -259,8 +259,15 @@ private struct DiaryZoomContainer: UIViewRepresentable { scroll.bounces = false scroll.bouncesZoom = true scroll.backgroundColor = .clear - // 한 손가락은 필기, 두 손가락은 이동 - scroll.panGestureRecognizer.minimumNumberOfTouches = 2 + // 이동/확대는 손가락 전용 — 펜슬 터치는 스크롤이 가로채지 않고 캔버스가 받아 필기한다. + // 손가락 한 개로도 (확대 상태에서) 페이지를 상하좌우로 이동할 수 있다. + 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 @@ -276,6 +283,8 @@ private struct DiaryZoomContainer: UIViewRepresentable { 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 { @@ -333,37 +342,28 @@ final class DiaryZoomScrollView: UIScrollView { y: contentSize.height / 2 + dy) } - /// 펜슬 캔버스의 래스터 해상도를 현재 줌 배율에 맞춘다. - /// 페이지는 논리 768×1024pt 고정이라 화면 fit/확대가 전부 스케일 변환인데, - /// PKCanvasView는 자기 contentScaleFactor로만 래스터하므로 그대로 두면 - /// 축소 표시(비정수 배율 미니피케이션)에선 획이 뭉개지고 확대에선 픽셀이 보인다. - /// → 기본 1.5배 슈퍼샘플링 + 확대 배율 추종(기기 스케일 × 최대 2.5)으로 항상 선명하게. + /// 펜슬 캔버스의 래스터 해상도를 현재 줌 배율에 정확히 맞춘다. + /// 페이지는 논리 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, 1) * 1.5, 2.5) + let target = display * min(max(zoomScale, 0.7), 3) guard abs(target - lastCanvasScale) > 0.01 else { return } lastCanvasScale = target - Self.applyCanvasScale(target, in: zoomedView) + Self.assignCanvasScale(target, in: zoomedView) } - /// 서브트리에서 PKCanvasView를 찾아 자신+내부 렌더 뷰들의 contentsScale을 올린다 - private static func applyCanvasScale(_ scale: CGFloat, in view: UIView) { - if view is PKCanvasView { - setScaleRecursively(scale, view: view) + /// 서브트리에서 펜슬 캔버스를 찾아 목표 래스터 배율을 지정한다 + private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) { + if let canvas = view as? SharpPencilCanvasView { + canvas.targetContentsScale = scale return } for subview in view.subviews { - applyCanvasScale(scale, in: subview) - } - } - - private static func setScaleRecursively(_ scale: CGFloat, view: UIView) { - view.contentScaleFactor = scale - view.layer.contentsScale = scale - view.setNeedsDisplay() - for subview in view.subviews { - setScaleRecursively(scale, view: subview) + assignCanvasScale(scale, in: subview) } } } @@ -496,6 +496,48 @@ struct DiaryLineShape: Shape { // 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 /// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만 @@ -506,12 +548,17 @@ struct DiaryPencilCanvas: UIViewRepresentable { Coordinator(onChange: onChange) } - func makeUIView(context: Context) -> PKCanvasView { - let canvas = PKCanvasView() + 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 @@ -520,14 +567,14 @@ struct DiaryPencilCanvas: UIViewRepresentable { return canvas } - func updateUIView(_ canvas: PKCanvasView, context: Context) { + func updateUIView(_ canvas: SharpPencilCanvasView, context: Context) { context.coordinator.setActive(isActive) } @MainActor final class Coordinator: NSObject, PKCanvasViewDelegate { let onChange: (Data) -> Void - weak var canvas: PKCanvasView? + weak var canvas: SharpPencilCanvasView? private let toolPicker = PKToolPicker() private var observing = false @@ -556,6 +603,8 @@ struct DiaryPencilCanvas: UIViewRepresentable { func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { onChange(canvasView.drawing.dataRepresentation()) + // 새 획의 렌더 레이어가 기본 해상도로 생겼을 수 있어 다시 맞춘다 + (canvasView as? SharpPencilCanvasView)?.applySharpness() } } }