iPadOS Keyboard CMD+Z calls UndoManager's undo selector twice? - ios

I'm having an issue with UndoManager which might be an iPadOS bug, but could also be my mistake.
I have a simple test app. One button performs an action, which increments an int by 1 and registers an undo with the system UndoManager. Another button manually calls undoManager.undo(), which undoes the last action until no more actions on the undo stack remain. And the overall app is a first responder with the system, so hitting CMD+Z should also trigger the undoManager to call undo() once per CMD+Z press.
Here's some code.
The UIHostingController subclass that allows becoming first responder:
class FirstResponderHostingController<Content: View>: UIHostingController<Content> {
override var canBecomeFirstResponder: Bool { true }
}
SceneDelegate, where I pass in the system undo manager:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let model = MyObject(undoManager: window.undoManager)
let contentView = ContentView(model: model)
window.rootViewController = FirstResponderHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
//...
}
And my SwiftUI view and associated model:
class MyObject: ObservableObject {
var undoManager: UndoManager?
var state: Int = 0
init(undoManager: UndoManager? = nil) {
self.undoManager = undoManager
}
func registerAction() {
state += 1
undoManager?.registerUndo(withTarget: self, selector: #selector(performUndo), object: nil)
print("registered action - state is \(state)")
}
#objc func performUndo() {
state -= 1
print("performUndo called - state is \(state)")
}
func undo() {
print("undo() called")
undoManager?.undo()
}
}
struct ContentView: View {
#ObservedObject var model: MyObject
var body: some View {
VStack(spacing: 16) {
Button(action: {
self.model.registerAction()
}) {
Text("Register an Action")
}
Button(action: {
self.model.undo()
}) {
Text("Manual Undo (Not from Keyboard)")
}
}
}
}
So for example, if I tap "Register Action" twice, and then "Manual Undo" three times, the output is what I expect - the state was incremented twice, then decremented twice, and the last undo() does nothing because the undo stack is empty:
registered action - state is 1
registered action - state is 2
undo() called
performUndo called - state is 1
undo() called
performUndo called - state is 0
undo() called
But if I tap "Register Action" twice, and then hit CMD+Z on the iPad keyboard, I expect the state to be incremented twice and decremented only once, with one call to performUndo, but instead performUndo is still called twice:
registered action - state is 1
registered action - state is 2
performUndo called - state is 1
performUndo called - state is 0
Am I using the system UndoManager incorrectly somehow, or is this a bug?
Update: The above is with Xcode 11.3.1 and iPadOS 13.3. Using the latest Xcode 11.4 beta 2 and iPadOS 13.4 beta 2, the double-undo no longer occurs. Seems fixed!

Related

How to access own window within SwiftUI view?

The goal is to have easy access to hosting window at any level of SwiftUI view hierarchy. The purpose might be different - close the window, resign first responder, replace root view or contentViewController. Integration with UIKit/AppKit also sometimes require path via window, so…
What I met here and tried before,
something like this
let keyWindow = shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
or via added in every SwiftUI view UIViewRepresentable/NSViewRepresentable to get the window using view.window looks ugly, heavy, and not usable.
Thus, how would I do that?
SwiftUI Lift-Cycle (SwiftUI 2+)
Here is a solution (tested with Xcode 13.4), to be brief only for iOS
We need application delegate to create scene configuration with our scene delegate class
#main
struct PlayOn_iOSApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// ...
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
}
Declare our SceneDelegate and confirm it to both (!!!+) UIWindowSceneDelegate and ObservableObject
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow? // << contract of `UIWindowSceneDelegate`
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
self.window = windowScene.keyWindow // << store !!!
}
}
Now we can use our delegate anywhere (!!!) in view hierarchy as EnvironmentObject, because (bonus of confirming to ObservableObject) SwiftUI automatically injects it into ContentView
#EnvironmentObject var sceneDelegate: SceneDelegate
var body: some View {
// ...
.onAppear {
if let myWindow = sceneDelegate.window {
print(">> window: \(myWindow.description)")
}
}
}
Complete code in project is here
UIKit Life-Cycle
Here is the result of my experiments that looks appropriate for me, so one might find it helpful as well. Tested with Xcode 11.2 / iOS 13.2 / macOS 15.0
The idea is to use native SwiftUI Environment concept, because once injected environment value becomes available for entire view hierarchy automatically. So
Define Environment key. Note, it needs to remember to avoid reference cycling on kept window
struct HostingWindowKey: EnvironmentKey {
#if canImport(UIKit)
typealias WrappedValue = UIWindow
#elseif canImport(AppKit)
typealias WrappedValue = NSWindow
#else
#error("Unsupported platform")
#endif
typealias Value = () -> WrappedValue? // needed for weak link
static let defaultValue: Self.Value = { nil }
}
extension EnvironmentValues {
var hostingWindow: HostingWindowKey.Value {
get {
return self[HostingWindowKey.self]
}
set {
self[HostingWindowKey.self] = newValue
}
}
}
Inject hosting window in root ContentView in place of window creation (either in AppDelegate or in SceneDelegate, just once
// window created here
let contentView = ContentView()
.environment(\.hostingWindow, { [weak window] in
return window })
#if canImport(UIKit)
window.rootViewController = UIHostingController(rootView: contentView)
#elseif canImport(AppKit)
window.contentView = NSHostingView(rootView: contentView)
#else
#error("Unsupported platform")
#endif
use only where needed, just by declaring environment variable
struct ContentView: View {
#Environment(\.hostingWindow) var hostingWindow
var body: some View {
VStack {
Button("Action") {
// self.hostingWindow()?.close() // macOS
// self.hostingWindow()?.makeFirstResponder(nil) // macOS
// self.hostingWindow()?.resignFirstResponder() // iOS
// self.hostingWindow()?.rootViewController?.present(UIKitController(), animating: true)
}
}
}
}
Add the window as a property in an environment object. This can be an existing object that you use for other app-wide data.
final class AppData: ObservableObject {
let window: UIWindow? // Will be nil in SwiftUI previewers
init(window: UIWindow? = nil) {
self.window = window
}
}
Set the property when you create the environment object. Add the object to the view at the base of your view hierarchy, such as the root view.
let window = UIWindow(windowScene: windowScene) // Or however you initially get the window
let rootView = RootView().environmentObject(AppData(window: window))
Finally, use the window in your view.
struct MyView: View {
#EnvironmentObject private var appData: AppData
// Use appData.window in your view's body.
}
Access the current window by receiving NSWindow.didBecomeKeyNotification:
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
if let window = notification.object as? NSWindow {
// ...
}
}
At first I liked the answer given by #Asperi, but when trying it in my own environment I found it difficult to get working due to my need to know the root view at the time I create the window (hence I don't know the window at the time I create the root view). So I followed his example, but instead of an environment value I chose to use an environment object. This has much the same effect, but was easier for me to get working. The following is the code that I use. Note that I have created a generic class that creates an NSWindowController given a SwiftUI view. (Note that the userDefaultsManager is another object that I need in most of the windows in my application. But I think if you remove that line plus the appDelegate line you would end up with a solution that would work pretty much anywhere.)
class RootViewWindowController<RootView : View>: NSWindowController {
convenience init(_ title: String,
withView rootView: RootView,
andInitialSize initialSize: NSSize = NSSize(width: 400, height: 500))
{
let appDelegate: AppDelegate = NSApplication.shared.delegate as! AppDelegate
let windowWrapper = NSWindowWrapper()
let actualRootView = rootView
.frame(width: initialSize.width, height: initialSize.height)
.environmentObject(appDelegate.userDefaultsManager)
.environmentObject(windowWrapper)
let hostingController = NSHostingController(rootView: actualRootView)
let window = NSWindow(contentViewController: hostingController)
window.setContentSize(initialSize)
window.title = title
windowWrapper.rootWindow = window
self.init(window: window)
}
}
final class NSWindowWrapper: ObservableObject {
#Published var rootWindow: NSWindow? = nil
}
Then in my view where I need it (in order to close the window at the appropriate time), my struct begins as the following:
struct SubscribeToProFeaturesView: View {
#State var showingEnlargedImage = false
#EnvironmentObject var rootWindowWrapper: NSWindowWrapper
var body: some View {
VStack {
Text("Professional Version Upgrade")
.font(.headline)
VStack(alignment: .leading) {
And in the button where I need to close the window I have
self.rootWindowWrapper.rootWindow?.close()
It's not quite as clean as I would like it to be (I would prefer to have a solution where I did just say self.rootWindow?.close() instead of requiring the wrapper class), but it isn't bad and it allows me to create the rootView object before I create the window.
Instead of ProjectName_App use old fashioned AppDelegate approach as app entry point.
#main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
...
}
}
Then pass window as environment object. For example:
struct WindowKey: EnvironmentKey {
static let defaultValue: UIWindow? = nil
}
extension EnvironmentValues {
var window: WindowKey.Value {
get { return self[WindowKey.self] }
set { self[WindowKey.self] = newValue }
}
}
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let rootView = RootView()
.environment(\.window, window)
window?.rootViewController = UIHostingController(rootView: rootView)
window?.makeKeyAndVisible()
}
}
And use it when need it.
struct ListCell: View {
#Environment(\.window) private var window
var body: some View {
Rectangle()
.onTapGesture(perform: share)
}
private func share() {
let vc = UIActivityViewController(activityItems: [], applicationActivities: nil)
window?.rootViewController?.present(vc, animated: true)
}
}
2022, macOS only
Maybe not best solution, but works well for me and enough universal for almost any situation
Usage:
someView()
.wndAccessor {
$0?.title = String(localized: "All you need to know about FileBo in ONE minute")
}
extension code:
import SwiftUI
#available(OSX 11.0, *)
public extension View {
func wndAccessor(_ act: #escaping (NSWindow?) -> () )
-> some View {
self.modifier(WndTitleConfigurer(act: act))
}
}
#available(OSX 11.0, *)
struct WndTitleConfigurer: ViewModifier {
let act: (NSWindow?) -> ()
#State var window: NSWindow? = nil
func body(content: Content) -> some View {
content
.getWindow($window)
.onChange(of: window, perform: act )
}
}
//////////////////////////////
///HELPERS
/////////////////////////////
// Don't use this:
// Usage:
//.getWindow($window)
//.onChange(of: window) { _ in
// if let wnd = window {
// wnd.level = .floating
// }
//}
#available(OSX 11.0, *)
private extension View {
func getWindow(_ wnd: Binding<NSWindow?>) -> some View {
self.background(WindowAccessor(window: wnd))
}
}
#available(OSX 11.0, *)
private struct WindowAccessor: NSViewRepresentable {
#Binding var window: NSWindow?
public func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
}
return view
}
public func updateNSView(_ nsView: NSView, context: Context) {}
}

Open a new window in Mac Catalyst

I am porting an iPad app using Mac Catalyst. I am trying to open a View Controller in a new window.
If I were using strictly AppKit I could do something as described in this post. However, since I am using UIKit, there is no showWindow() method available.
This article states that this is possible by adding AppKit in a new bundle in the project (which I did), however it doesn't explain the specifics on how to actually present the new window. It reads...
Another thing you cannot quite do is spawn a new NSWindow with a UIKit view hierarchy. However, your UIKit code has the ability to spawn a new window scene, and your AppKit code has the ability to take the resulting NSWindow it's presented in and hijack it to do whatever you want with it, so in that sense you could spawn UIKit windows for auxiliary palettes and all kinds of other features.
Anyone know how to implement what is explained in this article?
TL;DR: How do I open a UIViewController as a new separate NSWindow with Mac Catalyst?
EDIT : ADDED INFO ON HOW TO HAVE ADDITIONAL DIFFERENT WINDOWS LIKE PANELS
In order to support multiple windows on the mac, all you need to do is to follow supporting multiple windows on the iPad.
You can find all the information you need in this WWDC session starting minute 22:28, but to sum it up what you need to do is to support the new Scene lifecycle model.
Start by editing your target and checking the support multiple window checkmark
Once you do that, click the configure option which should take you to your info.plist.
Make sure you have the proper entry for Application Scene Manifest
Create a new swift file called SceneDelegate.swift and just paste into it the following boilerplate code
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
guard let _ = (scene as? UIWindowScene) else { return }
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}
And you're basically done. Run your app, and hit command + N to create as many new windows as you want.
If you want to create a new window in code you can use this:
#IBAction func newWindow(_ sender: Any) {
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: nil, options: nil) { (error) in
//
}
}
And now we get to the big mystery of how to create additional windows
The key to this is to create multiple scene types in the app. You can do it in info.plist which I couldn't get to work properly or in the AppDelegate.
Lets change the function to create a new window to:
#IBAction func newWindow(_ sender: Any) {
var activity = NSUserActivity(activityType: "panel")
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil) { (error) in
}
}
Create a new storyboard for your new scene, create at least one viewcontroller and make sure to set in as the initalviewcontroller in the storyboard.
Lets add to the appdelegate the following function:
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
if options.userActivities.first?.activityType == "panel" {
let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
configuration.delegateClass = CustomSceneDelegate.self
configuration.storyboard = UIStoryboard(name: "CustomScene", bundle: Bundle.main)
return configuration
} else {
let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
configuration.delegateClass = SceneDelegate.self
configuration.storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
return configuration
}
}
By setting the userActivity when requesting a scene we can know which scene to create and create the configuration for it accordingly. New Window from the menu or CMD+N will still create your default new window, but the new window button will now create the UI from your new storyboard.
And tada:
With SwiftUI you can do it like this (thanks to Ron Sebro):
1. Activate multiple window support:
2. Request a new Scene:
struct ContentView: View {
var body: some View {
VStack {
// Open window type 1
Button(action: {
UIApplication.shared.requestSceneSessionActivation(nil,
userActivity: NSUserActivity(activityType: "window1"),
options: nil,
errorHandler: nil)
}) {
Text("Open new window - Type 1")
}
// Open window type 2
Button(action: {
UIApplication.shared.requestSceneSessionActivation(nil,
userActivity: NSUserActivity(activityType: "window2"),
options: nil,
errorHandler: nil)
}) {
Text("Open new window - Type 2")
}
}
}
}
3. Create your new window views:
struct Window1: View {
var body: some View {
Text("Window1")
}
}
struct Window2: View {
var body: some View {
Text("Window2")
}
}
4. Change SceneDelegate.swift:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
if connectionOptions.userActivities.first?.activityType == "window1" {
window.rootViewController = UIHostingController(rootView: Window1())
} else if connectionOptions.userActivities.first?.activityType == "window2" {
window.rootViewController = UIHostingController(rootView: Window2())
} else {
window.rootViewController = UIHostingController(rootView: ContentView())
}
self.window = window
window.makeKeyAndVisible()
}
}

SwiftUI - How to trigger an Alert from a seperate class

I have a class called Startup. In this class, I have a bunch of functions that I call from SceneDelegate when the app starts.
for example:
class Startup {
func doThis() {
print("I'm doing this")
}
}
At one point, it may download data, if its necessary. When this happens, I'd like to trigger an Alert w/ Activity Indicator to block the UI from user input until things are completed.
I have the Activity Indicator Alert all set up in ContentView, which triggers on an EnvironmentObject var Bool. So all I need to do to activate this UI blocking alert is to toggle() my env var.
The problem I am having is that I can not trigger this env var from within my class. I have tried the following:
When I put the #EnvironmentObject var dataBusy: DataBusy
and call it from within a function: dataBusy.isBusy = true, I get the error message:
Fatal error: No ObservableObject of type DataBusy found.
Which indicates that I need to shove the env object into the environment of the class when it is instantiated, however, when I try to do that, I get:
Value of tuple type '()' has no member 'environmentObject'
So, I can not add this env var into this class object.
Trying to use:
#ObservedObject var dataBusy = DataBusy()
In my class seems to not error out, but toggling this does not do anything to trigger my event.
I can't think of any other way to communicate with my View from this startup class.
Any ideas?
The following code works, you many find what you miss or misunderstanding.
class Env: NSObject, ObservableObject{
#Published var isEnabled = false
}
struct AlertTest: View {
#EnvironmentObject var envObject: Env
var body: some View {
Text("board").alert(isPresented: $envObject.isEnabled) { () -> Alert in
return Alert(title: Text("hello"))
}
}
}
//Scene Delegate
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let m = Env()
let contentView = AlertTest().environmentObject(m)
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { (Timer) in
DispatchQueue.main.async {
m.isEnabled = true
}
}
}
Got it..
E.Coms answer is very good, however, I was calling the func which would be responsible for this action from within another function, within another function from this other class, not directly from the scene delegate.
To get it, I had to pass the DataBusy object dataBusy from my scene delegate to my class func as a parameter:
//Scene Delegate:
let dataBusy = DataBusy() //my Observed Object Class
let myClass = MyClass(); //instantiate my class
myClass.myFunc(data: dataBusy) //pass the object through to the class function
This way, when the time arises within my class function, I can toggle the variable and it all works perfectly.
Thank you for your input.

swiftUI: Navigate to Home screen after Login Completed. Navigating Views by Button click

I'm doing a sign-in screen. after the sign-in button click, I'll do an API call on the response I will redirect the user to home screen.
Q - How to navigate from one View to Another by a button click with some action.
I've tried this,
Programatically navigate to new view in SwiftUI
But I'm getting an error like,
"Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type"
Please help me to resolve it.
There is an issue with some View ATM (hope it will be fixed soon).
In order to use code posted, you will need to write something like:
struct ContentView: View {
#EnvironmentObject var userAuth: UserAuth
#ViewBuilder
var body: some View {
if !userAuth.isLoggedin {
return LoginView()
} else {
return NextView()
}
}
}
At the list, at the moment of writing, this was the only thing working for me - both for body and for Group.
Reference for future: date 24 Oct 2019.
Alternatively look at SceneDelgate.swift where you can set the root view of the key window to whatever you want.
In your situation when there is a successful login you can signal the change of state to the SceneDelegate (such as by Notification). Then have the app set the root view controller to your main View (as a UIHostingController).
For example:
In your SceneDelegate class add:
var currentScene: UIScene? // need to keep reference
Then inside the func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
Save a reference to the scene in the variable declared above.
self.currentScene = scene
Next, add a listener to when you want to change the key window with the new view:
var keyWindow: UIWindow?
NotificationCenter.default.addObserver(forName: .newUser, object: nil, queue: .main) { [weak self] (notification) in
guard let windowScene = self?.currentScene as? UIWindowScene else { return }
keyWindow = UIWindow(windowScene: windowScene)
keyWindow?.rootViewController = UIHostingController(rootView: Text("hello"))
keyWindow?.makeKeyAndVisible()
}
Just set the Notification to post whenever you need and replace the Text with whatever View you want.

How should I trigger network call in SwiftUI app to refresh data on app open?

I'm writing a SwiftUI app, and I want it periodically refresh data from a server:
when the app is first opened
if the app enters the foreground and the data has not been updated in the past 5 minutes
Below is the code I have so far.
What is the best way to trigger this update code the first time the app is opened in a SwiftUI app? Is adding the observer in onAppear a good practice for triggering the update when the app enters the foreground? (This is the only view in the app)
class InfoStore {
var lastValueCheck: Date = .distantPast
}
struct ContentView : View {
var infoStore: InfoStore
private func updateValueFromServer() {
// request updated value from the server
// if the request is successful, store the new value
currentValue = 500
UserDefaults.cachedValue = 500
// hardcoded for this example
infoStore.lastValueCheck = Date()
}
private func updateValueIfOld() {
let fiveMinutesAgo: Date = Date(timeIntervalSinceNow: (-5 * 60))
if infoStore.lastValueCheck < fiveMinutesAgo {
updateValueFromServer()
}
}
#State var currentValue: Int = 100
var body: some View {
Text("\(currentValue)")
.font(.largeTitle)
.onAppear {
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main) { (notification) in
self.updateValueIfOld()
}
}
}
}
extension UserDefaults {
private struct Keys {
static let cachedValue = "cachedValue"
}
static var cachedValue: Int {
get {
return standard.value(forKey: Keys.cachedValue) as? Int ?? 0
}
set {
standard.set(newValue, forKey: Keys.cachedValue)
}
}
}
1) About the first point (app first opened): probably the best way to get what you want is to extract the logic outside the View (as MVVM suggests) using DataBinding and ObservableObjects. I changed your code as less as possible in order to show you what I mean:
import SwiftUI
class ViewModel: ObservableObject {
#Published var currentValue = -1
private var lastValueCheck = Date.distantPast
init() {
updateValueFromServer()
}
func updateValueIfOld() {
let fiveMinutesAgo: Date = Date(timeIntervalSinceNow: (-5 * 60))
if lastValueCheck < fiveMinutesAgo {
updateValueFromServer()
}
}
private func updateValueFromServer() {
// request updated value from the server
// if the request is successful, store the new value
currentValue = 500
UserDefaults.cachedValue = 500
// hardcoded for this example
lastValueCheck = Date()
}
}
struct ContentView : View {
#ObservedObject var viewModel: ViewModel
var body: some View {
Text("\(viewModel.currentValue)")
.font(.largeTitle)
.onAppear {
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main) { (notification) in
self.viewModel.updateValueIfOld()
}
}
}
}
extension UserDefaults {
private struct Keys {
static let cachedValue = "cachedValue"
}
static var cachedValue: Int {
get {
return standard.value(forKey: Keys.cachedValue) as? Int ?? 0
}
set {
standard.set(newValue, forKey: Keys.cachedValue)
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel())
}
}
#endif
This way, as soon as the ViewModel is created the currentValue is updated. Also, every time currentValue is changed by a server call the UI is automatically recreated for you. Note that you have to modify the sceneDelegate this way:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: ContentView(viewModel: ViewModel()))
self.window = window
window.makeKeyAndVisible()
}
}
2) About the second point (app enters foreground): you should be careful here because you're registering the observer multiple times (every time the onAppear is fired). Depending on your needs you should decide to:
remove the observer onDisappear (this is very frequent)
add the observer just one time checking if you have already added it.
In any case it's a good practice to implement the:
deinit {
}
method and eventually remove the observer.

Resources