Dan Schümacher
Dan Schümacher

Reputation: 265

How do I update a SwiftUI View in UIKit when value changes?

I am trying to integrate a SwiftUI view that animates on changes to a @State variable (originally progress was @State private progress: CGFloat = 0.5 in the SwiftUI View), into an existing UIKit application. I have read through lots of search results on integrating SwiftUI into UIKit (@State, @Binding, @Environment, etc.), but can't seem to figure this out.

I create a very simple example of what I am attempting to do, as I believe once I see this answer I can adopt it to my existing application.

The Storyboard is simply View controller with a UISlider. The code below displays the SwiftUI view, but I want it to update as I move the UISlider.

import SwiftUI

class ViewController: UIViewController {

    var progress: CGFloat = 0.5

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.

        let frame = CGRect(x: 20, y: 200, width: 400, height: 400)

        let childView = UIHostingController(rootView: Animate_Trim(progress: progress))
        addChild(childView)
        childView.view.frame = frame
        view.addSubview(childView.view)
        childView.didMove(toParent: self)
    }

    @IBAction func sliderAction(_ sender: UISlider) {
        progress = CGFloat(sender.value)
        print("Progress: \(progress)")
    }

}

struct Animate_Trim: View {
    var progress: CGFloat

    var body: some View {
        VStack(spacing: 20) {

            Circle()
                .trim(from: 0, to: progress) // Animate trim
                .stroke(Color.blue,
                        style: StrokeStyle(lineWidth: 40,
                                           lineCap: CGLineCap.round))
                .frame(height: 300)
                .rotationEffect(.degrees(-90)) // Start from top
                .padding(40)
                .animation(.default)

            Spacer()

        }.font(.title)
    }
}```

Upvotes: 10

Views: 10099

Answers (3)

MrAn3
MrAn3

Reputation: 1395

If you do not want to use NotificationCenter. You could use just @Published and assign or sink.

I wrote a working example in a Playground to show the concept:

//This code runs on Xcode playground
import Combine
import SwiftUI

class ObservableSlider: ObservableObject {
    @Published public var value: Double = 0.0
}

class YourViewController {
    var observableSlider:ObservableSlider = ObservableSlider()
    private var cancellables: Set<AnyCancellable> = []
    let hostController = YourHostingController() // I put it here for the sake of the example, but you do need a reference to the Hosting Controller.

    init(){ // In a real VC this code would probably be on viewDidLoad

        let swiftUIView = hostController.rootView

        //This is where values of SwiftUI view and UIKit get glued together
        self.observableSlider.$value.assign(to: \.observableSlider.value, on: swiftUIView).store(in:&self.cancellables)
    }
    func updateSlider() {
        observableSlider.value = 8.5
    }
}

// In real app it would be something like:
//class YourHostingController<YourSwiftUIView> UIHostingController
class YourHostingController {
    var rootView = YourSwiftUIView()

//In a real Hosting controller you would do something like:
//    required init?(coder aDecoder: NSCoder){
//         super.init(coder: aDecoder, rootView: YourSwiftUIView())
//     }
}

struct YourSwiftUIView: View{
    var body: some View {
        EmptyView() // Your real SwiftUI body...
    }
    @ObservedObject var observableSlider: ObservableSlider = ObservableSlider()
    func showValue(){
        print(observableSlider.value)
    }
    init(){
        print(observableSlider.value)
    }
}

let yourVC = YourViewController() // Inits view and prints 0.0
yourVC.updateSlider() // Updates from UIKit to 8.5
yourVC.hostController.rootView.showValue() // Value in SwiftUI View is updated (prints 8.5)

Upvotes: 11

nine stones
nine stones

Reputation: 3438

The accepted answer actually doesn't answer the original question "update a SwiftUI View in UIKit..."?

IMHO, when you want to interact with UIKit you can use a notification to update the progress view:

extension Notification.Name {
  static var progress: Notification.Name { return .init("progress") }
}
class ViewController: UIViewController {
  var progress: CGFloat = 0.5 {
    didSet {
      let userinfo: [String: CGFloat] = ["progress": self.progress]
      NotificationCenter.default.post(Notification(name: .progress,
                                                   object: nil,
                                                   userInfo: userinfo))
    }
  }
  var slider: UISlider = UISlider()
  override func viewDidLoad() {
    super.viewDidLoad()
    slider.addTarget(self, action: #selector(sliderAction(_:)), for: .valueChanged)
    slider.frame = CGRect(x: 0, y: 500, width: 200, height: 50)
    // Do any additional setup after loading the view.

    let frame = CGRect(x: 20, y: 200, width: 400, height: 400)

    let childView = UIHostingController(rootView: Animate_Trim())
    addChild(childView)
    childView.view.frame = frame
    view.addSubview(childView.view)
    view.addSubview(slider)
    childView.didMove(toParent: self)
  }

  @IBAction func sliderAction(_ sender: UISlider) {
    progress = CGFloat(sender.value)
    print("Progress: \(progress)")
  }
}

struct Animate_Trim: View {
  @State var progress: CGFloat = 0
  var notificationChanged = NotificationCenter.default.publisher(for: .progress)
  var body: some View {
    VStack(spacing: 20) {
      Circle()
        .trim(from: 0, to: progress) // Animate trim
        .stroke(Color.blue,
                style: StrokeStyle(lineWidth: 40,
                                   lineCap: CGLineCap.round))
        .frame(height: 300)
        .rotationEffect(.degrees(-90)) // Start from top
        .padding(40)
        .animation(.default)
        .onReceive(notificationChanged) { note in
          self.progress = note.userInfo!["progress"]! as! CGFloat
      }
      Spacer()
    }.font(.title)
  }
}

Upvotes: 9

user3441734
user3441734

Reputation: 17534

Combine is your friend ...

  1. create the model
  2. update the model from UISlider, or any other part of your application
  3. use the model to update your SwiftUI.View

I did simple example, solely with SwiftUI, but using the same scenario

import SwiftUI

class Model: ObservableObject {
    @Published var progress: Double = 0.2

}

struct SliderView: View {
    @EnvironmentObject var slidermodel: Model
    var body: some View {

        // this is not part of state of the View !!!
        // the bindig is created directly to your global EnnvironmentObject

        // be sure that it is available by
        // creating the SwiftUI view that provides the window contents
        // in your SceneDelegate.scene(....)
        // let model = Model()
        // let contentView = ContentView().environmentObject(model)

        let binding = Binding<Double>(get: { () -> Double in
            self.slidermodel.progress
        }) { (value) in
            self.slidermodel.progress = value
        }

        return Slider(value: binding, in: 0.0 ... 1.0)
    }
}


struct ContentView: View {
    @EnvironmentObject var model: Model
    var body: some View {
        VStack {

            Circle()
                .trim(from: 0, to: CGFloat(model.progress)) // Animate trim
                .stroke(Color.blue,
                        style: StrokeStyle(lineWidth: 40,
                                           lineCap: CGLineCap.round))
                .frame(height: 300)
                .rotationEffect(.degrees(-90)) // Start from top
                .padding(40)
                .animation(.default)

            SliderView()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(Model())
    }
}

and all is nicely working together

enter image description here

Upvotes: -2

Related Questions