swiftui

How can I make parameters of CGAffineTransform get updated automaticlly in Shape while keeping all of path in visable view in SwiftUI?


I have a Rect view which comes from a shape. For the final touch, I need to use CGAffineTransform when adding my custom path. When I use CGAffineTransform, the parameters a and tx need to be calculated and updated inside the shape to keep the entire final path visible. Currently, I don't know the exact method to have a and tx update themselves while I am changing the view size. I have to adjust them manually using a slider, which is not part of the app or program—it's just a debugging tool. As you can see in the video, by changing the values of a and tx, I can keep the entire path within the frame, but the user needs this to happen automatically. So, how can I make a and tx update automatically while keeping the entire path in the frame?

Part 1:

enter image description here

Part 2:

enter image description here

code:

import SwiftUI

struct ContentView: View {

    @State private var a: Double = 0.8
    @State private var b: Double = 0
    @State private var c: Double = -0.17
    @State private var d: Double = 1
    @State private var tx: Double = 100
    @State private var ty: Double = 0

    var body: some View {
        
        FinalShape(a: a, b: b, c: c, d: d, tx: tx, ty: ty)
            .strokeBorder(style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round))
            .background(Color.purple)
            .cornerRadius(5.0)
            .padding()

        VStack {

            HStack {
                Text("a:")
                Slider(value: $a, in: -1...1)
            }
            
            HStack {
                Text("b:")
                Slider(value: $b, in: -1...1)
            }
            
            HStack {
                Text("c:")
                Slider(value: $c, in: -1...1)
            }
            
            HStack {
                Text("d:")
                Slider(value: $d, in: -1...1)
            }
            
            HStack {
                Text("tx:")
                Slider(value: $tx, in: -200...200)
            }
            
            HStack {
                Text("ty:")
                Slider(value: $ty, in: -200...200)
            }
 
        }
        .padding()

        HStack {
            VStack(alignment: .leading) {
                Text("a:" + String(describing: a))
                Text("b:" + String(describing: b))
                Text("c:" + String(describing: c))
                Text("d:" + String(describing: d))
                Text("tx:" + String(describing: tx))
                Text("ty:" + String(describing: ty))
            }
            
            Spacer()
        }
        .padding()

    }
}


struct FinalShape: InsettableShape {

    var a: Double
    var b: Double
    var c: Double
    var d: Double
    var tx: Double
    var ty: Double
    
    var insetAmount: CGFloat = CGFloat.zero

    func path(in rect: CGRect) -> Path {
        return Path { path in

            let transform = CGAffineTransform(a: a, b: b, c: c, d: d, tx: tx, ty: ty)

            path.addPath(RecShape(insetAmount: insetAmount).path(in: CGRect(origin: CGPoint(x: rect.minX, y: rect.minY), size: rect.size)), transform: transform)

        }
    }
    
    func inset(by amount: CGFloat) -> some InsettableShape {
        var myShape: Self = self
        myShape.insetAmount += amount
        return myShape
    }
}


struct RecShape: InsettableShape {
    var insetAmount: CGFloat = CGFloat.zero
    func path(in rect: CGRect) -> Path {
        return Path { path in
            path.addRect(CGRect(origin: CGPoint(x: rect.minX + insetAmount, y: rect.minY + insetAmount), size: CGSize(width: rect.width - 2*insetAmount, height: rect.height - 2*insetAmount)))
        }
    }
    func inset(by amount: CGFloat) -> some InsettableShape {
        var myShape: Self = self
        myShape.insetAmount += amount
        return myShape
    }
}

Solution

  • I assume that b, c, d, ty are all fixed.

    Recall the equation that describes how a point will be transformed by an affine transform.

    x' = ax + cy + tx
    y' = bx + dy + ty
    

    Based on your gifs, you seem to want the rectangle's minX and maxX to not change after applying the transformation. We can set up two equations and find the two unknowns a and tx. Here I have considered the two points (minX, maxY) and (maxX, minY).

    minX = a * minX + c * maxY + tx
    maxX = a * maxX + c * minY + tx
    

    After solving these, you can write:

    func path(in rect: CGRect) -> Path {
        let insettedRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
    
        if insettedRect.minX - insettedRect.maxX == 0 {
            // in this case, the rect has no width, and the equation has no solution
            // - you should decide what you want to return 
            return Path()
        }
        
        // I just used the path from a Rectangle() here, but you can use any path you like
        let path = Rectangle().path(in: insettedRect)
        
        let numerator = (insettedRect.minX - c * insettedRect.maxY - insettedRect.maxX - c * insettedRect.minY)
        let a = numerator / (insettedRect.minX - insettedRect.maxX)
        let tx = insettedRect.maxX - a * insettedRect.maxX + c * insettedRect.minY
        return path.applying(CGAffineTransform(a, b, c, d, tx, ty))
    }
    

    What "inset" means for this shape is questionable. Here I just insetted the given rect, but I suggest not making this insettable at all.

    Example usage:

    FinalShape(b: 0, c: -0.17, d: 1, ty: 0)
                .strokeBorder(style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round))
                .background(Color.purple)
                .padding()
    

    Note that it is still possible to pass in values of d and ty such that the shape exceeds the rectangle vertically. No values of a and tx can help you with that, because a and tx controls the transformation in the horizontal axis.