To make a wrapped NSTextView fit a SwiftUI frame, represent an NSScrollView, accept finite proposed dimensions through sizeThatFits, and let the text document grow vertically inside it. Configure the text container to track the editor’s width so long lines wrap.
This tutorial builds a plain-text editor for your own Mac app with a bounded visible area. Adding text should produce vertical scrolling without enlarging the SwiftUI layout. The complete example targets macOS 13 or later, where Apple makes the representable sizing callback available. Apple’s sizeThatFits documentation
Why can an NSTextView appear to ignore a SwiftUI frame?
There are three sizes to coordinate. SwiftUI sizes the represented AppKit view. An NSScrollView provides the visible area. Its document view contains the text, while an NSTextContainer determines the area available for text layout. Apple documents the document view as the view that the scroll view scrolls inside its content view. Apple’s documentView documentation
A SwiftUI .frame(width:height:) creates an enclosing layout frame. It does not independently configure the text container’s wrapping behavior. Apple also explains that a child’s bounds can extend beyond an enclosing frame. That makes a border useful for diagnosis: the border can have the requested dimensions even when the represented content behaves differently. Apple’s frame documentation
The central constraint is ownership. Apple explicitly says SwiftUI controls the represented AppKit view’s frame and bounds; changing those properties yourself conflicts with SwiftUI. Return your preferred size through the representable sizing callback instead. In this implementation, the represented view is the scroll view, while AppKit manages its internal text document. Apple’s NSViewRepresentable documentation
Choose the behavior before changing layout flags:
| Desired behavior | Outer sizing policy | Text document policy |
|---|---|---|
| Fixed editor panel | Accept the supplied width and height | Wrap horizontally; grow vertically and scroll |
| Editor filling a bounded pane | Accept the pane’s finite proposed dimensions | Wrap horizontally; grow vertically and scroll |
| Editor expanding to show all text | Calculate height for the proposed width | Coordinate content measurement and layout updates |
The first two rows are the task covered by the executable example. The third needs a separate content-height policy; accepting the proposed height does not implement it.
Build a bounded, wrapping editor
Create a macOS SwiftUI app in Xcode and set its deployment target to macOS 13 or later. Add the following file as FramedTextEditor.swift. The second code block supplies the app entry point and sample content; together, the two files form the complete example.
The configuration follows Apple’s documented vertical-scrolling arrangement: the text view grows vertically, its width follows its superview, and its text container follows the text view’s width. Apple’s assembly guide is archived, so the example uses current Swift APIs and explicitly selects TextKit 2 through NSTextView(usingTextLayoutManager: true). Apple documents that initializer choice in its TextKit presentation. Apple’s scrolling text-view guide, What’s new in TextKit and text views
import AppKit
import SwiftUI
@MainActor
struct FramedTextEditor: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator($text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
let textView = NSTextView(usingTextLayoutManager: true)
// This is the internal document, not the represented root view.
textView.setFrameSize(scrollView.contentSize)
textView.minSize = .zero
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.isRichText = false
textView.importsGraphics = false
textView.font = .systemFont(ofSize: 14)
textView.textContainerInset = NSSize(width: 8, height: 8)
guard let container = textView.textContainer else {
preconditionFailure("Expected a text container")
}
container.size = NSSize(
width: max(1, scrollView.contentSize.width),
height: CGFloat.greatestFiniteMagnitude
)
container.widthTracksTextView = true
container.heightTracksTextView = false
textView.string = text
textView.delegate = context.coordinator
scrollView.documentView = textView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
context.coordinator.text = $text
guard let textView = nsView.documentView as? NSTextView else {
preconditionFailure("Expected an NSTextView document")
}
if textView.string != text {
textView.string = text
}
}
func sizeThatFits(
_ proposal: ProposedViewSize,
nsView: NSScrollView,
context: Context
) -> CGSize? {
let ideal = CGSize(width: 320, height: 180)
let proposed = proposal.replacingUnspecifiedDimensions(by: ideal)
return CGSize(
width: proposed.width.isFinite
? max(0, proposed.width) : ideal.width,
height: proposed.height.isFinite
? max(0, proposed.height) : ideal.height
)
}
static func dismantleNSView(
_ nsView: NSScrollView,
coordinator: Coordinator
) {
(nsView.documentView as? NSTextView)?.delegate = nil
}
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<String>
init(_ text: Binding<String>) {
self.text = text
super.init()
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
if text.wrappedValue != textView.string {
text.wrappedValue = textView.string
}
}
}
}
Replace the generated app entry point and content view with this second file. Keep only one @main declaration in the target. All labels and text are synthetic, and the example operates entirely within its own app window.
import SwiftUI
@main
@MainActor
struct FrameSizingDemoApp: App {
var body: some Scene {
WindowGroup {
EditorDemo()
}
}
}
@MainActor
struct EditorDemo: View {
@State private var compact = false
@State private var text = Array(
repeating: "Sample note: arrange the blue tiles beside the green tiles.",
count: 16
).joined(separator: "\n\n")
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Compact editor", isOn: $compact)
FramedTextEditor(text: $text)
.frame(width: compact ? 280 : 420, height: 180)
.border(Color.secondary)
Text("Character count: \(text.count)")
.font(.caption)
}
.padding(20)
.frame(minWidth: 460, minHeight: 280, alignment: .topLeading)
}
}
These files have been inspected but were not compiled or run here: the available environment is Linux and has no Swift executable or macOS frameworks. Build and check the app in Xcode using the verification sequence below.
How do the sizing and wrapping settings work?
Accept finite proposals without measuring the document
For this editor, a finite proposed width and height become the preferred viewport dimensions. The 320 × 180 fallback is an illustrative design choice for unspecified or nonfinite dimensions. It is not a measured text size. Zero remains zero so a minimum-size probe does not become an arbitrary positive minimum.
SwiftUI can ask for different sizes during one layout pass. Its standard proposals include minimum, maximum, and ideal-size queries. This sample deliberately supplies a finite fallback for an unbounded proposal; it is intended for a bounded editor panel rather than a general-purpose sizing policy for every container. Returning nil would instead select SwiftUI’s default sizing algorithm. Apple’s ProposedViewSize documentation, Apple’s representable sizing callback
The callback does not change the scroll view’s frame, replace text, or write SwiftUI state. Its job is to answer the sizing question. Text length therefore does not determine the outer editor height in this implementation.
Let width track the viewport while height follows content
The two width settings serve different purposes. The internal text view’s .width autoresizing mask makes its width respond to changes in its superview. The text container’s widthTracksTextView = true makes its layout width respond to changes in the text view. Both relationships matter when the editor becomes narrower. Apple’s autoresizingMask documentation, Apple’s widthTracksTextView documentation
Vertically, isVerticallyResizable = true permits the document view to change height to fit text. The large text-container height provides room for vertical layout, while heightTracksTextView = false avoids tying that layout limit to each document-view resize. The scroll view remains the bounded visible area. Apple’s vertical resizing documentation, Apple’s heightTracksTextView documentation
Expect some space between the border and the text. The example explicitly adds an eight-point text-container inset. Text can also have additional inset from the container’s line-fragment padding, so neither the viewport width nor the text-view width is necessarily the exact width occupied by characters. Apple’s textContainerInset documentation
Keep text updates separate from size negotiation
The coordinator forwards editing changes through textDidChange, and the binding connects them to the parent’s state. updateNSView handles changes flowing back from SwiftUI. Its equality check avoids replacing the editor’s string when the same value returns after an ordinary edit. Apple’s text-change delegate documentation, Apple’s Binding documentation
An unequal incoming string is treated as a complete replacement. If your app also replaces text programmatically during editing, define how to preserve selection, undo history, and in-progress composition before extending this sample. The demonstration itself only changes the string through the editor.
The example marks its UI types and coordinator with @MainActor. Swift’s concurrency guide describes main-actor isolation as the mechanism for protecting UI state and requiring annotated code to execute on that actor. The Swift Programming Language: Concurrency
Verify the result and locate remaining layout problems
Run these checks in the sample app. They separate viewport sizing, line wrapping, scrolling, and state synchronization instead of treating every symptom as a SwiftUI frame failure.
- Observe the editor border. The initial requested editor area is
420 × 180points. - Enable
Compact editor. The width should become280points while the height remains180points. Sentences should wrap into more lines. - Scroll through the supplied paragraphs. The document should move inside the same border.
- Type additional lines. The character count should change without increasing the editor’s requested height.
- Delete the text. The viewport should retain its requested dimensions even though its document has little content.
| Symptom | First check |
|---|---|
| Border has the right size, but long lines do not wrap | Check both the document’s width autoresizing mask and widthTracksTextView |
| Adding text enlarges the surrounding layout | Confirm the representable returns the scroll view and does not return content height from sizeThatFits |
| Later lines cannot be reached | Check vertical scrolling, vertical document resizing, and the text-container height limit |
| Editor remains narrow in a different parent layout | Inspect the width proposed by that parent; the callback accepts finite proposals rather than inventing extra space |
| Text position changes during unrelated state updates | Check for unconditional assignments to textView.string |
These checks are a diagnostic sequence derived from the sizing relationships above, not reported runtime test results.
What changes for content-driven height or older macOS versions?
An editor that expands to show every line has a different task. It needs a content-height calculation at the proposed width, plus a policy for any maximum height. Simply enabling vertical resizing on the internal document does not communicate a new preferred height to SwiftUI. Likewise, accepting the proposed height—as this example does—cannot calculate a text-driven height.
Preserve the chosen text engine when adding measurement. Apple explains that accessing an NSTextView instance’s layoutManager can trigger TextKit 1 compatibility mode. Its documented TextKit 2 counterpart for the text-container usage bounds is NSTextLayoutManager.usageBoundsForTextContainer. This bounded editor needs neither measurement API. Apple’s TextKit migration guidance
The version boundary also matters. NSViewRepresentable is available from macOS 10.15, but its sizeThatFits callback requires macOS 13. For earlier deployment targets, keep the scrolling-document arrangement and use the older representable’s default sizing behavior, with AppKit sizing priorities or a container designed for that target. Do not expect this callback to control layout there; validate that implementation separately. Apple’s representable documentation, Apple’s sizing callback availability
For a bounded Mac editor, keep the responsibilities explicit: SwiftUI allocates the visible rectangle, the scroll view contains the growing document, and the text container follows the available width. That arrangement gives you a concrete way to diagnose frame sizing without making text measurement part of a fixed-height editor.
Sources
- NSViewRepresentable — Apple.
- sizeThatFits(_:nsView:context:) — Apple.
- ProposedViewSize — Apple.
- frame(width:height:alignment:) — Apple.
- documentView — Apple.
- Putting an NSTextView Object in an NSScrollView — Apple Documentation Archive.
- What’s new in TextKit and text views — Apple.
- autoresizingMask — Apple.
- widthTracksTextView — Apple.
- heightTracksTextView — Apple.
- isVerticallyResizable — Apple.
- textContainerInset — Apple.
- textDidChange(_:) — Apple.
- Binding — Apple.
- Concurrency — Swift.org.