Search code examples
iosswiftuiswiftui-texteditor

SwiftUI TextEditor Initial Content Size Is Wrong


iOS 14.4 + Xcode 12.4

I want to make a simple checklist in SwiftUI on iOS where the text for each item is a TextEditor.

enter image description here

First, I create the basic app structure and populate it with some demo content:

import SwiftUI

@main
struct TestApp: App {
  @State var alpha = "Alpha"
  @State var bravo = "Bravo is a really long one that should wrap to multiple lines."
  @State var charlie = "Charlie"
  
  init(){
    //Remove the default background of the TextEditor/UITextView
    UITextView.appearance().backgroundColor = .clear
  }
  
  var body: some Scene {
    WindowGroup {
      ScrollView{
        VStack(spacing: 7){
          TaskView(text: $alpha)
          TaskView(text: $bravo)
          TaskView(text: $charlie)
        }
        .padding(20)
      }
      .background(Color.gray)
    }
  }
}

Then each TaskView represents a task (the white box) in the list:

struct TaskView:View{
  @Binding var text:String
  
  var body: some View{
    HStack(alignment:.top, spacing:8){
      Button(action: {
        print("Test")
      }){
        Circle()
          .strokeBorder(Color.gray,lineWidth: 1)
          .background(Circle().foregroundColor(Color.white))
          .frame(width:22, height: 22)
      }
      .buttonStyle(PlainButtonStyle())
      
      FieldView(name: $text)
       
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding(EdgeInsets(top:10, leading:10, bottom: 10, trailing: 30))
    .background(Color.white)
    .cornerRadius(5)
  }
}

Then finally, each of the TextEditors is in a FieldView like this:

struct FieldView: View{
  @Binding var name: String
  var body: some View{
    ZStack{
      Text(name)
        .padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -3))
        .opacity(0)
      TextEditor(text: $name)
        .fixedSize(horizontal: false, vertical: true)
        .padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -3))
    }
  }
}

As you can see in the screenshot above, the initial height of the TextEditor doesn't automatically size to fit the text. But as soon as I type in it, it resizes appropriately. Here's a video that shows that:

enter image description here

How can I get the view to have the correct initial height? Before I type in it, the TextEditor scrolls vertically so it seems to have the wrong intrinsic content size.


Solution

  • Note: views are left semi-transparent with borders so you can see/debug what's going on.

    struct FieldView: View{
        @Binding var name: String
        @State private var textEditorHeight : CGFloat = 100
        var body: some View{
            ZStack(alignment: .topLeading) {
                Text(name)
                    .background(GeometryReader {
                        Color.clear
                            .preference(key: ViewHeightKey.self,
                                               value: $0.frame(in: .local).size.height)
    
                    })
                    //.opacity(0)
                    .border(Color.pink)
                    .foregroundColor(Color.red)
                    
                TextEditor(text: $name)
                    .padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -7))
                    .frame(height: textEditorHeight + 12)
                    .border(Color.green)
                    .opacity(0.4)
            }
            .onPreferenceChange(ViewHeightKey.self) { textEditorHeight = $0 }
        }
    }
    
    struct ViewHeightKey: PreferenceKey {
        static var defaultValue: CGFloat { 0 }
        static func reduce(value: inout Value, nextValue: () -> Value) {
            value = value + nextValue()
            print("Reporting height: \(value)")
        }
    }
    

    First, I used a PreferenceKey to pass the height from the "invisible" text view back up the view hierarchy. Then, I set the height of the TextEditor frame with that value.

    Note that the view is now aligned to topLeading -- in your initial example, the invisible text was center aligned.

    One thing I'm not crazy about is the use of the edge insets -- these feel like magic numbers (well, they are...) and I'd rather have a solution without them that still kept the Text and TextEditor completely aligned. But, this works for now.

    Update, using UIViewRepresentable with UITextView

    This seems to work and avoid the scrolling problems:

    
    struct TaskView:View{
        @Binding var text:String
        @State private var textHeight : CGFloat = 40
        
        var body: some View{
            HStack(alignment:.top, spacing:8){
                Button(action: {
                    print("Test")
                }){
                    Circle()
                        .strokeBorder(Color.gray,lineWidth: 1)
                        .background(Circle().foregroundColor(Color.white))
                        .frame(width:22, height: 22)
                }
                .buttonStyle(PlainButtonStyle())
                
                FieldView(text: $text, heightToTransmit: $textHeight)
                    .frame(height: textHeight)
                    .border(Color.red)
                
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(EdgeInsets(top:10, leading:10, bottom: 10, trailing: 30))
            .background(Color.white)
            .cornerRadius(5)
        }
    }
    
    struct FieldView : UIViewRepresentable {
        @Binding var text : String
        @Binding var heightToTransmit: CGFloat
        
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            let textView = UITextView(frame: .zero, textContainer: nil)
            textView.delegate = context.coordinator
            textView.backgroundColor = .yellow // visual debugging
            textView.isScrollEnabled = false   // causes expanding height
            context.coordinator.textView = textView
            textView.text = text
            view.addSubview(textView)
    
            // Auto Layout
            textView.translatesAutoresizingMaskIntoConstraints = false
            let safeArea = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                textView.topAnchor.constraint(equalTo: safeArea.topAnchor),
                textView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
                textView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor)
            ])
            
            return view
        }
        
        func updateUIView(_ view: UIView, context: Context) {
            context.coordinator.heightBinding = $heightToTransmit
            context.coordinator.textBinding = $text
            DispatchQueue.main.async {
                context.coordinator.runSizing()
            }
        }
        
        func makeCoordinator() -> Coordinator {
            return Coordinator()
        }
        
        class Coordinator : NSObject, UITextViewDelegate {
            var textBinding : Binding<String>?
            var heightBinding : Binding<CGFloat>?
            var textView : UITextView?
            
            func runSizing() {
                guard let textView = textView else { return }
                textView.sizeToFit()
                self.textBinding?.wrappedValue = textView.text
                self.heightBinding?.wrappedValue = textView.frame.size.height
            }
            
            func textViewDidChange(_ textView: UITextView) {
                runSizing()
            }
        }
    }