Search code examples
iosswiftswiftuiuikituitextview

SwiftUI: .toolbar doesn't work with UITextView


The toolbar shows if I use a TextField, but not with UITextView. I'm not sure whether this is a bug, or I'm just not bridging UITextView properly with SwiftUI. Any idea on how to make .toolbar() work with UITextView? Here's my code:

struct MyUITextView: UIViewRepresentable {
    @Binding var text: String
    private let editor = UITextView()
    
    func makeUIView(context: Context) -> UITextView {
        editor.delegate = context.coordinator
        
        return editor
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)
    }
    
    func updateUIView(_ editor: UITextView, context: Context) {
        editor.text = text
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        @Binding private var text: String
        
        init(text: Binding<String>) {
            self._text = text
        }
        
        func textViewDidChange(_ editor: UITextView) {
            text = editor.text
        }
    }
}

struct ContentView: View {
    @State var uitextView = "UITextView"
    @State var textfield = "TextField"
    var body: some View {
        VStack {
            MyUITextView(text: $uitextView) // Toolbar doesn't show up when focused
                .border(.black, width: 1)
                .frame(maxHeight: 40)
            TextField("", text: $textfield) // Toolbar shows up when focused
                .border(.black, width: 1)
        }
        .toolbar {
            ToolbarItem(placement: .keyboard) {
                Button("Click") {}
            }
        }
    }
}

Demo


Solution

  • SwiftUI's toolbar doesn't know about UIKit views, so it doesn't add toolbars to your UITextView.

    You can still add a toolbar using the UIKit APIs:

    let toolbar = UIToolbar()
    toolbar.items = [...]
    editor.inputAccessoryView = toolbar
    toolbar.translatesAutoresizingMaskIntoConstraints = false
    

    If you want to use SwiftUI to write the toolbar's contents, you'd have to add a UIHostingController in the coordinator.

    For some reason, adding a single UIBarButtonItem(customView: hostingController.view) makes the user unable to press the SwiftUI Button. You have to make the entire inputAccessoryView a SwiftUI instead.

    Example:

    // UIViewRepresentable and Coordinator design inspired by https://stackoverflow.com/a/74788978/5133585
    struct MyUITextView<Toolbar: View>: UIViewRepresentable {
        init(text: Binding<String>, @ViewBuilder toolbar: @escaping () -> Toolbar) {
            self._text = text
            self.toolbar = toolbar
        }
        
        @Binding var text: String
        let toolbar: () -> Toolbar
        
        func makeCoordinator() -> Coordinator {
            Coordinator(hostingVC: UIHostingController(rootView: toolbar()))
        }
        
        func makeUIView(context: Context) -> UITextView {
            context.coordinator.editor
        }
        
        func updateUIView(_ uiView: UITextView, context: Context) {
            uiView.text = text
             context.coordinator.stringDidChange = { string in
                text = string
            }
        }
        
        class Coordinator: NSObject, UITextViewDelegate {
            
            let hostingVC: UIHostingController<Toolbar>
            
            init(hostingVC: UIHostingController<Toolbar>) {
                self.hostingVC = hostingVC
                hostingVC.sizingOptions = [.intrinsicContentSize]
            }
            
            lazy var editor: UITextView = {
                let editor = UITextView()
                editor.delegate = self
                editor.inputAccessoryView = hostingVC.view
                editor.inputAccessoryView?.translatesAutoresizingMaskIntoConstraints = false
                return editor
            }()
            
            var stringDidChange: ((String) -> ())?
            
            func textViewDidChange(_ textView: UITextView) {
                stringDidChange?(textView.text)
            }
        }
    }
    
    MyUITextView(text: $uitextView) {
        // this HStack is for imitating the UIToolbar
        HStack {
            Button("Click") { print("Foo") }
                .padding()
            Spacer()
        }
        .background(.bar.shadow(.drop(radius: 1)))
    }
    .border(.black, width: 1)
    .frame(maxHeight: 40)