I want to edit objects using popovers in my macOS application. But for some reason the popover does not appear anymore, when it was closed the popover while editing a TextField. (see gif bellow)
Any ideas, why this is happening?
Code:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
SubView()
SubView()
SubView()
}.padding()
}
}
struct SubView: View {
#State var showPopover = false
var body: some View {
VStack {
Text("Label")
}.onTapGesture {
self.showPopover = true
}
.popover(isPresented: $showPopover, arrowEdge: .trailing) {
Popover()
}
}
}
struct Popover: View {
#State var test: String = ""
var body: some View {
TextField("Text", text: $test)
}
}
It looks like it is not enough one event to resign editor first responder and close previous popover, so state of following popover is toggled, but new popover is not allowed, because previous is still on-screen.
The following workaround is possible (tested & works with Xcode 11.2)
}.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
self.showPopover = true // delay activating new popover
}
}
Also it is possible to consider design approach when there is only one popover bindable to models of different subviews (which seems to me more appropriate) and manageable by the one state.
Related
Context
NB: The question does NOT pertain to iOS
I have a Mac app that shows an NSPopover. The content of that popover is an NSHostingView that displays a simple SwiftUI view:
struct PopoverView: View
{
#State private var buttonWidthScale: CGFloat = 1.0
var body: some View
{
Button {
...
} label: {
RoundedRectangle(cornerRadius: 6.0)
.fill(.blue)
.scaleEffect(CGSize(width: buttonWidthScale, height: 1))
.animation(.easeInOut(duration: 2.5).repeatForever(), value: buttonWidthScale)
.onAppear {
buttonWidthScale = 0.96
}
}
}
}
The goal is to have a blue rectangle that very subtly "pulses" its width. The above works just fine to do that.
The Problem
I assumed (quite stupidly) that SwiftUI is smart enough to suspend the animation when the popover closes and the view is no longer on screen. That is not the case. Once the view appears for the first time, the app will now consume 5-6% CPU forever. The app correctly uses 0% CPU before this NSPopover appears for the first time and the animation kicks off.
What I Need
The SwiftUI .onAppear() and .onDisappear() methods are poorly named. They should really be called .onInsertion() and .onRemoval(), because they are only called when the view is added/removed from the hierarchy. (The "on appear" and "on disappear" names have historical meaning from NSViewController and Apple should never have recycled those names for a different intent.) As such, I cannot use these methods to start/stop the animation. .onAppear() is ever called only once and .onDisappear() is never called at all.
This animation should run continuously whenever the view is ON-SCREEN and then stop when the view disappears. So I need a replacement for .onAppear() and .onDisappear() that.....actually do what they imply they do!
My current approach is very hacky. From the NSViewController that holds the NSHostingView, I do this:
extension PopoverController: NSPopoverDelegate
{
func popoverWillShow(_ notification: Notification)
{
hostingView.rootView.popoverDidAppear()
}
func popoverDidClose(_ notification: Notification)
{
hostingView.rootView.popoverDidDisappear()
}
}
Where popoverDidAppear() and popoverDidDisappear() are two functions I've added to the PopoverView that replace the animation completely, as appropriate. (You can get rid of a .repeatForever() animation by replacing it with a new animation that is finite.)
But...this CANNOT be the right way, can it? There MUST be a canonical SwiftUI solution here that I just don't know about? Surely the future of Apple UI frameworks cannot need AppKit's help just to know when a view is shown and not shown?
This approach works, but I don't know if it's the "correct" way:
1. Add a Published Property in AppKit
To the NSViewController that manages the NSHostingView, I added this:
final class PopoverController: NSViewController, NSPopoverDelegate
{
#Published var popoverIsVisible: Bool = false
func popoverWillShow(_ notification: Notification)
{
popoverIsVisible = true
}
func popoverDidClose(_ notification: Notification)
{
popoverIsVisible = false
}
}
2. Use Combine in SwiftUI
In my SwiftUI view, I then did this:
struct PopoverView: View
{
#ObservedObject var popoverController: PopoverController
#State private var buttonWidthScale: CGFloat = 1.0
var body: some View
{
Button {
...
} label: {
RoundedRectangle(cornerRadius: 6.0)
.fill(.blue)
.scaleEffect(CGSize(width: buttonWidthScale, height: 1))
.onReceive(popoverController.$popoverIsVisible.dropFirst()) { isVisible in
if isVisible
{
withAnimation(.easeInOut(duration: 2.5).repeatForever()) {
buttonWidthScale = 0.96
}
}
else
{
// Replacing the repeating animation with a non-repeating one eliminates all animations.
withAnimation(.linear(duration: 0.001)) {
buttonWidthScale = 1.0
}
}
}
}
}
}
This appears to resolve the issue: CPU usage drops back to 0% when the popover is closed and the SwiftUI view leaves screen. The animation works correctly whenever the view appears.
But, again, there must be a better way to do this, right? This is a bunch of tight coupling and extra work just to accomplish something that ought to be automatic: "Don't waste CPU cycles on animations if the views aren't even on screen." Surely I'm just missing a SwiftUI idiom or modifier that does that?
I'm reworking my app for SwiftUI 2.0 but have come across a problem when replicating what I could do with AppDelegate.
I'm using NSViewRepresentable to get access to NSWindow so I can remove the titlebar of the window (I know it's not in the guidelines but this will never be submitted). When removing .titled from styleMask, the app crashes.
struct WindowAccessor: NSViewRepresentable {
#Binding var window: NSWindow?
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
self.window?.isOpaque = false
self.window?.titlebarAppearsTransparent = true
self.window?.backgroundColor = NSColor.clear
self.window?.styleMask = [.fullSizeContentView]
self.window?.isMovableByWindowBackground = true
self.window?.backingType = .buffered
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#main
struct MyApp_App: App {
#State private var window: NSWindow?
var body: some Scene {
WindowGroup {
ContentView().background(WindowAccessor(window: $window))
}
}
}
struct ContentView: View {
var body: some View {
Text("Hello, world!").padding().background(Color(NSColor.windowBackgroundColor))
}
}
When I run the app I get Thread 1: EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
All I'm trying to achieve with my app is a Menu Bar application that looks exactly like Spotlight. No dock icon, no title bar, all preferences to be handled by a popover or another window.
EDIT:
Is this something to do with the canBecomeKey property?
I'm looking to not show a window for a SwiftUI application on macOS. The app uses SwiftUI's application lifecycle and only runs in the status bar. Showing a window on start up is unnecessary. I'm unsure however how to get around the WindowGroup. There's no such a thing as an EmptyScene and putting an EmptyView inside the WindowGroup of course creates an empty window.
#main
struct MyApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
I basically only need the app delegate. I'm guessing using the default AppKit lifecycle makes more sense, but if there is a way to use SwiftUI's lifecycle, I'd love to know.
In your AppDelegate, do the following in your applicationDidFinishLaunching:
func applicationDidFinishLaunching(_ notification: Notification) {
// Close main app window
if let window = NSApplication.shared.windows.first {
window.close()
}
// Code continues here...
}
For example:
class AppDelegate: NSObject, NSApplicationDelegate {
var popover = NSPopover.init()
var statusBar: StatusBarController?
func applicationDidFinishLaunching(_ notification: Notification) {
// Close main app window
if let window = NSApplication.shared.windows.first {
window.close()
}
// Create the SwiftUI view that provides the contents
let contentView = ContentView()
// Set the SwiftUI's ContentView to the Popover's ContentViewController
popover.contentSize = NSSize(width: 160, height: 160)
popover.contentViewController = NSHostingController(rootView: contentView)
// Create the Status Bar Item with the above Popover
statusBar = StatusBarController.init(popover)
}
}
You should be able to do something like this:
var body: some Scene {
WindowGroup {
ZStack {
EmptyView()
}
.hidden()
}
}
If you app has settings (who doesn't?), you can do like this:
#main
struct TheApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
SettingsView()
}
}
}
The app uses SwiftUI's application lifecycle and only runs in the status bar.
Use a MenuBarExtra
scene to render a persistent control in the system menu bar with the native SwiftUI lifecycle. (Xcode 14.2, macOS Ventura)
#main
struct MyStatubarMenuApp: App {
var body: some Scene {
// -- no WindowGroup required --
// -- no AppDelegate needed --
MenuBarExtra("😎") {
Text("Hello Status Bar Menu!")
Divider()
Button(…)
MyCustomSubmenu()
}
}
}
Note: Add and set Application is agent = YES in Info.plist for the app icon to not show on the dock.
I am writing a small App for the Mac..
I need to disable to (Green Button) for full screen.
I am using SwiftUI App not AppKit App Delegate
Cant find how to disable the Full Screen Button for my app.
Any thoughts?
Because no one answered with a cleaner SwiftUI only version:
struct ContentView: View {
var body: some View {
HostingWindowFinder { window in
window?.standardWindowButton(.zoomButton)?.isHidden = true //this removes the green zoom button
}
Text("Hello world")
}
}
struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> ()
func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
HostingWindowFinder concept taken from https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/
I want to push a UIImagePickerViewController with a button in my parent view controller, but I want to display the image in a different view, not the parent view. I've tried
Pushing a new view and calling the image picker from a button there. However, because I have this view embedded inside a navigation view, I have a problem of 2 navigation bars. I can't hide navigation bar because then, I can't go back to the parent view.
Pushing a new view and calling the image picker directly (with no button). However, the image picker is not closing on its own and I can't go back to my parent view controller.
How about something like this:
Have an observed object to be the main object (your enviornmentObject).
Add a UIImage property to it or any property you want to share between views (after all that's the job of an environmentObject
Share the enviornmentObject
This is your class
class AppState: ObservableObject {
#Published var selectedImage: UIImage? = nil // default it to nil in case nothing is selected
}
This is your main view
struct ContentView: View {
#EnviornmentObject var appState: AppState
#State var presentModal: Bool = false
var body: some View {
VStack {
// Image can now easily be accessed by calling self.appState.selectedImage in any view that has #EnviornmentObject var appState: AppState
if(self.appState.selectedImage != nil) {
Image(uiImage: self.appState.selectedImage!)
} else {
// Image doesn't exist, add a placeholder
Text("No image selected")
}
Button("Show Modal") {
self.presentModal.toggle()
}
}.sheet(isPresented: self.$presentModal) {
ModalView(presentModal: self.$presentModal)
}
}
}
// Your ImagePicker view or any other view that will change the selected Image
struct ModalView: View {
#EnviornmentObject var appState: AppState
#Binding var presentModal: Bool = false
var body: some View {
// Your logic to pick image goes here, I will simulate a button click
Button("I will set an image") {
self.appState.selectedImage = UIImage(named: "test.jpg")
self.presentModal.toggle()
}
}
}
In your SceneDelegate (VERY IMPORTANT)
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.
let contentView = ContentView().environmentObject(AppState()) // <- The important part
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
Your modal view (or your image picker view, really anything)
EDIT: I agree it should be presented as a modal; however, it is not 100% necessary. Presenting it as a modal or not this should work though.
As the docs for UIUmagePickerController will tell you, it must be presented modally, not pushed. You are also responsible for dismissing it via the delegate.