Skip to content

How to use TextEditor in SwiftUI | SwiftUI Bootcamp #36

TextField handles a single line. When you need a notes field, a bio, a message composer, or any multi-line text input, you reach for TextEditor. This lesson covers the key differences between the two, how to style TextEditor (including a workaround for its non-standard background behavior), and how to wire up a save action.

What You'll Learn

  • What TextEditor is, when to use it over TextField, and how it handles multi-line scrollable text
  • How .colorMultiply works as a workaround for styling the TextEditor background
  • How to save text on demand and display the saved content below the editor

Mental Model

Think of TextField as a Post-it note — one line, limited space, you write it and stick it somewhere. TextEditor is a legal pad — multiple lines, scrollable, room to write paragraphs. In SwiftUI, TextEditor is the analog of UITextView from UIKit — it's a full-featured, scrollable text editing surface.

The main thing that trips people up with TextEditor is that it has a white background by default that you can't change with .background(Color.red) the way you can with other views. It ignores that modifier internally. The workaround is .colorMultiply(color), which applies a color multiplication blend to whatever is rendered — effectively tinting the background.

Detailed Explanation

TextEditor(text: $textEditorText) creates a multi-line, scrollable text input. Like TextField, it takes a binding to a String. Unlike TextField, there is no placeholder parameter — if you need a placeholder for TextEditor, you'll need to implement it yourself (usually by overlaying greyed-out Text that disappears when the binding is non-empty).

TextEditor expands to fill available space. Use .frame(height:) to constrain it to a fixed height, or omit the frame and let it fill the VStack. In this lesson, .frame(height: 250) gives it a defined area above the save button.

The background styling challenge: .background(Color.red) has no visible effect on TextEditor because the editor's internal UITextView draws its own white background on top of the SwiftUI background. .colorMultiply(Color(...)) works around this by applying a color-blend multiplication at the rendering level. Multiplying by a gray value darkens the white background to that gray. Multiplying by .white is the identity operation (no change). This isn't a perfect API, but it's the available workaround for iOS 14 and earlier.

In iOS 16+, .scrollContentBackground(.hidden) and .background(Color.myColor) work correctly together on TextEditor, making .colorMultiply unnecessary on modern targets.

Code Structure

TextEditorBootcamp.swift shows a TextEditor with a fixed height and a gray tinted background. A "Save" button copies textEditorText into savedText. The savedText string is displayed below the button, demonstrating the read-only display of saved content separately from the live editing area.

Complete Code

TextEditorBootcamp.swift

swift
import SwiftUI

struct TextEditorBootcamp: View {
    
    @State var textEditorText: String = "This is the starting text." // pre-populated with example content
    @State var savedText: String = ""  // holds the last saved version; separate from the live editing state
    
    var body: some View {
        NavigationView {
            VStack {
                TextEditor(text: $textEditorText) // multi-line scrollable editor, bound to textEditorText
                    .frame(height: 250)           // constrain height; without this it expands to fill all space
                    //.foregroundColor(.red)       // text color — works normally on TextEditor
                    //.background(Color.red)       // does NOT work — TextEditor ignores this modifier
                    .colorMultiply(Color(#colorLiteral(red: 0.8374180198, green: 0.8374378085, blue: 0.8374271393, alpha: 1))) // tints the white background to light gray via blend multiplication
                    .cornerRadius(10)
                Button(action: {
                    savedText = textEditorText    // captures the current editor content as a snapshot
                }, label: {
                    Text("Save".uppercased())
                        .font(.headline)
                        .foregroundColor(.white)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.blue)
                        .cornerRadius(10)
                })
                Text(savedText)  // displays the saved snapshot — doesn't update live, only on Save tap
                
                Spacer()
            }
            .padding()
            //.background(Color.green)
            .navigationTitle("TextEditor Bootcamp!")
        }
    }
}

struct TextEditorBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        TextEditorBootcamp()
    }
}

Code Walkthrough

  1. @State var textEditorText: String = "This is the starting text." — Pre-populated text shows in the editor on first render. This is useful for edit screens where you load existing content for the user to modify.

  2. @State var savedText: String = "" — A separate state for the committed/saved content. The editor content and saved content are intentionally separate — the user edits freely in the editor, and only tapping Save commits the change.

  3. TextEditor(text: $textEditorText) — Identical binding syntax to TextField. The editor shows and edits the string; every change reflects in textEditorText immediately.

  4. .frame(height: 250) — Without this, TextEditor fills all available vertical space in the VStack, pushing the button off screen. Constraining height is almost always necessary when mixing TextEditor with other views.

  5. .colorMultiply(Color(#colorLiteral(...))) — The gray color literal (approximately #D5D5D5) is being multiplied with the editor's white background. White × light-gray = light-gray. This is a render-level trick, not a semantic background color. The comment .background(Color.red) above it shows the approach that doesn't work.

  6. savedText = textEditorText — A simple assignment captures the current editor value as a snapshot. savedText doesn't update again until the next Save tap. This pattern (live editing state vs. committed state) is the foundation for draft/publish workflows.

Common Mistakes

Mistake: Using .background(Color.someColor) on TextEditor and wondering why it has no effect
TextEditor wraps a UITextView internally, and UITextView draws its own opaque background. SwiftUI's .background modifier doesn't penetrate this. Use .colorMultiply on iOS 14 and below, or .scrollContentBackground(.hidden) combined with .background on iOS 16+.

Mistake: Assuming TextEditor has placeholder support
Unlike TextField, TextEditor has no built-in placeholder. A common workaround is to overlay a Text("Placeholder...") with .opacity(textEditorText.isEmpty ? 1 : 0) on top of the editor, positioned at the top-leading edge.

Mistake: Not constraining TextEditor height in a VStack
TextEditor greedily takes all available space. In a VStack with a button, it will push the button off-screen. Always set a .frame(height:) or use a Spacer strategically to keep the layout usable.

Key Takeaways

  • TextEditor is the SwiftUI view for multi-line, scrollable text input — use it for notes, bios, message composers, and any input longer than one line.
  • TextEditor ignores .background(color) — use .colorMultiply(color) on iOS 14 or .scrollContentBackground(.hidden) + .background(color) on iOS 16+.
  • Keeping a separate savedText state distinct from the live textEditorText allows "draft then commit" workflows without extra complexity.

Last updated: June 27, 2026

Released under the MIT License.