I’m making an attempt to implement a perspective warp impact on a textual content in SwiftUI. Nonetheless, when I attempt to remodel the textual content path, it solely strikes the textual content slightly than stretching or squeezing it, as anticipated in perspective transformation. My purpose is to distort the textual content alongside a set of factors (top-left, top-right, bottom-right, and bottom-left) to create a perspective impact just like a photograph editor.
Here is the code I’m utilizing to realize the impact:
import SwiftUI
struct PerspectiveWarpView: View {
@State non-public var factors: [CGPoint] = [] // Initially empty
@State var shade: Colour = .white
@State var place: CGPoint = CGPoint(x: 100, y: 300)
@State var position2: CGPoint = CGPoint(x: 100, y: 250)
@State non-public var initialPosition: CGPoint = .zero
var physique: some View {
GeometryReader { geometry in
ZStack {
Colour.black.edgesIgnoringSafeArea(.all)
if !factors.isEmpty, let warpedPath = transformTextPath() {
warpedPath
.fill(shade)
.place(place)
.gesture(DragGesture().onChanged { worth in
if initialPosition == .zero {
initialPosition = place
}
let newPosition = CGPoint(
x: initialPosition.x + worth.translation.width,
y: initialPosition.y + worth.translation.peak
)
DispatchQueue.fundamental.async {
place = newPosition
position2 = CGPoint(x: newPosition.x, y: newPosition.y - 50)
}
}.onEnded({ _ in
initialPosition = .zero
})
)
PointsView(factors: $factors, path: warpedPath)
.place(position2)
.onAppear(){
factors = getCorners(of: warpedPath)
}
}
}
.onAppear {
// Initialize factors based mostly on the display screen measurement
let screenWidth = geometry.measurement.width
let screenHeight = geometry.measurement.peak
let offsetX = (screenWidth - 300) / 2 // Middle horizontally
let offsetY = (screenHeight - 200) / 2 // Middle vertically
factors = [
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 !factors.isEmpty else { return nil } // Guarantee factors are usually not empty
guard let originalPath = textToPath(textual content: "ELEVATED", font: .systemFont(ofSize: 80, weight: .daring)) else {
return nil
}
// Apply perspective remodel to the trail
return warpPath(originalPath, from: defaultRect(), to: factors)
}
func textToPath(textual content: String, font: UIFont) -> Path? {
let attributedString = NSAttributedString(string: textual content, 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 rely = CTRunGetGlyphCount(run)
for index in 0..<rely {
let vary = CFRangeMake(index, 1)
var glyph: CGGlyph = 0
var place: CGPoint = .zero
CTRunGetGlyphs(run, vary, &glyph)
CTRunGetPositions(run, vary, &place)
if let glyphPath = CTFontCreatePathForGlyph(font, glyph, nil) {
var remodel = CGAffineTransform(translationX: place.x, y: place.y)
remodel = remodel.scaledBy(x: 1, y: -1)
path.addPath(glyphPath, remodel: remodel)
}
}
}
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 remodel = computePerspectiveTransform(from: src, to: dst)
path.forEach { ingredient in
change ingredient {
case .transfer(to: let level):
newPath.transfer(to: applyPerspective(level, utilizing: remodel))
case .line(to: let level):
newPath.addLine(to: applyPerspective(level, utilizing: remodel))
case .quadCurve(to: let level, management: let management):
newPath.addQuadCurve(to: applyPerspective(level, utilizing: remodel),
management: applyPerspective(management, utilizing: remodel))
case .curve(to: let level, control1: let control1, control2: let control2):
newPath.addCurve(to: applyPerspective(level, utilizing: remodel),
control1: applyPerspective(control1, utilizing: remodel),
control2: applyPerspective(control2, utilizing: remodel))
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.rely
var matrix = A
var outcome = 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)
outcome.swapAt(i, maxRow)
let pivot = matrix[i][i]
if pivot == 0 { return nil }
for j in i..<rowCount {
matrix[i][j] /= pivot
}
outcome[i] /= pivot
for j in (i+1)..<rowCount {
let issue = matrix[j][i]
for ok in i..<rowCount {
matrix[j][k] -= issue * matrix[i][k]
}
outcome[j] -= issue * outcome[i]
}
}
for i in stride(from: rowCount-1, by way of: 0, by: -1) {
for j in (i+1)..<rowCount {
outcome[i] -= matrix[i][j] * outcome[j]
}
}
return outcome
}
func applyPerspective(_ level: CGPoint, utilizing matrix: [[CGFloat]]) -> CGPoint {
let x = level.x
let y = level.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 factors: [CGPoint]
var path: Path
var physique: some View {
ZStack {
// Draw the reworked path
Path { path in
path.transfer(to: factors[0])
path.addLine(to: factors[1])
path.addLine(to: factors[2])
path.addLine(to: factors[3])
path.closeSubpath()
}
.stroke(Colour.white.opacity(0.5), lineWidth: 2)
// Draw draggable factors
ForEach(0..<factors.rely, id: .self) { index in
Circle()
.fill(Colour.white)
.body(width: 12, peak: 12)
.place(factors[index])
.gesture(
DragGesture()
.onChanged { worth in
factors[index] = worth.location
}
)
}
}
}
}
#Preview {
PerspectiveWarpView()
}
Downside:
After I apply the transformTextPath() operate to the textual content, it merely strikes across the display screen as a substitute of stretching or squeezing based mostly on the attitude factors. I anticipated the textual content path to distort prefer it does in photograph editors when a perspective impact is utilized.
What I’ve tried:
Implementing a customized perspective remodel utilizing matrix manipulation.
Attempting alternative ways to use the warp impact utilizing Path and CGAffineTransform.
May somebody level out the place I is perhaps going mistaken, or provide recommendations on how I can obtain the proper perspective stretching/squeezing impact?