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:
Y-axis dragging stops working after zooming.
Image does not fully cover the frame after zooming + dragging.
Expected Behavior:
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:
How can I fix these issues so that:
Any suggestions or improvements would be greatly appreciated!
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:
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:
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:
Issue 2 in your question was sometimes happening if the blue rectangle was made bigger, then moved using drag before being made smaller again. To resolve this issue, the position should be re-validated and corrected whenever the magnification is changed.
The Path
for the middle area includes a margin of 0.5 points all around it. I found that this was necessary for ensuring that lineIntersection
works when at the limits (the corners of the shape) and for when the blue rectangle has not been scaled. When the rectangle has not been scaled, the area is effectively just a straight line, so by adding a margin of 0.5 around it, the line has a width of 1 point.
I would suggest showing the blue rectangle as an overlay, so that its size does not impact the size of the parent ZStack
. You probably want to clip the overlay too.
In your code, you were previously setting absolute positions on the yellow and blue rectangles. I would suggest, it is easier to use natural positioning and then apply an offset
to the blue rectangle instead. This way, the movement is relative to the starting position and the position validation logic is simpler. I have modified the code to work this way.
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
}