A Slider synchronized with a TextField in SwiftUI on macOS

I want to create a control pair similar to Logic Pro's fader: it's a Slider synchronized with a numeric TextTield. If you move the slider, the text field updates with the slider position, and if you enter something in the text field, the slider moves to a position corresponding to the new value. (Also the value gets used by the program logic but that's not the issue.)

I did this previously with AppKit and it works, and now I'm learning Swift and SwiftUI and I'm stuck.

Attached is my attempt at this. My "Fader" has clamps, so the setting will never be outside of a given range.

When I type a value in the TextField and hit <Return> the Slider updates as expected. The operation in the other direction doesn't work: when I move the Slider, the TextField doesn't update. I know the properties are updating because the print statements tell me so.

My code below has one @State property for the slider and another for the text field. I did this because if they shared the value property, the slider would move after each keypress in the text field, basically ignoring onSubmit. I found an example that used an ObservableObject class with one Published property and that property was used as the value: for both the slider and text field and that failed in the same was as my code.

What am I missing? Thanks in advance to anyone who can help.


struct SliderWithEditView: View {

    private var rangeMin : Float = -100.0
    private var rangeMax : Float = 100.0

    @State private var textValue : Float
    @State private var sliderValue : Float

    /* accept default initializers */
    init() {
        textValue = rangeMin
        sliderValue = rangeMin
    }

    /* user initializers from instantiation */
    init(min : Float, max: Float) {
        rangeMin = min
        rangeMax = max
        textValue = min
        sliderValue = min

        print("Starting values:\nrangeMin = \(rangeMin) to rangeMax = \(rangeMax)")
        print("textValue = \(textValue)\tsliderValue = \(sliderValue)")
    }

    var body: some View {
        VStack {
            TextField("Value:",
                      value: $textValue,
                      format: .number.precision(.fractionLength(1)))
            .frame(width: 50, height: 50)
            .onSubmit {
                print("New value = \(textValue)")
                if textValue > rangeMax {
                    textValue = rangeMax
                } else if textValue < rangeMin {
                    textValue = rangeMin
                }
                sliderValue = textValue
                print("New slider value after text entry: \(sliderValue)")
            }
            .disableAutocorrection(true)

            Slider(value: $sliderValue,
                   in: rangeMin...rangeMax,
                   step: 0.5,
                   onEditingChanged: { editing in
                if editing == false {
                    textValue = sliderValue
                    print("After slide stops, sliderValue = \(sliderValue)\ttextValue = \(textValue)")
                }
            })
            .frame(width: 200, height: 10)
            .padding()
        }
    }
}

struct SliderWithEditView_Previews: PreviewProvider {
    static var previews: some View {
        SliderWithEditView()
    }
}

deleted comment

I sorted it out. There are a couple of related issues.

One, to get the slider position to update when you <Tab> away from the TextField, one must implement the .onChange() method and trigger that with a change of focus. This means a new property is necessary:

@FocusState private var isTextFieldFocused : Bool

and add the .focused($isTextFieldFocused) modifier to the TextField. Then in the .onChange() method, test for isTextFieldFocused false. This means the field has lost focus, so we can "accept" the value in the edit box and copy it to the slider value property, and that causes the slider value to update.

The .onSubmit() property does that same thing except the user presses the <Return> key to trigger it.

Two, I noticed that if you have been typing in the TextField and then you click your mouse on the Slider, the Slider does not get focus. And when it doesn't have focus, we can still move it, and we can see the value change (with the print statements). The problem is that our TextField won't update its displayed value when we move the slider. It seems to be related to the focus: the edit box still has focus so it seems to be ignoring updates to its value from the slider.

The fix is to add a similar .focused() modifier to the slider. When the slider moves, the slider value changes and we can detect that with .onChange(). In the body of the .onChange() method we force the slider to take focus and we update the text field's value from the slider's value. Do this and the value from the slider updates in the TextField when the slider is moved, which is what we want.

After the changes the two properties that hold the controls' values should be the same.

Here's the code:

import SwiftUI

struct SliderWithEditView: View {

    private var rangeMin : Float = -100.0
    private var rangeMax : Float = 100.0

    @State private var textValue : Float
    @State private var sliderValue : Float
    @FocusState private var isTextFieldFocused : Bool
    @FocusState private var isSliderFocused : Bool

    /* accept default initializers */
    init() {
        textValue = rangeMin
        sliderValue = rangeMin
    }

    /* user initializers from instantiation */
    init(min : Float, max: Float) {
        rangeMin = min
        rangeMax = max
        textValue = min
        sliderValue = min
        print("Starting sliderValue = \(sliderValue)\ttextValue = \(textValue)")
    }
    
    /* Clamp text edit entry to our range. */
    func clamp() {
        if self.textValue > rangeMax {
            self.textValue = rangeMax
        } else if textValue < rangeMin {
            self.textValue = rangeMin
        }
    }

    var body: some View {
        VStack {
            TextField("Value:",
                      value: $textValue,
                      format: .number.precision(.fractionLength(1)))
            .frame(width: 50, height: 50)
            .focused($isTextFieldFocused)
            /* onSubmit to handle user pressing the enter key to accept new value and thus updating
             * the slider. Normally the TextField would retain focus, but when the Slider notices
             * the value has changed, the Slider grabs focus. */
            .onSubmit {
                clamp()
                sliderValue = textValue
                print("Text field submitted, slider updated to \(sliderValue)")
            }
            /* onChange looks for TextField focus change. If we lost focus, either user hit <tab>
             * or moved the mouse out of the field. The only place in the view that can take focus
             * is the slider.
             * In either case we should copy the text field value to the slider. */
            .onChange(of: isTextFieldFocused) { isTextFieldFocused in
                print("TextField focus changed. it is: isTextFieldFocused = \(isTextFieldFocused)")
                print("TextField value after focus change = \(textValue)")
                /* Only update slider when we lose focus, that is, <tab> was hit or mouse clicked on the slider */
                if !isTextFieldFocused {
                    clamp()
                    sliderValue = textValue
                    print("TextField lost focus, so update new slider value from text entry: \(sliderValue)")
                } else {
                    print("Focus changed to TextField, not updating slider.")
                }
            }
            .disableAutocorrection(true)

            Slider(value: $sliderValue,
                   in: rangeMin...rangeMax,
                   step: 0.5)
            .focused($isSliderFocused)
            .frame(width: 200, height: 10)
            .onChange(of: sliderValue) { value in
                /* slider value changed, so force focus back to the slider. Oddly,
                 * clicking the mouse on the slider or its thumb doesn't give it focus.
                 * slider must have focus otherwise the textValue update from value here
                 * doesn't cause the TextField display to update. */
                isSliderFocused = true
                textValue = value
                print("Slider moved, copy slider value to new textValue = \(textValue)")
            }
            .padding()
        }
    }
}
A Slider synchronized with a TextField in SwiftUI on macOS
 
 
Q