Dynamic Height for Text Field in SwiftUI


In this article will see how we can implement a text field that can automatically resize if the text doesn't fit on one line.


In our app Cleora - HTTP and WebSocket client we need this functionality for the URL field. It's usually just one line but if the user enters a very long URL, the field can grow up to a certain height. When it reaches its maximum height, the text inside will become scrollable.


iPad screenshot iPad screenshot


To achieve this we will need to wrap UITextView into a SwiftUI view. It needs to be a UITextView, not UITextField. But because it will be used as a text field in our SwiftUI code and won't allow any new line characters, we will call our SwiftUI view DynamicHeightTextField.


struct DynamicHeightTextField: UIViewRepresentable {
    @Binding var text: String
    @Binding var height: CGFloat
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        
        textView.isEditable = true
        textView.isUserInteractionEnabled = true
        
        textView.isScrollEnabled = true
        textView.alwaysBounceVertical = false
        
        textView.text = text
        textView.backgroundColor = UIColor.clear

        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}


It will accept a height binding so that it can pass the height it wants to be to its parent.



Now we need to implement a Coordinator, which will act as a UITextViewDelegate.


class Coordinator: NSObject, UITextViewDelegate {

    var dynamicHeightTextField: DynamicHeightTextField
    

    init(dynamicHeightTextField: DynamicHeightTextField) {
        self.dynamicHeightTextField = dynamicHeightTextField
    }

    func textViewDidChange(_ textView: UITextView) {
        self.dynamicHeightTextField.text = textView.text
    }

    func textView(
        _ textView: UITextView,
        shouldChangeTextIn range: NSRange,
        replacementText text: String) -> Bool {
        if (text == "\n") {
            textView.resignFirstResponder()
            return false
        }
        return true
    }
}


If you need to implement a Text View with dynamic height, you can just remove the code that looks for new line characters inside textView(_:shouldChangeTextIn:replacementText:). The rest of the logic will be relevant.


To update the height of the text, we need to conform our Coordinator to NSLayoutManagerDelegate and implement layoutManager(_:didCompleteLayoutFor:atEnd:) method. This method will be called every time layout changes either because the text changed or because the view was resized or rotated.


class Coordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate {
    
    weak var textView: UITextView?
    
    func layoutManager(
        _ layoutManager: NSLayoutManager,
        didCompleteLayoutFor textContainer: NSTextContainer?,
        atEnd layoutFinishedFlag: Bool) {
        
        DispatchQueue.main.async { [weak self] in
            guard let view = self?.textView else {
                return
            }
            let size = view.sizeThatFits(view.bounds.size)
            if self?.dynamicHeightTextField.height != size.height {
                self?.dynamicHeightTextField.height = size.height
            }
        }

    }
}


Dispatch the block that gets the height to the main thread to be safe, because sizeThatFits(_:) has to be called on the main thread.


Now we need to add makeCoordinator() method to DynamicHeightTextField struct and setup the coordinator and the delegate inside makeUIView(context:).


struct DynamicHeightTextField: UIViewRepresentable {
    ...
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        
        ...
        
        context.coordinator.textView = textView
        textView.delegate = context.coordinator
        textView.layoutManager.delegate = context.coordinator

        return textView
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(dynamicHeightTextField: self)
    }
}


Here is how we can integrate the DynamicHeightTextField into a SwiftUI view. We can define a minimum and a maximum height for the text field and set the frame on it.


struct ContentView: View {

    @State var text = ""
    @State var textHeight: CGFloat = 0
    
    var textFieldHeight: CGFloat {
        let minHeight: CGFloat = 30
        let maxHeight: CGFloat = 70
        
        if textHeight < minHeight {
            return minHeight
        }
        
        if textHeight > maxHeight {
            return maxHeight
        }
        
        return textHeight
    }

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color(UIColor.secondarySystemBackground)
            
            DynamicHeightTextField(text: $text, height: $textHeight)

        }
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
        
        .frame(width: 300, height: textFieldHeight)

    }
}


To make it look like a regular text field, we can add a placeholder to it when the text is empty.


ZStack(alignment: .topLeading) {
    Color(UIColor.secondarySystemBackground)
    
    if text.isEmpty {
        Text("Placeholder text")
            .foregroundColor(Color(UIColor.placeholderText))
            .padding(4)
    }
    
    DynamicHeightTextField(text: $text, height: $textHeight)

}



The code for this article is available on our GitHub page.