Animating Neumorphic Digits with SwiftUI
June 17, 2024
The idea of making such a display in iOS has been on my mind for a while. Of course, such an element is not often seen in mobile app design. But it’s a great example for experimenting and learning some basic SwiftUI tools, like the Layout
protocol.
Digit Segmentation
First, let’s create a basic element — DigitSegment
.
Its schematic is simple. We draw a line from the left centre clockwise, the left and right ends are isosceles triangles. If you want, you can control the radius of the angle and use addArc instead of addLine.
Sketch an intermediate example to check that everything works as it should.
Looks great. All that’s left is to code the segments and we can call it a day.
But this article wouldn’t be here if we stopped here. The problem with the current solution is that its dimensions are rigidly set. Trying to integrate it into another view would require adjusting the dimensions for each segment, bruh.
I would like the display to be able to arrange segments and set their size relative to the parent container independently. For this purpose we will use the Layout protocol.
Layout Segmentation
Before going any further, I suggest you read about the Layout protocol from a great article from the folks at SwiftUI Lab.
The SwiftUI Layout Protocol - Part 1 - The SwiftUI Lab
We’ll start with defining a new type DigitLayout and subscribe it to the Layout protocol. The compiler will immediately offer us to implement the necessary methods.
The first one is sizeThatFits
, where we define the size of the whole container, in our case it is the size of one digit representation with segments.
To understand where all these calculations come from, let’s look at the anatomy of the digit in more detail. The proposal
parameter carries the size that the parent view is ready to give us. With its help we calculate the size for digit segments.
We take the available width as a basis and assume that the figure fits into it completely. We set 0.8 part of this width as the length of one segment, and the remaining 0.2 part as the height of one segment.
Then the height of the entire display is the sum of the lengths of the two segments and the height of one more segment (so we take into account the top and bottom segments, which stick out a bit).
We will also implement the placeSubviews
method. For the space between segments, we will slightly reduce the length of the segments themselves. To compensate for this reduction, we indent horizontally by one spacing unit.
The segments
array defines the parameters for displaying each segment, including offset and rotation angle. We assume that the layout we build handles only 7 segments.
💡 Note that all values for width, height and space between segments are defined in relative units. In this way, we are not bound to absolute values of display sizes and keep adaptability for different sizes.
The fact that we are taking length away from the segment has no effect on the value returned from sizeThatFits
, because we are still acting within the width that the parent view gives us.
To see an intermediate result, provide new layout with DigitSegment
instances.
Almost. We don’t apply a rotation angle yet. The Layout
protocol does not allow you to apply modifiers to views, it is only responsible for the layout of elements.
Fortunately, this problem has a simple and elegant solution that you can look up from SwiftUI Lab.
The SwiftUI Layout Protocol - Part 2
The idea is to apply rotation from outside DigitLayout
. First, let's define a SegmentRotation
type that will store a Binding<Angle>
, a hack that allows us to pass a rotation angle value between views.
To store and manage the actual state of the digit, define DigitView
.
And the last thing — in placeSubviews we set its rotation angle for displaying. The operation itself is wrapped in the DispatchQueue.main
call to avoid looping of the elements layout calculation.
We have corrected the situation, now the display works correctly and we can see the number 8. Let’s figure out how to encode the digits into instructions that are readable for DigitView.
Encoding Segmentation
Each segment can have two states: shown and hidden, or true
and false
. In our implementation, the segment indices correspond to the image below.
💡 To display the segment index, add an
overlay
with text in theDigitSegment
construction.
Then to encode numbers from 0 to 9, we need to define an array with a sequence of true
and false
values for each of them.
To see the logic behind it, we will use the example of the digit 0. To display it, we need to show the segments with indices 0, 1, 2, 4, 5, 6 and hide the segment with index 3. Thus the array looks as follows:
The other digits are encoded according to the same principle. Modify DigitView
by adding an array with configurations. As a parameter we will also pass digit
corresponding to the displayed digit.
Here we control the display of an individual segment through the opacity
modifier.
💡 As a good API practice, it is necessary to prevent situations where the user can pass a
digit
value greater than 9, thus going beyond the bounds of thestates
array. For example, declare an initialiser and reduce invalid values to the default value.
Now, if we substitute any number, we get its segmented view.
That’s pretty much the end of it, the view is ready to use. But we’ll go further and try to breathe some design into it.
Design Segmentation
We are going to add some realism to the digits. A few years ago, such a style as neumorphism was very popular. You can read more about this and how it can be implemented using SwiftUI on Sarunw’s blog.
How to create Neumorphic design in SwiftUI
First of all, we define a set of colours with which we will achieve the effect of protruding segments.
Next, colour DigitSegment
in Color.neuBackground
and drop some shadows on it. Note that we apply the shadows after we rotate the segment in order to render them correctly. To animate them, we also call the animation
modifier at the end of the entire segment hierarchy.
Remember to colour the entire background with Color.neuBackground
to achieve the desired effect.
💡
@Previwable
macro is part of Xcode 16. If you are running a different version, then define a separate view for storing the state.
This is the effect we get. When you change the digit, some segments hide in the background, and others appear on the contrary. Just like in an analogue display.
Since DigitSegment
conforms to the Shape
protocol, it can be styled in any way you like. For example, you can use a neon effect.
Making things glow and shine with SwiftUI
Conclusion
You can take the idea further and combine multiple DigitView
instances into full displays for multiple digits of numbers.
Other than that, Layout is a powerful tool when it comes to building simple and not-so-simple displays and arranging multiple elements relative to each other. Somewhat similar to UICollectionViewLayout, but more generic and with fewer methods to override. It has some work for improvement, but at least a segmented display can be built on it.
Thanks for reading and see you in the next articles 🙌