Search code examples
iosswiftswiftuifocustextfield

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?


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

Solution

  • 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.