I'm working on a SwiftUI view that displays some text which can be moved, resized, and rotated. Right now, I have a working solution where the user can drag a resize arrow (located at the bottom-right) to change the size (and rotation) of the text.
Here's a simplified version of my code:
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct ResizableTextView: View {
let text: String
let isEdittable: Bool
/// Called when the edit (pen) button is tapped.
var onEdit: (() -> Void)?
/// Called when the close (delete) button is tapped.
var onDelete: (() -> Void)?
var onTap: (() -> Void)?
// MARK: - Transformation State Variables
@State private var scale: CGFloat = 1.0
@State private var rotation: Angle = .zero
// "Committed" transformation values from previous gestures.
@State private var lastScale: CGFloat = 1.0
@State private var lastRotation: Angle = .zero
// For tracking the resize gesture’s starting values.
@State private var initialDistance: CGFloat = 0
@State private var initialAngle: Angle = .zero
@State private var isDragging: Bool = false
// The intrinsic size of the text (including padding), measured before any transforms.
@State private var textSize: CGSize = .zero
// MARK: - Position (Drag-to-move) State Variables
@State private var positionOffset: CGSize = .zero
@State private var lastPositionOffset: CGSize = .zero
var body: some View {
GeometryReader { geometry in
// The container's center is used as the anchor point for the text.
let containerCenter = CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2)
ZStack {
// Main text view with measured size.
Text(text)
.padding(20)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { newSize in
self.textSize = newSize
}
// Apply the current transformations.
.scaleEffect(scale)
.rotationEffect(rotation)
// Draw a border around the text.
.overlay(
Rectangle()
.stroke(Color.gray, lineWidth: isEdittable ? 1 : 0)
.scaleEffect(scale)
.rotationEffect(rotation)
)
// Position the text at the container center.
.position(containerCenter)
// Calculate half of the scaled text dimensions.
let halfWidth = (textSize.width * scale) / 2
let halfHeight = (textSize.height * scale) / 2
let angleRad = rotation.radians
// MARK: - Compute Control Positions
// Bottom-right (Resize control):
let arrowOffsetX = halfWidth * CGFloat(cos(angleRad)) - halfHeight * CGFloat(sin(angleRad))
let arrowOffsetY = halfWidth * CGFloat(sin(angleRad)) + halfHeight * CGFloat(cos(angleRad))
let arrowPosition = CGPoint(x: containerCenter.x + arrowOffsetX,
y: containerCenter.y + arrowOffsetY)
// Top-right (Edit/Pen control):
let penOffsetX = halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let penOffsetY = halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let penPosition = CGPoint(x: containerCenter.x + penOffsetX,
y: containerCenter.y + penOffsetY)
// Top-left (Close/Delete control):
let closeOffsetX = -halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let closeOffsetY = -halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let closePosition = CGPoint(x: containerCenter.x + closeOffsetX,
y: containerCenter.y + closeOffsetY)
// MARK: - Add Controls Using Helper Functions
if isEdittable {
closeButton(at: closePosition)
editButton(at: penPosition)
resizeButton(at: arrowPosition, containerCenter: containerCenter)
}
}
// Apply the offset to the entire structure.
.offset(x: positionOffset.width, y: positionOffset.height)
// Attach a drag gesture to reposition the whole structure.
.gesture(
DragGesture()
.onChanged { value in
positionOffset = CGSize(width: lastPositionOffset.width + value.translation.width,
height: lastPositionOffset.height + value.translation.height)
}
.onEnded { _ in
lastPositionOffset = positionOffset
}
)
.onTapGesture {
onTap?()
}
// Ensure the ZStack fills the container.
.frame(width: geometry.size.width, height: geometry.size.height)
}
// Set an arbitrary height for the view (adjust as needed).
.frame(height: 300)
}
// MARK: - Helper Functions for Control Buttons
private func closeButton(at position: CGPoint) -> some View {
Image("iconClose")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onDelete?()
}
}
private func editButton(at position: CGPoint) -> some View {
Image("iconPen")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onEdit?()
}
}
private func resizeButton(at position: CGPoint, containerCenter: CGPoint) -> some View {
Image("iconResize")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(Angle(degrees: 80))
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.gesture(
DragGesture()
.onChanged { value in
let currentLocation = value.location
// Compute the vector from the container's center to the current drag location.
let dx = currentLocation.x - containerCenter.x
let dy = currentLocation.y - containerCenter.y
let currentDistance = sqrt(dx * dx + dy * dy)
let currentAngle = Angle(radians: atan2(dy, dx))
if !isDragging {
isDragging = true
initialDistance = currentDistance
initialAngle = currentAngle
} else {
// Adjust scale based on the change in distance.
let scaleFactor = currentDistance / initialDistance
var newScale = lastScale * scaleFactor
newScale = min(max(newScale, 0.1), 5.0)
scale = newScale
// Adjust rotation based on the change in angle.
let angleDelta = currentAngle - initialAngle
rotation = lastRotation + angleDelta
}
}
.onEnded { _ in
isDragging = false
lastScale = scale
lastRotation = rotation
}
)
}
}
The drag-to-resize functionality via the arrow control works perfectly. However, I also want the user to be able to resize (and possibly rotate) the view using a two-finger pinch gesture directly on the text (or on the entire view), rather than having to drag the resize arrow.
I've tried looking into adding a MagnificationGesture (and perhaps a RotationGesture) to handle the pinch, but I'm running into a couple of issues:
Gesture conflicts: How do I combine or distinguish between the existing drag gestures (for moving and resizing) and the new pinch gestures?
Updating transformations: How can I update the scale (and potentially rotation) state variables based on the pinch gesture in a way that integrates well with the current drag-to-resize implementation?
Does anyone have suggestions or examples on how to integrate a pinch gesture (using MagnificationGesture and possibly RotationGesture) into this view to allow pinch-to-resize (and/or rotate) functionality?
I'm working on a SwiftUI view that displays some text which can be moved, resized, and rotated. Right now, I have a working solution where the user can drag a resize arrow (located at the bottom-right) to change the size (and rotation) of the text.
Here's a simplified version of my code:
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct ResizableTextView: View {
let text: String
let isEdittable: Bool
/// Called when the edit (pen) button is tapped.
var onEdit: (() -> Void)?
/// Called when the close (delete) button is tapped.
var onDelete: (() -> Void)?
var onTap: (() -> Void)?
// MARK: - Transformation State Variables
@State private var scale: CGFloat = 1.0
@State private var rotation: Angle = .zero
// "Committed" transformation values from previous gestures.
@State private var lastScale: CGFloat = 1.0
@State private var lastRotation: Angle = .zero
// For tracking the resize gesture’s starting values.
@State private var initialDistance: CGFloat = 0
@State private var initialAngle: Angle = .zero
@State private var isDragging: Bool = false
// The intrinsic size of the text (including padding), measured before any transforms.
@State private var textSize: CGSize = .zero
// MARK: - Position (Drag-to-move) State Variables
@State private var positionOffset: CGSize = .zero
@State private var lastPositionOffset: CGSize = .zero
var body: some View {
GeometryReader { geometry in
// The container's center is used as the anchor point for the text.
let containerCenter = CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2)
ZStack {
// Main text view with measured size.
Text(text)
.padding(20)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { newSize in
self.textSize = newSize
}
// Apply the current transformations.
.scaleEffect(scale)
.rotationEffect(rotation)
// Draw a border around the text.
.overlay(
Rectangle()
.stroke(Color.gray, lineWidth: isEdittable ? 1 : 0)
.scaleEffect(scale)
.rotationEffect(rotation)
)
// Position the text at the container center.
.position(containerCenter)
// Calculate half of the scaled text dimensions.
let halfWidth = (textSize.width * scale) / 2
let halfHeight = (textSize.height * scale) / 2
let angleRad = rotation.radians
// MARK: - Compute Control Positions
// Bottom-right (Resize control):
let arrowOffsetX = halfWidth * CGFloat(cos(angleRad)) - halfHeight * CGFloat(sin(angleRad))
let arrowOffsetY = halfWidth * CGFloat(sin(angleRad)) + halfHeight * CGFloat(cos(angleRad))
let arrowPosition = CGPoint(x: containerCenter.x + arrowOffsetX,
y: containerCenter.y + arrowOffsetY)
// Top-right (Edit/Pen control):
let penOffsetX = halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let penOffsetY = halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let penPosition = CGPoint(x: containerCenter.x + penOffsetX,
y: containerCenter.y + penOffsetY)
// Top-left (Close/Delete control):
let closeOffsetX = -halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let closeOffsetY = -halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let closePosition = CGPoint(x: containerCenter.x + closeOffsetX,
y: containerCenter.y + closeOffsetY)
// MARK: - Add Controls Using Helper Functions
if isEdittable {
closeButton(at: closePosition)
editButton(at: penPosition)
resizeButton(at: arrowPosition, containerCenter: containerCenter)
}
}
// Apply the offset to the entire structure.
.offset(x: positionOffset.width, y: positionOffset.height)
// Attach a drag gesture to reposition the whole structure.
.gesture(
DragGesture()
.onChanged { value in
positionOffset = CGSize(width: lastPositionOffset.width + value.translation.width,
height: lastPositionOffset.height + value.translation.height)
}
.onEnded { _ in
lastPositionOffset = positionOffset
}
)
.onTapGesture {
onTap?()
}
// Ensure the ZStack fills the container.
.frame(width: geometry.size.width, height: geometry.size.height)
}
// Set an arbitrary height for the view (adjust as needed).
.frame(height: 300)
}
// MARK: - Helper Functions for Control Buttons
private func closeButton(at position: CGPoint) -> some View {
Image("iconClose")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onDelete?()
}
}
private func editButton(at position: CGPoint) -> some View {
Image("iconPen")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onEdit?()
}
}
private func resizeButton(at position: CGPoint, containerCenter: CGPoint) -> some View {
Image("iconResize")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(Angle(degrees: 80))
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.gesture(
DragGesture()
.onChanged { value in
let currentLocation = value.location
// Compute the vector from the container's center to the current drag location.
let dx = currentLocation.x - containerCenter.x
let dy = currentLocation.y - containerCenter.y
let currentDistance = sqrt(dx * dx + dy * dy)
let currentAngle = Angle(radians: atan2(dy, dx))
if !isDragging {
isDragging = true
initialDistance = currentDistance
initialAngle = currentAngle
} else {
// Adjust scale based on the change in distance.
let scaleFactor = currentDistance / initialDistance
var newScale = lastScale * scaleFactor
newScale = min(max(newScale, 0.1), 5.0)
scale = newScale
// Adjust rotation based on the change in angle.
let angleDelta = currentAngle - initialAngle
rotation = lastRotation + angleDelta
}
}
.onEnded { _ in
isDragging = false
lastScale = scale
lastRotation = rotation
}
)
}
}
The drag-to-resize functionality via the arrow control works perfectly. However, I also want the user to be able to resize (and possibly rotate) the view using a two-finger pinch gesture directly on the text (or on the entire view), rather than having to drag the resize arrow.
I've tried looking into adding a MagnificationGesture (and perhaps a RotationGesture) to handle the pinch, but I'm running into a couple of issues:
Gesture conflicts: How do I combine or distinguish between the existing drag gestures (for moving and resizing) and the new pinch gestures?
Updating transformations: How can I update the scale (and potentially rotation) state variables based on the pinch gesture in a way that integrates well with the current drag-to-resize implementation?
Does anyone have suggestions or examples on how to integrate a pinch gesture (using MagnificationGesture and possibly RotationGesture) into this view to allow pinch-to-resize (and/or rotate) functionality?
Share Improve this question asked 2 days ago Codebane the SwiftbreakerCodebane the Swiftbreaker 331 silver badge5 bronze badges New contributor Codebane the Swiftbreaker is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.1 Answer
Reset to default 1You can use .simultaneousGesture
to add a MagnifyGesture
and just use your existing scale
state to update on changes.
Add these modifiers to your Text
view:
.simultaneousGesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, scale) // Prevent shrinking too much
}
}
)
Same for the RotationGesture
, use your rotation
state:
.gesture(
RotationGesture()
.onChanged { value in
rotation = value
}
)
But, it's better practice to define the gestures separately (rather than within the .gesture
modifier). You can define them in the body, before the ZStack
, like this:
//Drag gesture
let dragGesture = DragGesture()
.onChanged { value in
positionOffset = CGSize(width: lastPositionOffset.width + value.translation.width,
height: lastPositionOffset.height + value.translation.height)
}
.onEnded { _ in
lastPositionOffset = positionOffset
}
//Magnify gesture
let magnifyGesture = MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, scale) // Prevent shrinking too much
}
}
//Rotation gesture
let rotationGesture = RotationGesture()
.onChanged { value in
rotation = value
}
Then, your existing gesture can simply be applied like this: .gesture(dragGesture)
And the new modifiers on the Text, like this:
.simultaneousGesture(magnifyGesture)
.gesture(rotationGesture)
But the real benefit lies in being able to combine gesture more easily, so instead of having three modifiers, you can do it all in your existing .gesture
modifier:
.gesture(
dragGesture
.simultaneously(with: magnifyGesture)
.simultaneously(with: rotationGesture)
)
Here's your complete updated code:
import SwiftUI
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct ResizableTextView: View {
let text: String
let isEdittable: Bool
/// Called when the edit (pen) button is tapped.
var onEdit: (() -> Void)?
/// Called when the close (delete) button is tapped.
var onDelete: (() -> Void)?
var onTap: (() -> Void)?
// MARK: - Transformation State Variables
@State private var scale: CGFloat = 1.0
@State private var rotation: Angle = .zero
// "Committed" transformation values from previous gestures.
@State private var lastScale: CGFloat = 1.0
@State private var lastRotation: Angle = .zero
// For tracking the resize gesture’s starting values.
@State private var initialDistance: CGFloat = 0
@State private var initialAngle: Angle = .zero
@State private var isDragging: Bool = false
// The intrinsic size of the text (including padding), measured before any transforms.
@State private var textSize: CGSize = .zero
// MARK: - Position (Drag-to-move) State Variables
@State private var positionOffset: CGSize = .zero
@State private var lastPositionOffset: CGSize = .zero
var body: some View {
GeometryReader { geometry in
// The container's center is used as the anchor point for the text.
let containerCenter = CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2)
//Drag gesture
let dragGesture = DragGesture()
.onChanged { value in
positionOffset = CGSize(width: lastPositionOffset.width + value.translation.width,
height: lastPositionOffset.height + value.translation.height)
}
.onEnded { _ in
lastPositionOffset = positionOffset
}
//Magnify gesture
let magnifyGesture = MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, scale) // Prevent shrinking too much
}
}
//Rotation gesture
let rotationGesture = RotationGesture()
.onChanged { value in
rotation = value
}
ZStack {
// Main text view with measured size.
Text(text)
.padding(20)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { newSize in
self.textSize = newSize
}
// Apply the current transformations.
.scaleEffect(scale)
.rotationEffect(rotation)
// Draw a border around the text.
.overlay(
Rectangle()
.stroke(Color.gray, lineWidth: isEdittable ? 1 : 0)
.scaleEffect(scale)
.rotationEffect(rotation)
)
// Position the text at the container center.
.position(containerCenter)
// Calculate half of the scaled text dimensions.
let halfWidth = (textSize.width * scale) / 2
let halfHeight = (textSize.height * scale) / 2
let angleRad = rotation.radians
// MARK: - Compute Control Positions
// Bottom-right (Resize control):
let arrowOffsetX = halfWidth * CGFloat(cos(angleRad)) - halfHeight * CGFloat(sin(angleRad))
let arrowOffsetY = halfWidth * CGFloat(sin(angleRad)) + halfHeight * CGFloat(cos(angleRad))
let arrowPosition = CGPoint(x: containerCenter.x + arrowOffsetX,
y: containerCenter.y + arrowOffsetY)
// Top-right (Edit/Pen control):
let penOffsetX = halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let penOffsetY = halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let penPosition = CGPoint(x: containerCenter.x + penOffsetX,
y: containerCenter.y + penOffsetY)
// Top-left (Close/Delete control):
let closeOffsetX = -halfWidth * CGFloat(cos(angleRad)) + halfHeight * CGFloat(sin(angleRad))
let closeOffsetY = -halfWidth * CGFloat(sin(angleRad)) - halfHeight * CGFloat(cos(angleRad))
let closePosition = CGPoint(x: containerCenter.x + closeOffsetX,
y: containerCenter.y + closeOffsetY)
// MARK: - Add Controls Using Helper Functions
if isEdittable {
closeButton(at: closePosition)
editButton(at: penPosition)
resizeButton(at: arrowPosition, containerCenter: containerCenter)
}
}
// Apply the offset to the entire structure.
.offset(x: positionOffset.width, y: positionOffset.height)
// Attach a drag gesture to reposition the whole structure.
.gesture(
dragGesture
.simultaneously(with: magnifyGesture)
.simultaneously(with: rotationGesture)
)
.onTapGesture {
onTap?()
}
// Ensure the ZStack fills the container.
.frame(width: geometry.size.width, height: geometry.size.height)
}
// Set an arbitrary height for the view (adjust as needed).
.frame(height: 300)
}
// MARK: - Helper Functions for Control Buttons
private func closeButton(at position: CGPoint) -> some View {
Image("iconClose")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onDelete?()
}
}
private func editButton(at position: CGPoint) -> some View {
Image("iconPen")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(.zero)
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.onTapGesture {
onEdit?()
}
}
private func resizeButton(at position: CGPoint, containerCenter: CGPoint) -> some View {
Image("iconResize")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(.black)
.rotationEffect(Angle(degrees: 80))
.padding(4)
.background(
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.shadow(radius: 5)
)
.position(position)
.gesture(
DragGesture()
.onChanged { value in
let currentLocation = value.location
// Compute the vector from the container's center to the current drag location.
let dx = currentLocation.x - containerCenter.x
let dy = currentLocation.y - containerCenter.y
let currentDistance = sqrt(dx * dx + dy * dy)
let currentAngle = Angle(radians: atan2(dy, dx))
if !isDragging {
isDragging = true
initialDistance = currentDistance
initialAngle = currentAngle
} else {
// Adjust scale based on the change in distance.
let scaleFactor = currentDistance / initialDistance
var newScale = lastScale * scaleFactor
newScale = min(max(newScale, 0.1), 5.0)
scale = newScale
// Adjust rotation based on the change in angle.
let angleDelta = currentAngle - initialAngle
rotation = lastRotation + angleDelta
}
}
.onEnded { _ in
isDragging = false
lastScale = scale
lastRotation = rotation
}
)
}
}
#Preview {
ResizableTextView(text: "Scale and rotate me", isEdittable: true)
}