Update BrushCanvasView and CanvasView, add screenshots
@@ -252,7 +252,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// BrushCanvasView.swift
|
// BrushCanvasView.swift
|
||||||
// CheapRetouch
|
// CheapRetouch
|
||||||
//
|
//
|
||||||
// Canvas overlay for brush-based manual selection.
|
// Canvas overlay for brush-based manual selection with support for
|
||||||
|
// single-finger drawing and two-finger pan/zoom.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -12,40 +13,74 @@ struct BrushCanvasView: View {
|
|||||||
@Bindable var viewModel: EditorViewModel
|
@Bindable var viewModel: EditorViewModel
|
||||||
let imageSize: CGSize
|
let imageSize: CGSize
|
||||||
let displayedImageFrame: CGRect
|
let displayedImageFrame: CGRect
|
||||||
|
@Binding var scale: CGFloat
|
||||||
|
@Binding var lastScale: CGFloat
|
||||||
|
@Binding var offset: CGSize
|
||||||
|
@Binding var lastOffset: CGSize
|
||||||
|
let minScale: CGFloat
|
||||||
|
let maxScale: CGFloat
|
||||||
|
let clampOffset: () -> Void
|
||||||
|
|
||||||
@State private var currentStroke: [CGPoint] = []
|
|
||||||
@State private var allStrokes: [[CGPoint]] = []
|
@State private var allStrokes: [[CGPoint]] = []
|
||||||
|
@State private var currentStroke: [CGPoint] = []
|
||||||
@State private var currentTouchLocation: CGPoint?
|
@State private var currentTouchLocation: CGPoint?
|
||||||
@State private var gradientImage: EdgeRefinement.GradientImage?
|
@State private var gradientImage: EdgeRefinement.GradientImage?
|
||||||
|
|
||||||
|
/// The effective brush size on screen, scaled by zoom level
|
||||||
|
private var scaledBrushSize: CGFloat {
|
||||||
|
viewModel.brushSize * scale
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Canvas { context, size in
|
GeometryReader { geometry in
|
||||||
// Draw all completed strokes
|
ZStack {
|
||||||
for stroke in allStrokes {
|
// UIKit gesture handler for proper single/two-finger differentiation
|
||||||
drawStroke(stroke, in: &context, color: .white)
|
// This is at the bottom of the ZStack but receives all touches
|
||||||
}
|
BrushGestureView(
|
||||||
|
displayedImageFrame: displayedImageFrame,
|
||||||
// Draw current stroke
|
scale: $scale,
|
||||||
if !currentStroke.isEmpty {
|
lastScale: $lastScale,
|
||||||
drawStroke(currentStroke, in: &context, color: .white)
|
offset: $offset,
|
||||||
}
|
lastOffset: $lastOffset,
|
||||||
|
minScale: minScale,
|
||||||
// Draw brush preview circle at current touch location
|
maxScale: maxScale,
|
||||||
if let location = currentTouchLocation {
|
currentStroke: $currentStroke,
|
||||||
let previewRect = CGRect(
|
allStrokes: $allStrokes,
|
||||||
x: location.x - viewModel.brushSize / 2,
|
currentTouchLocation: $currentTouchLocation,
|
||||||
y: location.y - viewModel.brushSize / 2,
|
clampOffset: clampOffset
|
||||||
width: viewModel.brushSize,
|
|
||||||
height: viewModel.brushSize
|
|
||||||
)
|
|
||||||
context.stroke(
|
|
||||||
Path(ellipseIn: previewRect),
|
|
||||||
with: .color(.white.opacity(0.8)),
|
|
||||||
lineWidth: 2
|
|
||||||
)
|
)
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
|
|
||||||
|
// SwiftUI Canvas for rendering strokes (on top for visibility)
|
||||||
|
Canvas { context, size in
|
||||||
|
// Draw all completed strokes
|
||||||
|
for stroke in allStrokes {
|
||||||
|
drawStroke(stroke, in: &context, color: .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw current stroke
|
||||||
|
if !currentStroke.isEmpty {
|
||||||
|
drawStroke(currentStroke, in: &context, color: .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw brush preview circle at current touch location
|
||||||
|
if let location = currentTouchLocation {
|
||||||
|
let previewRect = CGRect(
|
||||||
|
x: location.x - scaledBrushSize / 2,
|
||||||
|
y: location.y - scaledBrushSize / 2,
|
||||||
|
width: scaledBrushSize,
|
||||||
|
height: scaledBrushSize
|
||||||
|
)
|
||||||
|
context.stroke(
|
||||||
|
Path(ellipseIn: previewRect),
|
||||||
|
with: .color(.white.opacity(0.8)),
|
||||||
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false) // Let the gesture view handle all touches
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.gesture(drawingGesture)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Precompute gradient for edge refinement if enabled
|
// Precompute gradient for edge refinement if enabled
|
||||||
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
||||||
@@ -59,11 +94,9 @@ struct BrushCanvasView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.editedImage) { _, _ in
|
.onChange(of: viewModel.editedImage) { _, _ in
|
||||||
// Recompute gradient when image changes (e.g., after undo/redo)
|
// Recompute gradient when image changes (e.g., after undo/redo)
|
||||||
// to ensure edge refinement works correctly with the current image
|
|
||||||
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
|
||||||
computeGradientAsync(from: image)
|
computeGradientAsync(from: image)
|
||||||
} else {
|
} else {
|
||||||
// Invalidate cached gradient so it will be recomputed when edge refinement is enabled
|
|
||||||
gradientImage = nil
|
gradientImage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,47 +152,24 @@ struct BrushCanvasView: View {
|
|||||||
path,
|
path,
|
||||||
with: .color(color.opacity(0.7)),
|
with: .color(color.opacity(0.7)),
|
||||||
style: StrokeStyle(
|
style: StrokeStyle(
|
||||||
lineWidth: viewModel.brushSize,
|
lineWidth: scaledBrushSize,
|
||||||
lineCap: .round,
|
lineCap: .round,
|
||||||
lineJoin: .round
|
lineJoin: .round
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var drawingGesture: some Gesture {
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
|
||||||
let point = value.location
|
|
||||||
currentTouchLocation = point
|
|
||||||
|
|
||||||
// Only add points within the image bounds
|
|
||||||
if displayedImageFrame.contains(point) {
|
|
||||||
currentStroke.append(point)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
currentTouchLocation = nil
|
|
||||||
if !currentStroke.isEmpty {
|
|
||||||
allStrokes.append(currentStroke)
|
|
||||||
currentStroke = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyBrushMask() async {
|
private func applyBrushMask() async {
|
||||||
DebugLogger.action("BrushCanvasView.applyBrushMask called")
|
DebugLogger.action("BrushCanvasView.applyBrushMask called")
|
||||||
|
|
||||||
// Check if we have strokes to apply or a pending mask to refine
|
|
||||||
let hasStrokes = !allStrokes.isEmpty
|
let hasStrokes = !allStrokes.isEmpty
|
||||||
let hasPendingMask = viewModel.pendingRefineMask != nil
|
let hasPendingMask = viewModel.pendingRefineMask != nil
|
||||||
|
|
||||||
guard hasStrokes || hasPendingMask else {
|
guard hasStrokes || hasPendingMask else {
|
||||||
DebugLogger.log("No strokes or pending mask to apply")
|
DebugLogger.log("No strokes or pending mask to apply")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the actual display image dimensions - this may differ from originalImage
|
|
||||||
// after undo/redo due to preview scaling in renderPreview
|
|
||||||
guard let displayImage = viewModel.displayImage else {
|
guard let displayImage = viewModel.displayImage else {
|
||||||
DebugLogger.error("No display image available")
|
DebugLogger.error("No display image available")
|
||||||
return
|
return
|
||||||
@@ -172,12 +182,10 @@ struct BrushCanvasView: View {
|
|||||||
DebugLogger.log("Displayed frame: \(displayedImageFrame)")
|
DebugLogger.log("Displayed frame: \(displayedImageFrame)")
|
||||||
DebugLogger.log("Has pending mask: \(hasPendingMask)")
|
DebugLogger.log("Has pending mask: \(hasPendingMask)")
|
||||||
|
|
||||||
// Use actual display image size for scaling, not the original image size
|
|
||||||
let scaleX = actualImageSize.width / displayedImageFrame.width
|
let scaleX = actualImageSize.width / displayedImageFrame.width
|
||||||
let scaleY = actualImageSize.height / displayedImageFrame.height
|
let scaleY = actualImageSize.height / displayedImageFrame.height
|
||||||
DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)")
|
DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)")
|
||||||
|
|
||||||
// Convert all strokes to image coordinates (using actual display image dimensions)
|
|
||||||
var imageCoordStrokes: [[CGPoint]] = []
|
var imageCoordStrokes: [[CGPoint]] = []
|
||||||
for stroke in allStrokes {
|
for stroke in allStrokes {
|
||||||
let imageStroke = stroke.map { point in
|
let imageStroke = stroke.map { point in
|
||||||
@@ -189,7 +197,6 @@ struct BrushCanvasView: View {
|
|||||||
imageCoordStrokes.append(imageStroke)
|
imageCoordStrokes.append(imageStroke)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply edge refinement if enabled and gradient is available
|
|
||||||
if viewModel.useEdgeRefinement, let gradient = gradientImage {
|
if viewModel.useEdgeRefinement, let gradient = gradientImage {
|
||||||
imageCoordStrokes = imageCoordStrokes.map { stroke in
|
imageCoordStrokes = imageCoordStrokes.map { stroke in
|
||||||
EdgeRefinement.refineSelectionToEdges(
|
EdgeRefinement.refineSelectionToEdges(
|
||||||
@@ -200,24 +207,17 @@ struct BrushCanvasView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mask image from strokes at the ACTUAL display image size
|
|
||||||
// (not the original image size, which may differ after undo/redo)
|
|
||||||
let format = UIGraphicsImageRendererFormat()
|
let format = UIGraphicsImageRendererFormat()
|
||||||
format.scale = 1.0 // Don't use screen scale, use actual pixel size
|
format.scale = 1.0
|
||||||
let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format)
|
let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format)
|
||||||
let maskImage = renderer.image { ctx in
|
let maskImage = renderer.image { ctx in
|
||||||
// Fill with black (not masked)
|
|
||||||
UIColor.black.setFill()
|
UIColor.black.setFill()
|
||||||
ctx.fill(CGRect(origin: .zero, size: actualImageSize))
|
ctx.fill(CGRect(origin: .zero, size: actualImageSize))
|
||||||
|
|
||||||
// If we have a pending mask from Person/Object detection, draw it first
|
|
||||||
// Note: pendingMask might be at original size, so we draw it scaled
|
|
||||||
if let pendingMask = viewModel.pendingRefineMask {
|
if let pendingMask = viewModel.pendingRefineMask {
|
||||||
// Draw the pending mask - white areas become part of the combined mask
|
|
||||||
ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize))
|
ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw brush strokes in white (masked areas) on top
|
|
||||||
UIColor.white.setStroke()
|
UIColor.white.setStroke()
|
||||||
|
|
||||||
for stroke in imageCoordStrokes {
|
for stroke in imageCoordStrokes {
|
||||||
@@ -239,7 +239,6 @@ struct BrushCanvasView: View {
|
|||||||
|
|
||||||
if let cgImage = maskImage.cgImage {
|
if let cgImage = maskImage.cgImage {
|
||||||
DebugLogger.imageInfo("Created brush mask", image: cgImage)
|
DebugLogger.imageInfo("Created brush mask", image: cgImage)
|
||||||
// Clear the pending mask since we've incorporated it
|
|
||||||
viewModel.pendingRefineMask = nil
|
viewModel.pendingRefineMask = nil
|
||||||
viewModel.maskPreview = nil
|
viewModel.maskPreview = nil
|
||||||
await viewModel.applyBrushMask(cgImage)
|
await viewModel.applyBrushMask(cgImage)
|
||||||
@@ -247,17 +246,281 @@ struct BrushCanvasView: View {
|
|||||||
DebugLogger.error("Failed to create CGImage from brush mask")
|
DebugLogger.error("Failed to create CGImage from brush mask")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear strokes after applying
|
|
||||||
allStrokes.removeAll()
|
allStrokes.removeAll()
|
||||||
DebugLogger.log("Strokes cleared")
|
DebugLogger.log("Strokes cleared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UIKit Gesture Handler
|
||||||
|
|
||||||
|
/// A UIViewRepresentable that handles touch gestures with proper single vs two-finger differentiation.
|
||||||
|
/// - Single finger: Drawing brush strokes
|
||||||
|
/// - Two fingers: Pan (when zoomed) and pinch to zoom
|
||||||
|
struct BrushGestureView: UIViewRepresentable {
|
||||||
|
let displayedImageFrame: CGRect
|
||||||
|
@Binding var scale: CGFloat
|
||||||
|
@Binding var lastScale: CGFloat
|
||||||
|
@Binding var offset: CGSize
|
||||||
|
@Binding var lastOffset: CGSize
|
||||||
|
let minScale: CGFloat
|
||||||
|
let maxScale: CGFloat
|
||||||
|
@Binding var currentStroke: [CGPoint]
|
||||||
|
@Binding var allStrokes: [[CGPoint]]
|
||||||
|
@Binding var currentTouchLocation: CGPoint?
|
||||||
|
let clampOffset: () -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> BrushGestureUIView {
|
||||||
|
let view = BrushGestureUIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.isMultipleTouchEnabled = true
|
||||||
|
view.coordinator = context.coordinator
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: BrushGestureUIView, context: Context) {
|
||||||
|
context.coordinator.displayedImageFrame = displayedImageFrame
|
||||||
|
context.coordinator.scale = $scale
|
||||||
|
context.coordinator.lastScale = $lastScale
|
||||||
|
context.coordinator.offset = $offset
|
||||||
|
context.coordinator.lastOffset = $lastOffset
|
||||||
|
context.coordinator.minScale = minScale
|
||||||
|
context.coordinator.maxScale = maxScale
|
||||||
|
context.coordinator.currentStroke = $currentStroke
|
||||||
|
context.coordinator.allStrokes = $allStrokes
|
||||||
|
context.coordinator.currentTouchLocation = $currentTouchLocation
|
||||||
|
context.coordinator.clampOffset = clampOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(
|
||||||
|
displayedImageFrame: displayedImageFrame,
|
||||||
|
scale: $scale,
|
||||||
|
lastScale: $lastScale,
|
||||||
|
offset: $offset,
|
||||||
|
lastOffset: $lastOffset,
|
||||||
|
minScale: minScale,
|
||||||
|
maxScale: maxScale,
|
||||||
|
currentStroke: $currentStroke,
|
||||||
|
allStrokes: $allStrokes,
|
||||||
|
currentTouchLocation: $currentTouchLocation,
|
||||||
|
clampOffset: clampOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject {
|
||||||
|
var displayedImageFrame: CGRect
|
||||||
|
var scale: Binding<CGFloat>
|
||||||
|
var lastScale: Binding<CGFloat>
|
||||||
|
var offset: Binding<CGSize>
|
||||||
|
var lastOffset: Binding<CGSize>
|
||||||
|
var minScale: CGFloat
|
||||||
|
var maxScale: CGFloat
|
||||||
|
var currentStroke: Binding<[CGPoint]>
|
||||||
|
var allStrokes: Binding<[[CGPoint]]>
|
||||||
|
var currentTouchLocation: Binding<CGPoint?>
|
||||||
|
var clampOffset: () -> Void
|
||||||
|
|
||||||
|
// Track if we're in drawing mode (single finger) or navigation mode (two fingers)
|
||||||
|
var isDrawing = false
|
||||||
|
var initialPinchScale: CGFloat = 1.0
|
||||||
|
|
||||||
|
init(
|
||||||
|
displayedImageFrame: CGRect,
|
||||||
|
scale: Binding<CGFloat>,
|
||||||
|
lastScale: Binding<CGFloat>,
|
||||||
|
offset: Binding<CGSize>,
|
||||||
|
lastOffset: Binding<CGSize>,
|
||||||
|
minScale: CGFloat,
|
||||||
|
maxScale: CGFloat,
|
||||||
|
currentStroke: Binding<[CGPoint]>,
|
||||||
|
allStrokes: Binding<[[CGPoint]]>,
|
||||||
|
currentTouchLocation: Binding<CGPoint?>,
|
||||||
|
clampOffset: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.displayedImageFrame = displayedImageFrame
|
||||||
|
self.scale = scale
|
||||||
|
self.lastScale = lastScale
|
||||||
|
self.offset = offset
|
||||||
|
self.lastOffset = lastOffset
|
||||||
|
self.minScale = minScale
|
||||||
|
self.maxScale = maxScale
|
||||||
|
self.currentStroke = currentStroke
|
||||||
|
self.allStrokes = allStrokes
|
||||||
|
self.currentTouchLocation = currentTouchLocation
|
||||||
|
self.clampOffset = clampOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom UIView that handles touch events directly for precise control over single vs multi-touch.
|
||||||
|
class BrushGestureUIView: UIView {
|
||||||
|
var coordinator: BrushGestureView.Coordinator?
|
||||||
|
|
||||||
|
// Track active touches ourselves for reliability
|
||||||
|
private var activeTouches: [UITouch] = []
|
||||||
|
private var isDrawing = false
|
||||||
|
private var isNavigating = false
|
||||||
|
private var initialPinchDistance: CGFloat = 0
|
||||||
|
private var initialPinchScale: CGFloat = 1.0
|
||||||
|
private var lastPanLocation: CGPoint = .zero
|
||||||
|
|
||||||
|
private func activeTouchCount() -> Int {
|
||||||
|
return activeTouches.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getActiveTouchLocations() -> [CGPoint] {
|
||||||
|
return activeTouches.map { $0.location(in: self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let coordinator = coordinator else { return }
|
||||||
|
|
||||||
|
// Add new touches to our tracking
|
||||||
|
for touch in touches {
|
||||||
|
if !activeTouches.contains(touch) {
|
||||||
|
activeTouches.append(touch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let touchCount = activeTouchCount()
|
||||||
|
|
||||||
|
if touchCount == 1 {
|
||||||
|
// Single finger - start drawing
|
||||||
|
let point = activeTouches[0].location(in: self)
|
||||||
|
isDrawing = true
|
||||||
|
isNavigating = false
|
||||||
|
|
||||||
|
coordinator.currentTouchLocation.wrappedValue = point
|
||||||
|
if coordinator.displayedImageFrame.contains(point) {
|
||||||
|
coordinator.currentStroke.wrappedValue.append(point)
|
||||||
|
}
|
||||||
|
} else if touchCount >= 2 {
|
||||||
|
// Two or more fingers - switch to navigation mode
|
||||||
|
if isDrawing {
|
||||||
|
// Was drawing, cancel current stroke
|
||||||
|
coordinator.currentStroke.wrappedValue.removeAll()
|
||||||
|
coordinator.currentTouchLocation.wrappedValue = nil
|
||||||
|
isDrawing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start navigation
|
||||||
|
let locations = getActiveTouchLocations()
|
||||||
|
let p1 = locations[0]
|
||||||
|
let p2 = locations[1]
|
||||||
|
initialPinchDistance = hypot(p2.x - p1.x, p2.y - p1.y)
|
||||||
|
initialPinchScale = coordinator.scale.wrappedValue
|
||||||
|
lastPanLocation = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2)
|
||||||
|
isNavigating = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let coordinator = coordinator else { return }
|
||||||
|
|
||||||
|
let touchCount = activeTouchCount()
|
||||||
|
|
||||||
|
if touchCount == 1 && isDrawing {
|
||||||
|
// Single finger - continue drawing
|
||||||
|
let point = activeTouches[0].location(in: self)
|
||||||
|
coordinator.currentTouchLocation.wrappedValue = point
|
||||||
|
if coordinator.displayedImageFrame.contains(point) {
|
||||||
|
coordinator.currentStroke.wrappedValue.append(point)
|
||||||
|
}
|
||||||
|
} else if touchCount >= 2 && isNavigating {
|
||||||
|
// Two fingers - pan and zoom
|
||||||
|
let locations = getActiveTouchLocations()
|
||||||
|
let p1 = locations[0]
|
||||||
|
let p2 = locations[1]
|
||||||
|
|
||||||
|
// Handle pinch zoom
|
||||||
|
if initialPinchDistance > 0 {
|
||||||
|
let currentDistance = hypot(p2.x - p1.x, p2.y - p1.y)
|
||||||
|
let scaleFactor = currentDistance / initialPinchDistance
|
||||||
|
let newScale = initialPinchScale * scaleFactor
|
||||||
|
coordinator.scale.wrappedValue = min(max(newScale, coordinator.minScale), coordinator.maxScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pan
|
||||||
|
let currentCenter = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2)
|
||||||
|
let deltaX = currentCenter.x - lastPanLocation.x
|
||||||
|
let deltaY = currentCenter.y - lastPanLocation.y
|
||||||
|
|
||||||
|
coordinator.offset.wrappedValue = CGSize(
|
||||||
|
width: coordinator.offset.wrappedValue.width + deltaX,
|
||||||
|
height: coordinator.offset.wrappedValue.height + deltaY
|
||||||
|
)
|
||||||
|
lastPanLocation = currentCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let coordinator = coordinator else { return }
|
||||||
|
|
||||||
|
// Remove ended touches from tracking
|
||||||
|
activeTouches.removeAll { touches.contains($0) }
|
||||||
|
|
||||||
|
let remainingCount = activeTouchCount()
|
||||||
|
|
||||||
|
if isDrawing && remainingCount == 0 {
|
||||||
|
// Finish drawing stroke
|
||||||
|
coordinator.currentTouchLocation.wrappedValue = nil
|
||||||
|
if !coordinator.currentStroke.wrappedValue.isEmpty {
|
||||||
|
coordinator.allStrokes.wrappedValue.append(coordinator.currentStroke.wrappedValue)
|
||||||
|
coordinator.currentStroke.wrappedValue = []
|
||||||
|
}
|
||||||
|
isDrawing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isNavigating && remainingCount < 2 {
|
||||||
|
// End navigation
|
||||||
|
coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue
|
||||||
|
coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
coordinator.clampOffset()
|
||||||
|
}
|
||||||
|
isNavigating = false
|
||||||
|
// Don't start drawing with remaining finger - wait for fresh touch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let coordinator = coordinator else { return }
|
||||||
|
|
||||||
|
// Remove cancelled touches
|
||||||
|
activeTouches.removeAll { touches.contains($0) }
|
||||||
|
|
||||||
|
// Cancel any ongoing gesture
|
||||||
|
coordinator.currentTouchLocation.wrappedValue = nil
|
||||||
|
coordinator.currentStroke.wrappedValue.removeAll()
|
||||||
|
|
||||||
|
if isNavigating {
|
||||||
|
coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue
|
||||||
|
coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue
|
||||||
|
coordinator.clampOffset()
|
||||||
|
}
|
||||||
|
|
||||||
|
isDrawing = false
|
||||||
|
isNavigating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
@Previewable @State var scale: CGFloat = 1.0
|
||||||
|
@Previewable @State var lastScale: CGFloat = 1.0
|
||||||
|
@Previewable @State var offset: CGSize = .zero
|
||||||
|
@Previewable @State var lastOffset: CGSize = .zero
|
||||||
|
|
||||||
let viewModel = EditorViewModel()
|
let viewModel = EditorViewModel()
|
||||||
return BrushCanvasView(
|
return BrushCanvasView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
imageSize: CGSize(width: 1000, height: 1000),
|
imageSize: CGSize(width: 1000, height: 1000),
|
||||||
displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300)
|
displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300),
|
||||||
|
scale: $scale,
|
||||||
|
lastScale: $lastScale,
|
||||||
|
offset: $offset,
|
||||||
|
lastOffset: $lastOffset,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 10.0,
|
||||||
|
clampOffset: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,20 +51,28 @@ struct CanvasView: View {
|
|||||||
BrushCanvasView(
|
BrushCanvasView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
imageSize: viewModel.imageSize,
|
imageSize: viewModel.imageSize,
|
||||||
displayedImageFrame: displayedImageFrame(in: geometry.size)
|
displayedImageFrame: displayedImageFrame(in: geometry.size),
|
||||||
|
scale: $scale,
|
||||||
|
lastScale: $lastScale,
|
||||||
|
offset: $offset,
|
||||||
|
lastOffset: $lastOffset,
|
||||||
|
minScale: minScale,
|
||||||
|
maxScale: maxScale,
|
||||||
|
clampOffset: { clampOffset(in: geometry.size) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.gesture(tapGesture(in: geometry))
|
.applyGestures(
|
||||||
.gesture(magnificationGesture(in: geometry))
|
isBrushMode: viewModel.selectedTool == .brush,
|
||||||
.simultaneousGesture(combinedDragGesture(in: geometry))
|
tapGesture: tapGesture(in: geometry),
|
||||||
.simultaneousGesture(longPressGesture)
|
magnificationGesture: magnificationGesture(in: geometry),
|
||||||
.onTapGesture(count: 2) {
|
dragGesture: combinedDragGesture(in: geometry),
|
||||||
doubleTapZoom()
|
longPressGesture: longPressGesture,
|
||||||
}
|
doubleTapAction: doubleTapZoom
|
||||||
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewSize = geometry.size
|
viewSize = geometry.size
|
||||||
}
|
}
|
||||||
@@ -155,8 +163,8 @@ struct CanvasView: View {
|
|||||||
private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture {
|
private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||||
DragGesture(minimumDistance: 1)
|
DragGesture(minimumDistance: 1)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
// Only allow panning when move tool is selected
|
// Allow panning for person, object, and move tools (not brush - uses two-finger pan)
|
||||||
guard viewModel.selectedTool == .move else { return }
|
guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return }
|
||||||
|
|
||||||
// Pan mode: only when zoomed in
|
// Pan mode: only when zoomed in
|
||||||
if scale > 1.0 {
|
if scale > 1.0 {
|
||||||
@@ -167,8 +175,8 @@ struct CanvasView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
// Only allow panning when move tool is selected
|
// Allow panning for person, object, and move tools (not brush - uses two-finger pan)
|
||||||
guard viewModel.selectedTool == .move else { return }
|
guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return }
|
||||||
|
|
||||||
lastOffset = offset
|
lastOffset = offset
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
@@ -180,15 +188,14 @@ struct CanvasView: View {
|
|||||||
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||||
MagnificationGesture()
|
MagnificationGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
// Only allow zooming when move tool is selected
|
// Allow zooming for person, object, and move tools (brush uses UIKit pinch gesture)
|
||||||
guard viewModel.selectedTool == .move else { return }
|
guard viewModel.selectedTool != .brush else { return }
|
||||||
|
|
||||||
let newScale = lastScale * value
|
let newScale = lastScale * value
|
||||||
scale = min(max(newScale, minScale), maxScale)
|
scale = min(max(newScale, minScale), maxScale)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
// Only allow zooming when move tool is selected
|
guard viewModel.selectedTool != .brush else { return }
|
||||||
guard viewModel.selectedTool == .move else { return }
|
|
||||||
|
|
||||||
lastScale = scale
|
lastScale = scale
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
@@ -210,9 +217,7 @@ struct CanvasView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func doubleTapZoom() {
|
private func doubleTapZoom() {
|
||||||
// Only allow double-tap zoom when move tool is selected
|
// Allow double-tap zoom for all tools
|
||||||
guard viewModel.selectedTool == .move else { return }
|
|
||||||
|
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
if scale > 1.0 {
|
if scale > 1.0 {
|
||||||
scale = 1.0
|
scale = 1.0
|
||||||
@@ -312,6 +317,37 @@ struct CanvasView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Conditional Gesture Application
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Applies gestures conditionally based on whether brush mode is active.
|
||||||
|
/// In brush mode, all SwiftUI gestures are disabled to allow UIKit touch handling in BrushCanvasView.
|
||||||
|
@ViewBuilder
|
||||||
|
func applyGestures<T: Gesture, M: Gesture, D: Gesture, L: Gesture>(
|
||||||
|
isBrushMode: Bool,
|
||||||
|
tapGesture: T,
|
||||||
|
magnificationGesture: M,
|
||||||
|
dragGesture: D,
|
||||||
|
longPressGesture: L,
|
||||||
|
doubleTapAction: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
if isBrushMode {
|
||||||
|
// In brush mode, don't attach any gestures - let BrushCanvasView handle everything
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
// In other modes, attach all gestures
|
||||||
|
self
|
||||||
|
.gesture(tapGesture)
|
||||||
|
.gesture(magnificationGesture)
|
||||||
|
.simultaneousGesture(dragGesture)
|
||||||
|
.simultaneousGesture(longPressGesture)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
doubleTapAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let viewModel = EditorViewModel()
|
let viewModel = EditorViewModel()
|
||||||
return CanvasView(viewModel: viewModel)
|
return CanvasView(viewModel: viewModel)
|
||||||
|
|||||||
BIN
screenshots/iPad/1.PNG
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
screenshots/iPad/2.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/iPad/3.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/iPhone/1.png
Normal file
|
After Width: | Height: | Size: 876 KiB |
BIN
screenshots/iPhone/2.png
Normal file
|
After Width: | Height: | Size: 937 KiB |
BIN
screenshots/iPhone/3.png
Normal file
|
After Width: | Height: | Size: 940 KiB |
BIN
screenshots/iPhone/4.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
screenshots/iPhone/5.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
screenshots/iPhone/6.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |