5

I'm new to SwiftUI and I've been experimenting with how to integrate SwiftUI and UIKit together in the same app. I made a simple login screen with SwiftUI.

struct LoginView: View {
    
    var body: some View {
        VStack {
            LogoView()
            InputView(title: "Company Code")
            ButtonView(title: "Proceed")
        }
    }
}

enter image description here

And I made all the components in this view reusable by extracting them to separate views (LogoView, InputView, ButtonView).

struct LogoView: View {
    var body: some View {
        VStack {
            Image("logo")
            Text("Inventory App")
                .foregroundColor(.blue)
                .fontWeight(.bold)
                .font(.system(size: 32))
        }
    }
}

struct InputView: View {
    let title: String
    
    @State private var text: String = ""
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
                .foregroundColor(.gray)
                .fontWeight(.medium)
                .font(.system(size: 18))
            
            TextField("", text: $text)
                .frame(height: 54)
                .textFieldStyle(PlainTextFieldStyle())
                .padding([.leading, .trailing], 10)
                .cornerRadius(10)
                .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray))
        }
        .padding()
    }
}

struct ButtonView: View {
    let title: String
    
    var body: some View {
        Button(title) {
            print(#function)
        }
        .frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 60, idealHeight: 60)
        .font(.system(size: 24, weight: .bold))
        .foregroundColor(.white)
        .background(Color.blue)
        .cornerRadius(10)
        .padding([.leading, .trailing])
    }
}

And I show the view by embedding it inside a UIHostingController in the View Controller.

class LoginViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let controller = UIHostingController(rootView: LoginView(observable: observable))
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        addChild(controller)
        view.addSubview(controller.view)
        controller.didMove(toParent: self)
        
        NSLayoutConstraint.activate([
            controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controller.view.topAnchor.constraint(equalTo: view.topAnchor),
            controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
}

My problem is how can I get the text inputted in the InputView and the button tap occurs in the ButtonView, all the way up to the View Controller?

In this tutorial, it uses ObservableObject to pass the data back to the View Controller. Although in that example, the entire view is in a single SwiftUI file. In my case, I broke down the view to separate components.

So I'm wondering, is ObservableObject still the way to do it? Since my views are subviews, I feel like creating multiple observable objects to propagate values up the subview chain is not ideal.

Is there a better way to achieve this?

Demo project

Isuru
  • 30,617
  • 60
  • 187
  • 303
  • https://stackoverflow.com/questions/69942854/how-to-pass-textfield-value-to-view-controller-through-button-click-in-swift-ui/69945806#69945806 – lorem ipsum Mar 15 '23 at 13:46

3 Answers3

6

First, use binding to your input view. And for action use closure to get action from SwiftUI to UIKit.

Here is a possible solution.

class LoginViewObservable: ObservableObject {
    @Published var code: String = ""
    var onLoginAction: (()->Void)! //<-- Button action closure
}

struct LoginView: View {
    @ObservedObject var observable: LoginViewObservable
    
    var body: some View {
        VStack {
            LogoView()
            InputView(title: "Company Code", text: $observable.code) //<- Binding text
            ButtonView(title: "Proceed", action: observable.onLoginAction) //<- Pass action
        }
    }
}

struct InputView: View {
    let title: String
    @Binding var text: String //<- Binding
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
                .foregroundColor(.gray)
                .fontWeight(.medium)
                .font(.system(size: 18))
            
            TextField("", text: $text)
                .frame(height: 54)
                .textFieldStyle(PlainTextFieldStyle())
                .padding([.leading, .trailing], 10)
                .cornerRadius(10)
                .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray))
        }
        .padding()
    }
}

struct ButtonView: View {
    let title: String
    var action: () -> Void
    
    var body: some View {
        Button(title) {
            action() //<- Send action
        }
        .frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 60, idealHeight: 60)
        .font(.system(size: 24, weight: .bold))
        .foregroundColor(.white)
        .background(Color(hex: "4980F3"))
        .cornerRadius(10)
        .padding([.leading, .trailing])
    }
}

in last, inside the viewDidLoad()

override func viewDidLoad() {
        super.viewDidLoad()
        // Other code----
        observable.onLoginAction = { [weak self] in //<-- Get login action
            print(self?.observable.code ?? "")
        }
    }
Raja Kishan
  • 16,767
  • 2
  • 26
  • 52
  • Thank you. This is exactly what I was looking for. I need to understand the differences between State and Binding more. – Isuru Feb 15 '21 at 08:42
  • Quick follow-up. It appears my LoginView SwiftUI preview fails rendering. And the culprit seems to be this line `ButtonView(title: "Proceed", action: observable.onProceedAction)`. Strange thing is the `ButtonView`'s preview works just fine. Any idea why that might be? – Isuru Feb 15 '21 at 08:54
  • use this inside the ButtonView_Previews. ```ButtonView(title: "Title", action: {})``` – Raja Kishan Feb 15 '21 at 09:02
  • use this for InputView_Previews : ```InputView(title: "Title", text: .constant(""))``` – Raja Kishan Feb 15 '21 at 09:03
  • Yes, that's what I'm already doing as well. The subview previews show up without an issue. This error still comes for the LoginView preview, where all the subviews are placed together. Weird. Must be a Xcode bug then. – Isuru Feb 15 '21 at 09:05
1

First for passing data on action(Tap on List) using NavigationLink and UIViewControllerRepresentable from SwiftUI to UIKit.

struct LessonsListView: View {
    @StateObject var viewModel = LessonsViewModel()
    var body: some View {
        NavigationView {
            List {
                ForEach(self.viewModel.lessonList,id: \.self) { lesson in
                    ZStack(alignment: .leading) {
                        NavigationLink(destination: LessonVideoPlayerRepresentable(thumbnail: lesson.thumbnail ?? "", videoURL: lesson.video_url ?? "", lessonDesc: lesson.description ?? "", name: lesson.name ?? "")) {
                            EmptyView()
                        }
                        LessonView(lesson: lesson)
                    }.listRowBackground(Color.black)
                    
                }
                .padding([.top, .leading, .bottom], 0.0)
                .foregroundColor(.clear).navigationBarTitleDisplayMode(.large).navigationTitle("Lessons").edgesIgnoringSafeArea([.all])
            }
            .listStyle(.plain).scrollContentBackground(.hidden).navigationBarBackButtonHidden()
            
        }.background(Color.black).onAppear {
            self.viewModel.getLessonsList{ success in
                print(success)
            }
        }.edgesIgnoringSafeArea(.all)
        
    }
}
struct LessonsListView_Previews: PreviewProvider {
    static var previews: some View {
        LessonsListView()
    }
}
struct LessonView: View {
    
    var lesson: LessonsModel?
    @StateObject var viewModel = LessonsViewModel()
    var body: some View {
        HStack(spacing: 10) {
            
            LessonImageView(
                url: URL(string: lesson?.thumbnail ?? "")!,
                placeholder: { Text("Loading ...") }
            ).frame(width: 100, height: 60).aspectRatio(contentMode: .fit)
            Text(lesson?.name ?? "").font(.headline).foregroundColor(.white)
            Spacer()
            Image("Forward")
                .renderingMode(.original)
                .padding(/*@START_MENU_TOKEN@*/.all, 10.0/*@END_MENU_TOKEN@*/)
        }
    }
}
struct LessonView_Previews: PreviewProvider {
    static var previews: some View {
        LessonView()
    }
}

struct LessonImageView<Placeholder: View>: View {
    @StateObject private var loader: ImageLoader
    private let placeholder: Placeholder
    private let image: (UIImage) -> Image
    
    init(
        url: URL,
        @ViewBuilder placeholder: () -> Placeholder,
        @ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:)
    ) {
        self.placeholder = placeholder()
        self.image = image
        _loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
    }
    
    var body: some View {
        content
            .onAppear(perform: loader.load)
    }
    
    private var content: some View {
        Group {
            if loader.image != nil {
                image(loader.image!).resizable()
            } else {
                placeholder
            }
        }
    }
}

Design will look like screenshot.

enter image description here

Second you need to add UIViewControllerRepresentable in SwiftUI View.

struct LessonVideoPlayerRepresentable: UIViewControllerRepresentable {
    var thumbnail: String
    var videoURL: String
    var lessonDesc: String
    var name: String
    func makeUIViewController(context: UIViewControllerRepresentableContext<LessonVideoPlayerRepresentable>) -> LessonVideoPlayer {
        
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let detailsVC = storyboard.instantiateViewController(withIdentifier: "LessonVideoPlayer") as! LessonVideoPlayer
        detailsVC.name = name
        detailsVC.thumbnail = thumbnail
        detailsVC.videoURL = videoURL
        detailsVC.lessonDescription = lessonDesc
        detailsVC.view.backgroundColor = .black
        return detailsVC
    }
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext<LessonVideoPlayerRepresentable>) {
        uiViewController.view.backgroundColor = .black
        uiViewController.navigationItem.largeTitleDisplayMode = .never
    }
}

Now make a UIKit ViewController and you will get data from SwiftUi to UIKit Class.

class LessonVideoPlayer: UIViewController {
    
    var thumbnail: String = ""
    var videoURL: String = ""
    var name: String = ""
    var lessonDescription: String = "" 

override func viewDidLoad() {
        super.viewDidLoad()
    
        
        print("Name:\(name)")
        print("Thumbnail:\(thumbnail)")
        print("Video URL:\(videoURL)")
        print("Description:\(lessonDescription)")
         
}

}
Priyank Patel
  • 791
  • 8
  • 6
  • https://stackoverflow.com/questions/69942854/how-to-pass-textfield-value-to-view-controller-through-button-click-in-swift-ui/69945806#69945806 You can also have a shared source of truth so you don’t have to pass everything individually – lorem ipsum Mar 15 '23 at 13:48
  • @loremipsum it will be easy for developers if they want to pass individual. – Priyank Patel Mar 17 '23 at 08:10
0

There certainly isn't one definitively right answer for this. The ObservableObject solution you mentioned is one. Assuming you just needed uni-directional data flow (which your example seems to have), I might be tempted to use a structure with just a simple delegate function that gets called with actions -- a Redux-esque approach:

enum AppAction {
    case buttonPress(value: String)
    case otherButtonPress(value: Int)
}

typealias DispatchFunction = (AppAction) -> Void

struct ContentView : View {
    var dispatch : DispatchFunction = { action in
        print(action)
    }
    
    var body: some View {
        VStack {
            SubView(dispatch: dispatch)
            SubView2(dispatch: dispatch)
        }
    }
}

struct SubView : View {
    var dispatch : DispatchFunction
    
    var body: some View {
        Button(action: { dispatch(.buttonPress(value: "Test")) }) {
            Text("Press me")
        }
    }
}

struct SubView2 : View {
    var dispatch : DispatchFunction
    
    var body: some View {
        Button(action: { dispatch(.otherButtonPress(value: 2)) }) {
            Text("Press me 2")
        }
    }
}

class ViewController : UIViewController {
    func dispatch(_ action: AppAction) {
        print("Action: \(action)")
    }
    
    override func viewDidLoad() {
        let controller = UIHostingController(rootView: ContentView(dispatch: self.dispatch))
        //...
    }
}

That way, you're still passing around something to all of your subviews, but it's a pretty simple and light dependency just to pass DispatchFunction around like this.

jnpdx
  • 45,847
  • 6
  • 64
  • 94