Using a SwiftUI List Sidebar in a UISplitViewController - swift

I am attempting to build my app's navigation such that I have a UISplitViewController (triple column style) with my views built with SwiftUI. My Primary Sidebar is currently quite simple:
struct PrimarySidebarView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
List(PrimarySidebarSelection.allCases, id: \.self, selection: $appModel.primarySidebarSelection) { selection in
Text(selection.rawValue)
}
.listStyle(SidebarListStyle())
.navigationBarItems(trailing: EditButton())
}
}
where PrimarySidebarSelection is an enum. I am planning to access the same AppModel environment object in my other sidebar, allowing me to change what is displayed in the Supplementary Sidebar, depending on the Primary Selection. I am using the new SwiftUI App life-cycle, rather than an AppDelegate.
I would like to know how to change the style of selection from this to the typical sidebar selection style that is used in SwiftUI's NavigationView. According to SwiftUI's List Documentation the selection is only available when the list is in edit mode (and the selection shows the circle next to each item, which I do not want, instead I want the row to highlight like how it does in NavigationView when working with NavigationLinks).
Thanks in advance.

enum PrimarySidebarSelection: String, CaseIterable {
case a,b,c,d,e,f,g
}
struct SharedSelection: View {
#StateObject var appModel: AppModel = AppModel()
var body: some View {
NavigationView{
PrimarySidebarView().environmentObject(appModel)
Text(appModel.primarySidebarSelection.rawValue)
}
}
}
class AppModel: ObservableObject {
#Published var primarySidebarSelection: PrimarySidebarSelection = .a
}
struct PrimarySidebarView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
List{
ForEach(PrimarySidebarSelection.allCases, id: \.self) { selection in
Button(action: {
appModel.primarySidebarSelection = selection
}, label: {
HStack{
Spacer()
Text(selection.rawValue)
.foregroundColor(selection == appModel.primarySidebarSelection ? .red : .blue)
Spacer()
}
}
)
.listRowBackground(selection == appModel.primarySidebarSelection ? Color(UIColor.tertiarySystemBackground) : Color(UIColor.secondarySystemBackground))
}
}
.listStyle(SidebarListStyle())
.navigationBarItems(trailing: EditButton())
}
}

Related

SwiftUI detect edit mode

I've returned to iOS development after a while and I'm rebuilding my Objective-C app from scratch in SwiftUI.
One of the things I want to do is use the default Edit Mode to allow entries in a List (backed by Core Data on CloudKit) to switch between a NavigationLink to a detail view and an edit view.
The main approach seems to be to handle it through a if statement that detects edit mode. The Apple documentation provides the following snippet for this approach on this developer page: https://developer.apple.com/documentation/swiftui/editmode
#Environment(\.editMode) private var editMode
#State private var name = "Maria Ruiz"
var body: some View {
Form {
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text(name)
}
}
.animation(nil, value: editMode?.wrappedValue)
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
However, this does not work (I've embedded the snippet in a NavigationView as assumed).
Is this a bug in Xcode 13.4.1? iOS 15.5? Or am I doing something wrong?
Update1:
Based on Asperi's answer I came up with the following generic view to handle my situation:
import SwiftUI
struct EditableRow: View {
#if os(iOS)
#Environment(\.editMode) private var editMode
#endif
#State var rowView: AnyView
#State var detailView: AnyView
#State var editView: AnyView
var body: some View {
NavigationLink{
if(editMode?.wrappedValue.isEditing == true){
editView
}
else{
detailView
}
}label: {
rowView
}
}
}
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
EditButton()
EditableRow(rowView: AnyView(Text("Row")), detailView: AnyView(Text("Detail")), editView: AnyView(Text("Edit")))
}
}
}
The preview works as expected, but this works partially in my real app. When I implement this the NavigationLink works when not in Edit Mode, but doesn't do anything when in Edit Mode. I also tried putting the whole NavigationLink in the if statement but that had the same result.
Any idea why this isn't working?
Update2:
Something happens when it's inside a List. When I change the preview to this is shows the behavior I'm getting:
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
EditableRow(rowView: AnyView(GroupRow(title: "Title", subTitle: "Subtitle", type: GroupType.personal)), detailView: AnyView(EntryList()), editView: AnyView(Text("Edit")))
}
.navigationBarItems(trailing:
HStack{
#if os(iOS)
EditButton()
#endif
}
)
}
}
}
Update3:
Found this answer: SwiftUI - EditMode and PresentationMode Environment
This claims the default EditButton is broken, which seems to be true. Replacing the default button with a custom one works (be sure to add a withAnimation{} block to get all the behavior from the stock button.
But it still doesn't work for my NavigationLink...
Update4:
Ok, tried passing an "isEditing" Bool to the above View, not to depend on the Environment variable being available. This works as long as the View (a ForEach within a List in my case) isn't in "Editing Mode" whatever happens at that point breaks any NavigationLink it seems.
Update5:
Basically my conclusion is that the default Edit Mode is meant to edit the "List Object" as a whole enabling moving and deleting of rows. In this mode Apple feels that editing the rows themselves isn't something you'd want to do. I can see this perspective.
If, however, you still want to enable a NavigationLink from a row in Edit Mode, this answer should help:
How to make SwiftUI NavigationLink work in edit mode?
Asperi's answer does cover why the detection doesn't work. I did find that Edit Mode detection does work better when setting the edit mode manually and not using the default EditButton, see the answer above for details.
It is on same level so environment is not visible, because it is activated for sub-views.
A possible solution is to separate dependent part into standalone view, like
Form {
InternalView()
}
.toolbar {
EditButton()
}
Tested with Xcode 13.4 / iOS 15.5
Test module on GitHub
#Asperi's answer worked well for me. However I wanted to still be able to access the editMode in the same hierarchy. As a workaround I created the following:
Usage
struct ContentView: View {
#State
private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
Form {
if editMode.isEditing == true {
Color.red
} else {
Color.blue
}
}
.editModeFix($editMode)
.toolbar {
EditButton()
}
}
}
}
Implementation
extension View {
func editModeFix(_ editMode: Binding<EditMode>) -> some View {
modifier(EditModeFixViewModifier(editMode: editMode))
}
}
private struct EditModeFixView: View {
#Environment(\.editMode)
private var editModeEnvironment
#Binding
var editMode: EditMode
var body: some View {
Color.clear
.onChange(of: editModeEnvironment?.wrappedValue) { editModeEnvironment in
if let editModeEnvironment = editModeEnvironment {
editMode = editModeEnvironment
}
}
.onChange(of: editMode) {
editModeEnvironment?.wrappedValue = $0
}
}
}
private struct EditModeFixViewModifier: ViewModifier {
#Binding
var editMode: EditMode
func body(content: Content) -> some View {
content
.overlay {
EditModeFixView(editMode: $editMode)
}
}
}
I've got it to work by using a .simultaneousGesture on the EditButton and playing with a #State wrapper.
struct EditingFix: View {
#Environment(\.editMode) var editMode
#State var showDeleteButton = false
var body: some View {
Text("hello")
.toolbar(content: {
if showDeleteButton {
ToolbarItem(placement: .navigationBarLeading, content: {
Label("Remove selected", systemImage: "trash")
.foregroundColor(.red)
})
}
ToolbarItem(placement: .navigationBarTrailing, content: {
EditButton()
.simultaneousGesture(TapGesture().onEnded({
showDeleteButton.toggle()
}))
})
})
.onChange(of: showDeleteButton, perform: { isEditing in
editMode?.wrappedValue = isEditing ? .active : .inactive
})
.animation(.default, value: editMode?.wrappedValue) // Restore the default smooth animation for list selection and others
}
I can definitly say that EditButton is not using the same EditMode environment as what we get when invoking #Environment(\.editMode) var editMode. So we have to do it all ourselves if we want to get the benefit of the EditButton. Mainly the localized Edit text that it displays in my case.
Alternatively
The above method led to some weird behavior where the EditButton editMode seemed to conflict in some situation with the #Environment(\.editMode) var editMode. I'd advise you use your own logic for editing using the reliable .environment(\.editMode, $editMode). This way you can do whatever you want with the binding that control editing.
struct EditingFix: View {
#State var editMode: EditMode = .inactive
#State var isEditing = false
var body: some View {
VStack {
if editMode.isEditing {
Text("Hello")
}
Text("World")
Button("Toggle hello", action: {
isEditing.toggle()
})
}
.environment(\.editMode, $editMode)
.onChange(of: isEditing, perform: { isEditing in
editMode = isEditing ? .active : .inactive
})
.animation(.default, value: editMode)
}
}

Use Swift ObservableObject to change view label when UserSettings change

I’ve created a small sample project in Swift Playgrounds to debug an issue I’ve encountered. This sample project contains the a primary ContentView with a single Text field and a button that opens Settings in a modal view.
When I open Settings and change the a setting via a picker, I would like to see the corresponding Text label change in my ContentView. In the current project, I’m using the #ObservableObject Type Alias to track the change, and I see that the setting changes correctly, but the view is not updated. If I restart the preview in Playgrounds, the view is updated with the changed setting. I would expect the Text label to change in real-time.
The code is as follows:
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var userSettings = UserSettings()
#State var isModal: Bool = false
var body: some View {
VStack {
Text("Setting: " + userSettings.pickerSetting)
.fontWeight(.semibold)
.font(.title)
Button(action: {
self.isModal = true
}) {
Image(systemName: "gear")
.font(.title)
}
.padding()
.foregroundColor(.white)
.background(Color.gray)
.cornerRadius(40)
.sheet(isPresented: $isModal, content: {
UserSettingsView()
})
.environmentObject(userSettings)
}
}
}
UserSettings.swift
import Foundation
class UserSettings: ObservableObject {
#Published var pickerSetting: String {
didSet {
UserDefaults.standard.set(pickerSetting, forKey: "pickerSetting")
}
}
public var pickerSettings = ["Setting 1", "Setting 2", "Setting 3"]
init() {
self.pickerSetting = UserDefaults.standard.object(forKey: "pickerSetting") as? String ?? "Setting 1"
}
}
UserSettingsView.swift
import SwiftUI
struct UserSettingsView: View {
#ObservedObject var userSettings = UserSettings()
var body: some View {
NavigationView {
Form {
Section(header: Text("")) {
Picker(selection: $userSettings.pickerSetting, label: Text("Picker Setting")) {
ForEach(userSettings.pickerSettings, id: \.self) { setting in
Text(setting)
}
}
}
}
.navigationBarTitle("Settings")
}
}
}
This happening because you have created two instances of UserSettings. One each in ContentView and UserSettingsView.
If you want to keep using .environmentObject(userSettings) the you need to use #EnvironmentObject var userSettings: UserSettings in UserSettingsView.
Otherwise you can drop the .environmentObject and use an #ObservedObject in UserSettingsView.

SwiftUI NavigationView adding extra view onto stack when sheet is dismissed

I have a List with ForEach using a NavigationLink that when tapped displays a detail view. The DetailsView includes a sheet to save the detail information into an array. After the save, the sheet is dismissed but an additional DetailsView is put on the navigation stack, so that I need to tap the back link twice to get back to the listing.
I'm likely doing something incorrect as I'm relatively new to swiftui, but can't determine what.
Three things of interest:
In the ListView, I use .navigationViewStyle(StackNavigationViewStyle()). When removed, the issue goes away but the iPad gets messy for the ListView.
I'm using insert(at: 0) to add data in my array because I want the most recent data at the top of the listing. If I use append instead, the issue does go away. Wanting the most recently saved item at the top of the list, I add a sort, however sorting causes the duplicate issue to reappear.
The issue only seems to occur when selecting the first item created in the list (the last in the array) and then saving a new item into the array.
steps:
click Tap Here First, then tap SAVE, enter a name then click Save.
click tab bar item Saved.
click on the list item from step 1 in the Saved Items listing (nav bar should show "< Saved Items").
click SAVE, enter another name then click Save. At this point, the duplicate view appears with "< Back" as the leading nav bar item, clicking it takes you to the original detail view, then clicking "< Saved Items" takes you to the list view.
What am I doing wrong or what should I be doing better?
xcode 12.4/iOS 14.1
Stripped down code to reproduce:
struct TestModel: Identifiable, Codable {
private(set) var id: UUID
var name: String
}
class AppData: ObservableObject {
#Published var testList = [TestModel]()
}
struct NewView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: DetailView(item: TestModel(id: UUID(), name: ""))) {
Text("Tap here first")
}.navigationBarTitle("Main View", displayMode: .inline)
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ListView: View {
#EnvironmentObject var appData: AppData
var body: some View {
NavigationView {
List {
ForEach(appData.testList) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
.navigationBarTitle("Saved Items", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle()) // remove this and issue goes away, but iPad gets "messy".
}
}
struct DetailView: View {
#State private var isSaveShowing = false
#State var item: TestModel
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .center, spacing: 20) {
Text(item.name)
Button(action: {
isSaveShowing = true
}) {
Text("Save".uppercased())
}.sheet(isPresented: $isSaveShowing) {
SaveView(currentItem: item)
}
}
}
}
}
struct SaveView: View {
var currentItem: TestModel
#State private var name = ""
#EnvironmentObject var appData: AppData
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter Name ".uppercased())
) {
TextField("Name (required)", text: $name)
}
}
.navigationBarItems(
trailing: Button(action: {
// appData.testList.append(TestModel(id: UUID(), name: name)) // using append instead of insert also resolves issue...
appData.testList.insert(TestModel(id: UUID(), name: name), at: 0)
presentationMode.wrappedValue.dismiss()
}) {
Text("Save")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ContentView: View {
var appData = AppData()
var body: some View {
TabView {
NewView().tabItem {
Image(systemName: "rectangle.stack.badge.plus")
Text("Calculate")
}
ListView().tabItem {
Image(systemName: "tray.and.arrow.down")
Text("Saved")
}
}
.environmentObject(appData)
}
}
Apparently, this must have been a bug in SwiftUI.
Running the same code using Xcode 12.5 beta 3 with iOS 14.5 the issue no longer occurs.

SwiftUI: Dismiss View Within macOS NavigationView

As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Here’s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Here’s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.

How to trigger sheet on TabView click

How can I show a sheet when I click a tab in TabView? All the examples on the internet use a Button to trigger an update but I want to make the sheet appear when a user clicks one of the tabs in TabView.
I tried changing the boolean state variable in a tabbed view by adding .onAppear(), but it doesn't seem to work.
struct ContentView: View {
#State var showSheet: Bool = false
var body: some View {
return TabView {
HomeView()
.tabItem {
Image(systemName: "house")
}
}
.sheet(isPresented: self.$showSheet) {
SheetView(isShown: self.$showSheet)
}
}
}
In the above example, I basically want SheetView to show up when I click the tab. I don't want to replace HomeView with SheetView since I want it to be a sheet instead of static view. Thanks!
This can be achieved, albeit rather hackishly, by moving your State variables up one level and controlling the flow within a Group. Here, I just moved them to the app state for simplicity.
final class AppState: ObservableObject {
#Published var shouldShowActionSheet: Bool = true
#Published var selectedContentViewTab: ContentViewTabs = .none
}
In SceneDelegate.swift:
. . .
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(AppState()))
. . .
And finally in your view:
public enum ContentViewTabs: Hashable {
case none
case home
}
struct ContentView: View {
#EnvironmentObject var appState: AppState
var body: some View {
Group {
if (appState.selectedContentViewTab == .home && self.appState.shouldShowActionSheet) {
Text("").sheet(isPresented: self.$appState.shouldShowActionSheet, onDismiss: {
self.appState.shouldShowActionSheet.toggle()
self.appState.selectedContentViewTab = .none
}, content: {
Text("Oll Korrect, Chaps!")
})
} else {
TabView(selection: self.$appState.selectedContentViewTab) {
Text("First View")
.font(.title)
.tabItem {
VStack {
Image("first")
Text("First")
}
}.tag(ContentViewTabs.home)
}
}
}
}
}