Zack Shapiro
Zack Shapiro

Reputation: 6998

SwiftUI: Custom UITextView UIViewRepresentable requires double tap on the first action to work, works fine with single taps thereafter

I've built this small demo view where I have two NoteRows and my goal is to be able to press the return key to create a new row and for it to become the first responder. This works, however, the first time around, the new row is created but it doesn't become the first responder. Subsequent taps of the return key both create the row and become first responder.

Any ideas what's going wrong here? Thanks!

import SwiftUI
import Combine

struct FirstResponderDemo: View {
    
    @State private var rows: [NoteRow] = [
        .init(parentNoteId: "1", text: "foo"),
        .init(parentNoteId: "1", text: "bar"),
    ]
    
    @State private var activeRowId: String?
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack {
                    ForEach(rows, id: \.id) { row in
                        ResponderTextView(
                            row: row,
                            text: $login,
                            activeRowId: $activeRowId,
                            returnPressed: returnPressed
                        )
                        .frame(width: 300, height: 44)
                    }
                }.padding(.horizontal, 12)
            }
        }
        .onAppear {
            self.activeRowId = rows[0].id
        }
    }
    
    private func returnPressed() {
        guard let id = activeRowId else { return }
        
        let newRow = NoteRow(parentNoteId: "1", text: "")
        print("new row id", newRow.id)
        
        if let index = rows.firstIndex(where: { $0.id == id }) {
            rows.insert(newRow, at: index + 1)
            activeRowId = newRow.id
        }
    }
}

struct FirstResponderDemo_Previews: PreviewProvider {
    static var previews: some View {
        FirstResponderDemo()
    }
}


struct ResponderView<View: UIView>: UIViewRepresentable {
    
    let row: NoteRow
    
    @Binding var activeRowId: String?
    
    var configuration = { (view: View) in }

    func makeUIView(context: Context) -> View { View() }

    func makeCoordinator() -> Coordinator {
        Coordinator(row: row, activeRowId: $activeRowId)
    }

    func updateUIView(_ uiView: View, context: Context) {
        context.coordinator.view = uiView
        
//        print(activeRowId)
        _ = activeRowId == row.id ? uiView.becomeFirstResponder() : uiView.resignFirstResponder()
        
        configuration(uiView)
    }
}

// MARK: - Coordinator

extension ResponderView {
    
    final class Coordinator {
    
        @Binding private var activeRowId: String?
        
        private var anyCancellable: AnyCancellable?
        
        fileprivate weak var view: UIView?

        init(row: NoteRow, activeRowId: Binding<String?>) {
            _activeRowId = activeRowId
            
            self.anyCancellable = Publishers.keyboardHeight.sink(receiveValue: { [weak self] keyboardHeight in
                guard let view = self?.view, let self = self else { return }
                
                DispatchQueue.main.async {
                    if view.isFirstResponder {
                        self.activeRowId = row.id
                        print("active row id is:", self.activeRowId)
                    }
                }
            })
        }
    }
    
}

// MARK: - keyboardHeight

extension Publishers {
    
    static var keyboardHeight: AnyPublisher<CGFloat, Never> {
        let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
            .map { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0 }

        let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
            .map { _ in CGFloat(0) }

        return MergeMany(willShow, willHide)
            .eraseToAnyPublisher()
    }
    
}

struct ResponderView_Previews: PreviewProvider {
    static var previews: some View {
        ResponderView<UITextView>.init(row: .init(parentNoteId: "1", text: "Hello world"), activeRowId: .constant(nil)) { _ in
        }.previewLayout(.fixed(width: 300, height: 40))
    }
}

struct ResponderTextView: View {
    
    let row: NoteRow
    
    @State var text: String
    
    @Binding var activeRowId: String?
    
    private var textViewDelegate: TextViewDelegate

    init(row: NoteRow, text: Binding<String>, activeRowId: Binding<String?>, returnPressed: @escaping () -> Void) {
        self.row = row
        self._text = State(initialValue: row.text)
        self._activeRowId = activeRowId
        self.textViewDelegate = TextViewDelegate(text: text, returnPressed: returnPressed)
    }

    var body: some View {
        ResponderView<UITextView>(row: row, activeRowId: $activeRowId) {
            $0.text = self.text
            $0.delegate = self.textViewDelegate
        }
    }
}

// MARK: - TextFieldDelegate

private extension ResponderTextView {
    
    final class TextViewDelegate: NSObject, UITextViewDelegate {
        
        @Binding private(set) var text: String
        
        let returnPressed: () -> Void

        init(text: Binding<String>, returnPressed: @escaping () -> Void) {
            _text = text
            self.returnPressed = returnPressed
        }
        
        func textViewDidChange(_ textView: UITextView) {
            DispatchQueue.main.async {
               self.text = textView.text
            }
        }
        
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            if (text == "\n") {
                returnPressed()
                return false
            }
            
            return true
        }
    }

}

And the definition of NoteRow:

final class NoteRow: ObservableObject, Identifiable {
    
    let id: String = UUID().uuidString
    
    let parentNoteId: String

    let text: String
   
    init(parentNoteId: String, text: String) {
        self.parentNoteId = parentNoteId
        self.text = text
    }
 
}

extension NoteRow: Equatable {
    
    static func == (lhs: NoteRow, rhs: NoteRow) -> Bool {
        lhs.id == rhs.id &&
        lhs.parentNoteId  == rhs.parentNoteId
    }
    
}

Edit: Debugging this more

active row id is: Optional("71D8839A-D046-4DC5-8E02-F124779309E6") // first default row active row id is: Optional("5937B1D0-CBB0-4BE4-A235-4D57835D7B0F") // second default row

// I hit return key: new row id F640D1F9-0708-4099-BDA4-2682AF82E3BD active row id is: Optional("5937B1D0-CBB0-4BE4-A235-4D57835D7B0F") // ID for 2nd row is set as active for some reason

// After that, new row id and active row ID follow the expected path:

new row id 9FDEB548-E19F-4572-BAD3-00E6CBB951D1 active row id is: Optional("9FDEB548-E19F-4572-BAD3-00E6CBB951D1")

new row id 4B5C1AA3-15A1-4449-B1A2-9D834013496A active row id is: Optional("4B5C1AA3-15A1-4449-B1A2-9D834013496A")

new row id 22A61BE8-1BAD-4209-B46B-15666FF82D9B active row id is: Optional("22A61BE8-1BAD-4209-B46B-15666FF82D9B")

new row id 95DD6B33-4421-4A32-8478-DCCBCBB1824E active row id is: Optional("95DD6B33-4421-4A32-8478-DCCBCBB1824E")

Upvotes: 0

Views: 389

Answers (1)

Asperi
Asperi

Reputation: 257739

Ok, I did not get idea of this code, but the below fixes case in question (tested with Xcode 12b)

    if (text == "\n") {
        DispatchQueue.main.async {     // << defer to next event !!
            self.returnPressed()
        }
        return false
    }
    return true
}

Upvotes: 1

Related Questions