Customizing the macOS toolbar with Swift UI - macos

I'm working on a macOS app, with the view layers written in SwiftUI. I know that iOS toolbars can have the background color changed at least, but when I try to do this in macOS, it doesn't behave as I'd expect.
Here's a (simplified) example:
struct ContentView: View {
var body: some View {
NavigationView {
Collections()
.layoutPriority(0)
Photos()
.frame(maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.background(Color.Alt.black)
.layoutPriority(1)
}
.toolbar {
Toolbar().background(Color.red500)
}
}
}
struct Toolbar: View {
var body: some View {
Group {
Slider(value: 250, in: 150...400) {
Text("Toolbar.PreviewSize")
} minimumValueLabel: {
Image(systemName: "photo").resizable().scaledToFit().frame(width: 15)
} maximumValueLabel: {
Image(systemName: "photo").resizable().scaledToFit().frame(width: 23)
} onEditingChanged: { _ in
// do nothing
}.frame(minWidth: 200)
Spacer()
Text("Toolbar.SelectionCount")
Spacer()
AddPhotosButton()
}
}
}
Which produces something like this, which as you can see, doesn't apply the background color to the entire toolbar, just to the items in the toolbar:
I'm guessing I could make my own WindowToolbarStyle style, but there's no documentation on the protocol!
If I make my own toolbar as a View rather than with the .toolbar modifier, I can't read the safe area insets for the window traffic buttons when the sidebar is collapsed, resulting in a complete mess:
Thanks for any help!

I recommend
{
.toolbar {
Toolbar()
}.toolbarBackground(Color.gray)
}

Related

SwiftUI: Toolbar jitters when toggling sidebar (macOS)

When I toggle the sidebar in my macOS app from the toolbar, sometimes the share button on the upper right jitters (see video). This does not happen on every click, so it might take a few tries to reproduce with the example. The app I am working on has much more content, and there it happens more frequently.
I assume this has something to do with using two .toolbar modifiers: When I move the sidebar button to the content area and trigger from there, it seems like the jittering is gone.
Any idea how to fix this? I was not able to recreate this toolbar button layout without using two .toolbar modifiers. Code below video.
struct ContentView: View {
var body: some View {
NavigationView {
Text("Sidebar")
.frame(width: 300)
.toolbar {
sidebarButton
}
Text("Content")
.frame(minWidth: 500)
.toolbar {
shareButton
}
}
.frame(height: 500)
}
var sidebarButton: some View {
Button {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil
)
} label: {
Image(systemName: "sidebar.left")
}
}
var shareButton: some View {
Button {
print("Share")
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}

Make Notes like collapsing/staying sidebar

In Apple's Notes App, you can drag the sidebar divider left/right and it will make the sidebar 'appear'/'disappear' based on it's width:
How is this achievable? I'm quite new to OSX development and I might simply not have the right terminology to search for this.
Addendum: the apple docs provide info to the API, but is there a more visual docs or kitchen sink like app for learning the API? I've found the api to tell me options (i.e., compact, compactUnified, etc) but doesn't really show them.
Well, you can simply use Navigation to achieve this. Just copy the following code:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Sidebar View")
.padding()
.frame(minWidth: 100)
.toolbar {
ToolbarItem(placement: .navigation) {
Image(systemName: "list.bullet")
.resizable()
}
ToolbarItem(placement: .navigation) {
Image(systemName: "square.grid.2x2")
.resizable()
}
}
Text("Main View")
.padding()
}
}
}
And add some code to your XXXXXApp.swift file:
var body: some Scene {
WindowGroup {
ContentView()
}
.windowToolbarStyle(UnifiedCompactWindowToolbarStyle())
.windowStyle(HiddenTitleBarWindowStyle())
}

Placing the toggle sidebar button on the sidebar toolbar

I have a three column layout macOS application with the first being the sidebar. I have a button that toggles that enabling the user to hide the sidebar.
On Xcode and other macOS, the toggle sidebar button resides on the toolbar on top of the sidebar, and becomes part of the main toolbar when the sidebar is hidden.
For example, open sidebar on Xcode:
And when you hide the sidebar:
I have added the toolbar with the toggle sidebar to the view containing my sidebar, and another toolbar to the second column, but still toggle sidebar appears on the main toolbar, on top of the second column.
Am I missing anything? Here's the code:
// sidebar view, first of three columns
struct ContentView: View {
#State var selection: Set<Int> = [0]
var body: some View {
NavigationView {
List(selection: self.$selection) {
NavigationLink(destination: AllData()) {
Label("All Data", systemImage: "note.text")
}
.tag(0)
Label("Trash", systemImage: "trash")
}
.listStyle(SidebarListStyle())
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: toggleSidebar, label: {
Image(systemName: "sidebar.left") }).help("Toggle Sidebar")
}
}
}
}
func toggleSidebar() {
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
}
}
// second column view, with the rest of the toolbar
struct AllData: View {
var body: some View {
NavigationView {
List(filteredData) {
// list items here.
}
.toolbar {
ToolbarItem(placement: .automatic) {
Button("Press Me") {
print("Pressed")
}
}
ToolbarItem(placement: .automatic) {
Button("Press Me too") {
print("Pressed too")
}
}
}
}
}
}
If you use the .frame modifier on the sidebar then you are able to set constraints for minimum width, ideal width and maximum width.
I find a minWidth: 148 ensures the sidebar cannot be collapsed to the point where SwiftUI shuffles the toolbar button off to the trailing edge of the nav toolbar and behind (need to click) the double chevron expander.
So your code might look like this...
...
var body: some View {
NavigationView {
List(selection: self.$selection) {
NavigationLink(destination: AllData()) {
Label("All Data", systemImage: "note.text")
}
.tag(0)
Label("Trash", systemImage: "trash")
}
.listStyle(.sidebar) //<-- from iOS 14 and macOS 10.15
.frame(minWidth: 148, idealWidth: 160, maxWidth: 192, maxHeight: .infinity)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {
toggleSidebar
}, label: {
Image(systemName: "sidebar.left")
})
.help("Toggle Sidebar")
}
}
}
}
...
PS. I note that the .primaryAction and .status modifiers both place the sidebar button in a similar position, although I've not tested this thoroughly or completed any research.
try this:
ToolbarItem(placement: .primaryAction) {

Detail View partly visible before Navigation Link selected SwiftUI for Mac OS

I'm using a NavigationView with SwiftUI MacOS (Catalina) and for some reason the detail view and it's divider are partly visible before the NavigationLink is actually selected. Ideally I'd like the detail view to be hidden until something in the list is selected.
I've tried all sorts of combinations of minWidth on all the views but just can't get it to view correctly:
Heres my Main View:
var body: some View {
VStack {
NavigationView {
List(networkManager.FileList!.items) { file in
NavigationLink(destination: FileDetail(fileDetail: file)) {
FileRow(fileRow: file)
}
}
}.frame(minHeight:300).background(Color.white)
}
}
Here's my Row View:
var fileRow: Item
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(fileRow.name).font(Font.system(size: 12, weight: .regular, design: .default))
Text(fileRow.path).font(Font.system(size: 12, weight: .regular, design: .default))
}
}
}
Here's my Detail View:
var fileDetail: Item
var body: some View {
HStack {
VStack {
Text(fileDetail.name).font(.title)
Text(fileDetail.created).font(Font.system(size: 12, weight: .regular, design: .default))
}.background(Color.white).frame(minWidth:250, idealWidth:300, maxHeight: .infinity)
}
}
EDIT:
Looking in the Debug View Hierarchy it looks like the empty view is created for the DetailView with a view.width of 10:
On macOS NavigationView has master/details style, so even if no detail is provided it adds some implicit.
Here is possible solution.
Not all dependent entities provided so tested on some replicated code. Xcode 11.4 / macOS 10.15.5.
var body: some View {
VStack {
NavigationView {
List(items) { file in
NavigationLink(destination: FileDetail(fileDetail: file)) {
FileRow(fileRow: file)
}
}
// add explicit stub view for details pane
// on no selection with zero width
Rectangle().frame(maxWidth: 0, maxHeight: .infinity)
}.frame(minHeight:300).background(Color.white)
}
}

macOS SwiftUI Navigation for a Single View

I'm attempting to create a settings view for my macOS SwiftUI status bar app. My implementation so far has been using a NavigationView, and NavigationLink, but this solution produces a half view as the settings view pushes the parent view to the side. Screenshot and code example below.
Navigation Sidebar
struct ContentView: View {
var body: some View {
VStack{
NavigationView{
NavigationLink(destination: SecondView()){
Text("Go to next view")
}}
}.frame(width: 800, height: 600, alignment: .center)}
}
struct SecondView: View {
var body: some View {
VStack{
Text("This is the second view")
}.frame(width: 800, height: 600, alignment: .center)
}
}
The little information I can find suggests that this is unavoidable using SwiftUI on macOS, because the 'full screen' NavigationView on iOS (StackNavigationViewStyle) is not available on macOS.
Is there a simple or even complex way of implementing a transition to a settings view that takes up the whole frame in SwiftUI for macOS? And if not, is it possible to use AppKit to call a View object written in SwiftUI?
Also a Swift newbie - please be gentle.
Here is a simple demo of possible approach for custom navigation-like solution. Tested with Xcode 11.4 / macOS 10.15.4
Note: background colors are used for better visibility.
struct ContentView: View {
#State private var show = false
var body: some View {
VStack{
if !show {
RootView(show: $show)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
.transition(AnyTransition.move(edge: .leading)).animation(.default)
}
if show {
NextView(show: $show)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.green)
.transition(AnyTransition.move(edge: .trailing)).animation(.default)
}
}
}
}
struct RootView: View {
#Binding var show: Bool
var body: some View {
VStack{
Button("Next") { self.show = true }
Text("This is the first view")
}
}
}
struct NextView: View {
#Binding var show: Bool
var body: some View {
VStack{
Button("Back") { self.show = false }
Text("This is the second view")
}
}
}
I've expanded upon Asperi's great suggestion and created a generic, reusable StackNavigationView for macOS (or even iOS, if you want). Some highlights:
It supports any number of subviews (in any layout).
It automatically adds a 'Back' button for each subview (just text for now, but you can swap in an icon if using macOS 11+).
Swift v5.2:
struct StackNavigationView<RootContent, SubviewContent>: View where RootContent: View, SubviewContent: View {
#Binding var currentSubviewIndex: Int
#Binding var showingSubview: Bool
let subviewByIndex: (Int) -> SubviewContent
let rootView: () -> RootContent
var body: some View {
VStack {
VStack{
if !showingSubview { // Root view
rootView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(AnyTransition.move(edge: .leading)).animation(.default)
}
if showingSubview { // Correct subview for current index
StackNavigationSubview(isVisible: self.$showingSubview) {
self.subviewByIndex(self.currentSubviewIndex)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(AnyTransition.move(edge: .trailing)).animation(.default)
}
}
}
}
init(currentSubviewIndex: Binding<Int>, showingSubview: Binding<Bool>, #ViewBuilder subviewByIndex: #escaping (Int) -> SubviewContent, #ViewBuilder rootView: #escaping () -> RootContent) {
self._currentSubviewIndex = currentSubviewIndex
self._showingSubview = showingSubview
self.subviewByIndex = subviewByIndex
self.rootView = rootView
}
private struct StackNavigationSubview<Content>: View where Content: View {
#Binding var isVisible: Bool
let contentView: () -> Content
var body: some View {
VStack {
HStack { // Back button
Button(action: {
self.isVisible = false
}) {
Text("< Back")
}.buttonStyle(BorderlessButtonStyle())
Spacer()
}
.padding(.horizontal).padding(.vertical, 4)
contentView() // Main view content
}
}
}
}
More info on #ViewBuilder and generics used can be found here.
Here's a basic example of it in use. The parent view tracks current selection and display status (using #State), allowing anything inside its subviews to trigger state changes.
struct ExampleView: View {
#State private var currentSubviewIndex = 0
#State private var showingSubview = false
var body: some View {
StackNavigationView(
currentSubviewIndex: self.$currentSubviewIndex,
showingSubview: self.$showingSubview,
subviewByIndex: { index in
self.subView(forIndex: index)
}
) {
VStack {
Button(action: { self.showSubview(withIndex: 0) }) {
Text("Show View 1")
}
Button(action: { self.showSubview(withIndex: 1) }) {
Text("Show View 2")
}
Button(action: { self.showSubview(withIndex: 2) }) {
Text("Show View 3")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
}
}
private func subView(forIndex index: Int) -> AnyView {
switch index {
case 0: return AnyView(Text("I'm View One").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.green))
case 1: return AnyView(Text("I'm View Two").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.yellow))
case 2: return AnyView(VStack {
Text("And I'm...")
Text("View Three")
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.orange))
default: return AnyView(Text("Inavlid Selection").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.red))
}
}
private func showSubview(withIndex index: Int) {
currentSubviewIndex = index
showingSubview = true
}
}
Note: Generics like this require all subviews to be of the same type. If that's not so, you can wrap them in AnyView, like I've done here. The AnyView wrapper isn't required if you're using a consistent type for all subviews (the root view’s type doesn’t need to match).
Heyo, so a problem I had is that I wanted to have multiple navigationView-layers, I'm not sure if that's also your attempt, but if it is: MacOS DOES NOT inherit the NavigationView.
Meaning, you need to provide your DetailView (or SecondView in your case) with it's own NavigationView. So, just embedding like [...], destination: NavigationView { SecondView() }) [...] should do the trick.
But, careful! Doing the same for iOS targets will result in unexpected behaviour. So, if you target both make sure you use #if os(macOS)!
However, when making a settings view, I'd recommend you also look into the Settings Scene provided by Apple.
Seems this didn't get fixed in Xcode 13.
Tested on Xcode 13 Big Sur, not on Monterrey though...
You can get full screen navigation with
.navigationViewStyle(StackNavigationViewStyle())

Resources