Search code examples
iosswiftswiftuigesture

How to implement three gestures at the same time in Swift?


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
        }
    }
}


Solution

  • 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:

    • if the start of drag is on the dot and the direction of drag is along the circle of rotation then perform rotation;
    • otherwise, if the start of drag is on the dot and the direction of drag is at a tangent to the circle of rotation then perform scale;
    • otherwise, if the start of drag is not on the dot then perform move.

    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))
        }
    }
    

    Signature