admin管理员组

文章数量:1390564

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:

  • The image rectangle stays inside the fixed frame at all times.
  • The movement remains natural and continuous (not stuck or jittery).
  • The edges of the image always fully cover the fixed frame.

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

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:

  • The image rectangle stays inside the fixed frame at all times.
  • The movement remains natural and continuous (not stuck or jittery).
  • The edges of the image always fully cover the fixed frame.

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()
    }
}
Share Improve this question edited Mar 14 at 7:32 DarkBee 15.5k8 gold badges72 silver badges118 bronze badges asked Mar 13 at 11:26 HectorHector 3,8092 gold badges32 silver badges46 bronze badges 3
  • You're using a version of .onChange that was deprecated in iOS 17. Which iOS version are you targeting? – Benzy Neez Commented Mar 13 at 12:21
  • I'm aware of the deprecation in iOS 17, but that's not important right now. My focus is on improving the smoothness of dragging when the rectangle is rotated. Do you have any suggestions for handling movement constraints more naturally? – Hector Commented Mar 13 at 13:05
  • @BenzyNeez Right now, my priority is fixing the smoothness of dragging when the rectangle is rotated. Once that's resolved, I plan to add pinch-to-zoom and rotation gestures as well. – Hector Commented Mar 13 at 13:16
Add a comment  | 

1 Answer 1

Reset to default 1

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:

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:

  • In body, there is no need to wrap the content in a GeometryReader. In order to achieve square proportions, where the height of the content is the same as the screen width, the modifier .aspectRatio can be used instead.
  • The drag gesture only needs to be attached to the blue rectangle, not the whole ZStack.
  • Assuming your deployment target is in fact iOS 17 or later, the deprecation warning for .onChange can be eliminated by deleting _ in.
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:


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
}

本文标签: swiftHow to Smoothly Drag a Rotated Rectangle Inside a Fixed FrameStack Overflow