TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: Synchronized Slider and TextField (macOS) in SwiftUI

Forums > SwiftUI

I thought this would be straightforward, but ... nope.

I want to implement a control that synchronizes a Slider and a TextField. That is, if you move the Slider, the TextField updates, and if you enter a value in the text field, the slider updates. The value is a Float. You see this in Garage Band and Logic for the faders and such. I implemented this in standard Swift with AppKit, and now I want to do it in SwiftUI.

Here's the code. For testing, the View here is instantiated in ContentView.swift in the basic template code.

It's synchronizing in only one direction: enter a value into the TextField box and the Slider updates. But it doesn't work in the other direction, that is, moving the Slider thumb doesn't update the TextField.

The debug print statements tell me that textValue property updates in the Slider's onEditingChanged: callback but the text field contents do not update. What am I missing?

Thanks in advance.

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

    /* 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()
    }
}

2      

Hi! Just use .onChange modifider like so. .onEditingChanged is more suitable for indicating that your are in the process of changing the value as you can see in the code.

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
    @State private var isEditing = false

    /* 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)
            .foregroundColor(isEditing ? .red : .primary)
            .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
                isEditing = editing
            })
            .frame(width: 200, height: 10)
            .padding()
        }
        // just add this
        .onChange(of: sliderValue) { newValue in
            textValue = newValue
        }
    }
}

2      

Thanks for the tip! It's close ... but not quite there. And in a very subtle way. Here's what I see.

When the TextField has focus, only pressing <Return> after typing in a value triggers the field's .onSubmit() method. I guess I need to read up on SubmitTriggers, as I think that the user pressing the <Tab> key should trigger that method.

OK, that's fine for starters. And pressing <Return> updates the Slider thumb position as desired. The TextField retains focus.

If you grab the Slider thumb with your mouse while the TextField has focus, the textValue property is updated in the .onChange() method. (Verified by adding a print() to that method.) But this does not update the TextField. Weird!

If I <tab> from the TextField to the Slider thumb, to give the Slider focus, then dragging the thumb now updates the textValue property and what is displayed in the TextField.

Also it doesn't matter if the .onChange() method is attached to the Slider or to the enclosing VStack: the behavior is the same.

Getting close, I think!

2      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Maybe it makes more sense to use just one slider value for both textField and slider... like that...

struct SliderWithEditView: View {

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

    @State private var sliderValue : Float
    @State private var isEditing = false
    @FocusState private var isFieldFocused: Bool

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

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

    var body: some View {
        VStack {
            TextField("Value:",
                      value: $sliderValue,
                      format: .number.precision(.fractionLength(1)))
            .frame(width: 50, height: 50)
            .focused($isFieldFocused)
            .foregroundColor(isEditing ? .red : .primary)
            .onSubmit {
                if sliderValue > rangeMax {
                    sliderValue = rangeMax
                } else if sliderValue < rangeMin {
                    sliderValue = rangeMin
                }
                isFieldFocused = false
            }
            .disableAutocorrection(true)

            Slider(value: $sliderValue,
                   in: rangeMin...rangeMax,
                   step: 0.5,
                   onEditingChanged: { editing in
                isEditing = editing
                isFieldFocused = false
            })
            .frame(width: 200, height: 10)
            .padding()
        }
    }
}

2      

That was close, but still ... it requires pressing <Return> to accept the value in the TextField and pass it along to the Slider. Pressing <Tab> -- which is how the faders work in Logic -- doesn't change the focus.

But you gave me clues! As I'm new at SwiftUI, I didn't know about either the @FocusState property wrapper or the .focused() modifier.

I added that isFieldFocused property and the .focused() modifier to the TextField, and ... still, it did not work if I pressed <Tab> to get out of the field. That is, the Slider didn't update.

This was helpful in my searching. .onSubmit() only works, as far as I can tell, when the user presses a key that actually causes a submission. This is <Return> or numeric keypad <Enter>. <Tab> doesn't submit, it just changes focus to the next control (<Ctrl><Tab> goes to the previous). But Logic accepts the new value with <Tab>, so ...

The answer is to change the .onSubmit() modifier to the TextField to .onChange(of: isFieldFocused) and this means only if isFieldFocused changed to false (the edit lost focus) should it update the Slider value.

Thus we get the following. The only flaw here is that clicking the mouse on the Slider area or thumb doesn't give the focus to the slider, it remains with the TextField. That's next. But this works. Oh, yeah, there's also a bit of a race, in that when you enter a value in the TextField and press <Return> or <Tab>, the new sliderValue updates the Slider, and it also triggers the .onChange(of: sliderValue) method, and that updates textValue. Gotta fix that. But the functionality is what I want, and thanks for the help.

I hope this helps someone else!

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
    @State private var isEditing = false
    @FocusState private var isTextFieldFocused : 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 values:\nrangeMin = \(rangeMin) to rangeMax = \(rangeMax)")
        print("Starting sliderValue = \(sliderValue)\ttextValue = \(textValue)")
    }

    var body: some View {
        VStack {
            TextField("Value:",
                      value: $textValue,
                      format: .number.precision(.fractionLength(1)))
            .frame(width: 50, height: 50)
            .focused($isTextFieldFocused)
            .onChange(of: isTextFieldFocused) { isTextFieldFocused in
                print("Raw text value = \(textValue)")
                print("isTextFieldFocused = \(isTextFieldFocused)")
                /* Only update when we lose focus, that is, <return> or <tab> hit */
                if !isTextFieldFocused {
                    if textValue > rangeMax {
                        textValue = rangeMax
                    } else if textValue < rangeMin {
                        textValue = rangeMin
                    }
                    sliderValue = textValue
                    print("New slider value after text entry: \(sliderValue)")
                } else {
                    print("Focus changed to TextField, not updating slider.")
                }
            }
            .disableAutocorrection(true)

            Slider(value: $sliderValue,
                   in: rangeMin...rangeMax,
                   step: 0.5,
                   onEditingChanged: { editing in
                if editing == false {
                    textValue = sliderValue
                    print("Only update text field after slide stops! New textValue = \(textValue)")
                }
            })
            .frame(width: 200, height: 10)
            .padding()
        }
        .onChange(of: sliderValue) { newValue in
            textValue = newValue
            print("Slider moved, new textValue = \(textValue)")
        }
    }
}

2      

For closure: I figured it out.

In Logic and Garage Band, hitting <Return> in the TextField accepts the value and updates the fader position. Hitting <Tab> does the same thing. However, the actions in SwiftUI are different.

When the TextField has focus, the user expects that if <Return> is pressed, the value in the TextField box is accepted. This action is handled by .onSubmit() -- in this case, we copy the TextField value to the Slider value so the Slider should move -- and notably, the TextField retains focus.

If the user hits <Tab> while the TextField has focus, focus moves to the next control in the View, in this case the Slider. The value in the TextEdit box is not accepted, so the Slider position and value do not change. However, this behavior is not what I want. I want the value in the TextField to be the new Slider position. This requires using the .onChange() method. This is set to trigger when the focus on the TextField changes. If the TextField loses focus -- that is, the user hit <tab> -- then we update the Slider value with the TextField value, which updates the Slider position.

In the other direction, when we move the Slider thumb or we click anywhere in the Slider we want the value in the TextField to update to the Slider value.

There is an oddity with the Slider. If the TextEdit has focus and you click your mouse on the Slider (anywhere in it, including the thumb), the Slider does not get focus.

And if it does not have focus, for whatever reason even though when the Slider position changes you copy the sliderValue to the textValue which should update the TextField display, that doesn't happen. The TextField display does not change. The trick, then, is to use a property to monitor/control the Slider's FocusState, and when a change is detected in the Slider position, we force the Slider to have focus. Then updating the TextField's value from the Slider .onChange() works.

I'm sure this code can be more Swifty, but it works as is and is a good start for bigger things.

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, we tell it to grab 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()
        }
    }
}

2      

@aspdigital: Thanks for sharing your useful solution!

Just for fun, here's an alternative implementation of clamp() without if statements (from Paul's article on Swift 5.5 new features): https://www.hackingwithswift.com/swift/5.5/property-wrapper-function-parameters

3      

@bobstern: I knew there was a clamp function somewhere, but couldn't find it. Thanks for the link!

2      

I removed the "SOLVED" tag because this control has a very serious flaw.

It's impossible for something outside to get control's value. I've tried various combinations of @Binding and a class for the data with @Published for the value and various, and it always gets caught with an error in the initializer.

I added @Binding var value : Double to the SliderWithEditView and I get the usual "'self' used before all stored properties are initialized" complaint and I have no idea how to solve this. There are lots of search hits with answers of various quality, but none actually said how to solve this problem!

2      

Actual closure:

I sorted out how to get the control's values outside of the view using @ObservedObject/@StateObject. You end up getting both the TextField value and the SliderValue, which are redundant because the point is that they're forced to be the same.

Xcode project is here.

2      

@Magdi  

I suggest another way of linking a TextField and a Slider together without having an infinite loop between the two. For this, I use the onKeyPress down and up events, combined with the onChange event. In fact, I've noticed that when you type in the textField, the order of the events is as follows: 1) onKeyPress down 2) onChange 3) onKeyPress up Knowing this, when you type in the TextField, you can change the position of the slider while disabling slider feedback. In the same way, when you move the slider, you can update the Textfield without receiving feedback from the TextField. Here's an example:

struct ContentView: View {
    @State private var text: String = "0" //value binded with the textfield
    @State private var value: Double = 0 // value binded with the slider
    @State private var keyIsPressed = false
    func textToSlider() {
        //Handle the Slider from the value of the TextField
        if let v = Double(text) {
            value = v
        }
    }
    func sliderToText() {
        //Display the value of the Slider in the TextField
        text = String(value)
    }
    var body: some View {
        VStack {
            TextField("", text: $text)
                .onChange(of: text) {
                    if keyIsPressed {
                        textToSlider()
                        /* we can change the slider from here only if we use
                         * the keyboard
                         */
                    }
                }
                .onKeyPress(phases: .down){key in
                    keyIsPressed = true
                    return .ignored
                }
                .onKeyPress(phases: .up){key in
                    keyIsPressed = false
                    return .ignored
                }
            Slider(value: $value, in: 0...100)
                .onChange(of: value) {
                    if !keyIsPressed {
                        sliderToText()
                        /* we can change the textField from here only if we
                         * handle the slider
                         */
                    }
                }
        }
        .padding()
        .frame(width: 300)
    }
}
#Preview {
    ContentView()
}

Disabling and enabling the change is performed by the keyIsPressed variable, which is modified by the onKeyPress event.

2      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.