An Adaptable Segmented Control

SegmentedPicker Demo Clip

On iOS, especially on iPads, the dimensions of the size of the views we work with have, over the years, become increasingly flexible and varied. Additionally, accessibility features, like dynamic, user controllable font size have been with us for a while. This means that our user interface items need to be increasingly flexible so that they can adapt to their sometimes dynamically changing environment.

Not so long ago, I found myself faced with a small challenge: I had created a segmented control in SwiftUI, that supports a visual style particular to one of the apps I work on. When I started testing how the control behaved, especially when combining dynamic font size changes with multi-tasking split screens on iPads, I found an issue: Even though my control only had three segments with short titles, at the larger accessibility font sizes, especially if the screen was split due to multitasking mode, the segments would not fit. After thinking about the various ways in which this issue could be solved, I quickly settled on allowing the control to dynamically change to a popup-menu-style control when space gets tight. Now, in UIKit, I knew exactly how to do that. But trying to do this in SwiftUI, which I’m fairly new to, and which uses a completely different approach to building layouts, this was a bit more challenging. Yet, a fun project to dive into.

The solution is made possible through the use of GeometryReaders, preferences and anchors. This post will assume you are already familiar with those, and will simply explain how to arrive at a solution using these and other relevant techniques, like @Bindings. If you are unfamiliar with GeometryReaders, PreferenceKeys, etc, I suggest you read up on those elsewhere. A website that I found particulalry helpful when figuring all this out is The SwiftUI Lab. Also very informative is an excellent book by objc.io called Thinking in SwiftUI.

Let’s get to it…

We start by creating the basic segmented control. Each segment will consist of a button that performs a very specific action when tapped: it sets the active segment. We visually indicate the active segment by underlining it with a thin yellow beam. We’ll call this control a ‘SegmentedPicker’.

Before we can build the segmented control, however, we need to implement a view type to provide the segments. We’ll call that a PickerButton, and here is a first draft of its implementation:

struct PickerButton: View {
    var index: Int
    let title: String
   @Binding var selectedButtonIndex: Int
    
    var body: some View {
        Button(action: {
            selectedButtonIndex = self.index
        }) {
            Text(title).fixedSize()
        }
        .padding(10)
    }
}

The index holds the index of the segment in the parent view (the segmented control), and the title is used for displaying the title of the segment. The view’s body is very simple: a single button that displays the title and sets the selectedButtonIndex when tapped. The selectedButtonIndex is a binding so that we can communicate the segment selection back to the control.

Now we are ready to start implementing the segmented control. We’ll start by implementing the basic control, without any fancy, flexibility oriented features. We’ll need to hold an array of titles to use for the button labels, and we’ll need to know which button represents the selected segment. The selectedSegment is a @Binding so that we can communicate, not only between the buttons and the picker, but also between the parent view that probably has a @State var to listen to selection changes in the picker.

struct SegmentedPicker: View {
    @Binding var selectedSegment: Int
    var labels: [String]

    var body: some View {
        HStack {
            ForEach(0 ..< labels.count, id: \.self) {
                PickerButton(index: $0, title: labels[$0], selectedButtonIndex: $selectedSegment)
            }
        }
    }
}

Since we want to lay out our content horizontally, we start out with an HStack, and within it, we loop through the labels, and create a PickerButton for each, providing it its title and index, and also passing in the binding to the active index.

If you create this picker in a project or playground, you’ll end up with the three buttons arranged horizontally on a single line. Here’s a sample implementation of the contentview in such a project, with some padding and spacers to make it look nice:

struct ContentView: View {
    @State private var selectedSegment: Int = 1

    var body: some View {
        VStack {
            Spacer()
            SegmentedPicker(selectedSegment: $selectedSegment, labels: ["Option 1", "Option 2", "Option 3"], markerHeight: 10)
                .padding()
            Spacer()
            Text("Current Option: \(selectedSegment + 1)")
            Spacer()
        }
    }
}

Feel free to try this out.

Tapping a button will set the picker’s selectedSegment to its index, but the picker won’t yet show the selected segment, so that’s the first addition we’ll make to the code above.

The SegmentedPicker will be responsible for drawing the beam underneath the selected segment. For this it needs to know the bounds of each segment. In SwiftUI we can use an anchorPreference on the PickerButton to communicate these bounds back up the view hierarchy. (We’ll also use these bounds to figure out the total width of the segments, and then compare that total to the width of the container view, but more on that later.)

Before we can add an anchorPreference to the PickerButton, we need to define a PreferenceKey to hold the bounds information.

struct ButtonPreferenceData {
    let buttonIndex: Int
    var bounds: Anchor<CGRect>? = nil
}

struct ButtonPreferenceKey: PreferenceKey {
    static var defaultValue: [ButtonPreferenceData] = []

    static func reduce(value: inout [ButtonPreferenceData], nextValue: () -> [ButtonPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

The ButtonPreferenceData struct will hold the info we need: the index of the button (segment), and its bounds. The reduce function in the ButtonPreferenceKey ensures we collect this data from all segments.

To actually collect this data we need to add an anchorPreference to PickerButton. Making the whole (and final) version of PickerButton look like this:

struct PickerButton: View {
    var index: Int
    let title: String
    @Binding var selectedButtonIndex: Int

    var body: some View {
        Button(action: {
            selectedButtonIndex = index
        }) {
            Text(title).fixedSize()
        }
        .padding(10)
        .anchorPreference(key: ButtonPreferenceKey.self, value: .bounds, transform: {
            [ButtonPreferenceData(buttonIndex: index, bounds: $0)]
        })
    }
}

In the anchorPreference we create an instance of ButtonPreferenceData and fill it with the index and bounds of the segment it represents. Finally, we return it wrapped in an array, since that is what our implementation of the reduce function expects.

If you did create a project and ran the code earlier, as we encouraged you to do, and saw the three segments, then, if you run the project again now, you will find nothing has changed visually. All we did was add in the plumbing that will enable us to draw the selection indicator, and figure out whether to display a compact or a regular view.

Let’s start with implementing the selection indicator. For that we need to get access to the data we collected in the anchorPreference on the PickerButton. We do that by adding a backgroundPreferenceValue view after the frame modifier in the body of SegmentedPicker, where we use a GeometryReader to obtain the bounds of the picker’s container. Once we have that, we pass it, along with the collected preference data, to a function that will either return an indicator view or an empty view if it can’t find a selection. We also need a height for the indicator. For that we’ll add a user accessible instance variable, with a default value. If you run the project with the incarnation of SegmentedPicker shown below, you’ll now see a selection indicator. If you tap another segment, the indicator animates into place to indicate the changed selection.

struct SegmentedPicker: View {
    @Binding var selectedSegment: Int
    var labels: [String]
    var markerHeight: CGFloat = 6

    var body: some View {
        return HStack {
            ForEach(0 ..< labels.count, id: \.self) {
                PickerButton(index: $0, title: labels[$0], selectedButtonIndex: $selectedSegment)
            }
        }
        .backgroundPreferenceValue(ButtonPreferenceKey.self) { preferences in
            GeometryReader { containerGeometry in
                showSelectionIndicator(containerGeometry: containerGeometry, preferences: preferences)
            }
        }
    }

    private func showSelectionIndicator(containerGeometry: GeometryProxy, preferences: [ButtonPreferenceData]) -> some View {
        if let preference = preferences.first(where: { $0.buttonIndex == self.selectedSegment }) {
            let anchorBounds = preference.bounds
            let bounds = containerGeometry[anchorBounds]
            return AnyView(RoundedRectangle(cornerRadius: markerHeight / 2)
                            .fill()
                            .foregroundColor(Color.yellow)
                            .frame(width: bounds.width, height: markerHeight)
                            .offset(x: bounds.minX, y: bounds.maxY)
                            .animation(.easeInOut(duration: 0.33)))
        } else {
            return AnyView(EmptyView())
        }
    }
}

To know where to place the selection indicator, we first extract the selected segment from the preferences we received. Then we access its bounds, and resolve the value of the anchor to the container view. Now that we have the bounds in the correct coordinate space, building the indicator is fairly straightforward.

With the code above you now have a fully functioning custom segmented picker, which supports dark mode and dynamic font sizes out of the box. However, on smaller screens, and even on iPads when multitasking with a split screen, and, of course, depending on the number of segments and the length of their titles, it may we happen that the outer segments get clipped if the available screen real estate is not wide enough. When this happens we want the picker to morph into a more compact, popup style control, which only shows the selected option, and allows the user to choose another option by tapping and holding the button, and then selecting from the menu that pops up.

To know when to show the popup style instead of the regular style we need to figure out the total width of all the segments (including their padding), and compare that to the width available to the picker. Fortunately, we already have the bounds of each segment, which we needed earlier for displaying the selection indicator. So there’s no need to add any code for that. Yay! But we do need to gain access to the picker’s surrounding geometry so that we can decide on which view to show. We will change the body of the picker view to gather geometry info, and will pass that along to helper functions so that they can make informed decisions.

In SegementedPicker replace the definition of the body with the following

    var body: some View {
        GeometryReader { stackProxy in
            appropriateView(stackProxy: stackProxy)
        }
    }

We now return a GeometryReader, which gives us access to data like the bounds available to the picker. We will modularize the code into small helper functions that return some appropriate view. This modularization not only helps in keeping the code clear and manageable, it also helps a very picky compiler, prone to complain about returning views of different types.

When deciding which view to show—the regular one, or the compact one—we immediately run into a challenge: we can’t measure the required width before building the view, and then just show the appropriate one, as we could have done in UIKit. We have to actually start building the regular view tree, and then switch to the compact view if we find the regular view won’t fit. But when we re-render the tree for the compact view, we cannot know the required width, so really we need to first build the regular view every time. But then, if we switch to compact again, we end up with an infinite loop. We need to break this cycle by only showing the compact view if we just re-entered from a regular pass through the layout process, and otherwise build the regular view. This ensures that, if we are showing the compact view, we will always re-evaluate by trying to build the regular view.

To keep track of whether we just switched to the compact view in the previous layout pass, we add an instance variable to the picker. We use this variable to decide which view to return:

    private func appropriateView(stackProxy: GeometryProxy) -> some View {
        if justSwitchedToCompactView {
            justSwitchedToCompactView = false
            return AnyView(compactView(stackProxy: stackProxy))
        } else {
            return AnyView(regularView(stackProxy: stackProxy))
        }
    }

Unfortunately, as this is a struct, we can’t easily modify this variable, as the compiler will complain that self is not modifiable. We could turn it into a @State variable, but then changing it would always trigger a new layout pass, which wouldn’t work either. Yet we need to ensure the variable becomes modifiable from within the SegmentedPicker. Custom property wrappers to the rescue:

@propertyWrapper class Modifiable<Value> {
    private(set) var value: Value
    
    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
    
    var wrappedValue: Value {
        get { value }
        set { value = newValue }
    }
}

The above property wrapper allows us to easily add a modifiable instance variable to a struct:

struct SegmentedPicker: View {
    @Modifiable private var justSwitchedToCompactView: Bool = false

}

Now the compiler is happy, and so are we. :-)

Next, let’s first get the implementation of the compact view out of the way, since it is more straightforward than the regular view’s implementation.

    private func compactView(stackProxy: GeometryProxy) -> some View {
        let validMenuItems = labels.filter {
            $0 != labels[selectedSegment]
        }

        return HStack {
            Text(labels[selectedSegment]).foregroundColor(Color(UIColor.label))
            Image(systemName: "arrowtriangle.down.circle").foregroundColor(Color(UIColor.label))
        }
        .padding()
        .background(Color(UIColor.systemBackground))
        .cornerRadius(12)
        .contextMenu {
            ForEach(validMenuItems, id: \.self) { menuItem in
                Button(menuItem) {
                    selectedSegment = labels.firstIndex(of: menuItem)!
                }
            }
        }
        .frame(width: stackProxy.size.width, height: stackProxy.size.height, alignment: .center)
    }

We do not want to show the currently selected option in the menu, so we first create an array with the selected segment filtered out. We show the selected segment along with a little icon that suggests you can tap it, through a Text and an Image wrapped in an HStack. We provide some formatting modifiers so that it looks right, especially when the popup menu is shown (and make sure it plays nicely with both dark and light mode by using the systemBackground color). Finally we add the contextMenu modifier, where we build the menu options by cycling through the valid menu items.

With the compact view covered, we are ready to focus on the regular view. Most of the code here you’ve seen before:

    private func regularView(stackProxy: GeometryProxy) -> some View {
        return HStack {
            ForEach(0 ..< labels.count, id: \.self) {
                PickerButton(index: $0, title: labels[$0], selectedButtonIndex: $selectedSegment)
            }
        }
        .frame(width: stackProxy.size.width, height: stackProxy.size.height, alignment: .center)
        .backgroundPreferenceValue(ButtonPreferenceKey.self) { preferences in
            processPreferenceValue(containerGeometry: stackProxy, preferences: preferences)
        }
    }

The regularView function builds the segments using an HStack, looping through all labels to create a PickerButton for each. Each button is provided with its title, its index and a binding to the selectedSegment, so that it can communicate a new selection back up when it is tapped. We make sure the HStack is centered within its parent view by setting its frame size to equal the parent’s size.

Now it’s time to figure out if the regular view fits its parent, and take action accordingly. Triggering a new layout pass if it doesn’t fit, or showing a selection indicator beneath the selected segment if it does.

This happens within the backgroundPreferenceValue modifier, where we have access to the collected geometry data for each segment. The code is extracted into the processPreferenceValue function to keep our code nicely modularized, as well as to help the compiler figure out the view types.

    private func processPreferenceValue(containerGeometry: GeometryProxy, preferences: [ButtonPreferenceData]) -> some View {
        let fits = fitsContainer(containerGeometry: containerGeometry, preferences: preferences)
        switchViewIfNeeded(fits: fits)

        return Group {
            if  fits {
                showSelectionIndicator(containerGeometry: containerGeometry, preferences: preferences)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            } else {
                EmptyView()
            }
        }
    }

processPreferenceValue first checks if the regular view fits its container. Then it calls a function that may trigger a new layout pass, depending on its current state and the state it needs to be in. Finally, if the regular view doesn’t fit, it will return an empty view, since we don’t need to show anything when in compact mode, and, if the regular view does fit, it will return a selection indicator. We need to envelop the returned views in a Group to keep the compiler happy. (The alternative being to return the views wrapped in an AnyView, but, for performance reasons, using a group is preferable where possible.)

The implementation of fitsContainer collects the combined width of each segment in the reduce function, and returns a bool indicating whether that width fits within the container:

    private func fitsContainer(containerGeometry: GeometryProxy, preferences: [ButtonPreferenceData]) -> Bool {
        let requiredWidth = preferences.reduce(0) { (result, pref) -> CGFloat in
            let anchorBounds = pref.bounds
            let bounds = containerGeometry[anchorBounds]
            return result + bounds.width
        }

        return requiredWidth <= containerGeometry.size.width
    }

On to the implementation of switchViewIfNeeded:

    private func switchViewIfNeeded(fits: Bool) {
        if fits {
            justSwitchedToCompactView = false
            if !allFit {
                DispatchQueue.main.async {
                    allFit = true
                }
            }
        } else {
            if allFit {
                justSwitchedToCompactView = true
                DispatchQueue.main.async {
                    allFit = false
                }
            }  else {
                justSwitchedToCompactView = false
                DispatchQueue.main.async {
                    allFit = true
                }
            }
        }
    }

If the regular view fits, we check our allFit state variable, and set it to true if needed, which will cause the view tree to be re-rendered. We set justSwitchedToCompactView to false, since we are not rendering the compact view. We cannot interrupt the layout process at this point without SwiftUI landing us in undefined behaviour territory, so we need to trigger the new layout on the next iteration through the run-loop, by wrapping the code in an async call on the main queue.

If the regular view doesn’t fit, we check whether we were already showing the compact view. If we were not, we are switching to the compact view, so we set justSwitchedToCompactView to true, and set allFit to false, to trigger a redraw.

If at this point the compact view was already being shown, the required width has not been calculated, and we need to trigger a new layout pass, that will first try to render the regular view. So we, unintuitively, have to set allFit to trueto trigger this new pass. (And we ensure justSwitchedToCompactView is set to false, since we were already showing the compact view, and, more importantly, otherwise the path to try to render the regular view, in the appropriateView function, will not be taken.)

With all this in place, we now have a fully functioning segmented control that adapts its display mode to the space it’s given, depending on the space its regular view needs.

You can find the full source code here.

Published on 7 July, 2020