Fabio
Fabio

Reputation: 333

Make TextEditor dynamic height SwiftUI

I'm trying to create a growing TextEditor as input for a chat view.

The goal is to have a box which expands until 6 lines are reached for example. After that it should be scrollable.

I already managed to do this with strings, which contain line breaks \n.

                        TextEditor(text: $composedMessage)
                            .onChange(of: self.composedMessage, perform: { value in
                                withAnimation(.easeInOut(duration: 0.1), {
                                    if (value.numberOfLines() < 6) {
                                        height = startHeight + CGFloat((value.numberOfLines() * 20))
                                    }
                                    if value.numberOfLines() == 0 || value.isEmpty {
                                        height = 50
                                    }
                                })
                            })

I created a string extension which returns the number of line breaks by calling string.numberOfLines() var startHeight: CGFloat = 50

The problem: If I paste a text which contains a really long text, it's not expanding when this string has no line breaks. The text get's broken in the TextEditor.

How can I count the number of breaks the TextEditor makes and put a new line character at that position?

Upvotes: 5

Views: 3533

Answers (4)

Dmitry
Dmitry

Reputation: 1041

There is a way to calculate height programmatically using NSAttributedString:

import Foundation
import UIKit

extension String{
    public func calculateHeight(font: UIFont, width: CGFloat) -> CGFloat {
        // Create an attributed string with the desired text and font
        let attributedText = NSAttributedString(string: self, attributes: [NSAttributedString.Key.font: font])
        
        // Create a CGSize object with the given width and a large enough height to contain the text
        let maxSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
        
        // Calculate the bounding rectangle for the text
        let boundingRect = attributedText.boundingRect(with: maxSize, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
        
        // Return the height of the bounding rectangle
        return ceil(boundingRect.height)
    }
}

Upvotes: 1

kittonian
kittonian

Reputation: 1421

If you're looking for an iOS 15 solution, I spent a while and figured it out. I didn't want to have to resort to UIKit or ZStacks with overlays or duplicative content as a "hack". I wanted it to be pure SwiftUI.

I ended up creating a separate struct that I could reuse anywhere I needed it, as well as add additional parameters in my various views.

Here's the struct:

struct FieldMultiEntryTextDynamic: View {
    var text: Binding<String>
    
    var body: some View {
        TextEditor(text: text)
            .padding(.vertical, -8)
            .padding(.horizontal, -4)
            .frame(minHeight: 0, maxHeight: 300)
            .font(.custom("HelveticaNeue", size: 17, relativeTo: .headline))
            .foregroundColor(.primary)
            .dynamicTypeSize(.medium ... .xxLarge)
            .fixedSize(horizontal: false, vertical: true)
    } // End Var Body
} // End Struct

The cool thing about this is that you can have placeholder text via an if statement and it supports dynamic type sizes.

You can implement it as follows:

struct MyView: View {

    @FocusState private var isFocused: Bool
    @State private var myName: String = ""

    var body: some View {
        HStack(alignment: .top) {
            Text("Name:")
            ZStack(alignment: .trailing) {

                if myName.isEmpty && !isFocused {
                    Text("Type Your Name")
                        .font(.custom("HelveticaNeue", size: 17, relativeTo: .headline))
                        .foregroundColor(.secondary)
                }

                HStack {
                    VStack(alignment: .trailing, spacing: 5) {
                        FieldMultiEntryTextDynamic(text: $myName)
                            .multilineTextAlignment(.trailing)
                            .keyboardType(.alphabet)
                            .focused($isFocused)
                    }
                }
            }
        }
        .padding()
        .background(.blue)
    }
}

Hope it helps!

Upvotes: 4

shanezzar
shanezzar

Reputation: 1170

Here's a solution adapted from question and answer,

struct ChatView: View {
    @State var text: String = ""

    // initial height
    @State var height: CGFloat = 30

    var body: some View {
        ZStack(alignment: .topLeading) {
            Text("Placeholder")
                .foregroundColor(.appLightGray)
                .font(Font.custom(CustomFont.sofiaProMedium, size: 13.5))
                .padding(.horizontal, 4)
                .padding(.vertical, 9)
                .opacity(text.isEmpty ? 1 : 0)

            TextEditor(text: $text)
                .foregroundColor(.appBlack)
                .font(Font.custom(CustomFont.sofiaProMedium, size: 14))
                .frame(height: height)
                .opacity(text.isEmpty ? 0.25 : 1)
                .onChange(of: self.text, perform: { value in
                    withAnimation(.easeInOut(duration: 0.1), {
                        if (value.numberOfLines() < 6) {
                            // new height
                            height = 120
                        }
                        if value.numberOfLines() == 0 || value.isEmpty {
                            // initial height
                            height = 30
                        }
                    })
                })
        }
        .padding(4)
        .overlay(
            RoundedRectangle(cornerRadius: 8)
                .stroke(Color.appLightGray, lineWidth: 0.5)
        )
    }

}

And the extension,

extension String {
    func numberOfLines() -> Int {
        return self.numberOfOccurrencesOf(string: "\n") + 1
    }

    func numberOfOccurrencesOf(string: String) -> Int {
        return self.components(separatedBy:string).count - 1
    }

}

Upvotes: 1

Fabio
Fabio

Reputation: 333

I found a solution!

For everyone else trying to solve this:

I added a Text with the same width of the input field and then used a GeometryReader to calculate the height of the Text which automatically wraps. Then if you divide the height by the font size you get the number of lines.

You can make the text field hidden (tested in iOS 14 and iOS 15 beta 3)

Upvotes: 0

Related Questions