Prevent NavigationLink from processing state variable - ios

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()
}
}

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

Using #FocusState on a TextField causing memory leaks

I am learning memory and ARC since a while and managed to use the Leaks instrument more often in order to produce quality code. Having that said, please consider my lack of experience in this zone.
Problem: I built a parent view A that presents a view B.
B contains a login form built using a TextField, a SecureField and a Button. I also have a #FocusState private var isFocused: Bool property that helps me hide the keyboard in order to bypass the "AttributeGraph: cycle detected" console error that shows up once I disable the textfield on button press having the keyboard on screen. (Get `AttributeGraph: cycle detected` error when changing disabled state of text field)
I noticed that when I use the #FocusState property, once I dismiss B, the Leaks instrument detects two "Malloc 32 Bytes" leaks just like in the picture below.
If I don't use the #FocusState property, the leaks will no longer show up. Am I doing something wrong or is this some kind of bug / false positive from Swift?
This view is partially extracted from my file so it doesn't have all it's properties and methods here.
struct AuthenticationLoginView: View {
#StateObject var viewModel = AuthenticationLoginViewModel()
#FocusState private var isFocused: Bool
var body: some View {
VStack {
TextField(text: $viewModel.username) {
Text("placeholder.")
}
.tag(AuthenticationLoginField.username)
.textInputAutocapitalization(.never)
.focused($isFocused)
.disabled(viewModel.isLoggingIn)
SecureField(text: $viewModel.password) {
Text("Password")
}
// .focused($isFocused)
.disabled(viewModel.isLoggingIn)
.tag(AuthenticationLoginField.password)
}
}
}
Without more code it is hard to tell what else you are doing that may have caused the leak.
My gut is that having a single focus boolean when you have two edit fields is an anti-pattern compared to Apple's way. When something is evolving like SwiftUI, try to follow their example styles more closely. Use a boolean only when there's just one focusable field.
This similar Hacking with Swift sample shows using an optional and changing the focus as fields submitted.
#FocusState private var focusedField: FocusedField?
#State private var username = "Anonymous"
#State private var password = "sekrit"
var body: some View {
VStack {
TextField("Enter your username", text: $username)
.focused($focusedField, equals: .username)
SecureField("Enter your password", text: $password)
.focused($focusedField, equals: .password)
}
.onSubmit {
if focusedField == .username {
focusedField = .password
} else {
focusedField = nil
}
}
I found this problem. In my case, it caused a massive retain loop which fed all the way back to coordinator with a bunch of objects.
Just showing a TextField with focus (either the bool way, or the enum way) caused a retain.
My fix was to use the introspect library and just set first responder manually
#State private var textField:UITextField?
#State private var secureTextField:UITextField?
var body: some View {
ZStack {
TextField(prompt, text: $text)
.opacity(isSecure ? 0 : 1)
.introspectTextField { field in
textField = field
}
SecureField(prompt,text: $text)
.opacity(isSecure ? 1 : 0)
.introspectTextField { field in
secureTextField = field
}
}
.animation(.easeInOut, value: isSecure)
.overlay(alignment: .trailing) {
Button {
isSecure.toggle()
setFocus()
} label: {
Image(isSecure ? "eyeClose" : "eyeOpen")
}
.opacity(canBeSecure ? 1 : 0)
}
.onAppear {
DispatchQueue.main.async {
setFocus()
}
}
}
func setFocus() {
if isSecure {
secureTextField?.becomeFirstResponder()
}
else {
textField?.becomeFirstResponder()
}
}

How do I stop a view from refreshing each time I return from a detail view in SwiftUI?

I have a view which displays a list of posts. I have implemented infinite scrolling, and it is functioning properly. however, there is one small problem I am running into, and attempts to solve it have me going round in circles.
Main view
struct PostsHomeView: View {
#EnvironmentObject var viewModel: ViewModel
#State var dataInitiallyFetched = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 0) {
if self.viewModel.posts.count > 0 {
PostsListView(posts: self.viewModel.posts,
isLoading: self.viewModel.canFetchMorePosts,
onScrolledAtBottom: self.viewModel.fetchMorePosts
)
} else {
VStack {
Text("You have no posts!")
}
}
}
.onAppear() {
if !self.dataInitiallyFetched {
self.viewModel.fetchMostRecentPosts()
self.dataInitiallyFetched = true
}
}
.navigationBarTitle("Posts", displayMode: .inline)
}
}
}
List view
struct PostsListView: View {
#EnvironmentObject var viewModel: ViewModel
let posts: [Post]
let isLoading: Bool
let onScrolledAtBottom: () -> Void
var body: some View {
List {
postsList
if isLoading {
loadingIndicator
}
}
}
private var postsList: some View {
ForEach(posts, id: \.self) { post in
PostsCellView(post: post)
.onAppear {
if self.posts.last == post {
self.onScrolledAtBottom()
}
}
}
.id(UUID())
}
}
Problem
Upon tapping one of the posts in the list, I am taken to a detail view. When I tap the nav bar's back button in order go back to the posts list, the whole view is reloaded and my post fetch methods are fired again.
In order to stop the fetch method that fetches most recent posts from firing, I have added a flag that I set to true after the initial load. This stops the fetch method that grabs the initial set of posts from firing when I go back and forth between the details view and posts home screen.
I have tried various things to stop the fetchMorePosts function from firing, but I keep going in circles. I added a guard statement to the top of the fetchMorePosts function in my view model. It checks to see if string is equal to "homeview", if not, then the fetch is not done. I set this string to "detailview" whenever the detail view is visited, then I reset it back to "homeview" in the guard statement.
guard self.lastView == "homeview" else {
self.lastView = "homeview"
return
}
This works to an extent, but I keep finding scenarios where it doesn't work as expected. There must be a straight-forward way to tell SwiftUI not to reload a view. The problem is the method sits in the onAppear closure which is vital for the infinite scrolling to work. I'm not using iOS 14 yet, so I can't use #StateObject.
Is there a way to tell SwiftUI not to fire onAppear everytime I return from a detail view?
Thanks in advance
The culprit was .id(UUID()). I removed it from my list and everything worked again.
Thanks Asperi. Your help is much appreciated.

SwiftUI View Code is seemingly getting called twice. What is the issue here?

I have a view that is being called using .sheet in SwiftUI. However, when this sheet is presented, I'm getting the debug "Tapped" print in console followed by this error:
Warning: Attempt to present <_TtGC7SwiftUIP13$7fff2c9bdf5c22SheetHostingControllerVS_7AnyView_: 0x7f8f1d7400f0> on <_TtGC7SwiftUI19UIHostingControllerVVS_22_VariadicView_Children7Element_: 0x7f8f17e0dae0> which is already presenting (null)
I'm not exactly sure what is causing this error, but from what I understand it's due to the view getting called twice. I'm not sure how the view is being called twice, or even if it is, which is why I'm asking here. Below is the main view that actually houses the NavigationView in which my List is being housed view
struct AllTablesView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: Table.getAllTables()) var tables: FetchedResults<Table>
#State private var tableTapped: Table = Table()
#State private var isModal = false
var body: some View {
NavigationView {
List {
ForEach(self.tables) { table in
tableCellView(tableNumber: table.number, clean: table.clean, inUse: table.inUse)
.onTapGesture {
self.tableTapped = table
self.isModal = true
print("tapped")
}
}
.onDelete { indexSet in
let deletedTable = self.tables[indexSet.first!]
self.managedObjectContext.delete(deletedTable)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
.sheet(isPresented: $isModal){
TableStatusView(table: self.tableTapped)
.environment(\.managedObjectContext, self.managedObjectContext)
}
}
.navigationBarTitle("All Tables")
.navigationBarItems(leading: EditButton(), trailing:
HStack {
DeleteAllTablesButton()
AddTableButton()
})
}
}
}
And, if for whatever reason this is the issue, here is the code for my view titled "TableStatusView"
struct TableStatusView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: Table.getAllTables()) var tables: FetchedResults<Table>
#State var table: Table
var body: some View {
VStack {
Form {
Section {
Text("Table \(table.number)")
}.padding(.all, 10.0)
Section {
Toggle(isOn: $table.inUse) {
Text("In Use")
}.padding(.all, 10.0)
Toggle (isOn: $table.clean) {
Text("Clean")
}.padding(.all, 10.0)
}
}
}
}
}
Is there a reason I'm getting this error, or why my view is being called twice? Is it actually that it's being called twice or am I doing something wrong here? I know certain parts of my code could probably be shrunk down or written better but this is just my first dive into SwiftUI, I haven't had much experience with it outside of basic stuff. Thanks!
I never did find a solution to the question, but I did solve the NavigationLink issue and swapped back to using that. I was using ".onTapGesture" to get a tap gesture and then generate a NavigationLink, thinking it was an action. NavigationLink, I've learned, is actually more or less a container to house content, so I replaced the .onTapGesture and TableCellView() function call with the following:
NavigationLink(destination: TableStatusView(table: table)) {
tableCellView(tableNumber: table.number, clean: table.clean, inUse: table.inUse)
}
And fixed the issue for me. I'm still seeing some errors but from some google-fu found out that they are current bugs of SwiftUI. SwiftUI 2.0 may fix some of these issues

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)
}

Resources