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)
}
}
Related
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?
Problem
I want to show a sheet or navigate to a specific view with SwiftUI when the user presses on a notification.
I'm seeking advice on what the best approach would be to solve this problem in a SwiftUI app.
Inside my AppDelegate I currently have:
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: #escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
if let messageID = userInfo[gcmMessageIDKey] {
print("Message ID: \(messageID)")
}
// Here I want to implement a function, that opens a sheet or navigates to a specific view //
print("User tapped notification")
completionHandler()
}
}
What would be the best way to do this?
Overview of structure of the app
#main
struct jsonlistingApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
MainView()
}
}
}
struct MainView: View {
var body: some View {
TabView {
PostList()
.tabItem {
Label("Søg", systemImage: "magnifyingglass")
}
FavoritesView()
.tabItem {
Label("Favoritter", systemImage: "heart")
}
if #available(iOS 15.0, *) {
BoligAgentView()
.tabItem {
Label("Boligagent", systemImage: "bell")
}
.badge(UIApplication.shared.applicationIconBadgeNumber)
}
else {
BoligAgentView()
.tabItem {
Label("Boligagent", systemImage: "bell")
}
}
Settings()
.tabItem {
Label("Profil", systemImage: "person")
}
}
}
Thanks in advance!
I'm trying to display a Subscribe Now modal view instantly after the app starts to encourage users to subscribe to the Pro In-App Purchase, so I used the .onAppear modifier, and it works fine only if I want to show the modal every time the app starts.
struct ContentView: View {
#State private var selection: String? = nil
#State private var showModal = false
#ObservedObject var storeManager: StoreManager
var body: some View {
NavigationView {
VStack {
// Contents Here
}
}
.onAppear {
self.selection = "Pro"
self.showModal.toggle()
}
.sheet(isPresented: $showModal) {
if self.selection == "Pro" {
Pro(showModal: self.$showModal, storeManager: self.storeManager)
.onAppear(perform: {
SKPaymentQueue.default().add(storeManager)
})
}
}
}
}
Now, the problem begins when I want to display the modal only to those who have not subscribed yet to the Pro IAP, so I modified .onAppear to:
.onAppear {
ForEach(storeManager.myProducts, id: \.self) { product in
VStack {
if !UserDefaults.standard.bool(forKey: product.productIdentifier) {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
}
But, the if and ForEach seems not to work smoothly with structs and views. How should I use them in my case?
Update:
Based on the answers, I have changed the loop inside .onAppear to make the code conforms to SwiftUI requirements:
.onAppear {
storeManager.myProducts.forEach { product in
// Alternatively, I can use (for in) loop:
// for product in storeManager.myProducts {
if !UserDefaults.standard.bool(forKey: product.productIdentifier) {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
Now, errors have gone away but the modal is not displayed on startup.
I discovered that the problem is, storeManager.myProducts is not loaded in .onAppear modifier, while it's loaded correctly when I put the same loop in a button instead of .onAppear, any ideas? Why does onAppear doesn't load the IAP? Where should I put the code to make the modal run when the view loaded?
Update 2:
Here is a Minimal Reproducible Example:
App:
import SwiftUI
#main
struct Reprod_SOFApp: App {
#StateObject var storeManager = StoreManager()
let productIDs = ["xxxxxxxxxxxxxxxxxxxxx"]
var body: some Scene {
DocumentGroup(newDocument: Reprod_SOFDocument()) { file in
ContentView(document: file.$document, storeManager: storeManager)
.onAppear() {
storeManager.getProducts(productIDs: productIDs)
}
}
}
}
ContentView:
import SwiftUI
import StoreKit
struct ContentView: View {
#Binding var document: Reprod_SOFDocument
#State private var selection: String? = nil
#State private var showModal = false
#ObservedObject var storeManager: StoreManager
var test = ["t"]
var body: some View {
TextEditor(text: $document.text)
.onAppear {
// storeManager.myProducts.forEach(id: \.self) { product in
// Alternatively, I can use (for in) loop:
for i in test {
if !i.isEmpty {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
.sheet(isPresented: $showModal) {
if self.selection == "Pro" {
Modal(showModal: self.$showModal, storeManager: self.storeManager)
.onAppear(perform: {
SKPaymentQueue.default().add(storeManager)
})
}
}
}
}
Modal:
import SwiftUI
import StoreKit
struct Modal: View {
#Binding var showModal: Bool
#ObservedObject var storeManager: StoreManager
var body: some View {
Text("hello world")
}
}
StoreManager:
import Foundation
import StoreKit
class StoreManager: NSObject, ObservableObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
#Published var myProducts = [SKProduct]()
var request: SKProductsRequest!
#Published var transactionState: SKPaymentTransactionState?
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing:
transactionState = .purchasing
case .purchased:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .purchased
case .restored:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .restored
case .failed, .deferred:
print("Payment Queue Error: \(String(describing: transaction.error))")
queue.finishTransaction(transaction)
transactionState = .failed
default:
queue.finishTransaction(transaction)
}
}
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
print("Did receive response")
if !response.products.isEmpty {
for fetchedProduct in response.products {
DispatchQueue.main.async {
self.myProducts.append(fetchedProduct)
}
}
}
for invalidIdentifier in response.invalidProductIdentifiers {
print("Invalid identifiers found: \(invalidIdentifier)")
}
}
func getProducts(productIDs: [String]) {
print("Start requesting products ...")
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
request.delegate = self
request.start()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Request did fail: \(error)")
}
func purchaseProduct(product: SKProduct) {
if SKPaymentQueue.canMakePayments() {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
} else {
print("User can't make payment.")
}
}
func restoreProducts() {
print("Restoring products ...")
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
Here is a link to Minimal Reproducible Example
Instead of using .onAppear modifier to display the modal, you can change the initial values of selection and showModal:
#State private var selection: String? = "Pro"
#State private var showModal = !UserDefaults.standard.bool(forKey: "xxxxxxxxxxxxxxxxxxxxx") ? true : false
// Write your product identifier instead of "xxxxxxxxxxxxxxxxxxxxx"
This way, modal view will be shown instantly after the content view loads.
Note: For showModal, I've applied a conditional if instead of simply true, since you said you want to show the modal only to those who have not subscribed yet to the Pro IAP.
I would recommend to separate logic into a viewmodel, and you only need to manage one identified object to show your pro modal.
struct ContentView: View {
#ObservedObject var viewModel: ContentViewModel
var body: some View {
Text("Hello")
.onAppear(perform: viewModel.fetchStatus)
.sheet(item: $viewModel.carrier) { carrier in
ModalView(storeManager: carrier.storeManager)
}
}
}
class ContentViewModel: ObservableObject {
#Published var carrier: ModalObject?
private let storeManager: StoreManager
func fetchStatus() {
// do something asynchronous like
storeManager.fetchProducts() { [self] products in
if !products.contains(proProducts) {
self.carrier = ModalObject(storeManager: self.storeManager)
}
}
}
}
struct ModalObject: Identifiable {
var id = UUID()
let storeManager: StoreManager
}
I just wrote without compiling, please check with your xcode.
Your .onAppear{} should be Swift code instead SwiftUI (ForEach, VStack). VStack are view structs.
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.
I'm using watchOS 7.0 and SwiftUI. My view listens to NSExtensionHostDidBecomeActive notification:
.onReceive(NotificationCenter.default.publisher(for: .NSExtensionHostDidBecomeActive)) { _ in
NSLog("Foreground")
viewModel.loadData()
}
However, it is not called.
I solved this issue by using WKExtensionDelegate and my own notifications.
#main
struct ExtensionApp: App {
#WKExtensionDelegateAdaptor(ExtensionDelegate.self) var appDelegate
#SceneBuilder var body: some Scene {
WindowGroup {
NavigationView {
MainView()
}
}
}
}
import WatchKit
final class ExtensionDelegate: NSObject, ObservableObject, WKExtensionDelegate {
func applicationDidFinishLaunching() {
NSLog("App launched")
}
func applicationDidBecomeActive() {
NSLog("App activated")
NotificationCenter.default.post(name: .appActivated, object: nil)
}
func applicationDidEnterBackground() {
NSLog("App deactivated")
NotificationCenter.default.post(name: .appDeactivated, object: nil)
}
}
import Foundation
extension Notification.Name {
static let appActivated = Notification.Name("app.activated")
static let appDeactivated = Notification.Name("app.deactivated")
}
then I was able to listen to these events in my SwiftUI view:
.onReceive(NotificationCenter.default.publisher(for: .appActivated)) { _ in
viewModel.appActivated()
}
.onReceive(NotificationCenter.default.publisher(for: .appDeactivated)) { _ in
viewModel.appDeactivated()
}