lastminute.com logo

Technology

Use SwiftUI Path and Shape to render your svg files: a practical example

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
marco de lucchi

Let's have some fun with SwiftUI by drawing an inspiration title component leveraging Path and Shape.


In the previous post, we presented our FriYaY project: an inspirational iOS widget. We talked about the journey from conception, design, implementation and finally going in production. During this process, we did multiple design iterations, and one of those contained an interesting branding proposition to catch the user’s attention. You can see it in the image below.

swiftui path shapes
Design iteration example

The easiest way to go would have been to include the asset representing the title above, with multiple localised versions, inside the target Bundle. This method is widely supported by Xcode, using the localised assets. But given that we were already having fun with SwiftUI (remember, it is a FriYaY project) and that we wanted to save bytes on the bundle size (that is always important), we decided to be bold and try to implement it using only code (no assets).
In this post we will show our personal adventure into the world of SwiftUI Path and Shape, and how we have been able to create the title in the screenshot above using only code. Let’s go! emoji-rocket

One side note: This UI element is not present in the final version of the widget available on the App Store. After some iterations, we decided to give more relevance to the inspirational image, and given the size of the widgets, the title was taking too much space.

Implementation

Let’s start from the most challenging part: the background. As you can see from the image above it is basically the same shape drawn twice, one above each other, with different color and a slightly different position in space. In the original Figma we received from UX we were able to extract the svg path coordinates for these shapes.
So how can we draw them in SwiftUI? We can use Path and Shape. The first one is a struct that represents the outline of a 2D shape in terms of a set of point and their connection. The second one is a protocol that contains one specific method required func path(in: CGRect) -> Path: by implementing it we must return a Path that describe the outline of the Shape itself in the current reference Rect, that can change based on the usage context of the Shape itself.
So given the svg coordinate that I mentioned before, we can create a component named TitleBackgroundShape that implement the Shape protocol that draws the path described by the svg. To quickly convert the svg path into code we used https://swiftvg.mike-engel.com. This tool transformed our two svg path as the following UIBezierPath.

// ...other code...

private var whitePath: UIBezierPath {
    let shape = UIBezierPath()
    shape.move(to: CGPoint(x: 247, y: 15.79))
    shape.addLine(to: CGPoint(x: 8.72, y: 21))
    shape.addLine(to: CGPoint(x: 2, y: -2.08))
    shape.addLine(to: CGPoint(x: 245.12, y: -3))
    shape.close()
    return shape
}
private var purplePath: UIBezierPath {
    let shape = UIBezierPath()
    shape.move(to: CGPoint(x: 245, y: 18.79))
    shape.addLine(to: CGPoint(x: 6.72, y: 24))
    shape.addLine(to: CGPoint(x: 0, y: 0.92))
    shape.addLine(to: CGPoint(x: 243.12, y: 0))
    shape.close()
    return shape
}

// ...other code...

Given the paths above a first naive implementation of the TitleBackgroundShape component could be the following.

private struct TitleBackgroundShape: Shape {
    private let path: UIBezierPath

    init(path: UIBezierPath) {
        self.path = path
    }

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

Seems easy, but of course there is a problem: if you look carefully at the svg path above, you will notice that it is defined for some specific dimension. Generally speaking, even if we export the svg with coordinate system define between 0 and 1 on each axis, we still need to find a way to scale the path correctly with respect to the current frame we are drawing into, represented by the rect parameter of the func path(in rect: CGRect) -> Path function. How can we do this? There are affine transforms to the rescue!! emoji-rocket They are very useful (and also used a lot in computer graphics emoji-laughing ) geometric linear transform that preserve collinearity and parallelism. The affine transform matrix for 2D scaling expressed as homogenous coordinates is the following one.

[Sx000Sy0001][xy1]=[xSxySy1]\begin{bmatrix} S_{x} & 0 & 0 \\ 0 & S_{y} & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \\ \end{bmatrix} = \begin{bmatrix} x \cdot S_{x}\\ y \cdot S_{y} \\ 1 \\ \end{bmatrix}

Guess what? Inside the Core Graphics framework we already have the CGAffineTransform struct with various init methods, one for each type of transform (rotate, scale and translation, but shear seems missing for unknown reasons emoji-laughing ). In our case we will use CGAffineTransform(scaleX: <value>, y: <value>). Which are the value to be used as scale factor? For our goal we need to calculate the ratio between the rect parameter of the func path(in rect: CGRect) -> Path function (that as we said before it is the CGRect in which we are drawing our shape) and the bounding rect of the path itself. Luckily, SwiftUI exposes a beautiful boundingRect property on the Path that is exactly what we need. So in the end the final implementation of our TitleBackgroundShape is the following one.

private struct TitleBackgroundShape: Shape {
    private let path: UIBezierPath

    init(path: UIBezierPath) {
        self.path = path
    }

    func path(in rect: CGRect) -> Path {
        let path = Path(path.cgPath)
        return path.applying(
            CGAffineTransform(
                scaleX: rect.width / path.boundingRect.width,
                y: rect.height / path.boundingRect.height
            )
        )
    }
}

Now that we have our shape, we need to draw it twice with the two svg paths we saw above. We can draw them in a ZStack in order to have the white one as overlay of the purple one. This is the final implementation of our shape components that we will call TitleBackground.

struct TitleBackground: View {
    private var whitePath: UIBezierPath {
        let shape = UIBezierPath()
        shape.move(to: CGPoint(x: 247, y: 15.79))
        shape.addLine(to: CGPoint(x: 8.72, y: 21))
        shape.addLine(to: CGPoint(x: 2, y: -2.08))
        shape.addLine(to: CGPoint(x: 245.12, y: -3))
        shape.close()
        return shape
    }
    private var purplePath: UIBezierPath {
        let shape = UIBezierPath()
        shape.move(to: CGPoint(x: 245, y: 18.79))
        shape.addLine(to: CGPoint(x: 6.72, y: 24))
        shape.addLine(to: CGPoint(x: 0, y: 0.92))
        shape.addLine(to: CGPoint(x: 243.12, y: 0))
        shape.close()
        return shape
    }

    var body: some View {
        ZStack(alignment: Alignment.topLeading) {
            TitleBackgroundShape(path: purplePath)
                .fill(Color("SecondaryColor"))
            TitleBackgroundShape(path: whitePath)
                .fill(Color("TextColor"))
        }
    }
}

Last but not least, for the message text we can use a standard Text component. As you can see from the image above it is a white bold text (represented in the example as SecondaryText). If you look at it carefully, you will notice that it is slightly rotate. In fact checking the Figma design we received from UX, we discovered that it has a 1. 38-degree rotation. This is why we used the .rotationEffect(Angle(degrees: -1.38)) modifier. We also want our text always on 1 line (forcing it to be truncated in some exceptional cases) and centered with the .lineLimit(1) and . multilineTextAlignment(.center) modifiers.
As you can see again from the initial image, we want also a specific padding around our text, in order to have some space with respect to the background. This is why we added the three .padding modifiers in order to have specific spacing for each side.
We are finally ready to add the custom background component TitleBackground we created before. We need to put it behind the text, but we want also to see it grows proportionally to the text dimension. This is why we included the Text component in a ZStack, and we added with the .background modifier the TitleBackground. Below you can find the implementation.

struct InspirationaTitle: View {
    var body: some View {
        ZStack {
            Text(LocalizedStringKey("It's event better when you get there!"))
                .foregroundColor(Color("SecondaryColor"))
                .fontWeight(.bold)
                .minimumScaleFactor(0.4)
                .rotationEffect(Angle(degrees: -1.38))
                .lineLimit(1)
                .multilineTextAlignment(.center)
                .padding(.leading, 12)
                .padding(.trailing, 4)
                .padding(.bottom, 8)
        }
        .background(TitleBackground())
    }
}

Conclusion

In this github repository you can find the implementation fo the example described above. If you want to know more about our Friyay Journey into the WidgetKit, follow the links below:


Read next

Impacts of contract tests in our microservice architecture

Impacts of contract tests in our microservice architecture

ivan_delloro
ivan delloro

We learned how to implement contract test for any kind of messages exchanged in a microservices' architecture, now let's see the impacts and why it sped up our development process [...]

Widget iOS: how to fix the image bundling problem

Widget iOS: how to fix the image bundling problem

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
marco de lucchi

Discover how we had a long day debugging WidgetKit problems with images. [...]