Reputation: 19
I am trying to implement a perspective warp effect on a text in SwiftUI. However, when I try to transform the text path, it only moves the text rather than stretching or squeezing it, as expected in perspective transformation. My goal is to distort the text along a set of points (top-left, top-right, bottom-right, and bottom-left) to create a perspective effect similar to a photo editor.
Here's the code I am using to achieve the effect:
import SwiftUI
struct PerspectiveWarpView: View {
@State private var points: [CGPoint] = [] // Initially empty
@State var color: Color = .white
@State var position: CGPoint = CGPoint(x: 100, y: 300)
@State var position2: CGPoint = CGPoint(x: 100, y: 250)
@State private var initialPosition: CGPoint = .zero
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if !points.isEmpty, let warpedPath = transformTextPath() {
warpedPath
.fill(color)
.position(position)
.gesture(DragGesture().onChanged { value in
if initialPosition == .zero {
initialPosition = position
}
let newPosition = CGPoint(
x: initialPosition.x + value.translation.width,
y: initialPosition.y + value.translation.height
)
DispatchQueue.main.async {
position = newPosition
position2 = CGPoint(x: newPosition.x, y: newPosition.y - 50)
}
}.onEnded({ _ in
initialPosition = .zero
})
)
PointsView(points: $points, path: warpedPath)
.position(position2)
.onAppear(){
points = getCorners(of: warpedPath)
}
}
}
.onAppear {
// Initialize points based on the screen size
let screenWidth = geometry.size.width
let screenHeight = geometry.size.height
let offsetX = (screenWidth - 300) / 2 // Center horizontally
let offsetY = (screenHeight - 200) / 2 // Center vertically
points = [
CGPoint(x: offsetX + 0, y: offsetY + 0), // Top-left
CGPoint(x: offsetX + 300, y: offsetY + 0), // Top-right
CGPoint(x: offsetX + 300, y: offsetY + 200), // Bottom-right
CGPoint(x: offsetX + 0, y: offsetY + 200) // Bottom-left
]
}
}
}
func getCorners(of path: Path) -> [CGPoint] {
let boundingBox = path.boundingRect
return [
CGPoint(x: boundingBox.minX, y: boundingBox.minY - 10), // Top-left
CGPoint(x: boundingBox.maxX, y: boundingBox.minY - 10), // Top-right
CGPoint(x: boundingBox.maxX, y: boundingBox.maxY + 10), // Bottom-right
CGPoint(x: boundingBox.minX, y: boundingBox.maxY + 10) // Bottom-left
]
}
func transformTextPath() -> Path? {
guard !points.isEmpty else { return nil } // Ensure points are not empty
guard let originalPath = textToPath(text: "ELEVATED", font: .systemFont(ofSize: 80, weight: .bold)) else {
return nil
}
// Apply perspective transform to the path
return warpPath(originalPath, from: defaultRect(), to: points)
}
func textToPath(text: String, font: UIFont) -> Path? {
let attributedString = NSAttributedString(string: text, attributes: [.font: font])
let line = CTLineCreateWithAttributedString(attributedString)
let runArray = CTLineGetGlyphRuns(line) as NSArray
let path = CGMutablePath()
for run in runArray {
let run = run as! CTRun
let count = CTRunGetGlyphCount(run)
for index in 0..<count {
let range = CFRangeMake(index, 1)
var glyph: CGGlyph = 0
var position: CGPoint = .zero
CTRunGetGlyphs(run, range, &glyph)
CTRunGetPositions(run, range, &position)
if let glyphPath = CTFontCreatePathForGlyph(font, glyph, nil) {
var transform = CGAffineTransform(translationX: position.x, y: position.y)
transform = transform.scaledBy(x: 1, y: -1)
path.addPath(glyphPath, transform: transform)
}
}
}
return Path(path)
}
func defaultRect() -> [CGPoint] {
return [
CGPoint(x: 0, y: 0), // Top-left
CGPoint(x: 300, y: 0), // Top-right
CGPoint(x: 300, y: 200), // Bottom-right
CGPoint(x: 0, y: 200) // Bottom-left
]
}
func warpPath(_ path: Path, from src: [CGPoint], to dst: [CGPoint]) -> Path {
var newPath = Path()
let transform = computePerspectiveTransform(from: src, to: dst)
path.forEach { element in
switch element {
case .move(to: let point):
newPath.move(to: applyPerspective(point, using: transform))
case .line(to: let point):
newPath.addLine(to: applyPerspective(point, using: transform))
case .quadCurve(to: let point, control: let control):
newPath.addQuadCurve(to: applyPerspective(point, using: transform),
control: applyPerspective(control, using: transform))
case .curve(to: let point, control1: let control1, control2: let control2):
newPath.addCurve(to: applyPerspective(point, using: transform),
control1: applyPerspective(control1, using: transform),
control2: applyPerspective(control2, using: transform))
case .closeSubpath:
newPath.closeSubpath()
}
}
return newPath
}
func computePerspectiveTransform(from src: [CGPoint], to dst: [CGPoint]) -> [[CGFloat]] {
let x0 = src[0].x, y0 = src[0].y
let x1 = src[1].x, y1 = src[1].y
let x2 = src[2].x, y2 = src[2].y
let x3 = src[3].x, y3 = src[3].y
let X0 = dst[0].x, Y0 = dst[0].y
let X1 = dst[1].x, Y1 = dst[1].y
let X2 = dst[2].x, Y2 = dst[2].y
let X3 = dst[3].x, Y3 = dst[3].y
let A = [
[x0, y0, 1, 0, 0, 0, -X0*x0, -X0*y0],
[x1, y1, 1, 0, 0, 0, -X1*x1, -X1*y1],
[x2, y2, 1, 0, 0, 0, -X2*x2, -X2*y2],
[x3, y3, 1, 0, 0, 0, -X3*x3, -X3*y3],
[0, 0, 0, x0, y0, 1, -Y0*x0, -Y0*y0],
[0, 0, 0, x1, y1, 1, -Y1*x1, -Y1*y1],
[0, 0, 0, x2, y2, 1, -Y2*x2, -Y2*y2],
[0, 0, 0, x3, y3, 1, -Y3*x3, -Y3*y3]
]
let B = [X0, X1, X2, X3, Y0, Y1, Y2, Y3]
guard let h = solveLinearSystem(A: A, B: B) else {
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
}
return [
[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1]
]
}
func solveLinearSystem(A: [[CGFloat]], B: [CGFloat]) -> [CGFloat]? {
let rowCount = A.count
var matrix = A
var result = B
for i in 0..<rowCount {
var maxRow = i
for j in (i+1)..<rowCount {
if abs(matrix[j][i]) > abs(matrix[maxRow][i]) {
maxRow = j
}
}
matrix.swapAt(i, maxRow)
result.swapAt(i, maxRow)
let pivot = matrix[i][i]
if pivot == 0 { return nil }
for j in i..<rowCount {
matrix[i][j] /= pivot
}
result[i] /= pivot
for j in (i+1)..<rowCount {
let factor = matrix[j][i]
for k in i..<rowCount {
matrix[j][k] -= factor * matrix[i][k]
}
result[j] -= factor * result[i]
}
}
for i in stride(from: rowCount-1, through: 0, by: -1) {
for j in (i+1)..<rowCount {
result[i] -= matrix[i][j] * result[j]
}
}
return result
}
func applyPerspective(_ point: CGPoint, using matrix: [[CGFloat]]) -> CGPoint {
let x = point.x
let y = point.y
let denominator = (matrix[2][0] * x + matrix[2][1] * y + matrix[2][2])
let newX = (matrix[0][0] * x + matrix[0][1] * y + matrix[0][2]) / denominator
let newY = (matrix[1][0] * x + matrix[1][1] * y + matrix[1][2]) / denominator
return CGPoint(x: newX, y: newY)
}
}
struct PointsView: View {
@Binding var points: [CGPoint]
var path: Path
var body: some View {
ZStack {
// Draw the transformed path
Path { path in
path.move(to: points[0])
path.addLine(to: points[1])
path.addLine(to: points[2])
path.addLine(to: points[3])
path.closeSubpath()
}
.stroke(Color.white.opacity(0.5), lineWidth: 2)
// Draw draggable points
ForEach(0..<points.count, id: \.self) { index in
Circle()
.fill(Color.white)
.frame(width: 12, height: 12)
.position(points[index])
.gesture(
DragGesture()
.onChanged { value in
points[index] = value.location
}
)
}
}
}
}
#Preview {
PerspectiveWarpView()
}
Problem: When I apply the transformTextPath() function to the text, it simply moves around the screen instead of stretching or squeezing based on the perspective points. I expected the text path to distort like it does in photo editors when a perspective effect is applied.
What I’ve tried:
Implementing a custom perspective transform using matrix manipulation. Trying different ways to apply the warp effect using Path and CGAffineTransform. Could someone point out where I might be going wrong, or offer suggestions on how I can achieve the correct perspective stretching/squeezing effect?
Upvotes: 0
Views: 26