Dynamic NavigationLink destination in SwiftUI? - swift

I have a View 'B' that has an initialiser that takes an argument.
struct B: View {
let arg: Int
init(arg: Int) {
self.arg = arg
}
var body: some View {
Text("\(arg)")
}
}
And I have a Navigation View 'A'.
'A' has one button, which when pressed, shows a popup where the user picks a number from 1-5. A closure of type (Int) -> Void is called with the chosen number.
struct A: View {
#State var showPicker = false
var body: some View {
NavigationView {
Button(action: { self.showPicker = true }) {
Text("Pick Number")
}
.sheet(isPresented: self.$showPicker, content: {
NumberPicker { number in
*** Possible to navigate to B from here? ***
}
})
}
}
}
Question
Is it possible to initialise view B with the result from the closure, and then navigate to it?
This used to be possible with DynamicNavigationDestinationLink, however Apple deprecated it and stated in the release notes that NavigationLink contains its capabilities now. I have searched through the docs, however, I have not been able to figure out how to use NavigationLink to produce the same outcome.

You need to add a new view with another navigationview where you can pick the number.
Here's a full code example how i would approach this:
import SwiftUI
struct viewA: View {
var body: some View {
NavigationView {
NavigationLink(destination: viewC()) {
Text("Pick Number")
}
}
}
}
struct viewB: View {
#Binding var showSheet: Bool
#Binding var arg: Int
var body: some View {
NavigationView {
List {
ForEach((1...5), id: \.self) {number in
Button(action: {
self.arg = number
self.showSheet.toggle()
}) {
Text("\(number)")
}
}
}
}
}
}
struct viewC: View {
#State var showSheet:Bool = true
#State var arg: Int = 0
var body: some View {
NavigationView {
Text("\(arg)")
}
.sheet(isPresented: $showSheet) {
viewB(showSheet: self.$showSheet, arg: self.$arg)
}
}
}

Related

How to update an element of an array in an Observable Object

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.
// model
struct Activity: Identifiable {
var id = UUID()
var name: String
var completeDescription: String
var completions: Int = 0
}
// view model
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
}
// view
struct ActivityView: View {
#StateObject var viewModel = ActivityViewModel()
#State private var showingAddEditActivityView = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.activities, id: \.id) {
activity in
NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
}
}
.navigationBarItems(trailing: Button("Add new"){
self.showingAddEditActivityView.toggle()
})
.navigationTitle(Text("Activity List"))
}
.sheet(isPresented: $showingAddEditActivityView) {
AddEditActivityView(copyViewModel: self.viewModel)
}
}
}
// detail view
struct ActivityDetailView: View {
#State var activity: Activity
#ObservedObject var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
}!
tempActivity.completions += 1
}
}
// Add new activity view (doesn't have anything to do with question)
struct AddEditActivityView: View {
#ObservedObject var copyViewModel : ActivityViewModel
#State private var activityName: String = ""
#State private var description: String = ""
var body: some View {
VStack {
TextField("Enter an activity", text: $activityName)
TextField("Enter an activity description", text: $description)
Button("Save"){
// I want this to be outside of my view
saveActivity()
}
}
}
func saveActivity() {
copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
print(copyViewModel.activities)
}
}
In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.
Thanks for any assistance or insight.
The problem is here:
struct ActivityDetailView: View {
#State var activity: Activity
...
This needs to be a #Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the #Binding, you can get rid of it.
// detail view
struct ActivityDetailView: View {
#Binding var activity: Activity /// here!
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
}
Text("\(activity.completeDescription)")
}
}
}
But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:
/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
NavigationLink(destination: ActivityDetailView(activity: $activity)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
And for iOS 14 or below, you'll need to loop over indices instead. But it works.
/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity) in
NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
You are changing and increment the value of tempActivity so it will not affect the main array or data source.
You can add one update function inside the view model and call from view.
The view model is responsible for this updation.
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
func updateCompletionCount(for id: UUID) {
if let index = activities.firstIndex(where: {$0.id == id}) {
self.activities[index].completions += 1
}
}
}
struct ActivityDetailView: View {
var activity: Activity
var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
self.viewModel.updateCompletionCount(for: activity.id)
}
}
Not needed #State or #ObservedObject for details view if don't have further action.

Picker selection within navigation link causes strange behaviour

I have a picker embedded in a form on a screen within a navigation view stack. I've re-created a simplistic version.
struct ContentView: View {
#State var showSecondView: Bool = false
var body: some View {
NavigationView {
VStack {
Button("SecondView", action: {
self.showSecondView = true
})
NavigationLink(destination: SecondContentView(), isActive: $showSecondView) {
EmptyView()
}
}
}
}
}
struct SecondContentView: View {
#State var showThirdView: Bool = false
var body: some View {
VStack {
Button("ThirdView", action: {
self.showThirdView = true
})
NavigationLink(destination: ThirdContentView(showThirdView: $showThirdView), isActive: $showThirdView) {
EmptyView()
}
}
}
}
struct ThirdContentView: View {
#Binding var showThirdView: Bool
#State var pickerSelection: String = ""
let pickerObjects = ["A", "B", "C"]
var body: some View {
VStack {
Form {
Picker(selection: $pickerSelection, label: Text("Abort Reason")
) {
ForEach(0 ..< pickerObjects.count) { i in
Text("\(self.pickerObjects[i])").tag(self.pickerObjects[i])
}
}
}
Button("Done", action: {
self.showThirdView.toggle()
})
}
}
}
In the example above when I set a value and press done it navigates back to the third screen (with the picker) but without a value selected. In my full app pressing done dismisses the third screen but then when I press back on the second screen it briefly shows the third screen for a second before dismissing it.
If I present the third view outside of a navigation link (if showThirdView == true) then no navigation errors. The setting of a value in the picker seems to add another instance of the third view to the NavigationView stack rather than going back. I like the navigation link style as the back button is consistent for the user. Is there any way to get the picker to work within a navigation link?
Here is fixed parts that works - replaced Binding, which becomes lost, with presentation mode. Tested with Xcode 12 / iOS 14.
struct SecondContentView: View {
#State var showThirdView: Bool = false
var body: some View {
VStack {
Button("ThirdView", action: {
self.showThirdView = true
})
NavigationLink(destination: ThirdContentView(), isActive: $showThirdView) {
EmptyView()
}
}
}
}
struct ThirdContentView: View {
#Environment(\.presentationMode) var mode
#State var pickerSelection: String = ""
let pickerObjects = ["A", "B", "C"]
var body: some View {
VStack {
Form {
Picker(selection: $pickerSelection, label: Text("Abort Reason")
) {
ForEach(0 ..< pickerObjects.count) { i in
Text("\(self.pickerObjects[i])").tag(self.pickerObjects[i])
}
}
}
Button("Done", action: {
self.mode.wrappedValue.dismiss()
})
}
}
}

Selection in SwiftUI NavigationView lost if List order changes

This is the test data model:
class Item: Identifiable {
let name: String
init( n: Int) {
self.name = "\(n)"
}
}
class Storage: ObservableObject {
#Published var items = [Item( n: 1), Item( n: 2)]
func reverse() {
items = self.items.reversed()
}
}
This is my content view, with a NavigationLink and a detail view with a button that reverses the item order:
struct ContentView: View {
#ObservedObject
var storage = Storage()
var body: some View {
NavigationView {
List {
ForEach( storage.items) { item in
NavigationLink( destination: Button( action: {
self.storage.reverse()
}) {
Text("Reverse")
}) {
Text( item.name).padding()
}
}
}
}
}
}
Now if I tap on Reverse the NavigationView or List seems to lose its selection, pops the view, and pushes it again:
Is this expected behaviour or a bug in SwiftUI? Is there a workaround? I would expect that the detail view simply stays as it is, without reloading.
You need to specify an explicit id for your ForEach loop.
If you use a static ForEach (without the id parameter) your view is rebuilt because the data (storage.items) is changed.
Try the following:
struct ContentView: View {
#ObservedObject
var storage = Storage()
var body: some View {
NavigationView {
List {
ForEach(storage.items, id:\.name) { item in // <- add `id` parameter
NavigationLink(destination: self.destinationView) {
Text(item.name).padding()
}
}
}
}
}
var destinationView: some View {
Button(action: {
self.storage.reverse()
}) {
Text("Reverse")
}
}
}
This method, however, only works if the original position of selected item is maintained.
In this example performing the update() from the detail screen for item 1 will not pop the NavigationLink.
class Storage: ObservableObject {
#Published var items = [Item(n: 1), Item(n: 2)]
func update() {
items = [Item(n: 1), Item(n: 3)]
}
}
Here is a workaround to make it work (use an empty NavigationLink):
struct ContentView: View {
#ObservedObject var storage = Storage()
#State var isLinkActive = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(storage.items, id:\.name) { item in
Button(action: {
self.isLinkActive = true
}) {
Text(item.name).padding()
}
}
}
NavigationLink(destination: self.destinationView, isActive: $isLinkActive) {
EmptyView()
}
}
}
}
var destinationView: some View {
Button(action: {
self.storage.reverse()
}) {
Text("Reverse")
}
}
}

How to pass a string to a child view?

I want to pass the text in the textBox to the child view and create a scrollable Button there. As for the output status, we hope that 'a ~ c' are arranged vertically and that each is a button.
struct ContentView: View {
var textBox = ["a","b","c"]
var body: some View {
VStack {
ScrollView(.vertical, showsIndicators: false) {
ForEach(0..<textBox.count) { number in
ScrollText(text: self.textBox[number].lowercased())
}
}
}
}
}
struct ScrollText: View {
#Binding var text: String
#State private var flag: Bool = false
var body: some View {
Button(action: {
self.flag.toggle()
}) {
Text(text)
}
}
}
I'm not totally clear what the problem is, or what you want, but I solved some compiler errors in your code, and it's showing three buttons as expected:
struct ContentView : View {
var textBox = ["a","b","c"]
var body: some View {
VStack {
ScrollView(.vertical, showsIndicators: false){
ForEach(textBox, id: \.self) { letter in
ScrollText(text: letter)
}
}
}
}
}
struct ScrollText: View {
var text: String
#State private var flag: Bool = false
var body: some View {
Button(action: {
self.flag.toggle()
}, label: {
Text(text)
})
}
}
Your question was how to pass a string, so you don't need #Binding for that. Just pass a string :)
If you're going to keep ScrollText untouched the here is possible modifications in ContentView which uses it
struct ContentView: View {
#State private var textBox = ["a","b","c"] // < make State, so modifiable
var body: some View {
VStack {
ScrollView(.vertical, showsIndicators: false) {
ForEach(0..<textBox.count) { number in
ScrollText(text: self.$textBox[number]) // < pass Binding as intended
}
}
}
}
}

SwiftUI: Support multiple modals

I'm trying to setup a view that can display multiple modals depending on which button is tapped.
When I add just one sheet, everything works:
.sheet(isPresented: $showingModal1) { ... }
But when I add another sheet, only the last one works.
.sheet(isPresented: $showingModal1) { ... }
.sheet(isPresented: $showingModal2) { ... }
UPDATE
I tried to get this working, but I'm not sure how to declare the type for modal. I'm getting an error of Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements.
struct ContentView: View {
#State var modal: View?
var body: some View {
VStack {
Button(action: {
self.modal = ModalContentView1()
}) {
Text("Show Modal 1")
}
Button(action: {
self.modal = ModalContentView2()
}) {
Text("Show Modal 2")
}
}.sheet(item: self.$modal, content: { modal in
return modal
})
}
}
struct ModalContentView1: View {
var body: some View {
Text("Modal 1")
}
}
struct ModalContentView2: View {
var body: some View {
Text("Modal 2")
}
}
This works:
.background(EmptyView().sheet(isPresented: $showingModal1) { ... }
.background(EmptyView().sheet(isPresented: $showingModal2) { ... }))
Notice how these are nested backgrounds. Not two backgrounds one after the other.
Thanks to DevAndArtist for finding this.
Maybe I missed the point, but you can achieve it either with a single call to .sheet(), or multiple calls.:
Multiple .sheet() approach:
import SwiftUI
struct MultipleSheets: View {
#State private var sheet1 = false
#State private var sheet2 = false
#State private var sheet3 = false
var body: some View {
VStack {
Button(action: {
self.sheet1 = true
}, label: { Text("Show Modal #1") })
.sheet(isPresented: $sheet1, content: { Sheet1() })
Button(action: {
self.sheet2 = true
}, label: { Text("Show Modal #2") })
.sheet(isPresented: $sheet2, content: { Sheet2() })
Button(action: {
self.sheet3 = true
}, label: { Text("Show Modal #3") })
.sheet(isPresented: $sheet3, content: { Sheet3() })
}
}
}
struct Sheet1: View {
var body: some View {
Text("This is Sheet #1")
}
}
struct Sheet2: View {
var body: some View {
Text("This is Sheet #2")
}
}
struct Sheet3: View {
var body: some View {
Text("This is Sheet #3")
}
}
Single .sheet() approach:
struct MultipleSheets: View {
#State private var showModal = false
#State private var modalSelection = 1
var body: some View {
VStack {
Button(action: {
self.modalSelection = 1
self.showModal = true
}, label: { Text("Show Modal #1") })
Button(action: {
self.modalSelection = 2
self.showModal = true
}, label: { Text("Show Modal #2") })
Button(action: {
self.modalSelection = 3
self.showModal = true
}, label: { Text("Show Modal #3") })
}
.sheet(isPresented: $showModal, content: {
if self.modalSelection == 1 {
Sheet1()
}
if self.modalSelection == 2 {
Sheet2()
}
if self.modalSelection == 3 {
Sheet3()
}
})
}
}
struct Sheet1: View {
var body: some View {
Text("This is Sheet #1")
}
}
struct Sheet2: View {
var body: some View {
Text("This is Sheet #2")
}
}
struct Sheet3: View {
var body: some View {
Text("This is Sheet #3")
}
}
I'm not sure whether this was always possible, but in Xcode 11.3.1 there is an overload of .sheet() for exactly this use case (https://developer.apple.com/documentation/swiftui/view/3352792-sheet). You can call it with an Identifiable item instead of a bool:
struct ModalA: View {
var body: some View {
Text("Hello, World! (A)")
}
}
struct ModalB: View {
var body: some View {
Text("Hello, World! (B)")
}
}
struct MyContentView: View {
enum Sheet: Hashable, Identifiable {
case a
case b
var id: Int {
return self.hashValue
}
}
#State var activeSheet: Sheet? = nil
var body: some View {
VStack(spacing: 42) {
Button(action: {
self.activeSheet = .a
}) {
Text("Hello, World! (A)")
}
Button(action: {
self.activeSheet = .b
}) {
Text("Hello, World! (B)")
}
}
.sheet(item: $activeSheet) { item in
if item == .a {
ModalA()
} else if item == .b {
ModalB()
}
}
}
}
I personally would mimic some NavigationLink API. Then you can create a hashable enum and decide which modal sheet you want to present.
extension View {
func sheet<Content, Tag>(
tag: Tag,
selection: Binding<Tag?>,
content: #escaping () -> Content
) -> some View where Content: View, Tag: Hashable {
let binding = Binding(
get: {
selection.wrappedValue == tag
},
set: { isPresented in
if isPresented {
selection.wrappedValue = tag
} else {
selection.wrappedValue = .none
}
}
)
return background(EmptyView().sheet(isPresented: binding, content: content))
}
}
enum ActiveSheet: Hashable {
case first
case second
}
struct First: View {
var body: some View {
Text("frist")
}
}
struct Second: View {
var body: some View {
Text("second")
}
}
struct TestView: View {
#State
private var _activeSheet: ActiveSheet?
var body: some View {
print(_activeSheet as Any)
return VStack
{
Button("first") {
self._activeSheet = .first
}
Button("second") {
self._activeSheet = .second
}
}
.sheet(tag: .first, selection: $_activeSheet) {
First()
}
.sheet(tag: .second, selection: $_activeSheet) {
Second()
}
}
}
I wrote a library off plivesey's answer that greatly simplifies the syntax:
.multiSheet {
$0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
$0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
$0.sheet(isPresented: $sheetCPresented) { Text("Sheet C") }
}
I solved this by creating an observable SheetContext that holds and manages the state. I then only need a single context instance and can tell it to present any view as a sheet. I prefer this to the "active view" binding approach, since you can use this context in multiple ways.
I describe it in more details in this blog post: https://danielsaidi.com/blog/2020/06/06/swiftui-sheets
I think i found THE solution. It's complicated so here is the teaser how to use it:
Button(action: {
showModal.wrappedValue = ShowModal {
AnyView( TheViewYouWantToPresent() )
}
})
Now you can define at the button level what you want to present. And the presenting view does not need to know anything. So you call this on the presenting view.
.background(EmptyView().show($showModal))
We call it on the background so the main view does not need to get updated, when $showModal changes.
Ok so what do we need to get this to work?
1: The ShowModal class:
public enum ModalType{
case sheet, fullscreen
}
public struct ShowModal: Identifiable {
public let id = ""
public let modalType: ModalType
public let content: () -> AnyView
public init (modalType: ModalType = .sheet, #ViewBuilder content: #escaping () -> AnyView){
self.modalType = modalType
self.content = content
}
}
Ignore id we just need it for Identifiable. With modalType we can present the view as sheet or fullscreen. And content is the passed view, that will be shown in the modal.
2: A ShowModal binding which stores the information for presenting views:
#State var showModal: ShowModal? = nil
And we need to add it to the environment of the view thats responsible for presentation. So we have easy access to it down the viewstack:
VStack{
InnerViewsThatWantToPresentModalViews()
}
.environment(\.showModal, $showModal)
.background(EmptyView().show($showModal))
In the last line we call .show(). Which is responsible for presentation.
Keep in mind that you have to create #State var showModal and add it to the environment again in a view thats shown modal and wants to present another modal.
4: To use .show we need to extend view:
public extension View {
func show(_ modal: Binding<ShowModal?>) -> some View {
modifier(VM_Show(modal))
}
}
And add a viewModifier that handles the information passed in $showModal
public struct VM_Show: ViewModifier {
var modal: Binding<ShowModal?>
public init(_ modal: Binding<ShowModal?>) {
self.modal = modal
}
public func body(content: Content) -> some View {
guard let modalType = modal.wrappedValue?.modalType else{ return AnyView(content) }
switch modalType {
case .sheet:
return AnyView(
content.sheet(item: modal){ modal in
modal.content()
}
)
case .fullscreen:
return AnyView(
content.fullScreenCover(item: modal) { modal in
modal.content()
}
)
}
}
}
4: Last we need to set showModal in views that want to present a modal:
Get the variable with: #Environment(\.showModal) var showModal. And set it like this:
Button(action: {
showModal.wrappedValue = ShowModal(modalType: .fullscreen) {
AnyView( TheViewYouWantToPresent() )
}
})
In the view that defined $showModal you set it without wrappedValue: $showModal = ShowModal{...}
As an alternative, simply putting a clear pixel somewhere in your layout might work for you:
Color.clear.frame(width: 1, height: 1, alignment: .center).sheet(isPresented: $showMySheet, content: {
MySheetView();
})
Add as many pixels as necessary.