3

in order to customise a UISlider, I use it in a UIViewRepresentable. It exposes a @Binding var value: Double so that my view model (ObservableObject) view can observe the changes and update a View accordingly.

The issue is that the view is not updated when the @Binding value is changed. In the following example, I have two sliders. One native Slider and one custom SwiftUISlider.

Both pass a binding value to the view model that should update the view. The native Slider does update the view but not the custom one. In the logs, I can see that the $sliderValue.sink { ... is correctly called but the view is not updated.

I noticed this is happening when the presenting view has the @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode> property. If I comment it out, it works as expected.

enter image description here

A complete sample code to reproduce this is

import SwiftUI
import Combine

struct ContentView: View {
    @State var isPresentingModal = false

    // comment this out
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {
        VStack {
            Button("Show modal") {
                isPresentingModal = true
            }
            .padding()
        }
        .sheet(isPresented: $isPresentingModal) {
            MyModalView(viewModel: TempViewModel())
        }
    }
}

class TempViewModel: ObservableObject {
    @Published var sliderText = ""
    @Published var sliderValue: Double = 0
    private var cancellable = Set<AnyCancellable>()

        init() {
            $sliderValue
                .print("view model")
                .sink { [weak self] value in
                    guard let self = self else { return }
                    print("updating view  \(value)")
                    self.sliderText = "\(value) C = \(String(format: "%.2f" ,value * 9 / 5 + 32)) F"
                }
                .store(in: &cancellable)
        }
}

struct MyModalView: View {
    @ObservedObject var viewModel: TempViewModel

    var body: some View {
        VStack(alignment: .leading) {
            Text("SwiftUI Slider")
            Slider(value: $viewModel.sliderValue, in: -100...100, step: 0.5)
                .padding(.bottom)

            Text("UIViewRepresentable Slider")
            SwiftUISlider(minValue: -100, maxValue: 100, value: $viewModel.sliderValue)
            Text(viewModel.sliderText)
        }
        .padding()
    }
}

struct SwiftUISlider: UIViewRepresentable {
    final class Coordinator: NSObject {
        var value: Binding<Double>
        init(value: Binding<Double>) {
            self.value = value
        }

        @objc func valueChanged(_ sender: UISlider) {
            let index = Int(sender.value + 0.5)
            sender.value = Float(index)
            print("value changed \(sender.value)")
            self.value.wrappedValue = Double(sender.value)
        }
    }

    var minValue: Int = 0
    var maxValue: Int = 0

    @Binding var value: Double

    func makeUIView(context: Context) -> UISlider {
        let slider = UISlider(frame: .zero)
        slider.minimumTrackTintColor = .systemRed
        slider.maximumTrackTintColor = .systemRed
        slider.maximumValue = Float(maxValue)
        slider.minimumValue = Float(minValue)

        slider.addTarget(
            context.coordinator,
            action: #selector(Coordinator.valueChanged(_:)),
            for: .valueChanged
        )

        adapt(slider, context: context)
        return slider
    }

    func updateUIView(_ uiView: UISlider, context: Context) {
        adapt(uiView, context: context)
    }

    func makeCoordinator() -> SwiftUISlider.Coordinator {
        Coordinator(value: $value)
    }

    private func adapt(_ slider: UISlider, context: Context) {
        slider.value = Float(value)
    }
}

struct PresentationMode_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Jan
  • 7,444
  • 9
  • 50
  • 74

2 Answers2

0

I found the issue. In in the updateUIView of the UIViewRepresentable, I also need to pass the binding to the new instance of the SwiftUISlider:

func updateUIView(_ uiView: UISlider, context: Context) {
    uiView.value = Float(value)

    // the _value is the Binding<Double> of the new View struct, we pass it to the coordinator 
    context.coordinator.value = _value
}

The SwiftUI.View can be recreated at any time and when it happens the updateUIView is called. A new View struct has a new var value: Binding<Double> so we assign it to our coordinator

Jan
  • 7,444
  • 9
  • 50
  • 74
0

What happens here is that the inclusion of @Environment(\.presentationMode) causes the ContentView's body to recompute as soon as a model is presented. (I don't know exactly why that happens; maybe because there's a change in the presentation mode when sheet is shown).

But when that happens, it initiates the MyModalView twice, and with two separate instances of TempViewModel.

On the first MyModalView, the view hierarchy with SwiftUISlider is created. This is where the Coordinator is created and a binding (bound to the first instance of TempViewModel) is stored.

On the second MyModelView, the view hierarchy is the same, so it doesn't call makeUIView (that's only called when view first appears), and only updateUIView is called. As you correctly noted, updating the binding to now-the-second instance of TempViewModel solves it.

So, one solution is what you did in your other answer - basically re-assign the binding to the new object's property (which, btw, also releases the old object). This solution actually feels to me like the right thing to do anyway.

But for completeness-sake, another approach is not to make multiple instances of TempViewModel, for example, by using a @StateObject to store the view model instance. This could be either inside the parent ContentView, or inside MyModalView:

// option 1
struct ContentView: View {
    @State var isPresentingModal = false
    @StateObject var tempViewModel = TempViewModel()

    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        // ...
        
        .sheet(isPresented: $isPresentingModal) {
            MyModalView(viewModel: tempViewModel)
        }
    }
}
// option 2
struct ContentView: View {
    @State var isPresentingModal = false
    @StateObject var tempViewModel = TempViewModel()

    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        // ...
    
        .sheet(isPresented: $isPresentingModal) {
            MyModalView()
        }
    }
}

struct MyModalView: View {
   @StateObject var viewModel = TempViewModel()

   // ...
}
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • yes your solution also works but involves using `@StateObject` which is not available in iOS 13. – Jan Nov 02 '20 at 20:20
  • @Jan, using a `@StateObject` isn't requirement. My broader point was to point out that `TempViewModel` is being instantiated twice, which was the root cause of the issue you had. Your solution patches the effect of that problem, but a comprehensive approach would be to also remove the problem to begin with – New Dev Nov 02 '20 at 20:33