kirit gareja
kirit gareja

Reputation: 19

Struggling to Apply Perspective Warp to Text Path in SwiftUI, Text Just Moves Instead of Stretching or Squeezing

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?

This is how it's looks Out Put Image

Upvotes: 0

Views: 26

Answers (0)

Related Questions