Material You rounded polygon with Angular
At this year’s Google I/O the new personalized and colorful design system for Android and other Google products was revealed. It is called “Material You” and one of the first things that caught my eye were the various rounded polygons used for UI elements.
It would be nice to have a flexible way of using these star-like shapes for any element. So, I developed an Angular Directive to do so. Thereby the directive creates an SVG clip path of the rounded polygon and applies it to its host element via the CSS clip-path property.
You can find the implementation of the directive along a simple test application on GitHub.
The following sections of this article describe some of the key concepts involved in developing this directive.
Define the configuration settings
After the directives skeleton was initialized, I wanted to nail down the basic configuration properties and value ranges for it. This will describe the public API of the directive:
- Number of corners: this number must be at least three. The corners define number of “rays” the star-like shapes can have.
- Outer Radius: this number between 0 and 1 defines the size of the polygon in percent relative to the total available size (when set to 1, the radius of the polygon will reach the edges of the svg viewport)
- Inner Radius Ratio: a number between 0 and 1 which describes the ratio of the inner radius to the outer radius of the shape (when set to 1, the inner radius will be the same as the outer radius).
- Corner Radius: a number between 0 and 1 which defines the size of the corner radius in relation to the maximal possible corner radius. This might seem like a rather curious definition for this property, as it does not describe the corner radius using an exact value. The idea behind it was that it is less about the exact definition and more about finding an elegant shape.
- Tilt: the angle of rotation of the shape in degrees.
These configuration properties are encapsulated into a single interface which allows us to apply all configuration settings via one input property binding. This was done for the sake of simplicity and because animation has not yet been considered. The property binding is implemented using a setter function, which ensures the validity of the configuration regardless of the parameters provided.
Construct the basic shape
The first step in drawing the rounded polygon shape would be to construct the edges of the underlying polygon. The rounded corners will be defined within a second pass.
The basic shape of the polygon was constructed using some simple trigonometry along with an alternating outer- and inner radius. For convenience, all vertices are centered around the origin and will be translated to their final position at the end of the drawing process.
To make the construction of the shapes easier to describe, the application contains a stripped-down 2D vector class (Vector2) that is increasingly used for the following steps in the drawing process.
const vertices: Vector2[] = [];
const numPoints = corners * 2;
const gamma = (2 * Math.PI) / numPoints;// the tilt angle form the configuration in radians
let angle = tilt;
for (let i = 0; i < numPoints; i++) {
// switch between the outer and inner radius from the configuration
const radius = i % 2 ? innerRadius : outerRadius;
const rx = Math.cos(angle) * radius;
const ry = Math.sin(angle) * radius;
vertices.push(new Vector2(rx, ry));
angle += gamma;
}
Add rounded corners
After the vertices of the basic shape are in place, the next step was to add the rounded corners. There is an excellent article on how to construct rounded corners by Mathieu Jouhet. However, there are some issues which I wanted to make differently as in this article. Mainly the determination of the maximum corner radius and the construction using arches.
Determining the maximum corner radius
The algorithm described within Mathieu Jouhets article, limits the maximum corner radius to half the length of the shortest edge. However, when using Illustrator or Figma to draw the shapes which I were aiming for, I noticed that the maximum corner radius goes beyond this limit. So, I needed a different method for determining this threshold value.
My approach was to find the maximum radius by calculating the point where both tangent points of the arcs for the involved points meet (marked as point C in the following figure). What is known is the length of the edge (s between A and B) and the half angles on the points A and B, namely α and β.
These known properties along with the unknown arc center point Q form an arbitrary triangle where one side and the two angles adjacent to it are known. By using the Law of Sines the missing angles and lengths can be calculated until we get to the length of r which defines the maximum radius. This is done for both edges adjacent to the point where the maximum radius gets calculated. Finally, the smaller radius gets picked.
Although this approach might not be bulletproof for every arbitrary polygon it works reasonably well for this particular use case.
private getMaxCornerRadius(A: PolygonPoint, B: PolygonPoint): number {
const s = Vector2.subtract(A.vertex, B.vertex).length();
const alpha = A.angle / 2;
const beta = B.angle / 2;
const gamma = Math.PI - alpha - beta;
return (s * Math.sin(alpha) * Math.sin(beta)) / Math.sin(gamma);
}
Construction using arches
Finding all the necessary points for the construction of the arches is already described in detail in the article by Mathieu Jouhets. Yet, when drawing the actual SVG arc elements, there was one parameter missing for my solution, namely the sweep-flag. This flag indicates if the arc should be drawn clockwise or counterclockwise (see the MDN docs for additional information).
Determining the winding of a polygon (clockwise or counterclockwise) is a common task within computer graphics and is solved by utilizing the cross product. In this case, the cross product gets calculated for the triangle forming the corner point of the polygon (for convenience within the code, this was done by using the tangent points of the arc). Consequently, the sign of the cross product determines the value of the sweep-flag parameter.
Apply styling to the host element
After the construction of the rounded polygon path, it must be applied as a clip-path to the host element. This was done by creating an SVG element containing the clip path and appending it to the host element. As the whole SVG tree gets created as a string, the ContextualFragment was utilized to create the SVG node.
const frag = document.createRange().createContextualFragment(svg);
this.renderer.appendChild(this.hostElementRef.nativeElement, frag);
// apply the clip path to the host element (the id is a static counter)
this.renderer.setStyle(this.hostElementRef.nativeElement, 'clip-path', `url(#${id})`);