Blog

A Signal Strength Indicator

In the second of our Thinking in SwiftUI challenges, we asked you to build a signal strength indicator with a flexible width and height, without using GeometryReader, that should fill the space proposed by SwiftUI’s layout system:

In this article, we’ll discuss our solution.

As a first step, we can draw five rounded rectangles that are filled with the primary color, and have their opacity based on whether they’re “active” bars or not:

struct SignalStrengthIndicator: View {
    var bars: Int = 3
    var totalBars: Int = 5
    var body: some View {
        HStack {
            ForEach(0..<totalBars) { bar in
                RoundedRectangle(cornerRadius: 10)
                    .fill(Color.primary.opacity(bar < self.bars ? 1 : 0.3))
            }
        }
    }
}

As the second step, we need to scale the rounded rectangles vertically. We could use .scaleEffect, but that scales the rendered view and distorts the rounded corners. Ideally, we would propose a different frame to the rectangles. To do this, we can use the Shape protocol, which consists of a single requirement: the conforming type needs to implement a method that draws a Path given a proposed rectangle.

We’ll create a Divided struct that conforms to the Shape protocol, so it’ll receive the proposed rectangle. It then divides the proposed rectangle and proposes that to its child shape:

struct Divided<S: Shape>: Shape {
    var amount: CGFloat // Should be in range 0...1
    var shape: S
    func path(in rect: CGRect) -> Path {
        shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
    }
}

To make the code a little more readable, we add an extension on Shape:

extension Shape {
    func divided(amount: CGFloat) -> Divided<Self> {
        return Divided(amount: amount, shape: self)
    }
}

Now we can use the divided method to change the implementation of SignalStrengthIndicator:

struct SignalStrengthIndicator: View {
    var bars: Int = 3
    var totalBars: Int = 5
    var body: some View {
        HStack {
            ForEach(0..<totalBars) { bar in
                RoundedRectangle(cornerRadius: 3)
                    .divided(amount: (CGFloat(bar) + 1) / CGFloat(self.totalBars))
                    .fill(Color.primary.opacity(bar < self.bars ? 1 : 0.3))
            }
        }
    }
}

Here’s the same signal strength indicator rendered with a frame of (200, 100) and (100, 50):

There are a few obvious improvements you could make, which we’ll leave as an exercise for the reader: the Divided struct could take the edge as a parameter, the rounded rectangle could have a corner radius that’s dependent on the proposed size, and the HStack could have spacing that’s dependent on the proposed size.

Making The View Scale With Text

To make our view scale with text, we could try to read the current font from the environment, but that won’t give us access to the point size. Fortunately, there’s this one weird trick we could use: take some fixed text, hide it, and add our signal strength indicator as an overlay. The overlay will receive the size of the rendered text as its proposed size, and fill up that space exactly:

struct SignalStrengthView: View {
    var body: some View {
        HStack {
            Text("NNNNN").hidden().overlay(SignalStrengthIndicator())
            Text("-75 dB")
        }
    }
}

Here’s what it looks like at different font sizes:

While we hope that newer versions of SwiftUI will make hacks like this unnecessary, it’s a fun trick to have in your arsenal.

Our new book, Thinking in SwiftUI, discusses the layout system in more detail in chapter four and five. At the time of writing, the first three chapters are already available for early access readers, with a new chapter and Q&A video released every week until the book is complete.

You can join the early access here.


Stay up-to-date with our newsletter or follow us on Twitter.

Back to the Blog

recent posts