Complex Custom Navigation SwiftUI - swift

Here is my current demo UI written in SwiftUI:
I want to make custom navigation and display decisions based on if the cell (View 1, View 2, ...) was tapped or if one of the buttons (arrows) in the cell was tapped.
For example, each cell represents a different view I want to navigate to and each arrow button represents some custom modal I want to present.
How do I keep track of context and propagate the different taps up to the view that will ultimately make the navigation decisions. Here is the view that makes the list and would make the navigation decisions:
import SwiftUI
struct ContentView: View {
#State var options: [String] = ["View 1", "View 2", "View 3", "View 4"]
var body: some View {
ZStack {
Color.white.edgesIgnoringSafeArea(.all)
ScrollView {
ForEach(self.options, id: \.self) { element in
OptionCell(optionDescription: element)
}
}
}
}
}
And here is my OptionCell class. I try and illustrate my problem in some comments in the button action here.
struct OptionCell {
//MARK: Input Properties
#State var optionDescription: String
//MARK: Computed Properties
//MARK: Init
//MARK: Functions
func cellTapped() {
///What can I call here to propagate the context of this cell along with the fact that it was tapped to something that needs to make UI transition decisions?
print("\(self.optionDescription) Tapped")
//What can I do to make micro navigation decisions if one of the "ArrowButtons" is tapped?
}
}
extension OptionCell: View {
var body: some View {
Button(action: { self.cellTapped() }) {
VStack {
HStack{
Text(self.optionDescription).foregroundColor(Color.black).padding()
Spacer()
HStack {
//These Views have a Button view in them with a call to a function in that struct. How do I get the call for the tap on each of these buttons propagated up to the decisions making view?
ArrowButton(direction: .LEFT)
ArrowButton(direction: ArrowDirection.RIGHT)
ArrowButton(direction: ArrowDirection.DOWN)
ArrowButton(direction: ArrowDirection.UP)
}.padding()
}
///Another custom view that I have
SeparatorView(separatorViewStyle: SeparatorViewStyle.init(weight: .THIN, gapPosition: .RIGHT, color: Colors.grey.medium))
}
}
}
}
In UIKit I would add button actions on the custom UITableViewCell and then create a delegate for the custom cell. Tapping the button would call the appropriate delegate function (Delegate.leftArrow(), Delegate.upArrow(), ...) and the UIViewController would become the delegate for the custom cell and make navigation decisions there.

I presume the question is:
How do I get the call for the tap on each of these buttons propagated up to the decisions making view?
In ContentView you setup something like this:
#ObservedObject var myChoice = MyChoiceClass()
where:
class MyChoiceClass: ObservableObject {
#Published var choice: String = ""
}
pass it around to your OptionCell(...) and then to the ArrowButton(...)
So when the ArrowButton(...) change the "choice" it is reflected everywhere.

Related

Run action when view is 'removed'

I am developing an app which uses UIKit. I have integrated a UIKit UIViewController inside SwiftUI and everything works as expected. I am still wondering if there is a way to 'know' when a SwiftUI View is completely gone.
My understanding is that a #StateObject knows this information. I now have some code in the deinit block of the corresponding class of the StateObject. There is some code running which unsubscribes the user of that screen.
The problem is that it is a fragile solution. In some scenario's the deinit block isn't called.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)? I don't want to get notified with the .onDisppear modifier because that is also called when the user taps somewhere on the screen which adds another view to the navigation stack. I want to run some code once when the screen is completely gone.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)?
This implies you're using a NavigationView and presenting your view with a NavigationLink.
You can be notified when the user goes “back” from your view by using one of the NavigationLink initializers that takes a Binding. Create a custom binding and in its set function, check whether the old value is true (meaning the child view was presented) and the new value is false (meaning the child view is now being popped from the stack). Example:
struct ContentView: View {
#State var childIsPresented = false
#State var childPopCount = 0
var body: some View {
NavigationView {
VStack {
Text("Child has been popped \(childPopCount) times")
NavigationLink(
"Push Child",
isActive: Binding(
get: { childIsPresented },
set: {
if childIsPresented && !$0 {
childPopCount += 1
}
childIsPresented = $0
}
)
) {
ChildView()
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Sweet child o' mine")
NavigationLink("Push Grandchild") {
GrandchildView()
}
}
}
}
struct GrandchildView: View {
var body: some View {
VStack {
Text("👶")
.font(.system(size: 100))
}
}
}
Note that these initializers, and NavigationView, are deprecated if your deployment target is iOS 16. In that case, you'll want to use a NavigationStack and give it a custom Binding that performs the pop-detection.

How to pass data from a modal view list to parent view in SwiftUI?

I have (probably) an easy question related to SwiftUI state management.
A have a modal view with a simple list of buttons:
struct ExerciseList: View {
var body: some View {
List {
ForEach(1..<30) { _ in
Button("yoga") {
}
}
}
}
}
The parent view is this one:
struct SelectExerciseView: View {
#State private var showingSheet = false
#State private var exercise = "select exercise"
var body: some View {
Button(exercise) {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet){
ExerciseList()
}
}
}
How can I do to pass the selected button text from the list to the parent view ?
I'm thinking that I need a Binding variable inside the modal and use that, but not really sure how in this example.
At its most basic, you need the selected exercise in your parent view (SelectExerciseView) as a state variable. You then pass that in to the child view (the modal) via a binding. Assuming exercise as a string holds the variable you want to change:
.sheet(isPresented: $showingSheet) {
ExerciseList(exercise: $exercise)
}
Your modal then needs to have a #Binding reference.
struct ExerciseList: View {
#Binding var exercise: Exercise
var body: some View {
List {
ForEach(1..<30) { _ in
Button("yoga") {
exercise = "yoga"
}
}
}
}
}
Im not sure what you're asking...
Are you trying to show a "Detail View" from the modal.
Meaning theres the parent view -> Modal View -> Detail View
In your case it would be the SelectExerciseView -> ExerciseListView -> DetailView which shows the text of the button that was pressed on the previous view (can be any view you want)
If thats what you're trying to do I would use a NavigationLink instead of a button on the modal. The destination of the NavigationLink would be the detail view

SwiftUI Editable NavigationTitle

Is it possible to make the navigation title of SwiftUI editable?
Unfortunately the navigationTitle modifier only accepts Text views and not TextField views.
I want to do this instead of just using a text field below the navigation bar because I still want the nice default behaviour of having the modified title appear in the navigation bar inline when the user scrolls down and the navigation bar allocates space for the navigation title whether you define one or not.
iOS/iPadOS 16+, macOS 13+
The navigationTitle modifier now accepts a Binding<String> argument, as well as the more usual String-based initializer.
When using a bound value and the navigation bar is in its inline form, the title gains a drop-down menu with a Rename option. Tapping this allows the user to edit the view's title:
struct EditableTitleView: View {
#State private var title = "View Title"
var body: some View {
Text("Editable Title View")
.navigationTitle($title)
.navigationBarTitleDisplayMode(.inline)
}
}
This isn't exactly the same UX as always using a text field, but as it's a standard SwiftUI implementation it's a lot easier to implement.
For earlier versions of iOS
You can place a custom view in your NavigationView at the position where the title might be expected to go by specifying a ToolbarItem with a placement value of .principal, for example in the code below. I've added the RoundedBorderTextFieldStyle to make the text field more visible:
struct EditableTitleView: View {
#State private var editableTitle: String = "My Title"
var body: some View {
NavigationView {
Text("View with editable title")
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Title", text: $editableTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
}
}
}
Note that if you also add a navigationTitle modifier into your view, its default large style on iOS will still display beneath the toolbar, but if it scrolls off the page it while disappear while your principal item will remain on screen. If you set .navigationBarTitleDisplayMode(.inline) then the larger style title will never display.
I mention this because you should consider keeping a title for your view anyway, for a couple of reasons:
If a NavigationLink pushes a view on the stack, you want a meaningful name to appear in the back button and the list of stacked views that appear on long press.
I haven't checked what happens with VoiceOver when you have a navigation subview without a title. The more you override native behaviour, the more you need to consider whether you are making your app less accessible than the SwiftUI defaults provide.
Try a TextField inside of a ToolbarItem in the principal slot of the toolbar. Create a computed property for the destination and give it an editable navigation title, too.
struct TextFieldNavigationTitleView: View {
#State var mainTitle = "Main Menu"
#State var areaOneTitle = "Area One"
var body: some View {
NavigationView {
NavigationLink("App Area One", destination: areaOne)
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Navigation Title", text: $mainTitle)
}
}
}
}
var areaOne : some View {
Text("AREA ONE")
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Area One Title", text: $areaOneTitle)
}
}
}
}

Navigation between SwiftUI Views

I don't know how to navigate between views with buttons.
The only thing I've found online is detail view, but I don't want a back button in the top left corner. I want two independent views connected via two buttons one on the first and one on the second.
In addition, if I were to delete the button on the second view, I should be stuck there, with the only option to going back to the first view being crashing the app.
In storyboard I would just create a button with the action TouchUpInSide() and point to the preferred view controller.
Also do you think getting into SwiftUI is worth it when you are used to storyboard?
One of the solutions is to have a #Statevariable in the main view. This view will display one of the child views depending on the value of the #Statevariable:
struct ContentView: View {
#State var showView1 = false
var body: some View {
VStack {
if showView1 {
SomeView(showView: $showView1)
.background(Color.red)
} else {
SomeView(showView: $showView1)
.background(Color.green)
}
}
}
}
And you pass this variable to its child views where you can modify it:
struct SomeView: View {
#Binding var showView: Bool
var body: some View {
Button(action: {
self.showView.toggle()
}) {
Text("Switch View")
}
}
}
If you want to have more than two views you can make #State var showView1 to be an enum instead of a Bool.

Dismiss navigation view when Core Data object is deleted

I'm attempting to use SwiftUI and CoreData to build a macOS application. This application's main window has a NavigationView, with list items bound to a fetch request, and selecting any of these items populates the detail view. The navigation view goes kind of like this:
NavigationView {
VStack(spacing: 0) {
List(fetchRequest) { DetailRow(model: $0) }
.listStyle(SidebarListStyle())
HStack {
Button(action: add) { Text("+") }
Button(action: remove) { Text("-") }
}
}
Text("Select a model object")
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
DetailRow is a NavigationLink that also defines the detail view:
NavigationLink(destination: ModelDetail(model: model)) {
Text(model.name)
}
I believe that the contents of ModelDetail isn't very important; either way, I'm fairly flexible with it.
In the navigation view, the "-" button, which calls the remove method, should delete the currently-selected model object and return to the default, empty detail view. Unfortunately, I'm struggling to come up with the right way to do this. I believe that I need the following interactions to happen:
subview communicates to navigation view which model object is currently selected
user clicks "-" button, navigation view's remove method deletes currently selected object
subview notices that its model object is being deleted
→ subview calls PresentationMode.dismiss()
Step 3 is the one I'm struggling with. Everything is working out alright so far without using view-model classes on top of the Core Data classes, but I feel stuck trying to figure out how to get the subview to call dismiss(). This needs to happen from the detail view, because it gets the PresentationMode from the environment, and the NavigationView changes it.
While I can get a Binding to the model's isDeleted property through #ObservedObject, I don't know how I can actually react to that change; Binding appears to use publishers under the hood, but they don't expose a publisher that I could hook up to with onPublish, for instance.
KVO over isDeleted might be possible, but listening from a value type isn't great; there's no good place to remove the observer, which could become problematic were the app to run for too long.
What's the guidance for this type of problem?
Heres my solution.
This is my NoteDetailView. It allows deletion from this view, or the "master" view in the Navigation hierarchy. This solution works on Mac, iPad, and iPhone.
I added an optional dateDeleted to my Entity. When a record is deleted, I simply add a value of Date() to this attribute and save the context. In my FetchRequests, I simply predicate for dateDeleted = nil. I'm going to add a trash can and stuff to my app later so people can view or permanently empty their trash.
Then I use a state variable and a notification to clear my View. You can change the code up for the functionality you want:
struct NoteDetailView: View {
var note: Note
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Environment(\.managedObjectContext) var managedObjectContext
#State var noteBody: String = ""
#State var showEditNoteView: Bool = false
#State var showEmptyView: Bool = false
init(note: Note) {
self.note = note
self._noteBody = State(initialValue: note.body)
}
var body: some View {
VStack {
if (!showEmptyView) {
Text("NOT DELETED")
}
else {
EmptyView()
}
}
.navigationBarTitle(!showEmptyView ? note.title : "")
.navigationBarItems(trailing:
HStack {
if (!showEmptyView) {
Button(action: {
self.showEditNoteView.toggle()
}, label: {
NavBarImage(image: "pencil")
})
.sheet(isPresented: $showEditNoteView, content: {
EditNoteView(note: self.note).environment(\.managedObjectContext, self.managedObjectContext)
})
}
}
)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)) { _ in
if (self.note.dateDeleted != nil) {
self.showEmptyView = true
self.presentationMode.wrappedValue.dismiss()
}
}
}
}