I am working on a SwiftUI project where I have:
Current Behavior
The rotation works correctly using a slider.
The image resizes properly to always cover the frame when rotated.
Dragging works but feels jerky 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()
}
}
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:
lastDragOffset
with a variable to record the start-of-drag position:// @State private var lastDragOffset: CGSize = .zero
@State private var imagePositionAtStartOfDrag: CGPoint?
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:
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.ZStack
..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
}