SwiftUI WindowGroup disable window persistence - macos

Apple added new functionality to SwiftUI this year, bringing persistence and multiple windows to our SwiftUI apps. How can we disable window persistence. I'm looking for a windowing system very similar to Xcode, where there's a Welcome window on start, users can open new windows with the content they're looking for, then on the next start of the app only the Welcome window is shown.
The below code achieves all of these goals except the unwanted windows remain
import SwiftUI
#main
struct StackApp: App {
#Environment(\.openWindow) var openWindow
var body: some Scene {
Window("Welcome to App", id: "welcome-to-app") {
VStack {
Text("Welcome")
Button(action: {
openWindow(id: "app-content")
}) {
Text("Open Content")
}
}
}
.defaultSize(CGSize(width: 200, height: 200))
WindowGroup(id: "app-content") {
VStack {
Text("App Content")
}
}
.defaultSize(CGSize(width: 200, height: 200))
}
}
Help is much appreciated

Here's a quick hack proof-of-concept workaround. Can definitely be cleaned up, but it seems to work in macOS 12.6.1.
Not pretty but if you adopt the SwiftUI app lifecycle there just aren't as many ways too hook in and override the system default behavior (can't override the default NSDocumentController etc).
import SwiftUI
#main
struct TestWindowPersistenceApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
DocumentGroup(newDocument: TestWindowPersistenceDocument()) { file in
ContentView(document: file.$document)
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
print("did finish launching")
flushSavedWindowState()
// trigger open new file or Welcome flow here
}
func flushSavedWindowState() {
do {
let libURL = try FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
guard let appPersistentStateDirName = Bundle.main.bundleIdentifier?.appending(".savedState") else { print("get bundleID failed"); return }
let windowsPlistFilePath = libURL.appendingPathComponent("Saved Application State", isDirectory: true)
.appendingPathComponent(appPersistentStateDirName, isDirectory: true)
.appendingPathComponent("windows.plist", isDirectory: false)
.path
print("path to remove: ", windowsPlistFilePath)
try FileManager.default.removeItem(atPath: windowsPlistFilePath)
} catch {
print("exception: \(error)")
}
}
}

Check out my Swift package which should solve this problem.
You could use it like this:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup(id: "MyWindow") {
ContentView()
}
.register("MyWindow")
.disableRestoreOnLaunch()
}
}
It seems to work fine and achieved this by setting the isRestorable property of NSWindow to false. This should disable the default behavior.

Related

How to observe for modifier key pressed (e.g. option, shift) with NSNotification in SwiftUI macOS project?

I want to have a Bool property, that represents that option key is pressed #Publised var isOptionPressed = false. I would use it for changing SwiftUI View.
For that, I think, that I should use Combine to observe for key pressure.
I tried to find an NSNotification for that event, but it seems to me that there are no any NSNotification, that could be useful to me.
Since you are working through SwiftUI, I would recommend taking things just a step beyond watching a Publisher and put the state of the modifier flags in the SwiftUI Environment. It is my opinion that it will fit in nicely with SwiftUI's declarative syntax.
I had another implementation of this, but took the solution you found and adapted it.
import Cocoa
import SwiftUI
import Combine
struct KeyModifierFlags: EnvironmentKey {
static let defaultValue = NSEvent.ModifierFlags([])
}
extension EnvironmentValues {
var keyModifierFlags: NSEvent.ModifierFlags {
get { self[KeyModifierFlags.self] }
set { self[KeyModifierFlags.self] = newValue }
}
}
struct ModifierFlagEnvironment<Content>: View where Content:View {
#StateObject var flagState = ModifierFlags()
let content: Content;
init(#ViewBuilder content: () -> Content) {
self.content = content();
}
var body: some View {
content
.environment(\.keyModifierFlags, flagState.modifierFlags)
}
}
final class ModifierFlags: ObservableObject {
#Published var modifierFlags = NSEvent.ModifierFlags([])
init() {
NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
self?.modifierFlags = event.modifierFlags
return event;
}
}
}
Note that my event closure is returning the event passed in. If you return nil you will prevent the event from going farther and someone else in the system may want to see it.
The struct KeyModifierFlags sets up a new item to be added to the view Environment. The extension to EnvironmentValues lets us store and
retrieve the current flags from the environment.
Finally there is the ModifierFlagEnvironment view. It has no content of its own - that is passed to the initializer in an #ViewBuilder function. What it does do is provide the StateObject that contains the state monitor, and it passes it's current value for the modifier flags into the Environment of the content.
To use the ModifierFlagEnvironment you wrap a top-level view in your hierarchy with it. In a simple Cocoa app built from the default Xcode template, I changed the application SwiftUI content to be:
struct KeyWatcherApp: App {
var body: some Scene {
WindowGroup {
ModifierFlagEnvironment {
ContentView()
}
}
}
}
So all of the views in the application could watch the flags.
Then to make use of it you could do:
struct ContentView: View {
#Environment(\.keyModifierFlags) var modifierFlags: NSEvent.ModifierFlags
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
if(modifierFlags.contains(.option)) {
Text("Option is pressed")
} else {
Text("Option is up")
}
}
.padding()
}
}
Here the content view watches the environment for the flags and the view makes decisions on what to show using the current modifiers.
Ok, I found easy solution for my problem:
class KeyPressedController: ObservableObject {
#Published var isOptionPressed = false
init() {
NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event -> NSEvent? in
if event.modifierFlags.contains(.option) {
self?.isOptionPressed = true
} else {
self?.isOptionPressed = false
}
return nil
}
}
}

Create a share sheet in iOS 15 with swiftUI

I am trying to create a share sheet to share a Text, it was working fine in iOS 14 but in iOS 15 it tells me that
'windows' was deprecated in iOS 15.0: Use UIWindowScene.windows on a
relevant window scene instead.
how can I make it work on iOS 15 with SwiftUI
Button {
let TextoCompartido = "Hola 😀 "
let AV = UIActivityViewController(activityItems: [TextoCompartido], applicationActivities: nil)
UIApplication.shared.windows.first?.rootViewController?.present(AV, animated: true, completion: nil)
}
I think you would be best served using SwiftUI APIs directly. Generally, I would follow these steps.
Create SwiftUI View named ActivityView that adheres to UIViewControllerRepresentable. This will allow you to bring UIActivityViewController to SwiftUI.
Create an Identifiable struct to contain the text you'd like to display in the ActivityView. Making this type will allow you to use the SwiftUI sheet API and leverage SwiftUI state to tell the app when a new ActivityView to be shown.
Create an optional #State variable that will hold on to your Identifiable text construct. When this variable changes, the sheet API will perform the callback.
When the button is tapped, update the state of the variable set in step 3.
Use the sheet API to create an ActivityView which will be presented to your user.
The code below should help get you started.
import UIKit
import SwiftUI
// 1. Activity View
struct ActivityView: UIViewControllerRepresentable {
let text: String
func makeUIViewController(context: UIViewControllerRepresentableContext<ActivityView>) -> UIActivityViewController {
return UIActivityViewController(activityItems: [text], applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityView>) {}
}
// 2. Share Text
struct ShareText: Identifiable {
let id = UUID()
let text: String
}
struct ContentView: View {
// 3. Share Text State
#State var shareText: ShareText?
var body: some View {
VStack {
Button("Show Activity View") {
// 4. New Identifiable Share Text
shareText = ShareText(text: "Hola 😀")
}
.padding()
}
// 5. Sheet to display Share Text
.sheet(item: $shareText) { shareText in
ActivityView(text: shareText.text)
}
}
}
For the future, iOS 16 will have the ShareLink view which works like this:
Gallery(...)
.toolbar {
ShareLink(item: image, preview: SharePreview("Birthday Effects"))
}
Source: https://developer.apple.com/videos/play/wwdc2022/10052/
Time code offset: 25 minutes 28 seconds
To avoid warning, change the way you retrieve the window scene.
Do the following:
Button {
let TextoCompartido = "Hola 😀 "
let AV = UIActivityViewController(activityItems: [TextoCompartido], applicationActivities: nil)
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
windowScene?.keyWindow?.rootViewController?.present(AV, animated: true, completion: nil)
}
Tested in in iOS 15 with SwiftUI
func shareViaActionSheet() {
if vedioData.vedioURL != nil {
let activityVC = UIActivityViewController(activityItems: [vedioData.vedioURL as Any], applicationActivities: nil)
UIApplication.shared.currentUIWindow()?.rootViewController?.present(activityVC, animated: true, completion: nil)
}
}
To avoid iOS 15 method deprecation warning use this extension
public extension UIApplication {
func currentUIWindow() -> UIWindow? {
let connectedScenes = UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.compactMap { $0 as? UIWindowScene }
let window = connectedScenes.first?
.windows
.first { $0.isKeyWindow }
return window
}
}
you could try the following using the answer from: How to get rid of message " 'windows' was deprecated in iOS 15.0: Use UIWindowScene.windows on a relevant window scene instead" with AdMob banner?
Note that your code works for me, but the compiler give the deprecation warning.
public extension UIApplication {
func currentUIWindow() -> UIWindow? {
let connectedScenes = UIApplication.shared.connectedScenes
.filter({
$0.activationState == .foregroundActive})
.compactMap({$0 as? UIWindowScene})
let window = connectedScenes.first?
.windows
.first { $0.isKeyWindow }
return window
}
}
struct ContentView: View {
let TextoCompartido = "Hola 😀 "
var body: some View {
Button(action: {
let AV = UIActivityViewController(activityItems: [TextoCompartido], applicationActivities: nil)
UIApplication.shared.currentUIWindow()?.rootViewController?.present(AV, animated: true, completion: nil)
// This works for me, but the compiler give the deprecation warning
// UIApplication.shared.windows.first?.rootViewController?.present(AV, animated: true, completion: nil)
}) {
Text("Hola click me")
}
}
}

SwiftUI & macOS : How to detect last window being closed and show alert that app will quit

I have a SwiftUI app that I am creating. Upon the user closing the last window, I would like to prompt the user and inform them that the app will also quit.
I have taken a look at both the solutions for creating an alert upon app quiting here and have also looked at the solution for closing the application when the last window closes here.
Both of which I have gotten to work however, not together. What I am looking for is a way to detect when a user closes the last window in the application, then prompt the user with an alert letting them know it will quit the application and asking if they would like to continue or cancel.
Using .onDisappear does not seem to work. I have implemented a appDelegate and it's applicationShouldTerminateAfterLastWindowClosed method, but when the last window closes, it does not seem to prompt the .alert behavior in my application.
Application class
class Application: NSObject, NSApplicationDelegate, ObservableObject {
#Published var willTerminate = false
override init() {
super.init()
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if NSApplication.shared.windows.count == 0 {
return .terminateNow
}
self.willTerminate = true
return .terminateLater
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func resume() {
NSApplication.shared.reply(toApplicationShouldTerminate: false)
}
func close() {
NSApplication.shared.reply(toApplicationShouldTerminate: true)
}
}
struct WindowAccessor: NSViewRepresentable {
#Binding var window: NSWindow?
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
ContentView
struct ContentView: View {
#State private var window: NSWindow?
#EnvironmentObject private var appDelegate: Application
var body: some View {
ZStack {
MyView()
// ...
.onDisappear(
// Code in here does not run when WindowAccessor is set to background
})
.background(WindowAccessor(window: self.$window))
.alert(isPresented: Binding<Bool>(get: { self.appDelegate.willTerminate && self.window?.isKeyWindow ?? false }, set: { self.appDelegate.willTerminate = $0 }), content: {
SoloLogger(for: .window).coreLog(message: "ApplicationClosedEvent", level: .info)
return Alert(title: Text("Quit Application?"),
message: Text("Do you really want to quit the application?"),
primaryButton: .default(Text("Cancel"), action: {self.appDelegate.resume() }),
secondaryButton: .destructive(Text("Quit"), action: {self.appDelegate.close()}))
})
}
}
}
I've been working on something similar.
You can pick up the #AppDelegate from the environment and don't need to create a WindowAccessor.
I created a view which can be added into your content view's ZStack:
struct MacOSQuitCheckView: View {
// MARK: - PROPERTIES
#EnvironmentObject private var appDelegate: AppDelegate
// MARK: - VIEW BODY
var body: some View {
EmptyView()
.alert("App wants to quit?"), isPresented: isPresented) {
Button("Do not quit", role: .cancel, action: appDelegate.resume)
Button("Quit", action: appDelegate.close)
}
}
// MARK: - PRIVATE COMPUTED PROPERTIES
private var isPresented: Binding<Bool> {
Binding<Bool>(get: { self.appDelegate.willTerminate }, set: { self.appDelegate.willTerminate = $0 })
}
}

UndoManager.setActionName not displayed in menu (SwiftUI macOS)

The Edit menu's Undo/Redo displayed title does not reflect the setActionName in this demo SwiftUI macOS app. The undo/redo functionality works fine and the manager reports back that it has set the action title.
Why is the menu not updated?
autoenablesItems is true for NSApp.menu. When looping through all Windows in NSApp (just one window), the UndoManager (just one for the app) is the same instance as the one SwiftUI presents via Environment. Checking the undo item title via NSApp's reference also shows the item title is set, even though it is not displayed in the Edit menu.
struct ContentView: View {
#Binding var document: DocumentTestDocument
#Environment(\.undoManager) var undo
#StateObject var vm = VM()
#State var autoEnables = false
var body: some View {
VStack(spacing: 25) {
HStack {
Button("<") { vm.performUndo(undo: undo) }
.disabled(!(undo?.canUndo ?? true))
Button("Up") { vm.increment(undo: undo) }
Button("Down") { vm.decrement(undo: undo) }
Button(">") { vm.performRedo(undo: undo) }
.disabled(!(undo?.canRedo ?? true))
}
Text(String(vm.count))
.font(.title)
Text("MenuItemTitle \(vm.title)")
}
.controlSize(.large)
.font(.title3)
.frame(width: 400, height: 300)
.onAppear { DispatchQueue.main.async { autoEnables = NSApp.menu?.autoenablesItems ?? false } }
}
}
class VM: ObservableObject {
#Published var count = 0
#Published var title = ""
func increment(undo: UndoManager?) {
count += 1
undo?.registerUndo(withTarget: self, handler: { (targetSelf) in
targetSelf.decrement(undo: undo)
})
undo?.setActionName("Increment")
title = undo?.undoMenuItemTitle ?? "Nil"
}
func decrement(undo: UndoManager?) {
count -= 1
undo?.registerUndo(withTarget: self, handler: { (targetSelf) in
targetSelf.increment(undo: undo)
})
undo?.setActionName("Increment")
title = undo?.undoMenuItemTitle ?? "Nil"
}
func performUndo(undo: UndoManager?) {
undo?.undo()
}
func performRedo(undo: UndoManager?) {
undo?.redo()
}
}
For some reason, this is not implemented in the new SwiftUI App lifecycle. If you set up your Edit menu in a storyboard and configure the NSHostingView yourself, the Undo menu item titles will change correctly with your existing code. I sure hope this feature is on the way soon, because well-named undos are my favorite part of a polished Mac app!

Is there a reliable workaround for onDisappear() not working within .sheet() or .popover() in SwiftUI on macOS?

I'm building an app that shares quite a bit of SwiftUI code between its iOS and macOS targets. On iOS, onDisappear seems to work reliably on Views. However, on macOS, onDisappear doesn't get called if the View is inside a sheet or popover.
The following code illustrates the concept:
import SwiftUI
struct ContentView: View {
#State private var textShown = true
#State private var showSheet = false
#State private var showPopover = false
var body: some View {
VStack {
Button("Toggle text") {
self.textShown.toggle()
}
if textShown {
Text("Text").onDisappear {
print("Text disappearing")
}
}
Button("Toggle sheet") {
self.showSheet.toggle()
}.sheet(isPresented: $showSheet, onDismiss: {
print("On dismiss")
}) {
VStack {
Button("Close sheet") {
self.showSheet = false
}
}.onDisappear {
print("Sheet disappearing")
}
}
Button("Toggle popover") {
self.showPopover.toggle()
}.popover(isPresented: $showPopover) {
VStack {
Text("popover")
}.onDisappear {
print("Popover disappearing")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Note that onDisappear works fine on the Text component at the beginning of the VStack but the other two onDisappear calls don't get executed on macOS.
One workaround I've found is to attach an ObservableObject to the View and use deinit to call cleanup code. However, this isn't a great solution for two reasons:
1) With the popover example, there's a significant delay between the dismissal of the popover and the deist call (although it works quickly on sheets)
2) I haven't had any crashes on macOS with this approach, but on iOS, deinit have been unreliable in SwiftUI doing anything but trivial code -- holding references to my data store, app state, etc. have had crashes.
Here's the basic approach I used for the deinit strategy:
class DeinitObject : ObservableObject {
deinit {
print("Deinit obj")
}
}
struct ViewWithObservableObject : View {
#ObservedObject private var deinitObj = DeinitObject()
var body: some View {
Text("Deinit view")
}
}
Also, I would have thought I could use the onDismiss parameter of the sheet call, but that doesn't get called either on macOS. And, it's not an available parameter of popover.
All of this is using Xcode 11.4.1 and macOS 10.15.3.
Any solutions for good workarounds?

Resources