2

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.

Clifton Labrum
  • 13,053
  • 9
  • 65
  • 128

1 Answers1

3

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()
        }
    }
}
jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • This is a very slick solution, thank you. The only remaining issue I see is that the `TextEditor` still scrolls if you swipe it up or down. Here's a GIF from the Simulator that shows what I mean: https://cln.sh/SL7xMv Is that an unavoidable side effect of using a `TextEditor`? – Clifton Labrum Mar 05 '21 at 23:59
  • I would think so, since TextEditor is inherently a scroll view, but I could be wrong. If it were UIKit, you could probably disable scrolling unless it was the current first responder. – jnpdx Mar 06 '21 at 00:01
  • I've tried setting `textView.isScrollEnabled = false` on a `UITextView` (via `UIViewRepresentable`) but it didn't behave like it does in a UIKit app. I don't know how to make it behave as if its edges have auto layout constraints pinned to its parent. – Clifton Labrum Mar 06 '21 at 03:07
  • This is really cool, thank you! I tried the `UIViewRepresentable` and it works great with one catch. If you set an initial text value for one of the tasks that's really long (like 5 lines worth of text) then the initial height of the task is still only the default `textHeight: 40`. But as soon as I type in the field, the height updates to be correct. I tried calling `sizeToFit` on the `textView` in `updateUIView` but it didn't help. Are you seeing that same issue or does it work for you? – Clifton Labrum Mar 06 '21 at 18:25
  • Just updated it to account for that scenario. Try again and let me know. – jnpdx Mar 06 '21 at 18:34
  • 1
    Ah, fantastic! I was trying to do something like that but inside `updateUIView` I kept getting a `0` for `context.coordinator.textView?.frame.size.height`. This situation shows where SwiftUI is a little weak--this scenario was much easier in UIKit. Thank you so much for your help! – Clifton Labrum Mar 06 '21 at 18:38
  • After some trial and error and a red-hot CPU, I discovered that calling `runSizing()` inside `updateUIView()` causes an infinite loop. I guess it triggers a view change and makes it call itself again. I tried setting the initial size in `makeUIView()` but it doesn't seem to have the right height yet. Any ideas? – Clifton Labrum Mar 09 '21 at 06:05
  • I'd probably be looking at ways to check whether it *really needs* to resize -- eg, has the text changed. So, maybe storing an older version of the state and then comparing the values, only calling runSizing if it needs it? – jnpdx Mar 09 '21 at 06:14
  • Yeah, I tried `guard self.textBinding?.wrappedValue != textView.text else{ return }` inside `runSizing()` but the values are always equal. It seems `runSizing` needs to get called at least once before the initial height can be calculated. I'll keep digging. Thanks! – Clifton Labrum Mar 09 '21 at 06:24