fix(diary): pencil-only drawing with finger panning, and truly sharp strokes
- 실기기에서 drawingPolicy .pencilOnly — 손가락은 절대 필기되지 않고, 확대 상태에서 한 손가락으로 페이지를 상하좌우 이동(팬)한다. 이동/확대 제스처는 allowedTouchTypes=direct로 손가락 전용이라 펜슬 획을 뺏지 않음 (시뮬레이터는 입력 수단이 없어 anyInput 유지). 배치 모드에선 한 손가락을 요소 드래그에 양보(팬 두 손가락) - 선명도 재수정: SharpPencilCanvasView가 목표 래스터 배율(기기 스케일 × 현재 줌, 상한 ×3)을 뷰+레이어 트리에 유지하고, 획을 그릴 때마다·레이아웃 때마다 재적용 — PencilKit이 새 획 렌더 레이어를 기본 해상도로 만들어 흐려지던 문제와, 기존 1.5배 슈퍼샘플링의 다운샘플 소프트닝을 함께 제거 (화면 픽셀과 1:1 매칭이 가장 선명) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
4c3b5a6c03
commit
5f9593923a
@ -259,8 +259,15 @@ private struct DiaryZoomContainer<Content: View>: 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<Content: View>: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user