swiftmathswiftuigeometry

SwiftUI Zoom & Drag: Image Does Not Fully Cover Frame After Zoom + Y-Axis Drag Not Working


I am implementing a draggable and zoomable image inside a rotated bounding frame in SwiftUI. The image should always fully cover the frame, even after zooming, rotating, and dragging. However, I am facing two major issues:

  1. Y-axis dragging stops working after zooming.

    • Initially, dragging in both X and Y axes works fine.
    • After zooming in or out, I can only move in the X direction, but not in Y.
  2. Image does not fully cover the frame after zooming + dragging.

    • After zooming in and dragging, the edges of the image sometimes go outside the frame.
    • I need a way to prevent dragging beyond the valid movement area while ensuring the frame is always covered.

Expected Behavior:

  1. The image should always cover the frame, even after zooming in/out.
  2. Dragging should work properly in both X and Y directions, even after zooming.
  3. The image should not move beyond valid constraints, keeping it fully covering the frame at all times.
import SwiftUI

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 imagePositionAtStartOfDrag: CGPoint?
    @State private var magnificationGestureState: CGFloat = 1

    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(Color.red)
                .frame(width: geometry.size.width, height: geometry.size.width)
                .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
            }
            .gesture(dragGesture().simultaneously(with: zoomGesture()))

            Slider(value: $rectData.imageRotation.degrees, in: 0...180, step: 1)
                .padding()
                .onChange(of: rectData.imageRotation) {
                    adjustRectSizeForRotation()
                }
        }
        .onAppear {
            adjustRectSizeForRotation()
        }
    }

    // MARK: - Adjust Rect Size to Fit After Rotation (Preserve Aspect Ratio)
    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)

        let aspectRatio = rectData.imageSize.width / rectData.imageSize.height
        let scaleFactorWidth = requiredWidth / rectData.imageSize.width
        let scaleFactorHeight = requiredHeight / rectData.imageSize.height

        let scaleFactor = max(scaleFactorWidth, scaleFactorHeight)

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

        rectData.imagePosition = rectData.framePosition
    }

    // MARK: - Drag Gesture (Ensures Image Covers Frame)
    func dragGesture() -> some Gesture {
        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
                
                rectData.imagePosition.x = newX
                rectData.imagePosition.y = newY
            }
            .onEnded { _ in
                imagePositionAtStartOfDrag = nil
            }
    }

    // MARK: - Zoom Gesture
    func zoomGesture() -> some Gesture {
        MagnifyGesture()
            .onChanged { value in
                let gestureRatio = value.magnification / magnificationGestureState
                let aspectRatio = rectData.imageSize.width / rectData.imageSize.height
                
                // Calculate rotated bounding box size
                let angleRadians = rectData.imageRotation.radians
                let cosAngle = abs(cos(angleRadians))
                let sinAngle = abs(sin(angleRadians))
                
                let rotatedFrameWidth = (rectData.frameSize.width * cosAngle) + (rectData.frameSize.height * sinAngle)
                let rotatedFrameHeight = (rectData.frameSize.width * sinAngle) + (rectData.frameSize.height * cosAngle)
                
                
                let scaleFactorWidth = rotatedFrameWidth / rectData.imageSize.width
                let scaleFactorHeight = rotatedFrameHeight / rectData.imageSize.height
                let requiredScaleFactor = max(scaleFactorWidth, scaleFactorHeight)
                
                
                let minWidth = rectData.imageSize.width * requiredScaleFactor
                
                

                var newWidth = rectData.imageSize.width * gestureRatio
                var newHeight = newWidth / aspectRatio
                
                
                newWidth = max(minWidth, newWidth)
                newHeight = newWidth / aspectRatio
                
                let imageCoversFrame = newWidth >= rotatedFrameWidth && newHeight >= rotatedFrameHeight
                
                if !imageCoversFrame {

                    let centerXAdjustment = (rectData.framePosition.x - rectData.imagePosition.x)
                    let centerYAdjustment = (rectData.framePosition.y - rectData.imagePosition.y)
                    rectData.imagePosition.x += centerXAdjustment
                    rectData.imagePosition.y += centerYAdjustment
                }
                
                rectData.imageSize.width = newWidth
                rectData.imageSize.height = newHeight
                
                magnificationGestureState = value.magnification
            }
            .onEnded { _ in
                magnificationGestureState = 1.0
            }
    }
}

// MARK: - Data Models
struct RectData {
    var frameSize: CGSize
    var framePosition: CGPoint
    var imageSize: CGSize
    var imageRotation: Angle
    var imagePosition: CGPoint
}

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

Summary of Issues:

  1. Y-axis dragging stops working after zooming.
  2. After zooming and dragging, the image sometimes does not fully cover the frame.
  3. The image moves too far outside the frame when dragging.

How can I fix these issues so that:

Any suggestions or improvements would be greatly appreciated!


Solution

  • This problem is now considerably harder than the 1-dimensional movement that was the subject of your previous question. It's now much more of a mathematical challenge and I was finding it difficult to work out formulae for computing the bounds of the drag movement. However, an alternative way to solve is to use a Path to determine the bounds instead.

    First, here is an update to the diagram that I provided in my last answer. This now shows the case of a scaled and rotated rectangle surrounding the inner frame. The values l and m are the lengths of drag that are possible in the parallel and perpendicular axes:

    diagram

    To make sure the area of the yellow rectangle is always fully covered by the blue rectangle when the blue rectangle is dragged, the drag movement needs to be constrained to the area shown in the center of the following diagram:

    diagram

    This area can be defined as a Path. It is then possible to use the function lineIntersection(_:eoFill:) to find the points that intersect a slice through the area. Using this technique, the bounds of the area can be determined and used to constrain the drag movement.

    Other notes:

    Here is the fully updated example to show it all working. It includes visualization of the drag area, you'll probably want to remove this later.

    struct RotatingDraggingRectView: View {
        @State private var rectData: RectData = RectData(
            frameSize: CGSize(width: 100, height: 160),
            imageSize: CGSize(width: 200, height: 150),
            imageRotation: .zero,
            imageOffset: .zero
        )
    
        @State private var imageOffsetAtStartOfDrag: CGSize?
        @State private var magnificationFactor: CGFloat = 1
    
        var body: some View {
            VStack {
                ZStack {
                    Color.red
    
                    Rectangle()
                        .fill(Color.yellow)
                        .frame(width: rectData.frameSize.width, height: rectData.frameSize.height)
                }
                .overlay {
                    Rectangle()
                        .fill(Color.blue.opacity(0.5))
                        .frame(width: rectData.imageSize.width, height: rectData.imageSize.height)
                        .rotationEffect(rectData.imageRotation)
                        .offset(rectData.imageOffset)
                        .gesture(dragGesture().simultaneously(with: zoomGesture()))
                }
                .overlay {
    
                    // Visualization of the bounds of movement
                    GeometryReader { proxy in
                        ZStack {
                            pathForPossibleMovement(bounds: boundsForMovement)
                                .stroke(.white.opacity(0.5))
                            crosshair
                                .stroke(.white, lineWidth: 2)
                        }
                        .offset(x: proxy.size.width / 2, y: proxy.size.height / 2)
                    }
                    .allowsHitTesting(false)
                }
                .aspectRatio(1.0, contentMode: .fit)
                .clipped()
    
                Slider(value: $rectData.imageRotation.degrees, in: 0...180, step: 1)
                    .padding()
                    .onChange(of: rectData.imageRotation) {
                        adjustRectSizeForRotation()
                    }
            }
            .onAppear {
                adjustRectSizeForRotation()
            }
        }
    
        private var crosshair: Path {
            Path { path in
                path.move(to: CGPoint(x: rectData.imageOffset.width, y: rectData.imageOffset.height - 10))
                path.addLine(to: CGPoint(x: rectData.imageOffset.width, y: rectData.imageOffset.height + 10))
                path.move(to: CGPoint(x: rectData.imageOffset.width - 10, y: rectData.imageOffset.height))
                path.addLine(to: CGPoint(x: rectData.imageOffset.width + 10, y: rectData.imageOffset.height))
            }
        }
    
        // MARK: - Adjust Rect Size to Fit After Rotation (Preserve Aspect Ratio)
        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)
    
    //        let aspectRatio = rectData.imageSize.width / rectData.imageSize.height
            let scaleFactorWidth = requiredWidth / rectData.imageSize.width
            let scaleFactorHeight = requiredHeight / rectData.imageSize.height
    
            let scaleFactor = max(scaleFactorWidth, scaleFactorHeight)
    
            rectData.imageSize.width *= scaleFactor
            rectData.imageSize.height *= scaleFactor
            rectData.imageOffset = .zero
        }
    
        private func pathForPossibleMovement(bounds: CGSize) -> Path {
            let l = bounds.width
            let m = bounds.height
            var path = Path()
            path.addRect(CGRect(x: -(l + 1) / 2, y: -(m + 1) / 2, width: l + 1, height: m + 1))
            path = path.applying(CGAffineTransform(rotationAngle: rectData.imageRotation.radians))
            return path
        }
    
        private var boundsForMovement: CGSize {
            let angleRadians = rectData.imageRotation.radians
            let sinAngle = sin(angleRadians)
            let cosAngle = cos(angleRadians)
            let A = rectData.frameSize.width * abs(cosAngle)
            let B = rectData.frameSize.height * abs(sinAngle)
            let l = rectData.imageSize.width - (A + B)
            let C = rectData.frameSize.width * abs(sinAngle)
            let D = rectData.frameSize.height * abs(cosAngle)
            let m = rectData.imageSize.height - (C + D)
            return CGSize(width: l, height: m)
        }
    
        private func validatePosition(dragOffset: CGSize) {
            let xOffsetBegin: CGFloat
            let yOffsetBegin: CGFloat
            if let imageOffsetAtStartOfDrag {
                xOffsetBegin = imageOffsetAtStartOfDrag.width
                yOffsetBegin = imageOffsetAtStartOfDrag.height
            } else {
                imageOffsetAtStartOfDrag = rectData.imageOffset
                xOffsetBegin = rectData.imageOffset.width
                yOffsetBegin = rectData.imageOffset.height
            }
            let angleRadians = rectData.imageRotation.radians
            let sinAngle = sin(angleRadians)
            let cosAngle = cos(angleRadians)
    
            // Determine maximum possible drag movement in the
            // parallel and perpendicular axes
            let bounds = boundsForMovement
            let l = bounds.width
            let m = bounds.height
    
            // Convert to width and height in x and y axes
            let maxWidth = abs(l * cosAngle) + abs(m * sinAngle)
            let maxHeight = abs(l * sinAngle) + abs(m * cosAngle)
    
            // Calculate min and max offsets in x and y direction
            let dxMin: CGFloat = -(maxWidth / 2)
            let dxMax: CGFloat = (maxWidth / 2)
            let dyMin: CGFloat = -(maxHeight / 2)
            let dyMax: CGFloat = (maxHeight / 2)
    
            // Constrain the drag movement to min/max limits
            let dx = min(dxMax, max(dxMin, xOffsetBegin + dragOffset.width))
            let dy = min(dyMax, max(dyMin, yOffsetBegin + dragOffset.height))
    
            // Determine the new offset, based on the drag movement
            let newOffsetX: CGFloat
            let newOffsetY: CGFloat
            if maxWidth == 0 || maxHeight == 0 {
                newOffsetX = dx
                newOffsetY = dy
            } else {
    
                // Get the path that defines the region of possible movement
                let rotatedBounds = pathForPossibleMovement(bounds: bounds)
    
                // Compute the adjusted y when fitting to x, and vice versa
                let largeOffset = max(maxWidth, maxHeight)
                let minXLine = Path { path in
                    path.move(to: CGPoint(x: largeOffset, y: dy))
                    path.addLine(to: CGPoint(x: -largeOffset, y: dy))
                }
                let dxAdjustedMin = minXLine.lineIntersection(rotatedBounds).currentPoint?.x ?? dxMin
    
                let maxXLine = Path { path in
                    path.move(to: CGPoint(x: -largeOffset, y: dy))
                    path.addLine(to: CGPoint(x: largeOffset, y: dy))
                }
                let dxAdjustedMax = maxXLine.lineIntersection(rotatedBounds).currentPoint?.x ?? dxMax
    
                let minYLine = Path { path in
                    path.move(to: CGPoint(x: dx, y: largeOffset))
                    path.addLine(to: CGPoint(x: dx, y: -largeOffset))
                }
                let dyAdjustedMin = minYLine.lineIntersection(rotatedBounds).currentPoint?.y ?? dyMin
    
                let maxYLine = Path { path in
                    path.move(to: CGPoint(x: dx, y: -largeOffset))
                    path.addLine(to: CGPoint(x: dx, y: largeOffset))
                }
                let dyAdjustedMax = maxYLine.lineIntersection(rotatedBounds).currentPoint?.y ?? dyMax
    
                // Constrain to the adjusted bounds
                let dxAdjusted = min(dxAdjustedMax, max(dxAdjustedMin, dx))
                let dyAdjusted = min(dyAdjustedMax, max(dyAdjustedMin, dy))
    
                // Choose whether to adjust x or y
                if abs(dx - dxAdjusted) < abs(dy - dyAdjusted) {
                    newOffsetX = dxAdjusted
                    newOffsetY = dy
                } else {
                    newOffsetX = dx
                    newOffsetY = dyAdjusted
                }
            }
            rectData.imageOffset.width = newOffsetX
            rectData.imageOffset.height = newOffsetY
        }
    
        // MARK: - Drag Gesture (Ensures Image Covers Frame)
        func dragGesture() -> some Gesture {
            DragGesture()
                .onChanged { value in
                    validatePosition(dragOffset: value.translation)
                }
                .onEnded { _ in
                    imageOffsetAtStartOfDrag = nil
                }
        }
    
        // MARK: - Zoom Gesture
        func zoomGesture() -> some Gesture {
            MagnifyGesture()
                .onChanged { value in
                    let gestureRatio = value.magnification / magnificationFactor
                    let aspectRatio = rectData.imageSize.width / rectData.imageSize.height
    
                    // Calculate rotated bounding box size
                    let angleRadians = rectData.imageRotation.radians
                    let cosAngle = abs(cos(angleRadians))
                    let sinAngle = abs(sin(angleRadians))
    
                    let rotatedFrameWidth = (rectData.frameSize.width * cosAngle) + (rectData.frameSize.height * sinAngle)
                    let rotatedFrameHeight = (rectData.frameSize.width * sinAngle) + (rectData.frameSize.height * cosAngle)
    
                    let scaleFactorWidth = rotatedFrameWidth / rectData.imageSize.width
                    let scaleFactorHeight = rotatedFrameHeight / rectData.imageSize.height
                    let requiredScaleFactor = max(scaleFactorWidth, scaleFactorHeight)
    
                    let minWidth = rectData.imageSize.width * requiredScaleFactor
    
                    var newWidth = rectData.imageSize.width * gestureRatio
                    var newHeight = newWidth / aspectRatio
                    newWidth = max(minWidth, newWidth)
                    newHeight = newWidth / aspectRatio
    
                    rectData.imageSize.width = newWidth
                    rectData.imageSize.height = newHeight
                    magnificationFactor = value.magnification
                    validatePosition(dragOffset: .zero)
                }
                .onEnded { _ in
                    magnificationFactor = 1.0
                    imageOffsetAtStartOfDrag = nil
                }
        }
    }
    
    // MARK: - Data Models
    struct RectData {
        var frameSize: CGSize
        var imageSize: CGSize
        var imageRotation: Angle
        var imageOffset: CGSize
    }
    

    animation