I am building the SwiftUI app, where I need to rotate, place and resize signature in a view. I've implemented the last two gestures, but when I'm activating the last one, it's breaking everything. The ideal thing will be if black circle would be able to configure the angle and size at the same time, but both of them begin to conflict with each other. In this test snippet the I've selected the used code and removed the code, that was connected to the image behind the signature, because it was not important.
The main question is how to make .simultaneousGesture(applyRotation(width: width, height: height))
work correctly.
struct NEwVIew: View {
@State private var location: CGPoint = CGPoint(x: 150, y: 300)
@GestureState private var fingerLocation: CGPoint? = nil
@GestureState private var startLocation: CGPoint? = nil
// Initialise to a size proportional to the screen dimensions.
@State private var width: CGFloat = 100
@State private var height: CGFloat = 100
@State private var previousRotation: Double = 0.0
@State private var knobRotation: Double = 0.0
@State var rotationActive = false
private func location2Degrees(location: CGPoint, midX: CGFloat, midY: CGFloat) -> CGFloat {
let radians = location.y < midY
? atan2(location.x - midX, midY - location.y)
: .pi - atan2(location.x - midX, location.y - midY)
let degrees = (radians * 180 / .pi) - 135
return degrees < 0 ? degrees + 360 : degrees
}
private func applyRotation(width: CGFloat, height: CGFloat) -> some Gesture {
DragGesture()
.onChanged { value in
let midX = width / 2
let midY = height / 2
let startAngle = location2Degrees(location: value.startLocation, midX: midX, midY: midY)
let endAngle = location2Degrees(location: value.location, midX: midX, midY: midY)
let dAngle = endAngle - startAngle
knobRotation = previousRotation + dAngle
}
.onEnded { value in
previousRotation = knobRotation
}
}
var simpleDrag: some Gesture {
DragGesture()
.onChanged { value in
var newLocation = startLocation ?? location // 3
newLocation.x += value.translation.width
newLocation.y += value.translation.height
self.location = newLocation
}.updating($startLocation) { (value, startLocation, transaction) in
startLocation = startLocation ?? location // 2
}
}
var fingerDrag: some Gesture {
DragGesture()
.updating($fingerLocation) { (value, fingerLocation, transaction) in
fingerLocation = value.location
}
}
var body: some View {
VStack {
GeometryReader { geometry in
ZStack {
VStack {
ZStack {
if let image = loadImageFromDocumentDirectory(filename: "signature.png") {
ZStack(alignment: .bottomTrailing) {
Rectangle()
.stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
.fill(.blue)
.frame(width: width, height: height)
// I've commented it because you don't have this image in app files.
// Image(uiImage: image)
// .resizable()
// .scaledToFit()
// .frame(width: width, height: height)
// BLACK CIRCLE I WAS TALKING ABOUT
Circle()
.frame(width: 25, height: 25)
.gesture(
DragGesture()
.onChanged { value in
// Enforce minimum dimensions.
DispatchQueue.main.async {
withAnimation {
width = max(50, width + value.translation.width / 10)
height = width
}
}
}
)
.zIndex(1)
}
.rotationEffect(Angle(degrees: knobRotation))
// NEEDS TO WORK TOO AT THE SAME TIME WITH TWO FIRST!
// .simultaneousGesture(applyRotation(width: width, height: height))
}
}
}
.frame(maxWidth: width, maxHeight: height, alignment: .center)
.position(location)
.gesture(
simpleDrag.simultaneously(with: fingerDrag)
)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.background(Color.black.opacity(0.3))
.overlay(
VStack {
Spacer()
Button {
} label: {
Text("Save")
}
}
)
.ignoresSafeArea(.all)
}
func loadImageFromDocumentDirectory(filename: String) -> UIImage? {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let fileURL = documentsDirectory.appendingPathComponent(filename)
do {
let imageData = try Data(contentsOf: fileURL)
return UIImage(data: imageData)
} catch {
print("Error loading image: \(error)")
return nil
}
}
}
I don't think you need three gestures. You can perform all the transformations with just one drag gesture if you distinguish between different start positions and different drag directions:
Determining the start position and the direction of drag is simply a matter of trigonometry!
I didn't want to start making drastic changes to your code, but I was intrigued to try this out, so I have tried to create a working example to demonstrate the concept. The following is a standalone signature image manipulation panel which uses a single drag gesture for performing all transformations. The rotation part is based on my answer to your other post, but it is now more complex because the other transformations need to be taken into consideration too. I hope there may be some useful bits in it that you can re-use or take ideas from.
struct ContentView: View {
enum TransformationType {
case unknown
case move
case rotation
case scale
}
let cornerDotSize = CGFloat(28)
let defaultSignatureWidth = CGFloat(250)
let defaultSignatureHeight = CGFloat(125)
@State private var transformationType = TransformationType.unknown
@State private var offset = CGSize.zero
@State private var previousOffset = CGSize.zero
@State private var degrees = CGFloat.zero
@State private var previousRotation = CGFloat.zero
@State private var scaleFactor = 1.0
private func reset() {
withAnimation {
offset = .zero
previousOffset = .zero
degrees = degrees > 180 ? 360 : 0
previousRotation = .zero
scaleFactor = 1.0
}
}
struct SignatureLine : Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(
to: CGPoint(
x: rect.width * 0.1,
y: rect.height * 0.75
)
)
path.addLine(
to: CGPoint(
x: rect.width * 0.9,
y: rect.height * 0.75
)
)
path.closeSubpath()
return path
}
}
private func location2Degrees(origin: CGPoint, location: CGPoint) -> CGFloat {
let radians = location.y < origin.y
? atan2((location.x - origin.x) * scaleFactor, (origin.y - location.y) * scaleFactor)
: .pi - atan2((location.x - origin.x) * scaleFactor, (location.y - origin.y) * scaleFactor)
let degrees = (radians * 180 / .pi) - 135
return degrees < 0 ? degrees + 360 : degrees
}
private var midPoint: CGPoint {
let midX = (defaultSignatureWidth / 2) + offset.width
let midY = (defaultSignatureHeight / 2) + offset.height
return CGPoint(x: midX, y: midY)
}
/// - Returns the position of the bottom-right corner in the
/// local coordinate space, after transformation
private var cornerPoint: CGPoint {
let initialAngle = atan2(defaultSignatureHeight, defaultSignatureWidth)
let latestAngle = initialAngle + (previousRotation * .pi / 180)
let midPoint = midPoint
let halfDiagonalLength: CGFloat = (
(defaultSignatureWidth * defaultSignatureWidth) +
(defaultSignatureHeight * defaultSignatureHeight)
)
.squareRoot() * scaleFactor / 2
let cornerX = midPoint.x + (cos(latestAngle) * halfDiagonalLength)
let cornerY = midPoint.y + (sin(latestAngle) * halfDiagonalLength)
return CGPoint(x: cornerX, y: cornerY)
}
private func transformationTypeForDrag(startLocation: CGPoint, dragLocation: CGPoint) -> TransformationType {
let result: TransformationType
// See if the start location is inside the dot
let cornerPoint = cornerPoint
let dx = cornerPoint.x - startLocation.x
let dy = cornerPoint.y - startLocation.y
let distance = ((dx * dx) + (dy * dy)).squareRoot()
if distance <= (cornerDotSize / 2) {
// The dot is being dragged. Calculate the difference in angles
// between the middle point and the drag position w.r.t. the corner
let midAngle = location2Degrees(origin: cornerPoint, location: midPoint)
let dragAngle = location2Degrees(origin: cornerPoint, location: dragLocation)
let dAngle = abs(midAngle - dragAngle)
// Determine the transformation according to the angle.
// If the angle is acute then scale, otherwise rotate
result = (dAngle > 315 || dAngle < 45) || (dAngle > 135 && dAngle < 225)
? .scale
: .rotation
} else {
// The start of drag is not on the dot
result = .move
}
return result
}
private func performMove(dragTranslation: CGSize) {
let width = previousOffset.width + dragTranslation.width
let height = previousOffset.height + dragTranslation.height
offset = CGSize(width: width, height: height)
}
private func performRotation(startLocation: CGPoint, dragLocation: CGPoint) {
let midPoint = midPoint
let startAngle = location2Degrees(origin: midPoint, location: startLocation)
let endAngle = location2Degrees(origin: midPoint, location: dragLocation)
let dAngle = endAngle - startAngle
let combinedAngle = (previousRotation + dAngle).truncatingRemainder(dividingBy: 360)
degrees = combinedAngle < 0 ? combinedAngle + 360 : combinedAngle
}
private func performScale(dragLocation: CGPoint) {
let midPoint = midPoint
let dX = dragLocation.x - midPoint.x
let dY = dragLocation.y - midPoint.y
let draggedDiagonalLength = 2 * ((dX * dX) + (dY * dY)).squareRoot()
let unscaledDiagonalLength = (
(defaultSignatureWidth * defaultSignatureWidth) +
(defaultSignatureHeight * defaultSignatureHeight)
).squareRoot()
let draggedScaleFactor = draggedDiagonalLength / unscaledDiagonalLength
scaleFactor = min(1.5, max(0.5, draggedScaleFactor))
}
private var applyTransformation: some Gesture {
DragGesture()
.onChanged { value in
if transformationType == .unknown {
// Determine the transformation type on first call
transformationType = transformationTypeForDrag(
startLocation: value.startLocation,
dragLocation: value.location
)
}
if transformationType == .move {
performMove(dragTranslation: value.translation)
} else if transformationType == .rotation {
performRotation(startLocation: value.startLocation, dragLocation: value.location)
} else {
performScale(dragLocation: value.location)
}
}
.onEnded { value in
previousRotation = degrees
previousOffset = offset
transformationType = .unknown
}
}
@ViewBuilder
private var backgroundDuringTransformation: some View {
if transformationType != .unknown {
Color.accentColor.opacity(0.1)
}
}
private var signatureImage: some View {
Image(systemName: "scribble")
.resizable()
.scaledToFit()
.frame(width: defaultSignatureWidth, height: defaultSignatureHeight)
.contentShape(Rectangle())
.background {
Rectangle()
.stroke(style: StrokeStyle(lineWidth: 1, dash: [2, 5]))
.foregroundColor(Color(UIColor.secondaryLabel))
}
.background {
backgroundDuringTransformation
.animation(.easeInOut(duration: 0.2), value: transformationType)
}
.scaleEffect(scaleFactor)
.overlay {
// The dot in the bottom-right corner.
// The overlay is applied after the scaleFactor so that
// the dot does not get scaled, but before the rotationEffect
// and offset modifiers so that it undergoes the same
// rotation and shift transformations
Circle()
.foregroundColor(.accentColor)
.frame(width: cornerDotSize, height: cornerDotSize)
.offset(
x: (defaultSignatureWidth / 2) * scaleFactor,
y: (defaultSignatureHeight / 2) * scaleFactor
)
}
.rotationEffect(.degrees(degrees))
.offset(offset)
.gesture(applyTransformation)
}
private var hasTransformation: Bool {
(degrees != .zero && degrees != 360) || offset != .zero || scaleFactor != 1.0
}
private var resetButton: some View {
Image(systemName: "dot.squareshape.split.2x2")
.resizable()
.scaledToFit()
.foregroundColor(Color(UIColor.secondaryLabel))
.padding(12)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.onTapGesture(perform: reset)
.opacity(hasTransformation ? 1 : 0)
.animation(.easeInOut, value: hasTransformation)
}
var body: some View {
VStack(spacing: 20) {
// The signature area
ZStack {
SignatureLine()
.stroke(style: StrokeStyle(lineWidth: 1.5, dash: [7]))
.foregroundColor(Color(UIColor.secondaryLabel))
signatureImage
}
.overlay(alignment: .topTrailing) { resetButton }
.frame(maxWidth: .infinity)
.frame(height: 230)
.clipped()
.background(Color(UIColor.systemBackground))
// Display the adjustments
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Offset").frame(width: 110, alignment: .leading)
Text("Rotation").frame(width: 110, alignment: .leading)
Text("Scaling").frame(width: 80, alignment: .leading)
}
.bold()
HStack(alignment: .top) {
Text("x: \(offset.width)\ny: \(offset.height)").frame(width: 110, alignment: .leading)
Text("\(degrees)°").frame(width: 110, alignment: .leading)
Text("\(scaleFactor)").frame(width: 80, alignment: .leading)
}
}
.font(.subheadline)
.padding(.top, 20)
Spacer()
}
.padding(20)
.padding(.top, 100)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(UIColor.systemFill))
}
}