Bringing analogue things to life with SwiftUI

Uladzislau Volchyk
10 min readJun 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.

struct DigitSegment: Shape {
func path(in rect: CGRect) -> Path {
let width = rect.size.width
let height = rect.size.height
let heightCenter = height * 0.5
return Path { path in
path.move(to: CGPoint(x: .zero, y: heightCenter))
path.addLine(to: CGPoint(x: heightCenter, y: .zero))
path.addLine(to: CGPoint(x: width - heightCenter, y: .zero))
path.addLine(to: CGPoint(x: width, y: heightCenter))
path.addLine(to: CGPoint(x: width - heightCenter, y: height))
path.addLine(to: CGPoint(x: heightCenter, y: height))
path.closeSubpath()
}
}
}

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.

Single segment schematica

Sketch an intermediate example to check that everything works as it should.

#Preview {
ZStack {
SegmentView()
.frame(width: 100.0, height: 30.0)
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 55.0, y: 55.0)
.rotationEffect(.degrees(90.0))
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 55.0, y: -55.0)
.rotationEffect(.degrees(90.0))
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 0.0, y: 110.0)
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 165.0, y: 55.0)
.rotationEffect(.degrees(90.0))
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 165.0, y: -55.0)
.rotationEffect(.degrees(90.0))
SegmentView()
.frame(width: 100.0, height: 30.0)
.offset(x: 0.0, y: 220.0)
}
}

Looks great. All that’s left is to code the segments and we can call it a day.

Full-segmented display (ok way)

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.

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.

struct DigitLayout: Layout {
private enum Ratio {
static let width = 0.8
static let height = 0.2
static let spacing = 0.05
}

func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let width = proposal.width ?? .zero
return CGSize(
width: width,
height: width * (2 * Ratio.width + Ratio.height)
)
}
...
}

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).

Schematica of segments

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.

struct DigitLayout: Layout {
...

private struct LayoutConfiguration {
let xOffset: CGFloat
let yOffset: CGFloat
let rotation: Angle
}

private let segments: [LayoutConfiguration] = [
/// top
.init(
xOffset: Ratio.height * 0.5,
yOffset: .zero,
rotation: .zero
),
/// top-left
.init(
xOffset: -Ratio.height * 1.5,
yOffset: Ratio.width * 0.5,
rotation: .degrees(90.0)
),
/// top-right
.init(
xOffset: 0.5,
yOffset: Ratio.width * 0.5,
rotation: .degrees(90.0)
),
/// center
.init(
xOffset: Ratio.height * 0.5,
yOffset: Ratio.width,
rotation: .zero
),
/// bottom-left
.init(
xOffset: -Ratio.height * 1.5,
yOffset: Ratio.width + 2 * Ratio.height,
rotation: .degrees(90.0)
),
/// bottom-right
.init(
xOffset: 0.5,
yOffset: Ratio.width + 2 * Ratio.height,
rotation: .degrees(90.0)
),
/// bottom
.init(
xOffset: Ratio.height * 0.5,
yOffset: Ratio.width * 2,
rotation: .zero
)
]

func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let segmentWidth = bounds.width * (Ratio.width - 2 * Ratio.spacing)
let segmentHeight = bounds.width * Ratio.height

let originX = bounds.minX + Ratio.spacing * bounds.width
let originY = bounds.minY

subviews.enumerated().forEach { (index, subview) in
let size = subview.sizeThatFits(proposal)
let segment = segments[index]
let point = CGPoint(
x: size.width * segment.xOffset,
y: size.width * segment.yOffset
)
subview.place(
at: CGPoint(x: originX + point.x, y: originY + point.y),
proposal: .init(width: segmentWidth, height: segmentHeight)
)
}
}
}

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.

Schematica of a spacing between segments

To see an intermediate result, provide new layout with DigitSegment instances.

#Preview {
DigitLayout {
ForEach(0..<7) { _ in
DigitSegment()
}
}
.frame(width: 100.0)
}

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.

Spaced segments, but without rotation applied

Fortunately, this problem has a simple and elegant solution that you can look up from SwiftUI Lab.

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.

struct SegmentRotation: LayoutValueKey {
static let defaultValue: Binding<Angle>? = nil
}

To store and manage the actual state of the digit, define DigitView.

struct DigitView: View {
@State var rotations: [Angle] = Array<Angle>(repeating: .zero, count: 7)

var body: some View {
DigitLayout {
ForEach(0..<7) { idx in
DigitSegment()
.rotationEffect(rotations[idx])
.layoutValue(key: SegmentRotation.self, value: $rotations[idx])
}
}
}
}

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.

struct DigitLayout: Layout {
...

func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
...

subviews.enumerated().forEach { (index, subview) in
...

DispatchQueue.main.async {
subview[SegmentRotation.self]?.wrappedValue = segment.rotation
}
}
}
}

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.

Full-segmented display (cool way)

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.

Indexed segments

💡 To display the segment index, add an overlay with text in the DigitSegment 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:

[true, true, true, false, true, true, true]

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.

struct DigitView: View {
static let states: [[Bool]] = [
[true, true, true, false, true, true, true],
[false, false, true, false, false, true, false],
[true, false, true, true, true, false, true],
[true, false, true, true, false, true, true],
[false, true, true, true, false, true, false],
[true, true, false, true, false, true, true],
[true, true, false, true, true, true, true],
[true, false, true, false, false, true, false],
[true, true, true, true, true, true, true],
[true, true, true, true, false, true, true]
]

@Binding var digit: Int
@State var rotations: [Angle] = Array<Angle>(repeating: .zero, count: 7)

var body: some View {
DigitLayout {
ForEach(0..<7) { idx in
DigitSegment()
.rotationEffect(rotations[idx])
.layoutValue(key: SegmentRotation.self, value: $rotations[idx])
.opacity(Self.states[digit][idx] ? 1.0 : 0.0)
}
}
}
}

💡 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 the states 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.

2 in segmented representation

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.

First of all, we define a set of colours with which we will achieve the effect of protruding segments.

extension Color {
static let neuBackground = Color(red: 240 / 255, green: 240 / 255, blue: 243 / 255)
static let dropShadow = Color(red: 174 / 255, green: 174 / 255, blue: 192 / 255, opacity: 0.4)
static let dropLight = Color.white
}

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.

struct DigitView: View {
...

var body: some View {
DigitLayout {
ForEach(0..<7) { idx in
DigitSegment()
.fill(Color.neuBackground)
.rotationEffect(rotations[idx])
.layoutValue(key: SegmentRotation.self, value: $rotations[idx])
.shadow(
color: .dropShadow,
radius: Self.states[digit][idx] ? 4 : .zero,
x: Self.states[digit][idx] ? 4 : .zero,
y: Self.states[digit][idx] ? 4 : .zero
)
.shadow(
color: .dropLight,
radius: Self.states[digit][idx] ? 2 : .zero,
x: Self.states[digit][idx] ? -2 : .zero,
y: Self.states[digit][idx] ? -2 : .zero
)
.animation(.easeInOut, value: digit)
}
}
}
}

Remember to colour the entire background with Color.neuBackground to achieve the desired effect.

#Preview {
@Previewable @State var digit = 0

ZStack {
Color.neoBackground
.ignoresSafeArea()
DigitView(digit: $digit)
.frame(width: 100.0)
...
}
}

💡 @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.

Digit with neumorphic effect

Since DigitSegment conforms to the Shape protocol, it can be styled in any way you like. For example, you can use a neon effect.

struct DigitView: View {
...

var body: some View {
DigitLayout {
ForEach(0..<7) { idx in
DigitSegment()
.glow(
fill: Self.states[digit][idx] ? .orange : .clear,
lineWidth: 4.0
)
.rotationEffect(rotations[idx])
.layoutValue(key: SegmentRotation.self, value: $rotations[idx])
.animation(.easeInOut, value: digit)
}
}
}
}
Digit with neon effect

Conclusion

You can take the idea further and combine multiple DigitView instances into full displays for multiple digits of numbers.

Counter built with segmented digits

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 🙌

--

--