My NSWindow's shadow is getting cut off when switching screens? - swift

I wanted to have an NSWindow with a blurred background so I created a wrapper for NSVisualEffectView to be used in my ContentView() with some help from How do you blur the background in a SwiftUI macOS application?. I also tried doing it with just the NSWindow using https://github.com/lukakerr/NSWindowStyles#:~:text=true-,6.%20Vibrant%20background,A,-vibrant.
struct VisualEffectView: NSViewRepresentable
{
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView
{
let visualEffectView = NSVisualEffectView()
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
visualEffectView.state = NSVisualEffectView.State.active
return visualEffectView
}
func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context)
{
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
}
}
It works and looks great, however, when I move the window to a different screen -and pause with the window between both screens, then move it to the next window- it chops off a part of the NSWindow's shadow.
This is what it looks like when moving screens ⤵︎
Is there a way to prevent this shadow chop from happening?
Interface: SwiftUI
LifeCycle: Appkit AppDelegate

Figured it out! Without any hacks too thankfully lol
Rules
In order to achieve this look without the nasty artifacts in the question you have to do a few things the way macOS wants them.
1. Don't set your NSWindow.backgroundColor = .clear!
This is the cause for the nasty artifacts above in the first place! Leaving your window's color as is will make sure the window functions properly when changing screens. NSVisualEffectView captures the image behind the window and uses that for the background so there's no need to make anything transparent.
2. Make sure to include .titled in the window's styleMask!
Failure to do so will render the window without rounded corners. If you attempt to add rounded corners (like I did) to the SwiftUI view you will still have an opaque background on the NSWindow itself. If you then set your window's background color to .clear (like I did again) the shadow chop issues will ensue! However, this does not mean that the title bar will get in the way, it won't, we'll get to that in a bit.
3. Add your NSVisualEffectView to your SwiftUI view!
I found this to be easier than adding the visual effect to the NSWindow.contentView as a subview.
Solution
1. So start off by setting up your NSWindow and AppDelegate! ⤵︎
All you're doing is making sure the titlebar is present but hidden.
import Cocoa
import SwiftUI
#main
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Create the window and set the content view.
// Note: You can add any styleMasks you want, just don't remove the ones below.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 200),
styleMask: [.titled, .fullSizeContentView],
backing: .buffered, defer: false)
// Hide the titlebar
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
// Hide all Titlebar Controls
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
// Set the contentView to the SwiftUI ContentView()
window.contentView = NSHostingView(rootView: contentView)
// Make sure the window is movable when grabbing it anywhere
window.isMovableByWindowBackground = true
// Saves frame position between opening / closing
window.setFrameAutosaveName("Main Window")
// Display the window
window.makeKeyAndOrderFront(nil)
window.center()
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
Your window will probably look something like this at this point (if starting with a blank project). You can see the 'Hello world!' isn't exactly centred due to the title bar. ⤵︎
2. Once your NSWindow is setup, time to do the ContentView() ⤵︎
In here you just want to create a wrapper for NSVisualEffectView and add it as a background. AND THEN make sure you remove the safe areas from the view! This makes sure to get rid of any space the title bar was eating up in the view.
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(VisualEffectView(material: .popover, blendingMode: .behindWindow))
// Very important! (You could technically just ignore the top so you do you)
.edgesIgnoringSafeArea(.all)
}
}
/// Takes the image directly behind the window and uses that to create a blurred material. It can technically be added anywhere but most often it's used as a backing material for sidebars and full windows.
struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffectView = NSVisualEffectView()
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
visualEffectView.state = NSVisualEffectView.State.active
return visualEffectView
}
func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) {
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
}
}
At this point your view should look how you want it without any negative effects! Enjoy <3 (If you're having any problems with this solution leave a comment!)
Resources
Thank you to #eonil for this clever way to keep the rounded corners on. Couldn't have figured this out without this answer ⤵︎
https://stackoverflow.com/a/27613308/13142325
Thank you to lukakerr for making this list of NSWindow styles!
https://github.com/lukakerr/NSWindowStyles

Related

Request key for TextField in macOS status bar app

I am writing a little status bar app, which includes a text input.
The app is added to the status bar as a NSMenuItem which holds a NSHostingViewController with the SwiftUI-View. The status bar menu is added through an App delegate inside SwiftUIs #main scene. I've set "Application is agent" in the info.plist to true and I have an empty scene as #main in SwiftUI.
Problem: When I try to edit text, in the status bar app, the text field is not clickable and does not receive text input. When I add a window, the text field in the status bar app works as expected as long as the window is in foreground.
From my understanding, this is caused by the text field not being inside a window marked as key window. Is there any workaround to make the text field work as expected without the need for an additional app window?
Minimal example for the app delegate:
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let statusBarButton = statusItem?.button {
statusBarButton.image = NSImage(systemSymbolName: "6.circle", accessibilityDescription: "")
statusBarButton.image?.isTemplate = true
}
let menuItem = NSMenuItem()
menuItem.view = createView()
let menu = NSMenu()
menu.addItem(menuItem)
statusItem?.menu = menu
}
private func createView() -> NSView {
let view = HostingView(rootView: ContentView())
view.frame = NSRect(x: 0, y: 0, width: 520, height: 260)
return view
}
}
SwiftUI #main
import SwiftUI
import AppKit
#main
struct MacApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
EmptyView()
}
}
}
Edit: I figured out, that there are two windows registered for my app. One for the status bar icon and one for the menu. However, the code below does not result in the menu window to become the key window. (although it is in fact the only window which returns true for .canBecomeKey)
.onAppear {
let window = NSApp.windows.first { $0.canBecomeKey }
window?.makeKey()
}
Second Edit: Apparently, the problem is not, that the window is not able to become key, but that the app is not set as the active app when the status bar menu is opened. I came up with this rather ugly hack, which causes the menu to close and re-open when another app was in focus before, but at least buttons and textfields work as expected with this:
func applicationDidFinishLaunching(_ notification: Notification) {
...
// Also let AppDelegate implement NSMenuDelegate
menu.delegate = self
...
}
func menuWillOpen(_ menu: NSMenu) {
#warning("This is a hack to activate the app when the menu is shown. This will cause the menu to close and re-open when another window was focused before.")
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
DispatchQueue.main.async {
self.statusItem?.button?.performClick(nil)
}
}
}
I am still looking for a proper solution.

Is there a way to get keyboard input inside an NSMenuItem?

I'm trying to make an NSMenu which contains an NSMenuItem, and inside that NSMenuItem I want a TextField that I can interact with. My goal is to make a menubar note taking app, so in theory I could just make a window and align it with the top edge, but I want to use an NSMenu if possible for the aesthetic.
Problem is, the text field doesn't seem to be receiving input properly. The text cursor doesn't appear when I click on it, and no characters appear when I type. Also, as soon as I press a key, the menu disappears.
Here's my applicationDidFinishLaunching, which creates the menu itself and assigns it to a status bar item:
func applicationDidFinishLaunching(_ notification: Notification) {
let contentView = ContentView()
let view = NSHostingView(rootView: contentView)
view.frame = NSRect(x: 0, y: 0, width: 350, height: 100)
let item = NSMenuItem()
item.view = view
let menu = NSMenu()
menu.addItem(item)
self.statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.statusBarItem.menu = menu
self.statusBarItem?.button?.title = "Test"
}
And, here's my content view:
struct ContentView: View {
var body: some View {
TextField("Placeholder", text: ...some swiftUI thing...)
.padding(10)
}
}
Is there any way to do what I want, or will I have to resort to making a regular window and pretending it's an NSMenu?

UIViewRepresentable and its content's intrinsic size

I've created a UIViewRepresentable for UIVisualEffectView in order to make certain components vibrant. This works, however it seems to shrink controls vertically at times or just at random alter their bounds at runtime. I can't seem to make it work reliably. I need this to work with any SwiftUI content or even other UIViewRepresentable used in place of content. Wrapping the UIVisualEffectView inside of a UIView and using auto layout seems to help, but other controls (such as a custom UILabel wrapped inside of a UIViewRepresnetable gets vertically clipped).
public struct VibrantView<Content: View>: UIViewRepresentable {
private let content: UIView!
private let vibrancyBlurEffectStyle: UIBlurEffect.Style
init(vibrancyBlurEffectStyle: UIBlurEffect.Style, #ViewBuilder content: () -> Content) {
self.content = UIHostingController(rootView: content()).view
self.vibrancyBlurEffectStyle = vibrancyBlurEffectStyle
}
public func makeUIView(context: Context) -> UIView {
let containerView = UIView()
let blurEffect = UIBlurEffect(style: vibrancyBlurEffectStyle)
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect)
let blurView = UIVisualEffectView(effect: vibrancyEffect)
blurView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(blurView)
content.backgroundColor = .clear
content.translatesAutoresizingMaskIntoConstraints = false
blurView.contentView.addSubview(content)
NSLayoutConstraint.activate([
blurView.widthAnchor.constraint(equalTo: containerView.widthAnchor),
blurView.heightAnchor.constraint(equalTo: containerView.heightAnchor),
content.widthAnchor.constraint(equalTo: blurView.widthAnchor),
content.heightAnchor.constraint(equalTo: blurView.heightAnchor),
])
content.setContentHuggingPriority(.defaultLow, for: .vertical)
content.setContentHuggingPriority(.defaultLow, for: .horizontal)
content.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
content.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
blurView.setContentHuggingPriority(.defaultLow, for: .vertical)
blurView.setContentHuggingPriority(.defaultLow, for: .horizontal)
blurView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
blurView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
return containerView
}
public func updateUIView(_ uiView: UIView, context: Context) {
}
}
Used as:
...
VibrantView(vibrancyBlurEffectStyle: .dark) {
Text("Hello")
.foregroundColor(Color.gray)
}
When run on device with other views inside of a VStack, you'll see "Hello" clipped partially from the bottom. In Preview, you'll see a much larger blue rectangle (bounds) around "Hello", whereas I'd like this to be hugging the content. The VStack does not assume the full natural height of the overall view.
Using fixedSize() doesn't work and it produces even weirder results when used with other controls.
After trying various techniques and hacks - I simply could not get the UIKit container (i.e. VibrantView) to hug its SwiftUI contents reliably, without adding a fixed sized .frame(...) modifier on top - which makes it difficult to use this with dynamically sized Text.
What did work for me was a bit of a hack and probably won't work for every generic view out there (and probably won't scale well for dozens of views), but works well for simple use cases, especially if you're hosting this inside of a dynamically sized UITableViewCell.
The idea is to use a dummy version of the same view, and set the VibrantView in an .overlay( ... ). This will force the overlay to assume the same overall size of the parent SwitfUI View. Since the view being applied the modifier is a copy of the same view that VibrantView wraps, you end up with the correct dynamic size at runtime and in Xcode previews.
So something like this:
SomeView()
.frame(minWidth: 0, maxWidth: .infinity)
.overlay(
VibrantView(vibrancyBlurEffectStyle: .dark) {
SomeView()
.frame(minWidth: 0, maxWidth: .infinity)
}
)
I can imagine turning this into a modifier so that it wraps the above in a single call, but in my case to ensure it remains performant for Images, I'm doing something like this:
Circle()
.foregroundColor(.clear)
.frame(width: 33, height: 33)
.overlay(
VibrantView(vibrancyBlurEffectStyle: .systemMaterialDark) {
Image("some image")
.resizable()
}
)
Creating a Circle is arguably lighter weight compared to the actual image. I create a transparent circle, set the actual size of the image there, and then put the VibrantView container into the overlay.
why so complicated?
try this:
struct Blur: UIViewRepresentable {
#if os(iOS)
var style: UIBlurEffect.Style = .systemMaterial
#else
var style: UIBlurEffect.Style = .light
#endif
init(_ style: UIBlurEffect.Style = .dark) {
self.style = style
}
func makeUIView(context: Context) -> UIVisualEffectView {
return UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
and use:
Text("Great text with prominent")
.font(.largeTitle)
.padding()
.background(Blur(.prominent))
in general you should not use constraints in UIViewRepresentable -> the size will be defined by parent view like Text in this example or VStack, who gives the right size to the blur. Normally a blur is not standing alone but the typical is a blurred background because you want to put text on it so you can read the text better.

How to add buttons when NSVisualEffectView is used

I have created a window using NSVisualEffectView to get blur and rounded corners. Like here
The problem is I don't see my button in the window when I have NSVisualEffectView code. If I remove the code, the button is displayed. What is going wrong?
NSVisualEffectView code in AppDelegate.swift:
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
guard let window = NSApplication.shared().windows.first else { return }
let effect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 0, height: 0))
effect.blendingMode = .behindWindow
effect.state = .active
effect.material = .dark
effect.wantsLayer = true
effect.layer?.cornerRadius = 15.0
effect.layer?.masksToBounds = true
window.isOpaque = false
window.backgroundColor = .clear
window.contentView = effect
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
}
I have added some buttons in storyboard. When I run the project I don't see any buttons.
When I remove everything from applicationDidFinishLaunching(_ aNotification: Notification) i.e., NSVisualEffectView code, I can see the buttons.
Can anyone tell me what is happening?
I think I should have corrected you in your previous question only but I didn't.
You are using Storyboard so why are you creating NSVisualViewEffect variable in your code?
Search for nsvisualeffectview in the right panel(Utilities panel) where you search for buttons etc. (object library).
Drag it and resize it according to your main view controller.
To add the blur effect and mode, go to "Attribites Inspector" in the "Utilities panel"
and set window.backgroundColor = .clear and window.isOpaque = false
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
guard let window = NSApplication.shared.windows.first else { return }
window.isOpaque = false
window.backgroundColor = .clear
}
Now you can add your buttons, text fields and run the project. You can see all your added elements.
I hope it helps!
window is above the view you are adding buttons to, so the buttons are below the blurred-out window, and are therefore impossible to see. Why not add the visualEffectView to the same view as the buttons? You'd need to insert it below the buttons to make the buttons visible.

How can I use NSVisualEffectView to blend window with background

There seem to be a bunch of questions on this for old versions of Swift/Xcode, but for some reason it hasn't been working with the latest update. I created a NSVisualEffectView, blurryView, and added the subview to my main view:
class ViewController: NSViewController {
#IBOutlet weak var blurryView: NSVisualEffectView!
override func viewDidLoad() {
super.viewDidLoad()
//background styling
blurryView.wantsLayer = true
blurryView.blendingMode = NSVisualEffectBlendingMode.behindWindow
blurryView.material = NSVisualEffectMaterial.dark
blurryView.state = NSVisualEffectState.active
self.view.addSubview(blurryView, positioned: NSWindowOrderingMode.above, relativeTo: nil)
// Do any additional setup after loading the view.
}
...
}
But when I run it, there is no effect on the window. (when I set it to within window, and layer it on top of my other view, the blur works correctly, but I only want the window to blur.) I also tried doing the same thing in my App Delegate class, but I can't connect my window as an outlet, and therefore can't add the blurry view to the window. Here's what the code would look like:
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
blurryView.wantsLayer = true
blurryView.blendingMode = NSVisualEffectBlendingMode.withinWindow
blurryView.material = NSVisualEffectMaterial.dark
blurryView.state = NSVisualEffectState.active
self.window.contentView?.addSubview(blurryView)
}
...
}
To get an idea if what I'm looking for: NSVisualEffectView Vibrancy
It works quite easy:
In Interface Builder drag a NSVisualEffectView directly as a subview of the main view of your scene.
In the Properties Inspector set Blending Mode to Behind Window
Add the rest of the views you need as subviews of the NSVisualEffectView
That's it, you're done
Here's an example:
Panel 1 View Controller is my blurred view, Background View is the first (non-blurred) view in my "real"view hierarchy.
Swift 5:
Simply add this to your viewWillAppear and it should work:
override func viewWillAppear() {
super.viewWillAppear()
//Adds transparency to the app
view.window?.isOpaque = false
view.window?.alphaValue = 0.98 //you can remove this line but it adds a nice effect to it
let blurView = NSVisualEffectView(frame: view.bounds)
blurView.blendingMode = .behindWindow
blurView.material = .fullScreenUI
blurView.state = .active
view.window?.contentView?.addSubview(blurView)
}