简体   繁体   中英

SwiftUI: How to show next view after button click + API call

It might sound like a trivial task but I can't find a proper solution for this problem. Possibly I haven't internalized the "SwiftUI-ish" way of thinking yet.

I have a view with a button. When the view loads, there is a condition (already logged in?) under which the view should directly go to the next view. If the button is clicked, an API call is triggered (login) and if it was successful, the redirect to the next view should also happen.

My attempt was to have a model (ObservableObject) that holds the variable "shouldRedirectToUploadView" which is a PassThroughObject. Once the condition onAppear in the view is met or the button is clicked (and the API call is successful), the variable flips to true and tells the observer to change the view.

Flipping the "shouldRedirectToUploadView" in the model seems to work but I can't make the view re-evaluate that variable so the new view won't open.

Here is my implementation so far:

The model

import SwiftUI
import Combine

class SboSelectorModel: ObservableObject {

    var didChange = PassthroughSubject<Void, Never>()

    var shouldRedirectToUpdateView = false {
        didSet {
            didChange.send()
        }
    }

    func fetch(_ text: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.shouldRedirectToUpdateView = true
        }
    }
}

The view

import SwiftUI

struct SboSelectorView: View {
    @State var text: String = ""
    @ObservedObject var model: SboSelectorModel

    var body: some View {
        return ZStack {
            if (model.shouldRedirectToUpdateView) {
                UpdateView()
            }
            else {
                Button(action: {
                    self.reactOnButtonClick()
                }) {
                    Text("Start")
                }
            }
        }.onAppear(perform: initialActions)
    }

    public func initialActions() {
        self.model.shouldRedirectToUpdateView = true
    }

    private func reactOnButtonClick() {
        self.model.fetch()
    }
}

In good old UIKit I would have just used a ViewController to catch the action of button click and then put the new view on the navigation stack. How would I do it in SwiftUI?

In the above example I would expect the view to load, execute the onAppear() function which executes initialActions() to flip the model variable what would make the view react to that change and present the UploadView . Why doesn't it happen that way?

There are SO examples like Programatically navigate to new view in SwiftUI or Show a new View from Button press Swift UI or How to present a view after a request with URLSession in SwiftUI? that suggest the same procedure. However it does not seem to work for me. Am I missing something?

Thank you in advance!

Apple has introduced @Published which does all the model did change stuff.

This works for me and it looks much cleaner.

You can also use .onReceive() to perform stuff on a view when something in your model changes.

class SboSelectorModel: ObservableObject {
    @Published var shouldRedirectToUpdateView = false

    func fetch(_ text: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.shouldRedirectToUpdateView = true
        }
    }
}

struct UpdateView: View {
    var body: some View {
        Text("Hallo")
    }
}

struct SboSelectorView: View {
    @State var text: String = ""
    @ObservedObject var model = SboSelectorModel()

    var body: some View {
        ZStack {
            if (self.model.shouldRedirectToUpdateView) {
                UpdateView()
            }
            else {
                Button(action: {
                    self.reactOnButtonClick()
                }) {
                    Text("Start")
                }
            }
        }.onAppear(perform: initialActions)
    }

    public func initialActions() {
        self.model.shouldRedirectToUpdateView = true
    }

    private func reactOnButtonClick() {
        self.model.fetch("")
    }
}

I hope this helps.

EDIT

So this seems to have changed in beta 5

Here a working model with PassthroughSubject :

class SboSelectorModel: ObservableObject {
    let objectWillChange = PassthroughSubject<Bool, Never>()


    var shouldRedirectToUpdateView = false {
        willSet {
            objectWillChange.send(shouldRedirectToUpdateView)
        }
    }

    func fetch(_ text: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.shouldRedirectToUpdateView = true
        }
    }
}

Just in case any wants an alternative to the SwiftUI way of having Observable Objects and the like - which can be great, but as I was building out, I noticed I had like, 100 objects and didn't like in the slightest how complicated it all felt. (Oh, how I wanted to just type self.present("nextScene", animated: true) ). I know a large part of this is my mind just not up to that SwiftUI life yet but just in case anyone else wants a more... UIKit meets SwiftUI alternative, here's a system that works.

I'm not a professional so I don't know if this is the best memory management way.

First, create a function that allows you to know what the top view controller is on the screen. The code below was borrowed from db0Company on GIT .

import UIKit

extension UIViewController { 

func topMostViewController() -> UIViewController {
        if let presented = self.presentedViewController {
            return presented.topMostViewController()
        }
        if let navigation = self.presentedViewController as? UINavigationController {
            return navigation.visibleViewController?.topMostViewController() ?? navigation
        }
        if let tab = self as? UITabBarController {
            return tab.selectedViewController?.topMostViewController() ?? tab
        }
        return self
    }
}

extension UIApplication {
    func topMostViewController() -> UIViewController? {
        return self.keyWindow?.rootViewController?.topMostViewController()
    }
}

Create an enum - now this is optional, but I think very helpful - of your SwiftUI and UIViewControllers; for demonstration purposes, I have 2.

enum RootViews { 
    case example, welcome
}

Now, here's some fun; create a delegate you can call from your SwiftUI views to move you from scene to scene. I call mine Navigation Delegate.

I added some default presentation styles here, to make calls easier via the extension.

import UIKit //SUPER important!

protocol NavigationDelegate {
    func moveTo(view: RootViews, presentation: UIModalPresentationStyle, transition: UIModalTransitionStyle)
}

extension NavigationDelegate {
    func moveTo(view: RootViews) {
        self.moveTo(view: view, presentation: .fullScreen, transition: .crossDissolve)
    }

    func moveTo(view: RootViews, presentation: UIModalPresentationStyle) {
        self.moveTo(view: view, presentation: presentation, transition: .crossDissolve)
    }

    func moveTo(view: RootViews, transition: UIModalTransitionStyle) {
        self.moveTo(view: view, presentation: .fullScreen, transition: transition)
    }
}

And here, I create a RootViewController - a classic, Cocoa Touch Class UIViewController . This will conform to the delegate, and be where we actually move screens.

class RootViewController: UIViewController, NavigationDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
    }


    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.moveTo(view: .welcome) //Which can always be changed
    }

    //The Moving Function
    func moveTo(view: RootViews, presentation: UIModalPresentationStyle = .fullScreen, transition: UIModalTransitionStyle = .crossDissolve) {
        let newScene = self.returnSwiftUIView(type: view)
        newScene.modalPresentationStyle = presentation
        newScene.modalTransitionStyle = transition

        //Top View Controller
        let top = self.topMostViewController()

        top.present(newScene, animated: true)
    }

    //Swift View switch. Optional, but my Xcode was not happy when I tried to return a UIHostingController in line. 

    func returnSwiftUIView(type: RootViews) -> UIViewController {
        switch type {
        case .welcome:
            return UIHostingController(rootView: WelcomeView(delegate: self))
        case .example:
            return UIHostingController(rootView: ExampleView(delegate: self))
        }
    }
} 

So now, when you create new SwiftUI Views, you just need to add the Navigation Delegate, and call it when a button is pressed.

import SwiftUI 
import UIKit //Very important! Don't forget to import UIKit

struct WelcomeView: View {
    var delegate: NavigationDelegate?

    var body: some View {
        Button(action: {
           print("full width")
           self.delegate?.moveTo(view: .name)
        }) {
           Text("NEXT")
            .frame(width: UIScreen.main.bounds.width - 20, height: 50, alignment: .center)
            .background(RoundedRectangle(cornerRadius: 15, style: .circular).fill(Color(.systemPurple)))
            .accentColor(.white)
        }
}

And last but not least, in your scene delegate, create your RootViewController() and use that as your key, instead of the UIHostingController(rootView: contentView) .

Voila.

I hope this can help someone out there, And for my more professional senior developers out there. if you can see a way to make it..? cleaner, Or whatever it is that makes code less bad, feel free!

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM