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.
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!
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!!
They are very useful (and also used a lot in computer graphics ) geometric linear transform that
preserve collinearity and parallelism. The affine transform matrix for
2D scaling expressed as homogenous coordinates is the following one.
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
).
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: