70

When using SwiftUI's new TextEditor, you can modify its content directly using a @State. However, I haven't see a way to add a placeholder text to it. Is it doable right now?

enter image description here

I added an example that Apple used in their own translator app. Which appears to be a multiple lines text editor view that supports a placeholder text.

5
  • I don't think it's possible now. It's still beta so it might change though. Commented Jul 5, 2020 at 14:33
  • 1
    I hardly believe it will be ever, it is TextEditor, not TextField. There was no placeholder in UITextView as well. Commented Jul 5, 2020 at 14:34
  • @Asperi I added an example from Apple's translator app, which seems to have a TextEditor view that supports placeholder. I'm trying to achieve the same. Commented Jul 5, 2020 at 15:04
  • key word is seems ... looks at this solution How do I create a multiline TextField in SwiftUI? Commented Jul 5, 2020 at 15:08
  • 1
    I created a Feedback Assistant asking for this be available in the final Xcode 12 release 🙏 (FB8118309) Commented Jul 23, 2020 at 19:56

23 Answers 23

54

It is not possible out of the box but you can achieve this effect with ZStack or the .overlay property.

What you should do is check the property holding your state. If it is empty display your placeholder text. If it's not then display the inputted text instead.

And here is a code example:

ZStack(alignment: .leading) {
    if email.isEmpty {
        Text(Translation.email)
            .font(.custom("Helvetica", size: 24))
            .padding(.all)
    }
    
    TextEditor(text: $email)
        .font(.custom("Helvetica", size: 24))
        .padding(.all)
}

Note: I have purposely left the .font and .padding styling for you to see that it should match on both the TextEditor and the Text.

EDIT: Having in mind the two problems mentioned in Legolas Wang's comment here is how the alignment and opacity issues could be handled:

  • In order to make the Text start at the left of the view simply wrap it in HStack and append Spacer immediately after it like this:
HStack {
   Text("Some placeholder text")
   Spacer()
}
  • In order to solve the opaque problem you could play with conditional opacity - the simplest way would be using the ternary operator like this:
TextEditor(text: stringProperty)        
        .opacity(stringProperty.isEmpty ? 0.25 : 1)

Of course this solution is just a silly workaround until support gets added for TextEditors.

Sign up to request clarification or add additional context in comments.

5 Comments

It is a brilliant thought, but unfortunately it suffered from two problems. The first one is the TextEditor view, which is opaque, so it will block the placeholder view when layering on top in a ZStack. Tweaking with opacity could help a little in this case. The second problem is the frame logic with Text and TextEditor, The TextEditor begin from left top corner, and the Text starts from the center of the view. Which makes them very hard to overlay exactly on top. Do you have some thoughts about the alignment issue?
@LegolasWang I didn't want to include anything super specific about styling but instead left the font and padding only in order to demonstrate that the styling, aligning etc. should match. I am adding an edit to my answer to demonstrate how those 2 for-mentioned problems could be handled.
You can actually put the HStack below the TextEditor and give it a .contentShape of NoShape: ``` struct NoShape: Shape { func path(in rect: CGRect) -> Path { return Path() } } // ... HStack { Text("Some placeholder text") .contentShape(NoShape()) } ```
For placeholder text color you can use: .foregroundColor(Color(UIColor.placeholderText))
Using ZStack(alignment: .topLeading) puts the placeholder text in the correct spot. You can add .top and .leading padding on the Text to fine-tune it.
45

You can use a ZStack with a disabled TextEditor containing your placeholder text behind. For example:

ZStack {
    if self.content.isEmpty {
            TextEditor(text:$placeholderText)
                .font(.body)
                .foregroundColor(.gray)
                .disabled(true)
                .padding()
    }
    TextEditor(text: $content)
        .font(.body)
        .opacity(self.content.isEmpty ? 0.25 : 1)
        .padding()
}

3 Comments

As of April 2023, there is still no placeholder API available. Therefore, this workaround remains the simplest solution for this issue.
Dear Apple, how bout a placeholder mate.
Thanks, I used .constant("Description...") instead $placeholderText
24

Using TextField and init(_:text:axis:) initializer

With iOS 16, as an alternative to TextEditor, you can create a multiline TextField using init(_:text:axis:) initializer.

The following code shows a multiline TextField with its native placeholder:

import SwiftUI

struct ContentView: View {
    @State private var note = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        Form {
            TextField("Note", text: $note, axis: .vertical)
                .lineLimit(2...)
                .focused($isFocused)
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Spacer()
                Button("Done") {
                    isFocused = false
                }
            }
        }
    }
}

Using TextEditor with a custom placeholder

If you need to use a TextEditor and not a multiline TextField, you can create a custom placeholder for it. Starting with iOS 15, FocusState can help manage the focus state of a TextEditor (e.g. show or hide its custom placeholder).

The following code shows a possible implementation of a TextEditor with a custom placeholder:

import SwiftUI

struct ContentView: View {
    @State private var note = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        Form {
            ZStack(alignment: .topLeading) {
                TextEditor(text: $note)
                    .focused($isFocused)
                if !isFocused && note.isEmpty {
                    Text("Note")
                        .foregroundColor(Color(uiColor: .placeholderText))
                        .padding(.top, 10)
                        .allowsHitTesting(false)
                }
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Spacer()
                Button("Done") {
                    isFocused = false
                }
            }
        }
    }
}

2 Comments

I believe this to be most superior and most up-to-date version of all solutions here in 2022! +1 on FocusState and +1 on using .allowsHitTesting(false)
Still the best solution in 2024.
23

I modified @bde.dev solution and here is the code sample and a screenshot..

   struct TextEditorWithPlaceholder: View {
        @Binding var text: String
        
        var body: some View {
            ZStack(alignment: .leading) {
                if text.isEmpty {
                   VStack {
                        Text("Write something...")
                            .padding(.top, 10)
                            .padding(.leading, 6)
                            .opacity(0.6)
                        Spacer()
                    }
                }
                
                VStack {
                    TextEditor(text: $text)
                        .frame(minHeight: 150, maxHeight: 300)
                        .opacity(text.isEmpty ? 0.85 : 1)
                    Spacer()
                }
            }
        }
    }

And I used it in my view like:

   struct UplodePostView: View {
        @State private var text: String = ""
        
        var body: some View {
            NavigationView {
                Form {
                    Section {
                        TextEditorWithPlaceholder(text: $text)
                    }
                }
            }
        }
    }

Output

3 Comments

Should be at top 🙏
you've got a typo on "Cancel" ;-)
Thanks @airstrike! It's till today not in production 😭
15

I built a custom view that can be used like this (until TextEditor officially supports it - maybe next year)

TextArea("This is my placeholder", text: $text)

Full solution below:

struct TextArea: View {
    private let placeholder: String
    @Binding var text: String
    
    init(_ placeholder: String, text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
    }
    
    var body: some View {
        TextEditor(text: $text)
            .background(
                HStack(alignment: .top) {
                    text.isBlank ? Text(placeholder) : Text("")
                    Spacer()
                }
                .foregroundColor(Color.primary.opacity(0.25))
                .padding(EdgeInsets(top: 0, leading: 4, bottom: 7, trailing: 0))
            )
    }
}

extension String {
    var isBlank: Bool {
        return allSatisfy({ $0.isWhitespace })
    }
}

I'm using the default padding of the TextEditor here, but feel free to adjust to your preference.

3 Comments

somehow, there's a white plane overlaying the placeholder 🤔
Still using this on iOS 14.2 (light and dark mode) and no issues so far. If you're using it with other custom views though, you might want to change the code a bit to suit your needs. Feel free to share your screenshot and code though 😊
The day where you can use a TextEditor a dismiss the keyboard, similar to a TextField is the day I rejoice.
12

Until we have some API support, an option would be to use the binding string as placeholder and onTapGesture to remove it

TextEditor(text: self.$note)
                .padding(.top, 20)
                .foregroundColor(self.note == placeholderString ? .gray : .primary)
                .onTapGesture {
                    if self.note == placeholderString {
                        self.note = ""
                    }
                }

2 Comments

What if the user types placeholder string?
Please don't do it like that, this can bring all sorts of problems.
5

Native Solution from iOS 16

Instead of TextEditor use TextField. Since iOS 16 it supports multiple lines and of course place holder natively, when using one of the initializers with the axis parameter.

TextField("Placeholder...", text: .constant("hey"), axis: .vertical)
    .lineLimit(1...5)

With the range on you can even say how big the TextField can get at minimum. Or use linelimit(_:reservesSpace:).

You may missing some features of TextEditor but for me it's working perfectly.

1 Comment

This solution is only suitable if you initially have 1-line text (which is no more than 1 line high). Otherwise, your placeholder will be in the middle (vertically) of the field-box, which doesn't look good
3

There are some good answers here, but I wanted to bring up a special case. When a TextEditor is placed in a Form, there are a few issues, primarily with spacing.

  1. TextEditor does not horizontally align with other form elements (e.g. TextField)
  2. The placeholder text does not horizontally align with the TextEditor cursor.
  3. When there is whitespace or carriage return/newline are added, the placeholder re-positions to the vertical-middle (optional).
  4. Adding leading spaces causes the placeholder to disappear (optional).

One way to fix these issues:

Form {
    TextField("Text Field", text: $text)

    ZStack(alignment: .topLeading) {
        if comments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            Text("Long Text Field").foregroundColor(Color(UIColor.placeholderText)).padding(.top, 8)
        }
        TextEditor(text: $comments).padding(.leading, -3)
    }
}

Comments

2

With an overlay, you won't be able to allow touch on the placeholder text for the user to write in the textEditor. You better work on the background, which is a view.

So, create it, while deactivating the default background:

struct PlaceholderBg: View {

let text: String?

init(text:String? = nil) {
        UITextView.appearance().backgroundColor = .clear // necessary to remove the default bg
    
    self.text = text
 }

var body: some View {
    VStack {
    HStack{
    
    Text(text!)
          
    Spacer()
    }
    Spacer()
    }
}
    
}

then, in your textEditor:

 TextEditor(text: $yourVariable)
                        
                        .frame(width: x, y)
                        .background(yourVariable.isEmpty ? PlaceholderBg(texte: "my placeholder text") : PlaceholderBG(texte:""))

Comments

2

Combined with the answer of @grey, but with white background coverage, you need to remove the background to have an effect

struct TextArea: View {
    private let placeholder: String
    @Binding var text: String
    
    init(_ placeholder: String, text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
        // Remove the background color here
        UITextView.appearance().backgroundColor = .clear
    }
    
    var body: some View {
        TextEditor(text: $text)
            .background(
                HStack(alignment: .top) {
                    text.isBlank ? Text(placeholder) : Text("")
                    Spacer()
                }
                .foregroundColor(Color.primary.opacity(0.25))
                .padding(EdgeInsets(top: 0, leading: 4, bottom: 7, trailing: 0))
            )
    }
}

extension String {
    var isBlank: Bool {
        return allSatisfy({ $0.isWhitespace })
    }
}

Comments

2

Here is how I solved it.

I used a Text for the placeholder together with the TextEditor in a ZStack.

The first problem was that since the Text is opaque, it would prevent the TextEditor from becoming focused if you tapped on the area covered by the Text. Tapping on any other area would make the TextEditor focused. So I solved it by adding a tap gesture with the new iOS 15 @FocusState property wrapper.

The second problem was that the TextEditor was not properly aligned to the left of the placeholder so I added a negative .leading padding to solve that.

struct InputView: View {
  
  @State var text: String = ""
  @FocusState var isFocused: Bool
  
  var body: some View {
      
      ZStack(alignment: .leading) {
        
        TextEditor(text: $text)
          .font(.body)
          .padding(.leading, -4)
          .focused($isFocused, equals: true)
        
        if text.isEmpty {
          Text("Placeholder text...")
            .font(.body)
            .foregroundColor(Color(uiColor: .placeholderText))
            .onTapGesture {
              self.isFocused = true
            }
        }
      }
  }
}

Hopefully it is natively supported in the future.

1 Comment

I added this InputView to a Form and it seems like using the @FocusState here breaks dismissing the keyboard when tapping outside of the InputView. Otherwise this works great.
1

As I know, this is the best way to add a placeholder text to TextEditor in SwiftUI

struct ContentView: View {
    @State var text = "Type here"
    
    var body: some View {

        TextEditor(text: self.$text)
            // make the color of the placeholder gray
            .foregroundColor(self.text == "Type here" ? .gray : .primary)
            
            .onAppear {

                // remove the placeholder text when keyboard appears
                NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
                    withAnimation {
                        if self.text == "Type here" {
                            self.text = ""
                        }
                    }
                }
                
                // put back the placeholder text if the user dismisses the keyboard without adding any text
                NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (noti) in
                    withAnimation {
                        if self.text == "" {
                            self.text = "Type here"
                        }
                    }
                }
            }
    }
}

Comments

1

I like Umayanga's approach but his code wasn't reusable. Here's the code as a reusable view:

struct TextEditorPH: View {
    
    private var placeholder: String
    @Binding var text: String
    
    init(placeholder: String, text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
    }
    
    var body: some View {
        TextEditor(text: self.$text)
            // make the color of the placeholder gray
            .foregroundColor(self.text == placeholder ? .gray : .primary)
            
            .onAppear {
                // create placeholder
                self.text = placeholder

                // remove the placeholder text when keyboard appears
                NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
                    withAnimation {
                        if self.text == placeholder {
                            self.text = ""
                        }
                    }
                }
                
                // put back the placeholder text if the user dismisses the keyboard without adding any text
                NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (noti) in
                    withAnimation {
                        if self.text == "" {
                            self.text = placeholder
                        }
                    }
                }
            }
    }
}

Comments

1

I was looking into this and after going through most of these solutions (which some are bit hacky) I came up with the most elegant one (still hacky though but it is what it is).

This approach shows TextField if the text is empty. Then, as soon as text is not empty anymore - TextField disappears and TextEditor appears with the same text. FocusStates are here to make sure that user doesn't lose focus on the field if they delete whole text (and TextEditor disappears while TextField appears).

Good thing about this approach is that you don't have to deal with paddings, changing text colors etc and it feels almost as natural as TextField. Bad thing is that it's not working that great outside of Form - since TextEditor is greedy it pushes all other views away so it can take as much of the space compared to TextField that uses only one line. Here is the video of how it looks like in action: link

    struct ContentView: View {
    
    @State private var text: String = ""
    @State private var topBottomTextFields: String = ""
    
    @FocusState private var isTextFieldFocused: Bool
    @FocusState private var isTextEditorFocused: Bool
    
    var body: some View {
        Form {
            TextField("TextField 1", text: $topBottomTextFields)
            
            if text.isEmpty {
                TextField("Enter something...", text: $text)
                    .focused($isTextFieldFocused)
            } else {
                TextEditor(text: $text)
                    .focused($isTextEditorFocused)
            }
            
            TextField("TextField 2", text: $topBottomTextFields)
        }
        .onChange(of: text) { oldValue, newValue in
            if newValue.isEmpty {
                print("Should switch to TextField")
                isTextFieldFocused = true
            }
            
            if oldValue.isEmpty && !newValue.isEmpty {
                print("Should switch to TextEditor")
                isTextEditorFocused = true
            }
        }
    }
}

Comments

0

SwiftUI TextEditor does not yet have support for a placeholder. As a result, we have to "fake" it.

Other solutions had problems like bad alignment or color issues. This is the closest I got to simulating a real placeholder. This solution "overlays" a TextField over the TextEditor. The TextField contains the placeholder. The TextField gets hidden as soon as a character is inputted into the TextEditor.

import SwiftUI

struct Testing: View {
  @State private var textEditorText = ""
  @State private var textFieldText = ""

  var body: some View {
    VStack {
      Text("Testing Placeholder Example")
      ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
        TextEditor(text: $textEditorText)
          .padding(EdgeInsets(top: -7, leading: -4, bottom: -7, trailing: -4)) // fix padding not aligning with TextField
        if textEditorText.isEmpty {
          TextField("Placeholder text here", text: $textFieldText)
            .disabled(true) // don't allow for it to be tapped
        }
      }
    }
  }
}

struct Testing_Previews: PreviewProvider {
  static var previews: some View {
    Testing()
  }
}

Comments

0

I've read all the comments above (and in the Internet at all), combined some of them and decided to come to this solution:

  1. Create custom Binding wrapper
  2. Create TextEditor and Text with this binding
  3. Add some modifications to make all this pixel-perfect.

Let's start with creating wrapper:

     extension Binding where Value: Equatable {
init(_ source: Binding<Value?>, replacingNilWith nilProxy: Value) {
    self.init(
        get: { source.wrappedValue ?? nilProxy },
        set: { newValue in
            if newValue == nilProxy {
                source.wrappedValue = nil
            } else {
                source.wrappedValue = newValue
            }
        })
}
}

Next step is to initialize our binding as usual:

@State private var yourTextVariable: String?

After that put TextEditor and Text in the ZStack:

ZStack(alignment: .topLeading) {
            Text(YOUR_HINT_TEXT)
                .padding(EdgeInsets(top: 6, leading: 4, bottom: 0, trailing: 0))
                .foregroundColor(.black)
                .opacity(yourTextVariable == nil ? 1 : 0)

            TextEditor(text: Binding($yourTextVariable, replacingNilWith: ""))
                .padding(.all, 0)
                .opacity(yourTextVariable != nil ? 1 : 0.8)
        }

And this will give us pixel-perfect UI with needed functionality:

https://youtu.be/T1TcSWo-Mtc

Comments

0

We can create a custom view to add placeholder text in the TextEditor.

Here is my solution:

AppTextEditor.swift

 import SwiftUI

// MARK: - AppTextEditor

struct AppTextEditor: View {

  @Binding var message: String
  let placeholder: LocalizedStringKey

  var body: some View {
    ZStack(alignment: .topLeading) {
      if message.isEmpty {
        Text(placeholder)
          .padding(8)
          .font(.body)
          .foregroundColor(Color.placeholderColor)
      }
      TextEditor(text: $message)
        .frame(height: 100)
        .opacity(message.isEmpty ? 0.25 : 1)

    }
    .overlay(
      RoundedRectangle(cornerRadius: 8)
        .stroke(Color.placeholderColor, lineWidth: 0.5))
  }
}

// MARK: - AppTextEditor_Previews

struct AppTextEditor_Previews: PreviewProvider {
  static var previews: some View {
    AppTextEditor(message: .constant(""), placeholder: "Your Message")
      .padding()
  }
}

Color+Extensions.swift

extension Color {
  static let placeholderColor = Color(UIColor.placeholderText)
}

Usage:

struct YourView: View {

  @State var message = ""

  var body: some View {
    AppTextEditor(message: $message, placeholder: "Your message")
      .padding()
  }
}

Comments

0

I did it this way:

            TextEditor(text: $bindingVar)
                .font(.title2)
                .onTapGesture{
                    placeholderText = true
                }
                .frame(height: 150)
                .overlay(
                    VStack(alignment: .leading){
                        HStack {
                            if !placeholderText {
                                Text("Your placeholdergoeshere")
                                    .font(.title2)
                                    .foregroundColor(.gray)
                            }
                            Spacer()
                        }
                        Spacer()
                    })

Comments

0

None of the suggested answers was helpful for me, When the user taps the TextEditor, it should hide the placeholder. Also there's a nasty bug from Apple that doesn't allow you to properly change the TextEditor's background color (iOS 15.5 time of writing this) I provided my refined code here.

Make sure add this code at the app initialization point:

@main
struct MyApplication1: App {
    let persistenceController = PersistenceController.shared
    init(){
        UITextView.appearance().backgroundColor = .clear // <-- Make sure to add this line
    }
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

struct PlaceHolderTextEditor: View {
    let cornerRadius:CGFloat = 8
    let backgroundColor:Color = .gray
    let placeholder: String

    @Binding var text: String
    @FocusState private var isFocused: Bool
    
    var body: some View {
        ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) {
            TextEditor(text: $text)
                .focused($isFocused)
                .onChange(of: isFocused) { isFocused in
                    self.isFocused = isFocused
                }
                .opacity((text.isEmpty && !isFocused) ? 0.02 : 1)
                .foregroundColor(.white)
                .frame(height:150)
                .background(backgroundColor)

            if text.isEmpty && !isFocused {
                Text(placeholder)
                    .padding(.top, 8)
                    .padding(.leading,8)
            }
        }.cornerRadius(cornerRadius)
    }
}

Comments

0

textEditor{...}.onTapGesture {

                if text == placeholder {
                    self.text = ""
                }
                
           
            }.onAppear {
                text = placeholder
            }
        
          
        

        Button {
            
            text = placeholder
            isFocused = false
            
        }....

1 Comment

As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center.
0

Fighting TextEditor recently I use this as an approximate and simple solution

        TextEditor(text: dvbEventText)
          .overlay(alignment:.topLeading) 
             {
              Text(dvbEventText.wrappedValue.count == 0 ? "Enter Event Text":"")
                .foregroundColor(Color.lightGray)
                .disabled(true)
             }

As soon as you start typing the hint goes away and the prompt text is where you type.

FWIW

1 Comment

ios 15 .... not working in 14
0

The answers here are very nice. after some research I wrote the following code which works great:

struct TextView: View {
    @FocusState private var keyboardFocused:Bool
    @Binding var text: String
    var placeholder = ""
    var shouldShowPlaceholder:Bool { text.isEmpty && !keyboardFocused }
    var body: some View {
        ZStack(alignment: .topLeading) {
            if shouldShowPlaceholder {
                Text(placeholder)
                    .padding(.top, 10)
                    .padding(.leading, 6)
                    .onTapGesture {
                        keyboardFocused = true
                    }
            }
            
            TextEditor(text: $text)
                .colorMultiply(shouldShowPlaceholder ? .clear : .white)
                .focused($keyboardFocused)
        }
    }
}

Comments

0

Declare and initialize the placeholder

@State private var specialInstructions = "Special Instructions"

Take it off once the user starts typing

TextEditor(text: $specialInstructions)
.foregroundStyle(.black) // Set your text color
.frame(height: 50) // Set the height you want for your field
.cornerRadius(8.0)
.onTapGesture(perform: {
if specialInstructions == "Special Instructions" {
    self.specialInstructions = ""
  }
})

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.