How can I dismiss a nspopover from within a view? - swift

I am writing a MacOS using Swift & SwiftUI. I am really very new to this, but I am a seasoned programmer.
The app is a menu bar app with one NSPopover. In the AppDelegate I include:
self.popover = NSPopover()
self.popover.contentViewController = NSHostingController(rootView: contentView)
Within the contentView, is it possible to include a button which closes the popover?

You can find your NSPopover in a responder chain and then close it. To implement this you should make a coordinator for your close button:
struct CloseButton: NSViewRepresentable {
class Coordinator: NSObject {
#objc func click(_ button: NSButton) {
var responder: NSResponder? = button
while responder != nil {
responder = responder?.nextResponder
if let popover = responder as? NSPopover {
popover.close()
break
}
}
}
}
func makeNSView(context: NSViewRepresentableContext<Self>) -> NSButton {
NSButton(title: "Close", target: context.coordinator, action: #selector(Coordinator.click(_:)))
}
func makeCoordinator() -> CloseButton.Coordinator {
return Coordinator()
}
func updateNSView(_ nsView: NSButton, context: NSViewRepresentableContext<Self>) {
}
}

Related

Capturing all selection change events for an NSTextView, including those caused by mouse drags

I have a NSViewRepresentable that contains an NSScrollView with a NSTextView inside.
struct MultilineTextField: NSViewRepresentable {
typealias NSViewType = NSScrollView
private let scrollView = NSScrollView()
private let textView = NSTextView()
#Binding var text: String
#Binding var loc: NSRange
func makeNSView(context: Context) -> NSScrollView {
textView.string = text
textView.delegate = context.coordinator
scrollView.documentView = textView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
let textField: MultilineTextField
init(_ textField: MultilineTextField) {
self.textField = textField
}
func textDidChange(_ notification: Notification) {
textField.text = textField.textView.string
}
func textViewDidChangeSelection(_ notification: Notification) {
DispatchQueue.main.async {
self.textField.loc = self.textField.textView.selectedRange()
}
}
}
}
I'm trying to extract the selectedRange of the NSTextView to be displayed in a different view, eg:
Text(String(loc.location))
It works when the selectedRange is changed with arrow keys and on mouse down and mouse up, but it seems like textViewDidChangeSelection doesn't get called when in the middle of dragging the mouse over the text. As a result, the displayed loc.location value doesn't change while dragging until the mouse is lifted up.
As a workaround, I've tried subclassing NSTextView to override the mouseMoved method, but it seems like that doesn't get called either for some reason.
Just to verify that selectedRange actually gets updated on mouse drag, I tried continually updating loc (this is in the Coordinator class):
init(_ textField: MultilineTextField) {
self.textField = textField
super.init()
updateSelection()
}
func updateSelection() {
DispatchQueue.main.async {
self.textField.loc = self.textField.textView.selectedRange()
self.updateSelection()
}
}
This code does work, but also uses 100% CPU for no reason.
Is there a way to be notified when selectedRange changes in the case of mouse dragging?

Can a Menu Bar Application switch between a popover or a menu?

I am writing a MacOS Menu Bar Application which uses a popover. I have relied on a number of tutorials to get things going. The application doesn’t use a storyboard or Interface Builder.
The plan is to present a menu on right-click or a popover on left-click.
Very briefly, the code looks something like this:
class AppDelegate: NSObject, NSApplicationDelegate {
var popover=NSPopover()
var statusBarItem: NSStatusItem!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Popover & Content View
let contentView = ContentView()
self.popover.contentViewController = NSHostingController(rootView: contentView)
// Menu
self.statusBarItem = NSStatusBar.system.statusItem(withLength: 18)
if let statusBarButton = self.statusBarItem.button {
statusBarButton.title = "☰"
statusBarButton.action = #selector(togglePopover(_:))
statusBarButton.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
}
#objc func togglePopover(_ sender: AnyObject?) {
let statusBarButton=self.statusBarItem.button!
let event = NSApp.currentEvent!
event.type == NSEvent.EventType.rightMouseUp ? print("Right-Click") : print("Left-Click")
func show(_ sender: AnyObject) {
self.popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY)
}
func hide(_ sender: AnyObject) {
popover.performClose(sender)
}
self.popover.isShown ? hide(sender as AnyObject) : show(sender as AnyObject)
}
}
I can get the popover working, but how would I like to show a different real menu when the button is right-clicked. How do I get this going?

How to make ContextMenu in NewView:NSViewRepresentable?

I have an old OldNSView : NSView, which is embedded in SwiftUI NewView<S:SomeProtocol> : NSViewRepresentable struct. And I need to add a context menu. But when I try to create NSMenuItems for oldNsView.menu, they asks for #Selector, which needs #objc func ... to work. Because it's inside SwiftUI View compiler doesn't allow to do it. (With no #selectors defined I can see menu items labels (of course they are greyed-out and they do nothing).
On the second hand, If I try to add .contextMenu to SwiftUI newView nothing happens. No menu at all. It works with Text(...) or another generic views but not with custom NSViewRepresentable struct.
All mouseUp(with event..), mouseDown(), and mouseDragged() call super.mouse..(with event)
if I use Coordinator
public class Coordinator: NSObject {
...
//Compiler does't like if (_ sender: OldView).
//It likes (_ sender: NSView), but in this case context menu is grayed-out
#objc func changeXAxis(_ sender: NSView) {
if let view = sender as? OldView {
print ("changeXAxis")
}
}
}
Is there a way to do it?
You have coordinator concept in representable to handle interaction with NSView.
Here is simple demo. Tested with Xcode 11.4 / macOS 10.15.5
struct DemoViewWithMenu: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
let menu = NSMenu()
let item = menu.addItem(withTitle: "Do Action", action: #selector(Coordinator.action(_:)), keyEquivalent: "")
item.target = context.coordinator
view.menu = menu
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject {
#objc func action(_ sender: Any) {
print(">> do action here")
}
}
}

Hide navigation bar without losing swipe back gesture in SwiftUI

In SwiftUI, whenever the navigation bar is hidden, the swipe to go back gesture is disabled as well.
Is there any way to hide the navigation bar while preserving the swipe back gesture in SwiftUI? I've already had a custom "Back" button, but still need the gesture.
I've seen some solutions for UIKit, but still don't know how to do it in SwiftUI
Here is the code to try yourself:
import SwiftUI
struct RootView: View {
var body: some View {
NavigationView {
NavigationLink(destination: SecondView()) {
Text("Go to second view")
}
}
}
}
struct SecondView: View {
var body: some View{
Text("As you can see, swipe to go back will not work")
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
Any suggestions or solutions are greatly appreciated
This should work by just extending UINavigationController.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
It is even easier than what Nick Bellucci answered.
Here is the simplest working solution:
extension UINavigationController {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = nil
}
}
When using the UINavigationController extension you might encounter a bug that blocks your navigation after you start swiping the screen and let it go without navigating back. Adding .navigationViewStyle(StackNavigationViewStyle()) to NavigationView does fix this issue.
If you need different view styles based on device, this extension helps:
extension View {
public func currentDeviceNavigationViewStyle() -> AnyView {
if UIDevice.current.userInterfaceIdiom == .pad {
return AnyView(self.navigationViewStyle(DefaultNavigationViewStyle()))
} else {
return AnyView(self.navigationViewStyle(StackNavigationViewStyle()))
}
}
}
I looked around documentation and other sources about this issue and found nothing. There are only a few solutions, based on using UIKit and UIViewControllerRepresentable. I tried to combine solutions from this question and I saved swipe back gesture even while replacing back button with other view. The code is still dirty a little, but I think that is the start point to go further (totally hide navigation bar, for example). So, here is how ContentView looks like:
import SwiftUI
struct ContentView: View {
var body: some View {
SwipeBackNavController {
SwipeBackNavigationLink(destination: DetailViewWithCustomBackButton()) {
Text("Main view")
}
.navigationBarTitle("Standard SwiftUI nav view")
}
.edgesIgnoringSafeArea(.top)
}
}
// MARK: detail view with custom back button
struct DetailViewWithCustomBackButton: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("detail")
.navigationBarItems(leading: Button(action: {
self.dismissView()
}) {
HStack {
Image(systemName: "return")
Text("Back")
}
})
.navigationBarTitle("Detailed view")
}
private func dismissView() {
presentationMode.wrappedValue.dismiss()
}
}
Here is realization of SwipeBackNavController and SwipeBackNavigationLink which mimic NavigationView and NavigationLink. They are just wrappers for SwipeNavigationController's work. The last one is a subclass of UINavigationController, which can be customized for your needs:
import UIKit
import SwiftUI
struct SwipeBackNavController<Content: View>: UIViewControllerRepresentable {
let content: Content
public init(#ViewBuilder content: #escaping () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> SwipeNavigationController {
let hostingController = UIHostingController(rootView: content)
let swipeBackNavController = SwipeNavigationController(rootViewController: hostingController)
return swipeBackNavController
}
func updateUIViewController(_ pageViewController: SwipeNavigationController, context: Context) {
}
}
struct SwipeBackNavigationLink<Destination: View, Label:View>: View {
var destination: Destination
var label: () -> Label
public init(destination: Destination, #ViewBuilder label: #escaping () -> Label) {
self.destination = destination
self.label = label
}
var body: some View {
Button(action: {
guard let window = UIApplication.shared.windows.first else { return }
guard let swipeBackNavController = window.rootViewController?.children.first as? SwipeNavigationController else { return }
swipeBackNavController.pushSwipeBackView(DetailViewWithCustomBackButton())
}, label: label)
}
}
final class SwipeNavigationController: UINavigationController {
// MARK: - Lifecycle
override init(rootViewController: UIViewController) {
super.init(rootViewController: rootViewController)
}
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
delegate = self
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
delegate = self
}
override func viewDidLoad() {
super.viewDidLoad()
// This needs to be in here, not in init
interactivePopGestureRecognizer?.delegate = self
}
deinit {
delegate = nil
interactivePopGestureRecognizer?.delegate = nil
}
// MARK: - Overrides
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
duringPushAnimation = true
setNavigationBarHidden(true, animated: false)
super.pushViewController(viewController, animated: animated)
}
var duringPushAnimation = false
// MARK: - Custom Functions
func pushSwipeBackView<Content>(_ content: Content) where Content: View {
let hostingController = SwipeBackHostingController(rootView: content)
self.delegate = hostingController
self.pushViewController(hostingController, animated: true)
}
}
// MARK: - UINavigationControllerDelegate
extension SwipeNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.duringPushAnimation = false
}
}
// MARK: - UIGestureRecognizerDelegate
extension SwipeNavigationController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == interactivePopGestureRecognizer else {
return true // default value
}
// Disable pop gesture in two situations:
// 1) when the pop animation is in progress
// 2) when user swipes quickly a couple of times and animations don't have time to be performed
let result = viewControllers.count > 1 && duringPushAnimation == false
return result
}
}
// MARK: Hosting controller
class SwipeBackHostingController<Content: View>: UIHostingController<Content>, UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.duringPushAnimation = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.delegate = nil
}
}
This realization provides to save custom back button and swipe back gesture for now. I still don't like some moments, like how SwipeBackNavigationLink pushes view, so later I'll try to continue research.

Cannot set delegate field of NSPopover

I'm trying to pass data back from my popover to another class which launched it. I read that the pattern to do this is using delegates, so I did this:
/*MyMainClass.swift*/
class MyMainClass: UserInfoPopoverDelegate {
var popover: NSPopover = NSPopover()
func showAskForUserInfoPopup() {
if let button = statusItem.button {
if !popover.isShown {
popover.delegate = self //error here
popover.contentViewController = UserInfoPopupController(nibName: "UserInfoPopup", bundle: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
}
func submitAndClose(str: String){
print(str)
popover.performClose(nil)
}
}
Then I have a xib with its controller:
class UserInfoPopupController: NSViewController {
#IBOutlet weak var phoneField: NSTextField!
#IBOutlet weak var emailField: NSTextField!
weak var delegate: UserInfoPopoverDelegate?
override func viewDidLoad() {
super.viewDidLoad()
}
#IBAction func close(_ sender: Any) {
delegate?.submitAndClose(str: "close pressed")
}
#IBAction func submitDetails(_ sender: Any) {
delegate?.submitAndClose(str: "submit pressed")
}
}
protocol UserInfoPopoverDelegate: class {
func submitAndClose(str: String)
}
The problem happens where I left the comment in the code, and is Cannot assign value of type 'MyMainClass' to type 'NSPopoverDelegate'. If my main class is titled class MyMainClass: NSPopoverDevelegate it will complain that i dont implement all the methods of NSObjectProtocol which I dont really want to do.
This is all pretty jumbled. You created a delegate property on your UserInfoPopupController, but you are assigning a delegate to the NSPopover instead. So you need to change your code to something like this:
func showAskForUserInfoPopup() {
if let button = statusItem.button {
if !popover.isShown {
let contentViewController = UserInfoPopupController(nibName: "UserInfoPopup", bundle: nil)
contentViewController.delegate = self //This is where you should be assigning the delegate
popover.contentViewController = contentViewController
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
}