SwiftUI: Showing a control and implementing avoidance

In this post we will create a SwiftUI control that can be popped up from the bottom of the screen. We will also make it possible to allow a specific view (usually the view that triggers the control and reflects the chosen value) to be raised, if it would otherwise be obscured by the control when the control pops up. This post assumes you have a reasonable knowledge of SwiftUI and are at least somewhat familiar with more advanced topics such as bindings, geometery readers, preference keys, etc. It won’t go into details as to how they work. I will simply show how to use them to achieve the desired effact.

We’ll use a wheel picker to demonstrate the technique, but of course it could be used for other content too.

Wheel Picker

To start with, let’s create a view that shows a wheel picker and a dismissal button, formatted to look somewhat like a system control.

struct WheelPicker: View {
    @Binding var selection: String
    @Binding private(set) var show: Bool
    var values: [String]
    var dismissButtonTitle: String

    var body: some View {
        VStack {
            VStack {
                Divider()
                Spacer()
                Button(action: {
                    withAnimation {
                        self.show = false
                    }
                }) {
                    HStack {
                        Spacer()
                        Text(dismissButtonTitle)
                            .padding(.horizontal, 16)
                    }
                }
                Divider()
            }
            .background(Color.init(uiColor: .secondarySystemGroupedBackground.withAlphaComponent(0.5)))
            Picker(selection: $selection, label: Text("")) {
                ForEach(values, id: \.self) {
                    Text("\($0)")
                }
            }
            .pickerStyle(.wheel)
        }
    }
}

The picker needs some values to show. To keep things simple, we only allow strings. We also allow setting the title of the button that dismisses the view when tapped. Finally, there are two bindings so that we can communicate with the client about when to hide the control, and about the selection. The show binding is readonly, since we only use it to communicate back up the chain that the dismissal button was pressed. The selection binding is two way: we simply pass it on to the picker. This should result in an initial selection, and when the user changes the selection the new value will be passed back up the chain. We use the withAnimation modifier to ensure the shifts are nicely animated.

Bottom Wheel Picker

Next, we need to implement a mechanism that can show the picker at the bottom of the screen when it needs to be visible, and that hides it just below the screen when it needs to be hidden.

struct BottomWheelPicker: View {
    /// A binding that controls showing or hiding the picker
    @Binding var show : Bool
    /// A binding that manages the selected option from the collection of values
    @Binding var selection: String
    /// The collection of values available to the picker
    var values: [String]

    var body: some View {
        WheelPicker(selection: self.$selection,
                    show: self.$show,
                    values: values,
                    dismissButtonTitle: "Done")
        .fixedSize() // Ensures the WheelPicker is exactly as large as it needs to be, and no larger
        .background(
            Color.init(uiColor: .systemGray6)
        )
        // When not shown, hide us below the bottom of the screen.
        .offset(y: self.show ? 0 : UIScreen.main.bounds.height)
    }
}

show, selection and values are simply passed directly to the picker.

In the body we provide the WheelPicker, and use fixedSize to ensure our wheel picker view is only as large as it needs to be. We set a background color in the background modifier, and use the offset modifier so that the picker is hidden below the screen when not shown.

Showing and Hiding the Picker in a Container View

Now we need a view that makes use of the wheel picker to allow the selection of a value from a list of predefined values.

struct ContentView: View {
    @State var showPicker = false
    @State var selection: String = "€1.500"
    @State var pickerHeight: CGFloat = 0
    var values = ["€1.500", "€3.000", "€6.000", "€9.000", "€10.000", "€11.000", "€12.000", "€13.000", "€14.000", "€15.000", "€16.000", "€17.000", "€18.000"]
    @State var showMoreText = true

    var body: some View {
        VStack {
            Group {
                Spacer()
                Text("Let's pick an amount")
                    .padding()
                Button(action: {
                    withAnimation {
                        self.showPicker.toggle()
                    }
                }) {
                    Text(selection)
                        .padding()
                }
            }
            Group {
                Text("The chosen amount")
                Text("is…")
                Text(selection)
                Text("Be kind")
                Text("…and friendly,")
                Text("really.")
            }
            if showMoreText {
                Spacer()
                Text("It works!")
            }
        }
        .frame(minWidth: 300, minHeight: 600)
        .overlay(BottomWheelPicker(show: $showPicker, selection: $selection, values: values), alignment: .bottom)
    }
}

The code above creates a view that has a button that reflects a monetary amount. The button acts as a toggle: Tapping it will show the picker, if it is hidden, and hide the picker, if it is on-screen.

The showPicker state variable is used to control the visibility of the picker. It is tied to the button, naturally, so that we trigger view updates when the button is tapped.

The selection state variable is tied to the button title. It will be updated when the user selects a value from the picker.

Below we have some filler text, mostly for demonstration purposes.

We use the frame modifier so that we have a reasonably sized view in a playground. If the content is hosted in a real app, you should remove this modifier.

Finally, but rather importantly, we use the overlay modifier to host the BottomWheelPicker, passing along the relevant state variables and picker values. When the picker is triggered it will be animated in, on top of the content view.

We now have all the pieces in place to show and hide the wheel picker on command. You could use the above code in a playground to see it in action.

There is a variable showMoreText, that defaults to true. It ensures the button is quite high up in the content view, so that it is not obscured by the picker, when the picker is shown. If you set that variable to false before running the playground, you’ll see that the button is considerably lower on the screen, and that it gets obscured by the picker when the picker is visible.

We need to provide a mechanism that allows us to raise the button when showing the picker, but only if the button would be obscured by the picker:

Wheel Picker Avoidance

To enable view avoidance, we need to do a fair bit of bookkeeping. We need to know the height of the picker, and we need to know the y-coordinate of the bottom of the view that wants to avoid the picker (in our case, the button). Then we can use that data to determine if, and if so, how much, the avoiding view needs to shift up, not to be obscured by the picker.

We also need a way to be notified when the picker is shown, and when it is hidden so that we can shift the view in the appropriate direction when we receive such a notification.

Let’s start by enabling access to the picker’s frame.

We create a preference key to hold the frame information. It is quite basic, as it doesn’t need to accumulate any values.

struct FramePreferenceKey: PreferenceKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}

Then we add a geometry reader to the background modifier, to provide the frame of the wheel picker. We put the reader around the color, and then provide the preference key hanging off the color with the frame of the picker in global coordinates.

        .background(
            // Use the geometry reader to extract the frame of the WheelPicker
            GeometryReader { geometry in
                Color.init(uiColor: .systemGray6)
                    .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
        })

We also add notifications to be sent when showing or hiding the picker.

At the top of the BottomWheelPicker:

struct BottomWheelPicker: View {
    static let willShowNotification: NSNotification.Name = NSNotification.Name(rawValue: "PickerWillShow")
    static let willHideNotification: NSNotification.Name = NSNotification.Name(rawValue: "PickerWillHide")

Between the background and offset modifiers add this modifier:

.onPreferenceChange(FramePreferenceKey.self) { newFrame in
// Post a notification that we are showing or hiding, passing along the resulting frame info
let notificationName = show ? Self.willShowNotification : Self.willHideNotification
NotificationCenter.default.post(name: notificationName, object: self, userInfo: ["frame": newFrame])
}

The following extension to Notification provides convenient access to frame info in its user dictionary:

extension Notification {
    /// Extends `Notificaton` with a shortcut to acces a frame from its user info dict.
    var frame: CGRect {
        userInfo?["frame"] as? CGRect ?? .zero
    }
}

We now have access to the frame of the picker, as it changes, and post a notification whenever the picker is shown or hidden. What we still lack is a mechanism to observe these changes and react to them.

To enable any view to avoid the picker, we are going to create a view modifier. This modifier can be attached to any view that needs to avoid the picker. Depending on the buildup of the encompassing view, more than one view might have this view modifier attached, but most likely you are only going to want to attach it to a single view in the scene, since usually all the views above it will also be shifted up (e.g. because you are in a vertical stack view).

The view modifier will provide a customisable padding to allow control over how much space to leave between the picker and the avoiding view when shifting it out of the way.

struct BottomWheelPickerAdaptive: ViewModifier {
    /// The current frame of the picker. This will be either zero (when the picker is hidden), or the actual frame (when the picker is shown)
    @State private var pickerFrame: CGRect = .zero
    /// The offset to be applied to the adaptee's bottom.
    @State private var offset: CGFloat = 0
    /// The current frame of the adaptee.
    @State private var adapteeFrame: CGRect = .zero
    /// A padding to be apply to the offset to ensure the adaptee does not rest flush on the picker.
    public var padding: CGFloat = 12

    func body(content: Content) -> some View {
        content
        // padd the bottom by offset. This will effectively raise the adapting view when offset > 0.
            .padding(.bottom, offset)
        // subscribe to wheel picker frame changes
            .onReceive(Publishers.wheelPickerFrame) { pickerFrame in
                withAnimation {
                    self.pickerFrame = pickerFrame

                    guard pickerFrame.height > 0 else {
                        offset = 0
                        return
                    }

                    // The padding ensures the picker will not sit too closely below the adaptee, even if it wouldn't obscure any part of the adaptee.
                    //
                    // Additionally, only set an offset if the adaptee's bottom (extended by the amount of padding) will be obscured by the picker,
                    // i.e. only when the offset required for avoidance is greater than zero, because negative values mean the adaptee will remain sufficiently clear from the picker, without raising it.
                    offset = max(0, (adapteeFrame.maxY + padding) - pickerFrame.minY)
                }
            }
            .background(
                GeometryReader { geometry in
                    // Use a neutral view to allow us to obtain the frame of the content (= adaptee)
                    Color.clear
                        .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
                }
            )
        // Here we subscribe to changes to the frame of the adaptee.
            .onPreferenceChange(FramePreferenceKey.self) { newFrame in
                adapteeFrame = newFrame
            }
    }
}

The crux of the view modifier, is that it shows its content with a bottom offset applied to it (.padding(.bottom, offset)). It accesses the content frame info in the background modifier, through a GeometryReader, and subscribes to frame changes of the adaptee (the avoiding view) in the onPreferenceChange modifier.

The subscription to changes to the picker wheel frame is established in the onReceive modifier, and the offset is calculated and set within its closure.

Finally, we want to make access to this view modifier convenient, which is what the following extension on View does:

extension View {
    /// Provides bottom wheel picker avoidance behavior
    func bottomWheelPickerAdaptive(padding: CGFloat = 12) -> some View {
        ModifiedContent(content: self, modifier: BottomWheelPickerAdaptive(padding: padding))
    }
}

Now, all that is left to do, is to add the bottomWheelPickerAdaptive modifier to the main view. We want the button showing the current amount to always remain visible, so that’s where we add the modifier. The Group holding the button now becomes:

Group {
                Spacer()
                Text("Let's pick an amount")
                    .padding()
                Button(action: {
                    withAnimation {
                        self.showPicker.toggle()
                    }
                }) {
                    Text(selection)
                        .bottomWheelPickerAdaptive()
                        .padding()
                }
            }

I hope this compact post was clear enough to let you use this technique in any project that might benefit from it.

You can find the full source code, ready for use in an Xcode Playground, in a Github gist here.

Published on 22 August, 2022