How can one change the FocusState of a SwiftUI app with TextFields in different child views without having View refresh which causes a bounce effect?
Asked Answered
C

1

5

My Problem: I want the user to be able to go from Textfield to TextField without the view bouncing as shown in the gif below.

My Use Case: I have multiple TextFields and TextEditors in multiple child views. These TextFields are generated dynamically so I want the FocusState to be a separate concern.

I made an example gif and code sample below.

enter image description here

Please check it out, any suggestions appreciated.

As suggested in the comments I made some changes with no effect to the bounce:

 - Using Identfiable does not change the bounce
 - A single observed object or multiple and a view model does not change the bounce

I think this is from the state change refresh. If it's not the refresh causing the bounce(as the user suggests in the comments) what is? Is there a way to stop this bounce while using FocusState?

To Reproduce: Create a new iOS app xcode project and replace the content view with this code body below. It seems to refresh the view when the user goes from one textfield to the next textfield causing a bounce of the whole screen.

Code Example

import SwiftUI

struct MyObject: Identifiable, Equatable {
    var id: String
    public var value: String
    init(name: String, value: String) {
        self.id = name
        self.value = value
    }
}

struct ContentView: View {

    @State var myObjects: [MyObject] = [
        MyObject(name: "aa", value: "1"),
        MyObject(name: "bb", value: "2"),
        MyObject(name: "cc", value: "3"),
        MyObject(name: "dd", value: "4")
    ]
    @State var focus: MyObject?

    var body: some View {
        VStack {
            Text("Header")
            ForEach(self.myObjects) { obj in
                Divider()
                FocusField(displayObject: obj, focus: $focus, nextFocus: {
                    guard let index = self.myObjects.firstIndex(of: $0) else {
                        return
                    }
                    self.focus = myObjects.indices.contains(index + 1) ? myObjects[index + 1] : nil
                })
            }
            Divider()
            Text("Footer")
        }
    }
}

struct FocusField: View {

    @State var displayObject: MyObject
    @FocusState var isFocused: Bool
    @Binding var focus: MyObject?
    var nextFocus: (MyObject) -> Void

    var body: some View {
        TextField("Test", text: $displayObject.value)
            .onChange(of: focus, perform: { newValue in
                self.isFocused = newValue == displayObject
            })
            .focused(self.$isFocused)
            .submitLabel(.next)
            .onSubmit {
                self.nextFocus(displayObject)
            }
    }
}
Cuisse answered 8/12, 2021 at 0:2 Comment(6)
Looking at this, you have made a highly complicated construct to handle a simple task of switching focus. Why are both MyObject and MyObjViewModel both observable and why are the both observed in FocusField? I am not sure you need to observe MyObject, so shouldn't it just be a struct and be identifiable? Why do you have a .submitLabel if you are not using it? The fact of a refresh should not be causing the bounce that occurs with this code. There is more to that issue. The simplest way to implement this would be to keep the focus code in ContenView and pull it out of the model.Tion
My Task is focus in multiple children, this is an example without using the multiple views. The MyObject does need to be Hashable/Equatable for this to work because of the ForEach and == comparison, perhaps Identifiable would work too. I pulled the focus code into the content view and the problem persists, I posted a new example with that update in an edit of the original question according to your suggestions. Also, I am using the .submitLabel, what do you mean by that comment? What does cause the bounce then if not a refresh from state change? Please try the code out to seeCuisse
Identifiable will work AND makes the ForEach work better. With the submitLabel, you tag the Textfield, but I don’t see where it is used in an onSubmit. Did I miss it? I will have to look at this again tomorrow.Tion
I altered the second code block to use identifiable. Identifiable does indeed work but it doesn't fix anything I'm asking about. It's syntax sugar, makes the ForEach and Object definition cleaner though which is nice. But it doesn't solve the problem unfortunately and is the wrong aspect to focus on, same with the next label on the keyboard's return key. The problem persists and in case you haven't loaded the code into Xcode. If you have any ideas on solving the bounce problem specifically please let me knowCuisse
Identifiable isn't working because you kept MyObject as a class & didn't put in an id:. It is not syntactic sugar in a struct with an id:. Does your MRE now accurately reflect the view hierarchy in your app? You mentioned a TextArea which would be a TextEditor in this context, but I do not see one listed. That probably does not matter, but you mentioned that specifically and said "which makes certain things not work."Tion
I updated the question post to address all of your concerns. The first code block now uses Identifiable as prescribed, which still does not affect the bouncing of the view. What is an MRE? I couldn't find what MRE is by googling Swift/Xcode/iOS a few times. My debug view heirarchy is how it should be, the hierarchy isn't the problem, working around it causes the problem because using FocusState seems to make the whole screen's view bounce. The TextEditor is not part of the issue, the bounce of the screen is the only issue I have. Any other ideas?Cuisse
T
4

After going through this a bunch of times, it dawned on me that when using FocusState you really should be in a ScrollView, Form or some other type of greedy view. Even a GeometryReader will work. Any of these will remove the bounce.

struct MyObject: Identifiable, Equatable {
    public let id = UUID()
    public var name: String
    public var value: String
}

class MyObjViewModel: ObservableObject {

    @Published var myObjects: [MyObject]
    
    init(_ objects: [MyObject]) {
        myObjects = objects
    }
}


struct ContentView: View {
    @StateObject var viewModel = MyObjViewModel([
        MyObject(name: "aa", value: "1"),
        MyObject(name: "bb", value: "2"),
        MyObject(name: "cc", value: "3"),
        MyObject(name: "dd", value: "4")
    ])

    @State var focus: UUID?
    
    var body: some View {
        VStack {
            Form {
                Text("Header")
                ForEach($viewModel.myObjects) { $obj in
                    FocusField(object: $obj, focus: $focus, nextFocus: {
                        guard let index = viewModel.myObjects.map( { $0.id }).firstIndex(of: obj.id) else {
                            return
                        }
                        focus = viewModel.myObjects.indices.contains(index + 1) ? viewModel.myObjects[index + 1].id : viewModel.myObjects[0].id
                    })
                }
                Text("Footer")
            }
        }
    }
}

struct FocusField: View {
    
    @Binding var object: MyObject
    @Binding var focus: UUID?
    var nextFocus: () -> Void
    
    @FocusState var isFocused: UUID?

    var body: some View {
        TextField("Test", text: $object.value)
            .onChange(of: focus, perform: { newValue in
                self.isFocused = newValue
            })
            .focused(self.$isFocused, equals: object.id)
            .onSubmit {
                self.nextFocus()
            }
    }
}

edit:

Also, it is a really bad idea to set id in the struct the way you did. An id should be unique. It works here, but best practice is a UUID.

Second edit: tightened up the code.

Tion answered 8/12, 2021 at 14:51 Comment(2)
I'm excited to finally make a story for doing textfield focus this way, thanks! My Database IDs are unique but thanks for your concern. I am just learning SwiftUI as I go and I'd like to become more familiar with this. I'll be studying greedy views for sure, and hopefully come to a similar understandingCuisse
I wasn't exactly happy with the code that was posted, so I tightened it up and reposted everything. You will see I used the id instead of the object, and changed var nextFocus: (MyObject) -> Void to var nextFocus: () -> Void since nothing needs to be sent in to the closure. (I like your solution with that, btw). Essentially everything modifying TextField has been tightened.Tion

© 2022 - 2024 — McMap. All rights reserved.