SwiftUI passing TabView binding variable around in global EnvironmentObject - ios

I've got a SwiftUI issue that only surfaces in a complex scenario.
My application has a TabView near the top of the view structure. There are 5 tabs in total. Tab 1 and 5 are pretty simple ScrollView type views called Home and Account. Tabs 2,3,4 are NavigationStack { List {...} } type views called Pending Active Inactive respectively.
The idea of the app is that a request will be sent to the user and show up in the Pending list first (say requestId = 123). If the user views that NavigationLink item and accepts the request, then I'll update the server (which will move request 123 from the Pending list and place it in the Active list) then I'll query the server for the new lists and update my global data (which I'd like to store as an EnvironmentObject), then I'd like to re-navigate the user up out of the Pending-NavigationLink-123 and over to the Active tab and select the same NavigationLink for request 123 that they were just in.
All of this works fine except for if the user were to (for some reason) navigate back to the Pending tab. If the user does go back to the Pending tab then there'd be an issue because request 123 was just moved to the Active tab. So I was the user to always be placed at the ROOT of the NavigationStack when the user changes tabs.
So what I did was, bind my NavigationStack to a navigationPath: [Int] variable for these three tabs that I would reset when I left the tab in the onDisappear method, so that if a user left a tab and came back to it then they'd wind up back at the root of the NavigationStack. This means I now have 4 state variables to control 1) what tab I'm in and 2) what request is being displayed in those tabs.
To add complexity... I also have an AppDelegate that will be receiving Apple Push Notifications. These notifications will have a RequestId associated with them so that if the user taps the push notification banner I can redirect the user to the correct tab and select for them the correct request to display.
All of this complexity means I'd like to place all 4 of these State variables into a GlobalData class and just pass it around the application as an EnvironmentObject. But when I do that I get undesired behavior
here is my minimum working example to show the problem:
enum Tabs: String {
case Home
case Pending
case Active
case Inactive
case Account
}
class GlobData: ObservableObject {
struct Lists {
var pendingList = [1, 2, 3, 4]
var activeList = [10, 11, 12]
var inactiveList = [20, 21, 22]
func forTab(_ tab: Tabs) -> [Int] {
return tab == .Pending ? pendingList : (tab == .Active ? activeList : inactiveList)
}
}
#Published var lists: Lists = Lists()
#Published var destinationId: Int? = nil
#Published var selectedTab: Tabs = .Pending // **** comment this to make the code work
}
#main
struct PilotAppApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// #State var selectedTab: Tabs = .Pending // **** uncomment this to make the code work
#State var navigationPathOne: [Int] = []
#State var navigationPathTwo: [Int] = []
#State var navigationPathThree: [Int] = []
var body: some Scene {
WindowGroup {
ContentView1(
// selectedTab: $selectedTab, // **** uncomment this to make the code work
navigationPathOne: $navigationPathOne,
navigationPathTwo: $navigationPathTwo,
navigationPathThree: $navigationPathThree)
.environmentObject(GlobData())
}
}
}
struct ContentView1: View {
#EnvironmentObject var globData: GlobData
// #Binding var selectedTab: Tabs // **** uncomment this to make the code work
#Binding var navigationPathOne: [Int]
#Binding var navigationPathTwo: [Int]
#Binding var navigationPathThree: [Int]
var body: some View {
// TabView(selection: $selectedTab) { // **** uncomment this to make the code work
TabView(selection: $globData.selectedTab) { // **** comment this to make the code work
NavStackView(
thisTab: Tabs.Pending,
// selectedTab: $selectedTab, // **** uncomment this to make the code work
navigationPath: $navigationPathOne
)
.tabItem {
Label("Pending", systemImage: "house")
}
.tag(Tabs.Pending)
NavStackView(
thisTab: Tabs.Active,
// selectedTab: $selectedTab, // **** uncomment this to make the code work
navigationPath: $navigationPathTwo
)
.tabItem {
Label("Active", systemImage: "house")
}
.tag(Tabs.Active)
NavStackView(
thisTab: Tabs.Inactive,
// selectedTab: $selectedTab, // **** uncomment this to make the code work
navigationPath: $navigationPathThree
)
.tabItem {
Label("Inactive", systemImage: "house")
}
.tag(Tabs.Inactive)
}
}
}
struct NavStackView: View {
#EnvironmentObject var globData: GlobData
let thisTab: Tabs
// #Binding var selectedTab: Tabs // **** uncomment this to make the code work
#Binding var navigationPath: [Int]
var body: some View {
NavigationStack(path: $navigationPath) {
List {
ForEach(globData.lists.forTab(thisTab), id: \.self) { id in
NavigationLink(value: id) {
Text("\(id)")
}
}
}
.navigationTitle(thisTab.rawValue)
.navigationDestination(for: Int.self) { id in
NavLinkContent(
id: id,
thisTab: thisTab,
// selectedTab: $selectedTab, // **** uncomment this to make the code work
navigationPath: $navigationPath
)
}
}
.onAppear() {
print("\nNavStackView OnAppear: \(thisTab)")
}
.task {
print("NavStackView Task: \(thisTab)")
DispatchQueue.main.async {
if let destinationId = globData.destinationId {
if globData.lists.forTab(thisTab).contains(destinationId) {
navigationPath = [destinationId]
}
globData.destinationId = nil
}
}
}
.onDisappear() {
print("NavStackView OnDisappear: \(thisTab)")
// ******************************************************************** //
// ** This is where I reset the navigation path when I leave the tab ** //
// ******************************************************************** //
navigationPath = []
}
}
}
struct NavLinkContent: View {
#EnvironmentObject var globData: GlobData
let id: Int
let thisTab: Tabs
// #Binding var selectedTab: Tabs // **** uncomment this to make the code work
#Binding var navigationPath: [Int]
func goToId(_ id: Int) {
if id == self.id {
return
} else if globData.lists.forTab(thisTab).contains(id) {
navigationPath = [id]
} else if globData.lists.pendingList.contains(id) {
globData.destinationId = id
// selectedTab = .Pending // **** uncomment this to make the code work
globData.selectedTab = .Pending // **** comment this to make the code work
} else if globData.lists.activeList.contains(id) {
globData.destinationId = id
// selectedTab = .Active // **** uncomment this to make the code work
globData.selectedTab = .Active // **** comment this to make the code work
} else if globData.lists.inactiveList.contains(id) {
globData.destinationId = id
// selectedTab = .Inactive // **** uncomment this to make the code work
globData.selectedTab = .Inactive // **** comment this to make the code work
} else {
navigationPath = []
}
}
var body: some View {
VStack {
Text("NavLinkContent(\(id))")
ForEach(globData.lists.pendingList, id: \.self) { id in
Button("Go To: \(id)") {
goToId(id)
}
}
ForEach(globData.lists.activeList, id: \.self) { id in
Button("Go To: \(id)") {
goToId(id)
}
}
ForEach(globData.lists.inactiveList, id: \.self) { id in
Button("Go To: \(id)") {
goToId(id)
}
}
}
.onAppear() {
print("\n NavLinkContent OnAppear(\(thisTab))(\(id))")
}
.task {
print(" NavLinkContent Task(\(thisTab))(\(id))")
}
.onDisappear() {
print(" NavLinkContent OnDisappear(\(thisTab))(\(id))")
}
}
}
Here are the steps to show the issue
starting from the Pending tab at app start
select one of the navigation links, say number 4
from here select one of the buttons to go to an id in another tab, say number 10 which will send you to the Active tab and it will select the navigation link for id 10
from here go back to the Pending tab
now on the surface everything may have seemed fine because you did end up back at the Root of the Pending tab...
But... if you look at the debug print statements you should see that when you navigated back to the Pending tab it ended up printing NavLinkContent OnAppear(Pending)(4) and NavLinkContent OnDisappear(Pending)(4) and NavLinkContent Task(Pending)(4)...
So what happened? when I left the Pending tab I set the NavigationPath = [] so when I came back it should have known to just put me at the root. Why did it try to open id = 4?
What's even worse is the NavLinkContent Task(Pending)(4) print. This means that the httprequests would end up running to get the content to fill that view even if the view isn't really visible
Here's what is printed out that I DON't want:
-- app start
NavStackView OnAppear: Pending
NavStackView Task: Pending
-- clicking nav link 4
NavLinkContent OnAppear(Pending)(4)
NavLinkContent Task(Pending)(4)
-- clicking go to 10
NavStackView OnAppear: Active
NavLinkContent OnDisappear(Pending)(4)
NavStackView OnDisappear: Pending
NavStackView Task: Active
NavLinkContent OnAppear(Active)(10)
NavLinkContent Task(Active)(10)
-- clicking Pending tab
NavLinkContent OnAppear(Pending)(4) -- this should not print
NavStackView OnAppear: Pending
NavLinkContent OnDisappear(Active)(10)
NavStackView OnDisappear: Active
NavLinkContent OnDisappear(Pending)(4) -- this should not print
NavLinkContent Task(Pending)(4) -- this should not print
NavStackView Task: Pending
If you take the supplied code and toggle where I've commented sections then I get the expected result. This will move the selectedTab variable out of the GlobalData class and make it so its has to be passed through every view... which sucks and isn't what I want... but it works...
Here's what's printed out that is correct:
-- app start
NavStackView OnAppear: Pending
NavStackView Task: Pending
-- clicking nav link 4
NavLinkContent OnAppear(Pending)(4)
NavLinkContent Task(Pending)(4)
-- clicking go to 10
NavStackView OnAppear: Active
NavLinkContent OnDisappear(Pending)(4)
NavStackView OnDisappear: Pending
NavStackView Task: Active
NavLinkContent OnAppear(Active)(10)
NavLinkContent Task(Active)(10)
-- clicking Pending
NavStackView OnAppear: Pending
NavLinkContent OnDisappear(Active)(10)
NavStackView OnDisappear: Active
NavStackView Task: Pending
What I'm really looking for:
I'd like these State variables to all live in a GlobalData class that's passed around (It's very annoying to have to explicitly pass each State variable through every view)
I need to find a way to get this GlobalData class into my AppDelegate as well so that it can be used when I receive Apple Push Notifications
Sorry for the large novel... but this issue can only be scene in a complex scenario...
Thanks for any help!
Update
I'm wondering if there's something wrong with my work phone. I've got a work iPhone13(iOS 16.2) and a personal iPhone11(iOS 16.1). I've been developing on my work phone (and that's where I've experienced the issues described above). So I just tested my project on my personal phone... and didn't see the issue... I then pulled up the simulator (which I don't normally use, I just prefer to be on the real device) set to an iPhone13(iOS 16.2) and it didn't have the issue...
Do you think this is just my phone? a bug to report to apple?

Related

Presence of #Environment dismiss causes list to constantly rebuild its content on scrolling

I need to build a list of TextFields where each field is associated with focus id, so that I can auto scroll to such a text field when it receives focus. In reality the real app is a bit more complex which also includes TextEditors and many other controls.
Now, I found out that if my view defines #Environment(\.dismiss) private var dismiss then the list is rebuilding all the time during manual scrolling. If I just comment out the line #Environment(\.dismiss) private var dismiss then there is no rebuilding of the list when I scroll. Obviously, I want to be able to dismiss my view when user clicks some button. In the real app it's even worse: during scrolling everything is lagging, I cannot get smooth scrolling. And my list is not huge it's just 10 items or so.
Here is a demo example:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink {
DismissListView()
} label: {
Text("Go to see the list")
}
}
}
}
struct DismissListView: View {
#Environment(\.dismiss) private var dismiss
enum Field: Hashable {
case line(Int)
}
#FocusState private var focus: Field?
#State private var text: String = ""
var body: some View {
ScrollViewReader { proxy in
List {
let _ = print("body is rebuilding")
Button("Dismiss me") {
dismiss()
}
Section("Section") {
ForEach((1...100), id: \.self) {num in
TextField("text", text: $text)
.id(Field.line(num))
.focused($focus, equals: .line(num))
}
}
}
.listStyle(.insetGrouped)
.onChange(of: focus) {_ in
withAnimation {
proxy.scrollTo(focus, anchor: .center)
}
}
}
}
}
The questions are:
Why is the list rebuilding during manual back and forth scrolling when #Environment(\.dismiss) private var dismiss is defined, and the same is NOT happening when dismiss is NOT defined?
Is there any workaround for this: I need to be able to use ScrollProxyReader to focus any text field when the focus changes, and I need to be able to dismiss the view, but in the same time I need to avoid constant rebuilds of the list during scrolling, because it drops app performance and scrolling becomes jagged...
P.S. Demo app constantly outputs "body is rebuilding" when dismiss is defined and the list is scrolled, but if any text field gets a focus manually, then the "body is rebuilding" is not printed anymore even if the dismiss is still defined.
I could make an assumption, but that would be really rather a guess (based on experience, observations, etc). In a fact, all WHYs like "why this sh... (bug) happens" should be asked on https://developer.apple.com/forums/ (there are Apple's engineers there) or reported to https://developer.apple.com/bug-reporting/
A solution is to separate dismiss depenent part into dedicated view, so hiding it from parent body (and so do not affect it)
struct DismissView: View {
// visible only for this view !!
#Environment(\.dismiss) private var dismiss
var body: some View {
Button("Dismiss me") {
// affects current context, so it does not matter
// in which sub-view is called
dismiss()
}
}
}
var body: some View {
ScrollViewReader { proxy in
List {
let _ = print("body is rebuilding")
DismissView() // << here !!
// ... other code

SwiftUI: How to detect navigation up or down

I have following SwiftUI code, which makes a connection (to the server) when the user navigates to this page (this is a part of navigation stack), and disconnects when the user navigate away (up).
struct ServiceView: View {
#ObservedObject var state:ServiceState
var body: some View {
ScrollView(.vertical) {
VStack {
...
NavigationLink(
destination: ChildPage(state: state),
label: {
Text("Child Page")
})
}
}
.onAppear() {
state.connection.connect()
}
.onDisappear() {
state.connection.disconnect()
}
}
}
This works fine as long as the user stays on this page, but breaks when the user clicks the "Child Page" link, because .onDisappear() event will be triggered when the user navigates down as well.
Is there any way to distinguish two different scenarios (navigate up and down)?

Is there a .onReappear equivalent in SwiftUI

I have this view that has a simple VStack that contains 4 IF conditions, each condition will show the right view:
VStack{
if(showLogin){
LoginView()
}
if(showMain){
Main()
}
if(showSplash){
Text("Splash screen")
}
if(showNoInternet){
noInternetView(showNoInternet: $showNoInternet, showSplash: $showSplash)
}
}
For this view I have a .onAppear() to run code when this view appears:
#State var showLogin = false
#State var showMain = false
#State var showNoInternet = false
#State var showSplash = true
var body: some View {
VStack{
if(showLogin){
LoginView()
}
if(showMain){
Main()
}
if(showSplash){
Text("Splash screen")
}
if(showNoInternet){
noInternetView(showNoInternet: $showNoInternet, showSplash: $showSplash)
}
}
.onAppear(){ ... }
}
Within the code of .onAppear() I check if the user has internet connection, if they don't then I toggle both the splash and the showNoInternetView to hide the splash screen and show the No Internet connection message. When the user presses a button in the showNoInternetView, it does the opposite to hide the message and show the splash screen again, which I would like to run the same code again within the .onAppear(). Obviously I know to re-render a view you'd have to change a state - which is what I do to update the views.
How would I invoke the .onAppear() function again each time I go back to the original splash view (as if it's the first time the view is being run)?
Attach it instead to splash view directly, so once you come into condition of splash view it will run as expected.
if(showSplash){
Text("Splash screen")
.onAppear(){ ... } // << move here !!
}

SwiftUI - trying to identify and pass the value of the list item being selected

I'm following the Apple Developer tutorial on interfacing with UIKIt
The only real difference is that that I'm trying to call
PageView<Page:View> {
from a NavigationLink. The example code within PageView has a default value hardcoded of
#State var currentPage = 0
I'd rather initiate the swipe process on the view that the user selected and not start at the first one and I'm trying to create a simple way to swipe through the items without having to return to the navigationlink to access the next item on the list.
Is there a way to trap the index of the link the user selected and pass this through to PageView as a variable.
I'd tried incrementing a counter and using a function to increment the counter and return the PageView sruct without any success.
animals is an array, just in case that wasn't clear and I'm using the map function to pass in the appropriate value to create the detailed view I'm looking to create . Also, I realize I could probably put the detail view in a scroll view.
Here's my code
var body: some View {
List {
ForEach(collection.animals, id: \.self) { animal in
NavigationLink(destination:PageView(self.collection.animals.map { AnimalDetail(animalMetadata: $0.wrappedMetadata )})) {
AnimalRow(thumbnailImage: Image(uiImage: UIImage(data: animal.image!)!), label: (animal.name!) ).font(.subheadline)
}
}.onDelete(perform: delete)
}
Everything else seems to work fine - just can't figure a way to capture an index value from the list to indicate which item/view was clicked and to pass that value through to PageView in order to create this as the first viewed View.
Thank you !
edit
I tried using .enumerated() on the Array but still couldn't figure out how to get the specific index value to the PAgeView view.
List {
ForEach(Array(collection.animals.enumerated()), id: \.1.id) {index, item in
NavigationLink(destination:PageView( self.collection.animals.map { AnimalDetail(animalMetadata: $0.wrappedMetadata)} )) {
AnimalRow(thumbnailImage: Image(uiImage: UIImage(data: item.image!)!), label: (animal.name!) ).font(.subheadline)
}
}.onDelete(perform: delete)
Try the following...
In PageView:
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
#State var currentPage: Int // no initial value
init(_ views: [Page], selection: Int) {
_currentPage = State(initialValue: selection)
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
// ... other code here
}
In your code:
var body: some View {
List {
ForEach(Array(collection.animals.enumerated()), id: \.element) { idx, animal in
NavigationLink(destination:
PageView(self.collection.animals.map {
AnimalDetail(animalMetadata: $0.wrappedMetadata )},
selection: idx)) { // << here !!
AnimalRowRow(thumbnailImage: Image(uiImage: UIImage(data: animal.image!)!),
label: (animal.name!) ).font(.subheadline)
}
}.onDelete(perform: delete)
}

Prevent NavigationLink from processing state variable

I don't like my title, but it's the best I could think of. Basically, I have two Views: SearchInput and SearchResultList. The user enters a search term with SearchInput, taps "Go" and then sees the results of the search with SearchResultList. The code is:
struct SearchInput: View {
#State private var searchTerm: String = ""
var searchResult: [RTDocument] {
return RuntimeDataModel.shared.fetchRTDocuments(matching: self.searchTerm)
}
var body: some View {
NavigationView {
VStack {
TextField("Enter search term...", text: self.$searchTerm)
NavigationLink(destination: SearchResultList(
rtDocuments: self.searchResult
)) { Text("Go") } //NavigationLink
} //VStack
} //NavigationView
} //body
} //SearchInput
The issue I'm having is that every time the user enters a character in TextField, the bound-state variable searchTerm is updated which causes NavigationLink to reevaluate its destination -- which causes a Core Data fetch up in the computed searchResult variable.
I'd like the fetch to happen only once, when the user taps "Go". Is there a way to accomplish that?
This is expected behaviour, so in general you should redesign your SearchResultList, but this can help as a walkaround:
Declare a state on your input:
#State var shouldNavigate = false
Then separate the button form the navigation link.
Group {
Button("Go") {
self.shouldNavigate = true
}
NavigationLink(destination: Text("SearchResultList"),
isActive: $shouldNavigate) {
EmptyView()
}
}

Resources