swiftmathswiftui

How to Smoothly Drag a Rotated Rectangle Inside a Fixed Frame?


I am working on a SwiftUI project where I have:

  1. A fixed frame (yellow rectangle)
  2. A draggable and rotatable image (blue rectangle) that should always fully cover the fixed frame while dragging.

Current Behavior

  1. The rotation works correctly using a slider.

  2. The image resizes properly to always cover the frame when rotated.

  3. Dragging works but feels jerky when rotated.

    • Sometimes, the movement stops abruptly due to rotation constraints.
    • The rectangle does not always move smoothly in all directions when rotated.

Goal

I want smooth dragging behavior even when the image rectangle is rotated, ensuring:

Current Code

Here is my SwiftUI implementation:

struct RotatingDraggingRectView: View {
    @State private var rectData: RectData = RectData(
        frameSize: CGSize(width: 100, height: 160),
        framePosition: CGPoint(x: 200, y: 200),
        imageSize: CGSize(width: 200, height: 150),
        ImageRotation: .zero,
        ImagePosition: CGPoint(x: 200, y: 300)
    )

    
    @State private var lastDragOffset: CGSize = .zero

    var body: some View {
        VStack {
            GeometryReader { geometry in
                ZStack {
                    Rectangle()
                        .fill(Color.yellow)
                        .frame(width: rectData.frameSize.width, height: rectData.frameSize.height)
                        .position(rectData.framePosition)
                    Rectangle()
                        .fill(Color.blue.opacity(0.5))
                        .frame(width: rectData.imageSize.width, height: rectData.imageSize.height)
                        .rotationEffect(rectData.ImageRotation)
                        .position(rectData.ImagePosition)

                }
                .background(.red)
                .frame(width: geometry.size.width,height: geometry.size.width)
                .position(CGPoint(x: geometry.size.width/2, y:  geometry.size.height/2))
            }
            
            
            .gesture(dragGesture())
            // Rotation Slider
            Slider(value: $rectData.ImageRotation.degrees, in: 0...180, step: 1)
                .padding()
                .onChange(of: rectData.ImageRotation) { _ in
                    adjustRectSizeForRotation()
                }

        }
        .onAppear {
            adjustRectSizeForRotation()
        }
    }
   

    func adjustRectSizeForRotation() {
        let angleRadians = rectData.ImageRotation.radians
        let absCos = abs(cos(angleRadians))
        let absSin = abs(sin(angleRadians))


        let requiredWidth = (rectData.frameSize.width * absCos) + (rectData.frameSize.height * absSin)
        let requiredHeight = (rectData.frameSize.width * absSin) + (rectData.frameSize.height * absCos)

        // Calculate the scaling factor to maintain aspect ratio
        let scaleFactorWidth = requiredWidth / rectData.imageSize.width
        let scaleFactorHeight = requiredHeight / rectData.imageSize.height


        let scaleFactor = max(scaleFactorWidth, scaleFactorHeight)


        rectData.imageSize.width *= scaleFactor * 1.01
        rectData.imageSize.height *= scaleFactor * 1.01
        
  
        
        rectData.ImagePosition = rectData.framePosition
    }
    
    // MARK: - Drag Gesture 
    
    func dragGesture() -> some Gesture {
        DragGesture()
            .onChanged { value in
                let newX = rectData.ImagePosition.x + value.translation.width - lastDragOffset.width
                let newY = rectData.ImagePosition.y + value.translation.height - lastDragOffset.height
                
                
                if isFullyCoveringRect(newPosition: CGPoint(x: newX, y: rectData.ImagePosition.y)){
                    rectData.ImagePosition.x = newX
                }
                if isFullyCoveringRect(newPosition: CGPoint(x: rectData.ImagePosition.x, y: newY)){
                    rectData.ImagePosition.y = newY
                }
                
                lastDragOffset = value.translation
            }
            .onEnded { _ in
                lastDragOffset = .zero
            }
    }

        // MARK: - Coverage Check
        private func isFullyCoveringRect(newPosition: CGPoint) -> Bool {
            let rect1Corners = getRotatedCorners(size: rectData.imageSize, position: newPosition, rotation: rectData.ImageRotation)
            let rect2Corners = getCorners(size: rectData.frameSize, position: rectData.framePosition)
            
            return rect2Corners.allSatisfy { point in
                isPointInsideRotatedRect(point: point, rectCorners: rect1Corners)
            }
        }



    func isPointInsideRotatedRect(point: CGPoint, rectCorners: [CGPoint]) -> Bool {
        let AB = CGVector(dx: rectCorners[1].x - rectCorners[0].x, dy: rectCorners[1].y - rectCorners[0].y)
        let AD = CGVector(dx: rectCorners[3].x - rectCorners[0].x, dy: rectCorners[3].y - rectCorners[0].y)
        let AP = CGVector(dx: point.x - rectCorners[0].x, dy: point.y - rectCorners[0].y)
        
        let ABdotAB = AB.dx * AB.dx + AB.dy * AB.dy
        let ADdotAD = AD.dx * AD.dx + AD.dy * AD.dy
        let APdotAB = AP.dx * AB.dx + AP.dy * AB.dy
        let APdotAD = AP.dx * AD.dx + AP.dy * AD.dy
        
        
        return (0 <= APdotAB && APdotAB <= ABdotAB) && (0 <= APdotAD && APdotAD <= ADdotAD)
        
    }


   
    // MARK: - Get Corners of a Rectangle
       func getCorners(size: CGSize, position: CGPoint) -> [CGPoint] {
           let halfWidth = size.width / 2
           let halfHeight = size.height / 2

           return [
               CGPoint(x: position.x - halfWidth, y: position.y - halfHeight),
               CGPoint(x: position.x + halfWidth, y: position.y - halfHeight),
               CGPoint(x: position.x + halfWidth, y: position.y + halfHeight),
               CGPoint(x: position.x - halfWidth, y: position.y + halfHeight)
           ]
       }
    
    
    func getRotatedCorners(size: CGSize, position: CGPoint, rotation: Angle) -> [CGPoint] {
        let halfWidth = size.width / 2
        let halfHeight = size.height / 2
        let angleRadians = rotation.radians
        let cosTheta = cos(angleRadians)
        let sinTheta = sin(angleRadians)

        let localCorners = [
            CGPoint(x: -halfWidth, y: -halfHeight),
            CGPoint(x: halfWidth, y: -halfHeight),
            CGPoint(x: halfWidth, y: halfHeight),
            CGPoint(x: -halfWidth, y: halfHeight)
        ]

        // Rotate and translate corners to the global coordinate system
        return localCorners.map { corner in
            let rotatedX = corner.x * cosTheta - corner.y * sinTheta
            let rotatedY = corner.x * sinTheta + corner.y * cosTheta
            
            let point = CGPoint(
                x: rotatedX + position.x,
                y: rotatedY + position.y
            )
            return point
        }
    }

    
}



 // MARK: - Data Models
 struct RectData {
     var frameSize: CGSize
     var framePosition: CGPoint
     var imageSize: CGSize
     var ImageRotation: Angle
     var ImagePosition: CGPoint

 }

// MARK: - Preview
struct RotatingDraggingRectView_Previews1: PreviewProvider {
    static var previews: some View {
        RotatingDraggingRectView()
    }
}

Solution

  • The problem with drag is being caused by the way the drag position is being validated. As soon as the drag position goes outside the bounds of the smaller rectangle, the movement is ignored. Also, instead of determining the max and min limits of the drag movement and constraining the movement to these bounds, the updated position is tested by examining the four corners of the new position. This check would be redundant if the limits were being applied correctly.

    The min and max limits of the drag movement can be computed from the maximum permitted drag length. The following diagram illustrates how this length l can be computed:

    diagram

    Based on the maximum drag length, the maximum x and y drag offset can be computed. Also, a movement that is not along the rotated axis can be adjusted, to keep it aligned with the axis.

    Here is how this validation can be integrated into the code:

    1. Replace the state variable lastDragOffset with a variable to record the start-of-drag position:
    // @State private var lastDragOffset: CGSize = .zero
    @State private var imagePositionAtStartOfDrag: CGPoint?
    
    1. Change the drag gesture validation
    DragGesture()
        .onChanged { value in
            let xBegin: CGFloat
            let yBegin: CGFloat
            if let imagePositionAtStartOfDrag {
                xBegin = imagePositionAtStartOfDrag.x
                yBegin = imagePositionAtStartOfDrag.y
            } else {
                imagePositionAtStartOfDrag = rectData.ImagePosition
                xBegin = rectData.ImagePosition.x
                yBegin = rectData.ImagePosition.y
            }
            let angleRadians = rectData.ImageRotation.radians
            let cosAngle = cos(angleRadians)
            let sinAngle = sin(angleRadians)
            let A = rectData.frameSize.width * abs(cosAngle)
            let B = rectData.frameSize.height * abs(sinAngle)
            let maxDragLen = rectData.imageSize.width - (A + B)
            let dxMax = maxDragLen * cosAngle
            let dyMax = maxDragLen * sinAngle
            let minX = rectData.framePosition.x - abs(dxMax / 2)
            let maxX = rectData.framePosition.x + abs(dxMax / 2)
            let minY = rectData.framePosition.y - abs(dyMax / 2)
            let maxY = rectData.framePosition.y + abs(dyMax / 2)
            let xDrag = min(maxX, max(minX, xBegin + value.translation.width))
            let yDrag = min(maxY, max(minY, yBegin + value.translation.height))
            let dxDrag = xDrag - xBegin
            let dyDrag = yDrag - yBegin
            let dx: CGFloat
            let dy: CGFloat
            if dxMax == 0 || dyMax == 0 {
                dx = dxDrag
                dy = dyDrag
            } else {
                let ratio = dxMax / dyMax
                let dxAdjusted = dyDrag * ratio
                let dyAdjusted = dxDrag / ratio
                if abs(dxDrag - dxAdjusted) < abs(dyDrag - dyAdjusted) {
                    dx = dxAdjusted
                    dy = dyDrag
                } else {
                    dx = dxDrag
                    dy = dyAdjusted
                }
            }
            let newX = xBegin + dx
            let newY = yBegin + dy
            // if isFullyCoveringRect(newPosition: CGPoint(x: newX, y: newY)){
                rectData.ImagePosition.x = newX
                rectData.ImagePosition.y = newY
            // }
        }
        .onEnded { _ in
            imagePositionAtStartOfDrag = nil
        }
    

    As you can see, the check isFullyCoveringRect is now redundant and can be omitted.

    Other suggested changes:

    var body: some View {
        VStack {
            ZStack {
                Rectangle()
                    .fill(Color.yellow)
                    .frame(width: rectData.frameSize.width, height: rectData.frameSize.height)
                    .position(rectData.framePosition)
                Rectangle()
                    .fill(Color.blue.opacity(0.5))
                    .frame(width: rectData.imageSize.width, height: rectData.imageSize.height)
                    .rotationEffect(rectData.ImageRotation)
                    .position(rectData.ImagePosition)
                    .gesture(dragGesture())
            }
            .aspectRatio(1.0, contentMode: .fit)
            .background(.red)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    
            // Rotation Slider
            Slider(value: $rectData.ImageRotation.degrees, in: 0...180, step: 1)
                .padding()
                .onChange(of: rectData.ImageRotation) {
                    adjustRectSizeForRotation()
                }
        }
        .onAppear {
            adjustRectSizeForRotation()
        }
    }
    

    Also, in adjustRectSizeForRotation - if the reason for scaling the blue rectangle by a factor of 1.01 was to add a bit of flexibility to the drag movement then this is no longer necessary.

    rectData.imageSize.width *= scaleFactor // * 1.01
    rectData.imageSize.height *= scaleFactor // * 1.01
    

    Here is how it works with the changes in operation:

    animation


    EDIT You said in a comment, that the drag doesn't work when imageSize is initialized to CGSize(width: 100, height: 150).

    This is because the axis of movement is different to the diagram, so the drag length needs to be calculated differently. You could try this adaption:

    let A = rectData.frameSize.width * abs(cosAngle)
    let B = rectData.frameSize.height * abs(sinAngle)
    let maxDragLenWhenImageWider = rectData.imageSize.width - (A + B)
    let C = rectData.frameSize.width * abs(sinAngle)
    let D = rectData.frameSize.height * abs(cosAngle)
    let maxDragLenWhenImageTaller = rectData.imageSize.height - (C + D)
    let dxMax: CGFloat
    let dyMax: CGFloat
    if maxDragLenWhenImageWider > maxDragLenWhenImageTaller {
        dxMax = maxDragLenWhenImageWider * cosAngle
        dyMax = maxDragLenWhenImageWider * sinAngle
    } else {
        dxMax = -maxDragLenWhenImageTaller * sinAngle
        dyMax = maxDragLenWhenImageTaller * cosAngle
    }