How do I hide default CommandMenu in SwiftUI on macOS? - swiftui

I'm (attempting) switching over my AppDelegate macOS app to the SwiftUI lifecycle - but can't seem to find out how to handle the CommandMenu. I just want to delete these default menu items (Fie, Edit, View, etc...). In the past, I would just delete them from the Storyboard - but I'm not using a storyboard here. Is there a way to delete these items in SwiftUI?
The items I want to delete:
I know how to add new items via:
.commands {
MyAppMenus()
}
But that just adds them inline with the existing menu items.

swiftUI -- override AppDelegate with your custom:
#main
struct PixieApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
///........
}
code of appDelegate:
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillUpdate(_ notification: Notification) {
if let menu = NSApplication.shared.mainMenu {
menu.items.removeFirst{ $0.title == "Edit" }
menu.items.removeFirst{ $0.title == "File" }
menu.items.removeFirst{ $0.title == "Window" }
menu.items.removeFirst{ $0.title == "View" }
}
}
}
result:

Until SwiftUI adds more support for adjusting menus, I think you have to worry about SwiftUI reseting the NSApp.mainMenu whenever it updates a window.body. I haven't tried every method for adjusting the mainMenu, but of the methods I tried, the flaw was that SwiftUI seems to have no check for whether it last set NSApp.mainMenu or if something else did.
So however you are managing the menu, update it after SwiftUI has.
Use KVO and watch the NSApp for changes on .mainMenu. Then make your changes with a xib, or reseting the whole thing, or editing SwiftUI's menus.
Example:
#objc
class AppDelegate: NSObject, NSApplicationDelegate {
var token: NSKeyValueObservation?
func applicationDidFinishLaunching(_ notification: Notification) {
// Adjust a menu initially
if let m = NSApp.mainMenu?.item(withTitle: "Edit") {
NSApp.mainMenu?.removeItem(m)
}
// Must refresh after every time SwiftUI re adds
token = NSApp.observe(\.mainMenu, options: .new) { (app, change) in
// Refresh your changes
guard let menu = app.mainMenu?.item(withTitle: "Edit") else { return }
app.mainMenu?.removeItem(menu)
}
}
}
struct MarblesApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some View {
//...
}
}
This seems to work in Xcode 13.4.1 with Swift 5 targeting macOS 12.3.
Hopefully Apple adds greater control soon. It seems Catalyst has other options. Or you can create a traditional AppKit app and insert the SwiftUI views into it.

You can remove command menu items through the AppDelegate file:
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
builder.remove(menu: .services)
builder.remove(menu: .format)
builder.remove(menu: .toolbar)
}
This thread on the Apple Developer forum might help as well: https://developer.apple.com/forums/thread/649096

CommandGroup(replacing: CommandGroupPlacement.appVisibility, addition: {})

Related

Document based app shows 2 back chevrons on iPad

I did a small sample application to show my problem. I used the multi-platform document app template that Xcode 14.0.1 offers, creating my own package file format for this.
I want to create a document based app running on macOS and on iPad.
When running on macOS, everything works as expected.
On the iPad, when opening the app, the file chooser opens.
On opening an existing or creating a new file, the screen looks like this:
The left chevron does nothing, while the right chevron shows the document chooser again.
What's the left, ever so slightly larger chevron on the left doing here and how can I get of it? Is this an error with the framework that should be reported to Apple?
PS don't get distracted by the name of this sample app–the real app will need some navigation and I first thought the 2nd chevron show up cause of this–in the sample I built for this post, there is no navigation though. So this 2nd chevron seems to be a "built in" issue...
For the sake of completeness, here's my code:
import SwiftUI
#main
struct so_DocumentAppWithNavigationShowsMultipleChevronsApp: App {
var body: some Scene {
DocumentGroup(newDocument: so_DocumentAppWithNavigationShowsMultipleChevronsDocument()) { file in
ContentView(document: file.$document)
}
}
}
import UniformTypeIdentifiers
extension UTType {
static var appfroschFile: UTType {
UTType(importedAs: "ch.appfros.so-DocumentAppWithNavigationShowsMultipleChevrons")
}
}
struct so_DocumentAppWithNavigationShowsMultipleChevronsDocument: FileDocument {
var document: Document
init(document: Document = Document(text: "Hello, world!")) {
self.document = document
}
static var readableContentTypes: [UTType] { [.appfroschFile] }
init(configuration: ReadConfiguration) throws {
guard let fileWrappers = configuration.file.fileWrappers
else {
throw CocoaError(.fileReadCorruptFile)
}
guard let documentFileWrapper = fileWrappers["document"],
let data = documentFileWrapper.regularFileContents,
let string = String(data: data, encoding: .utf8)
else {
throw CocoaError(.fileReadCorruptFile)
}
document = try JSONDecoder().decode(Document.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(document)
let documentFileWrapper = FileWrapper(regularFileWithContents: data)
let mainFileWrapper = FileWrapper(directoryWithFileWrappers: [
"document": documentFileWrapper
])
return mainFileWrapper
}
}
struct Document: Codable {
var text: String
}
struct ContentView: View {
#Binding var document: so_DocumentAppWithNavigationShowsMultipleChevronsDocument
var body: some View {
TextEditor(text: $document.document.text)
}
}
Can see the same problem with the default Document based app when using Xcode 14.1 (14B47) running on the iPad simulator with iOS 16.1. So definitely a bug (and worth reporting to A as such).
At a guess, the second, non-functional back button is what would have been the back button for navigating in SideBar. And the logic to not display when working on a document is what has been broken.
Fortunately simple workaround for the bug is to explicitly specify toolbar's role using the toolbarRole modifier, e.g.
#main
struct so_DocumentAppWithNavigationShowsMultipleChevronsApp: App {
var body: some Scene {
DocumentGroup(newDocument: so_DocumentAppWithNavigationShowsMultipleChevronsDocument()) { file in
ContentView(document: file.$document)
.toolbarRole(.navigationStack) // <-- Specifying this gets rid of double chevron on iOS
}
}
}

SwiftUI run function every time app is opened

I have a very troubling problem. I have searched for days on how to solve it. I have some code that I want to run every time the app is opened, not just when the app is launched for the first time. I've basically tried everything available. I've tried scenePhase, I've tried AppDelegate, I've tried onAppear, I've tried init and custom extensions to the View class, I've even tried simply running a function in the view, but nothing is working. I'll show my code here.
#main
struct CouponDeckApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
AppContentView()
}
}
}
struct AppContentView: View {
init() {
let userDefaults = UserDefaults.standard
if userDefaults.value(forKey: "hour") == nil { // 1
userDefaults.set(9, forKey: "hour") // 2
}
// 3
if userDefaults.value(forKey: "minute") == nil {
userDefaults.set(30, forKey: "minute")
}
}
#State var currentview: String = "Main"
var body: some View {
Group {
switch currentview {
case "Main":
MainView(currentview: $currentview)
case "Form":
FormView(currentview: $currentview)
case "Settings":
SettingsView(currentview: $currentview)
default:
if currentview.contains("Coupon") {
CouponView(currentview: $currentview)
}
else {
EditView(currentview: $currentview)
}
}
}
}
}
//MainView(), CouponView(), FormView(), etc.
I'm starting to suspect that the problem is with the switch statement in AppContentView that allows you to move between the different views.
Does anyone know:
A. Why this is happening,
B. How to fix it, or
C. Another alternative?
Thanks in advance!
P.S. I'm running my code on the simulator.
Here is a very simple way, using native scenePhase, I did not make it more complicated. You can use Preference method as well for better result! But onChange is good enough for this example:
struct ContentView: View {
#Environment(\.scenePhase) var scenePhase
var body: some View {
Text("Welcome to my App!")
.onAppear() { customFunction() }
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
customFunction()
}
}
}
}
func customFunction() {
print("App is opened!")
}
The simple problem was that it doesn't work when you close out of the app. I realized if you just exit the app but don't completely close out of it, it works just fine.
I also learned about the NotificationCenter's applications to this. By triggering a response when UIApplication sends out the willEnterForegroundNotification by using the onReceive method, you can trigger a response that way.
Do it in your AppDelegate's application(_:didFinishLaunchingWithOptions:).

Prevent updateUIView() from being called in UIRepresentableView?

I think I need to change a boolean in a UIRepresentableView before removing it from the ContentView but can't find a way, since I can't mutate the struct properties.
Why do I need that? Because I need to prevent updateUIView() from being called one last time before the view being released.
So probably this is an XY problem... anyway, I'm lost.
I have a model like this:
class PlayerModel: NSObject, ObservableObject {
#Published var lottie: [LottieView]
Where LottieView is this:
struct LottieView: UIViewRepresentable {
var name: String
var loopMode: LottieLoopMode = .playOnce
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView()
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
uiView.subviews.forEach({ sub in
sub.removeFromSuperview()
})
let animationView = AnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
uiView.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: uiView.widthAnchor),
animationView.heightAnchor.constraint(equalTo: uiView.heightAnchor)
])
animationView.animation = Animation.named(name, animationCache: LRUAnimationCache.sharedCache)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.play()
}
}
In my ContentView I only display a LottieView if there's a recent one in the published array:
struct ContentView: View {
#ObservedObject var model: PlayerModel
var body: some View {
NavigationView {
VStack {
// ...
if let lottie = model.lottie.last {
lottie
}
// ...
}
}
}
}
Now this works ok, but when in the model I remove the last LottieView from the published array, the updateUIView() func is called one last time by SwiftUI, resulting in the animation being recreated again just before the LottieView is removed from the view, resulting in a visible flicker.
So I thought I'd add a boolean that I could update just before removing the last LottieView from the published array, and do an if/else in the updateUIView(), but... is this even possible? I can't find a way.
Ideally I would just have the animation view created in makeUIView() and nothing done in updateUIView() but this is not possible, I need to remove the subviews first, otherwise the previous shown animation is not replaced by the new one.
While I would still love to have someone give me a proper solution to my issue, or tell me an alternative way of doing things, I found a workaround.
In the model, I was previously emptying the published array by doing
lottie = []
But if instead we do this:
lottie.removeAll()
We see no flicker anymore (however updateView() is still called!!).

How to detect Light\Dark mode change in iOS 13?

Some of the UI setups not working automatically with the Dark/Light mode change as the UIColor. For example shadow in layer. As I need to remove and drop shadow in dark and light mode, I need somewhere to put updateShadowIfNeeded() function. I know how to detect what is the mode currently:
func dropShadowIfNeeded() {
switch traitCollection.userInterfaceStyle {
case .dark: removeShadow()
case .light: dropShadowIfNotDroppedYet()
default: assertionFailure("Unknown userInterfaceStyle")
}
}
Now I put the function inside the layoutSubviews, since it gets called every time appearance change:
override func layoutSubviews() {
super.layoutSubviews()
dropShadowIfNeeded()
}
But this function is getting called A LOT. What is the proper function to trigger only if userInterfaceStyle changed?
SwiftUI
With a simple environment variable on the \.colorScheme key:
struct ContentView: View {
#Environment(\.colorScheme) var colorScheme
var body: some View {
Text(colorScheme == .dark ? "Its Dark" : "Its. not dark! (Light)")
}
}
UIKit
As it described in WWDC 2019 - Session 214 around 23:30.
As I expected, this function is getting called a lot including when colors changing. Along side with many other functions for ViewController and presentationController. But there is some especial function designed for that has a similar signature in all View representers.
Take a look at this image from that session:
Gray: Calling but not good for my issue, Green: Designed for this
So I should call it and check it inside this function:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
dropShadowIfNeeded()
}
}
This will guarantee to be called just once per change.
if you are only looking for the initial state of the style, check out this answer here
I think this should get called significantly less often, plus the guard makes sure you only react to user interface style changes:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else {
return
}
dropShadowIfNeeded()
}
With RxSwift and ObjectiveC runtime, you can achieve it without inheritance
here is the encapsulated version:
import UIKit
import RxSwift
import RxCocoa
enum SystemTheme {
static func get(on view: UIView) -> UIUserInterfaceStyle {
view.traitCollection.userInterfaceStyle
}
static func observe(on view: UIView) -> Observable<UIUserInterfaceStyle> {
view.rx.methodInvoked(#selector(UIView.traitCollectionDidChange(_:)))
.map { _ in SystemTheme.get(on: view) }
.distinctUntilChanged()
}
}

iOS8 TabbarController inside a UISplitviewController Master

I've tried to expand the default Apple MasterDetail Template by adding a UITabbarController in front of the UINavigationController of the MasterView, so there is a structure like this:
UISplitViewController (Master) > UITabbarController > UINavigationController > UITableViewController
But if I run the App, after changing application(didFinishLaunchingWithOptions) to use the correct ViewController, and try to perform the ShowDetails Segue the DetailsView ist presented Modally on the iPhone. On the other side the iPad Version is working as expected.
What am I forgot to do? Or how can I fix it?
I figured out how to put the detail on to the master's UINavigationController instead of presenting it modally over the UITabBarController.
Using the UISplitViewControllerDelegate method
- splitViewController:showDetailViewController:sender:
In case the UISplitViewController is collapsed get the masters navigation controller and push the detail view onto this navigation controller:
- (BOOL)splitViewController:(UISplitViewController *)splitViewController
showDetailViewController:(UIViewController *)vc
sender:(id)sender {
NSLog(#"UISplitViewController collapsed: %d", splitViewController.collapsed);
// TODO: add introspection
if (splitViewController.collapsed) {
UITabBarController *master = (UITabBarController *) splitViewController.viewControllers[0];
UINavigationController *masterNavigationController = (UINavigationController *)master.selectedViewController;
// push detail view on the navigation controller
[masterNavigationController pushViewController:vc animated:YES];
return YES;
}
return NO;
}
Just to update the answers above. Since you can't push navigation controllers anymore, you have to push its top view controller instead.
func splitViewController(splitViewController: UISplitViewController, showDetailViewController vc: UIViewController, sender: AnyObject?) -> Bool {
if splitViewController.collapsed {
let tabBarController = splitViewController.viewControllers.first as! UITabBarController
let selectedNavigationViewController = tabBarController.selectedViewController as! UINavigationController
// Push view controller
var viewControllerToPush = vc
if let navController = vc as? UINavigationController {
viewControllerToPush = navController.topViewController
}
selectedNavigationViewController.pushViewController(viewControllerToPush, animated: true)
return true
}
return false
}
Here's my solution. Place in MasterViewController.m and remember to give your detail view a Storyboard ID in IB. In my case 'detail'.
-(BOOL)shouldPerformSegueWithIdentifier:(NSString *)identifier sender:(id)sender {
if ([identifier isEqualToString:#"showDetail"] && self.splitViewController.collapsed) {
DetailViewController *myController = (DetailViewController *)[self.storyboard instantiateViewControllerWithIdentifier:#"detail"];
NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
NSManagedObject *object = [[self fetchedResultsController] objectAtIndexPath:indexPath];
[myController setDetailItem:object];
[self.navigationController showViewController:myController sender:self];
return NO;
}
return YES;
}
There is another way to do it without code.
After you embedded the the UINavigationController in the TabBarController embed the TabBarController in another UINavigationController. So you will have: SplitViewController -> Master -> NavCon -> TabBar -> NavCon -> TableViewController.
It's much easier doing like this, but there a bug that I haven't found out how to fix. The navigation bar presented will be that of the TabBarController, not the TableViewController. Any ideas how to fix that?
Subclass TabBarController like this:
- (void)showViewController:(UIViewController *)vc sender:(id)sender
{
if ([self.selectedViewController isKindOfClass:UINavigationController.class])
[self.selectedViewController showViewController:vc sender:sender];
else
[super showViewController:vc sender:sender];
}
- (UIViewController*)separateSecondaryViewControllerForSplitViewController:(UISplitViewController *)splitViewController
{
return [self.selectedViewController separateSecondaryViewControllerForSplitViewController:splitViewController];
}
- (void)collapseSecondaryViewController:(UIViewController *)secondaryViewController forSplitViewController:(UISplitViewController *)splitViewController
{
[self.selectedViewController.navigationController collapseSecondaryViewController:secondaryViewController forSplitViewController:splitViewController];
}
See this question for complete explanation.
Here is an alternative that is based on testing the size classes of the splitViewController :
Use a custom UISplitViewController (subclass)
Override the showDetailViewController operation
Use the traitCollection to determine the class of the UISplitViewController
If the horizontal class is Compact, get the navigationController to call showViewController
Here is the the code of the custom UISplitViewController :
import UIKit
class CustomSplitViewController: UISplitViewController {
override func showDetailViewController(vc: UIViewController!, sender: AnyObject!) {
if (self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClass.Compact) {
if let tabBarController = self.viewControllers[0] as? UITabBarController {
if let navigationController = tabBarController.selectedViewController as? UINavigationController {
navigationController.showViewController(vc, sender: sender)
return
}
}
}
super.showDetailViewController(vc, sender: sender)
}
}
Do not forget to the set the custom class in the storyboard.
Tested in the simulator of iPhone 6, iPhone 6+ and iPad Air and worked as expected.