I converted my app to use the new NavigationStack in my onboarding flow. My login view that uses SignInWithAppleButton failed. What seemed to happen is my view containing the button popped away when Apple's sign-in button was shown. This meant my handlers were gone, so none of my handlers were called. The same code outside the NavigationStack works fine.
I eventually tracked this down to a very unexpected statement.
import SwiftUI
#main
struct TestNavSignInApp: App {
// When scenePhase isn't commented out the SignInWithAppleButton view pops off
// and the sign-in never occurs. Comment it out, and everything works fine.
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#ObservedObject var onboarding = OnboardingFlow()
var body: some View {
NavigationStack(path: $onboarding.path) {
Button("Start") {
onboarding.start()
}
.navigationDestination(for: OnboardDestination.self) { destination in
ViewFactory.viewForDestination(destination)
}
}
.environmentObject(onboarding)
}
}
enum OnboardDestination {
case welcomPage
case loginPage
}
class ViewFactory {
#ViewBuilder
static func viewForDestination(_ destination: OnboardDestination) -> some View {
switch destination {
case .welcomPage:
WelcomeView()
case .loginPage:
SignInWithAppleView()
}
}
}
class OnboardingFlow: ObservableObject {
#Published var path = NavigationPath()
func gotoHomePage() {
path.removeLast(path.count)
}
func gotoPrev() {
path.removeLast()
}
func start() {
path.append(OnboardDestination.welcomPage)
}
func next() {
path.append(OnboardDestination.loginPage)
}
}
struct WelcomeView: View {
#EnvironmentObject var onboarding: OnboardingFlow
var body: some View {
Button("Next") {
onboarding.next()
}
.navigationBarBackButtonHidden(true)
}
}
struct SignInWithAppleView: View {
#EnvironmentObject var onboarding: OnboardingFlow
var body: some View {
VStack(spacing: 18) {
SignInWithAppleButton(
.signIn,
onRequest: configure,
onCompletion: handle
)
}
.navigationBarBackButtonHidden(true)
}
func configure(_ request: ASAuthorizationAppleIDRequest) {
request.requestedScopes = [.email, .fullName]
}
func handle(_ authResult: Result<ASAuthorization, Error>) {
print("Handle:")
switch authResult {
case.success(let auth):
print("Authorization successful: \(auth)")
switch auth.credential {
case let credential as ASAuthorizationAppleIDCredential:
print("credential: \(credential)")
default:
print("Authorization default credential: \(auth.credential.description)")
}
case.failure(let error):
print("Authorization failed:error: \(error.localizedDescription)")
}
}
}
I'm not sure why this would happen. While scenePhase isn't a must have, I was wondering if anyone could explain what is happening and a possible workaround?
I've implemented a simple camera view:
import SwiftUI
import AVFoundation
struct CameraView: View {
#StateObject var model = CameraModel()
var body: some View {
CameraPreview(camera: model).ignoresSafeArea().onAppear() {
model.check()
}
}
}
struct CameraPreview: UIViewRepresentable {
#ObservedObject var camera: CameraModel
func makeUIView(context: Context) -> some UIView {
let view = UIView(frame: UIScreen.main.bounds)
camera.preview = AVCaptureVideoPreviewLayer(session: camera.session)
camera.preview.frame = view.frame
camera.preview.videoGravity = AVLayerVideoGravity.resizeAspectFill
view.layer.addSublayer(camera.preview)
camera.session.startRunning()
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
struct CameraView_Previews: PreviewProvider {
static var previews: some View {
CameraView()
}
}
class CameraModel: ObservableObject {
#Published var session = AVCaptureSession()
#Published var alert = false
#Published var preview: AVCaptureVideoPreviewLayer!
func check() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setUp()
break
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { (status) in
if status {
self.setUp()
}
}
break
case .denied:
self.alert.toggle()
break
default:
break
}
}
func setUp() {
do {
self.session.beginConfiguration()
let device = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back)
let input = try AVCaptureDeviceInput(device: device!)
if self.session.canAddInput(input) {
self.session.addInput(input)
}
self.session.commitConfiguration()
}
catch {
print(error.localizedDescription)
}
}
}
And show it as a tab in TabView (root layer):
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Image("HomeIcon").renderingMode(.template)
Text("Home")
}
CameraView()
.tabItem {
Image("MapMarkerRadiusIcon").renderingMode(.template)
Text("Camera")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Unfortunately, CameraView() overlays the bottom tab bar:
Looks okay? Unfortunately, no... It shows unselected icons when I am making a screenshot or going to hide app. When app is just launched and Camera tab is opened, it looks as below:
How to fix this bug? I need to the tab bar was on the top always (like on the first screenshot)
If you want to see all tabs over camera view, there is only one workaround but it doesn't match what you showed in your first screenshot excatly.
To see all tabs, just add safeAreaInset(edge:alignment:spacing:_:) modifier right below CameraPreview(camera: model) and set ignoresSafeArea to .top.
Here is the complete sample code:
CameraPreview(camera: model)
.safeAreaInset(edge: .bottom, alignment: .center, spacing: 0) {
Color.clear
.frame(height: 0)
.background(Material.bar)
}
.ignoresSafeArea(.all, edges: .top)
The result:
Hope it will save you.
My test code:
import SwiftUI
#main
struct TestingIOS14App: App {
#Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.withHostingWindow { window in
let hostingController = UIHostingController(rootView: ContentView())
let mainNavigationController = UINavigationController(rootViewController: hostingController)
mainNavigationController.navigationBar.isHidden = true
window?.rootViewController = mainNavigationController
}
}
.onChange(of: scenePhase) { newScenePhase in
switch newScenePhase {
case .active:
print("App is active")
case .inactive:
print("App is inactive")
case .background:
print("App is in background")
#unknown default:
print("Oh - interesting: I received an unexpected new value.")
}
}
}
}
extension View {
func withHostingWindow(_ callback: #escaping (UIWindow?) -> Void) -> some View {
self.background(HostingWindowFinder(callback: callback))
}
}
struct HostingWindowFinder: UIViewRepresentable {
var callback: (UIWindow?) -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
#Asperi's answer here and the code above seem to works but the scene lifecycles in
.onChange(of: don't seem to hit except the active phase when the app is launched initially. Not sure what I am doing wrong, would appreciate any help on this please.
Many thanks :)
The difference between your code and the answer you link to is that you're completely replacing the app's view hierarchy. Notice how they use
if let controller = window?.rootViewController
and instead you're assigning a new root view controller
window?.rootViewController = mainNavigationController
My guess is that's what's causing your problem.
If all you want is to hide the navigation bar, though, there are SwiftUI methods to do that.
In a SwiftUI app I have code like this:
var body: some View {
VStack {
Spacer()
........
}
.onAppear {
.... I want to have some code here ....
.... to run when the view appears ....
}
}
My problem is that I would like to run some code inside the .onAppear block, so that it gets run when the app appears on screen, after launching or after being in the background for a while. But it seems like this code is only run once at app launch, and never after. Am I missing something? Or should I use a different strategy to get the result I want?
If you're linking against iOS14 then you can take advantage of the new scenePhase concept:
#Environment(\.scenePhase) var scenePhase
Where ever you are the Environment if injecting this property that you can test against three conditions:
switch newPhase {
case .inactive:
print("inactive")
case .active:
print("active")
case .background:
print("background")
}
So, all together:
struct ContentView: View {
#Environment(\.scenePhase) var scenePhase
var body: some View {
Text("Hello, World!")
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .inactive:
print("inactive")
case .active:
print("active")
case .background:
print("background")
}
}
}
}
You would have to observe the event when the app is entering foreground and publish it using #Published to the ContentView. Here's how:
struct ContentView: View {
#ObservedObject var observer = Observer()
var body: some View {
VStack {
Spacer()
//...
}
.onReceive(self.observer.$enteredForeground) { _ in
print("App entered foreground!") // do stuff here
}
}
}
class Observer: ObservableObject {
#Published var enteredForeground = true
init() {
if #available(iOS 13.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
} else {
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}
}
#objc func willEnterForeground() {
enteredForeground.toggle()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
I'm developing a simple iOS app with SwiftUI with two views: a LogInView() and a HomeView().
What I want is really simple: when the user clicks on the Login Button on LogInView() I want the app to hide the LogInView() and show the HomeView(), full screen, not like a modal and without allowing the user to go back.
This could be easily done with Storyboards in Swift and UIKit, is there any way to do this with SwiftUI?
Any help is appreciated.
Thanks in advance.
My code:
LogInView:
struct LogInView: View {
var body: some View {
VStack {
Text("Welcome to Mamoot!")
.font(.largeTitle)
.fontWeight(.heavy)
Text("We are glad to have you here.")
Text("Please log in with your Mastodon or Twitter account to continue.")
.multilineTextAlignment(.center)
.lineLimit(4)
.padding()
Spacer()
FloatingTextField(title: "Username", placeholder: "Username", width: 300, type: "Username")
FloatingTextField(title: "Password", placeholder: "Password", width: 300, type: "password")
.padding(.top, -50)
Spacer()
ZStack {
Button(action: { /* go to HomeView() */ }) {
Text("Log in")
.foregroundColor(Color.white)
.bold()
.shadow(color: .red, radius: 10)
}
.padding(.leading, 140)
.padding(.trailing, 140)
.padding(.top, 15)
.padding(.bottom, 15)
.background(Color.red)
.cornerRadius(10)
}
.padding(.bottom)
}
}
}
HomeView:
struct HomeView: View {
var body: some View {
Text("Home Page")
}
}
Update: I got some time to update the answer, and add a transition. Note that I changed Group by VStack, otherwise the transition does not work.
You can alter the duration in the withAnimationcall (button closure).
I also moved some modifiers in your button, so the whole thing is "tappable". Otherwise, only tapping on the text of the button would trigger the action.
You can use an ObservedObject, an EnvironmentObject or a Binding. Here's an example with ObservedObject:
import SwiftUI
class Model: ObservableObject {
#Published var loggedIn = false
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
if model.loggedIn {
HomeView().transition(.opacity)
} else {
LogInView(model: model).transition(.opacity)
}
}
}
}
struct HomeView: View {
var body: some View {
Text("Home Page")
}
}
struct LogInView: View {
#ObservedObject var model: Model
var body: some View {
VStack {
Text("Welcome to Mamoot!")
.font(.largeTitle)
.fontWeight(.heavy)
Text("We are glad to have you here.")
Text("Please log in with your Mastodon or Twitter account to continue.")
.multilineTextAlignment(.center)
.lineLimit(4)
.padding()
Spacer()
// FloatingTextField(title: "Username", placeholder: "Username", width: 300, type: "Username")
// FloatingTextField(title: "Password", placeholder: "Password", width: 300, type: "password")
// .padding(.top, -50)
Spacer()
ZStack {
Button(action: {
withAnimation(.easeInOut(duration: 1.0)) {
self.model.loggedIn = true
}
}) {
Text("Log in")
.foregroundColor(Color.white)
.bold()
.shadow(color: .red, radius: 10)
// moved modifiers here, so the whole button is tappable
.padding(.leading, 140)
.padding(.trailing, 140)
.padding(.top, 15)
.padding(.bottom, 15)
.background(Color.red)
.cornerRadius(10)
}
}
.padding(.bottom)
}
}
}
The answer by #kontiki is probably the most SwiftUI-y, but I will present a different solution, probably not as good! But maybe more flexible/scalable.
You can swap rootView of UIHostingController:
SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
fileprivate lazy var appCoordinator: AppCoordinator = {
let rootViewController: UIHostingController<AnyView> = .init(rootView: EmptyView().eraseToAny())
window?.rootViewController = rootViewController
let navigationHandler: (AnyScreen, TransitionAnimation) -> Void = { [unowned rootViewController, window] (newRootScreen: AnyScreen, transitionAnimation: TransitionAnimation) in
UIView.transition(
with: window!,
duration: 0.5,
options: transitionAnimation.asUIKitTransitionAnimation,
animations: { rootViewController.rootView = newRootScreen },
completion: nil
)
}
return AppCoordinator(
dependencies: (
securePersistence: KeyValueStore(KeychainSwift()),
preferences: .default
),
navigator: navigationHandler
)
}()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
self.window = .fromScene(scene)
appCoordinator.start()
}
}
enum TransitionAnimation {
case flipFromLeft
case flipFromRight
}
private extension TransitionAnimation {
var asUIKitTransitionAnimation: UIView.AnimationOptions {
switch self {
case .flipFromLeft: return UIView.AnimationOptions.transitionFlipFromLeft
case .flipFromRight: return UIView.AnimationOptions.transitionFlipFromRight
}
}
}
AppCoordinator
And here is the AppCoordinator:
final class AppCoordinator {
private let preferences: Preferences
private let securePersistence: SecurePersistence
private let navigationHandler: (AnyScreen, TransitionAnimation) -> Void
init(
dependencies: (securePersistence: SecurePersistence, preferences: Preferences),
navigator navigationHandler: #escaping (AnyScreen, TransitionAnimation) -> Void
) {
self.preferences = dependencies.preferences
self.securePersistence = dependencies.securePersistence
self.navigationHandler = navigationHandler
}
}
// MARK: Internal
internal extension AppCoordinator {
func start() {
navigate(to: initialDestination)
}
}
// MARK: Destination
private extension AppCoordinator {
enum Destination {
case welcome, getStarted, main
}
func navigate(to destination: Destination, transitionAnimation: TransitionAnimation = .flipFromLeft) {
let screen = screenForDestination(destination)
navigationHandler(screen, transitionAnimation)
}
func screenForDestination(_ destination: Destination) -> AnyScreen {
switch destination {
case .welcome: return AnyScreen(welcome)
case .getStarted: return AnyScreen(getStarted)
case .main: return AnyScreen(main)
}
}
var initialDestination: Destination {
guard preferences.hasAgreedToTermsAndPolicy else {
return .welcome
}
guard securePersistence.isAccountSetup else {
return .getStarted
}
return .main
}
}
// MARK: - Screens
private extension AppCoordinator {
var welcome: some Screen {
WelcomeScreen()
.environmentObject(
WelcomeViewModel(
preferences: preferences,
termsHaveBeenAccepted: { [unowned self] in self.start() }
)
)
}
var getStarted: some Screen {
GetStartedScreen()
.environmentObject(
GetStartedViewModel(
preferences: preferences,
securePersistence: securePersistence,
walletCreated: { [unowned self] in self.navigate(to: .main) }
)
)
}
var main: some Screen {
return MainScreen().environmentObject(
MainViewModel(
preferences: preferences,
securePersistence: securePersistence,
walletDeleted: { [unowned self] in
self.navigate(to: .getStarted, transitionAnimation: .flipFromRight)
}
)
)
}
}