Para fazer um NSTextView com quebra de linha caber em um frame do SwiftUI, represente um NSScrollView, aceite dimensões propostas finitas por meio de sizeThatFits e deixe o documento de texto crescer verticalmente dentro dele. Configure o contêiner de texto para acompanhar a largura do editor, para que as linhas longas sejam quebradas.

Este tutorial cria um editor de texto simples para seu próprio app para Mac, com uma área visível limitada. Ao adicionar texto, deve surgir rolagem vertical sem ampliar o layout do SwiftUI. O exemplo completo tem como alvo o macOS 13 ou posterior, versão em que a Apple disponibiliza o callback de dimensionamento do representable. Documentação da Apple sobre sizeThatFits

Por que um NSTextView parece ignorar um frame do SwiftUI?

Há três dimensões a coordenar. O SwiftUI dimensiona a view AppKit representada. Um NSScrollView fornece a área visível. A view de documento contém o texto, enquanto um NSTextContainer determina a área disponível para o layout do texto. A Apple documenta a view de documento como a view que a view de rolagem percorre dentro da sua view de conteúdo. Documentação da Apple sobre documentView

Um .frame(width:height:) do SwiftUI cria um frame de layout ao redor do conteúdo. Ele não configura, por si só, o comportamento de quebra de linha do contêiner de texto. A Apple também explica que os limites de uma view filha podem se estender além de um frame que a contém. Por isso, uma borda é útil para diagnosticar o problema: ela pode ter as dimensões solicitadas mesmo quando o conteúdo representado se comporta de outra maneira. Documentação da Apple sobre frames

A restrição central é quem controla cada propriedade. A Apple afirma explicitamente que o SwiftUI controla frame e bounds da view AppKit representada; alterá-las por conta própria entra em conflito com o SwiftUI. Em vez disso, retorne o tamanho preferido por meio do callback de dimensionamento do representable. Nesta implementação, a view representada é a view de rolagem, enquanto o AppKit gerencia o documento de texto interno. Documentação da Apple sobre NSViewRepresentable

Escolha o comportamento antes de alterar os sinalizadores de layout:

Comportamento desejado Política de dimensionamento externo Política do documento de texto
Painel de edição com tamanho fixo Aceitar a largura e a altura fornecidas Quebrar linhas horizontalmente; crescer verticalmente e permitir rolagem
Editor que preenche um painel limitado Aceitar as dimensões propostas finitas do painel Quebrar linhas horizontalmente; crescer verticalmente e permitir rolagem
Editor que se expande para exibir todo o texto Calcular a altura para a largura proposta Coordenar a medição do conteúdo e as atualizações de layout

As duas primeiras linhas correspondem ao que o exemplo executável aborda. A terceira exige uma política separada para a altura do conteúdo; aceitar a altura proposta não implementa esse comportamento.

Crie um editor com quebra de linha e área limitada

Crie um app SwiftUI para macOS no Xcode e defina o destino de implantação como macOS 13 ou posterior. Adicione o arquivo a seguir como FramedTextEditor.swift. O segundo bloco de código fornece o ponto de entrada do app e o conteúdo de exemplo; juntos, os dois arquivos formam o exemplo completo.

A configuração segue o arranjo documentado pela Apple para rolagem vertical: a view de texto cresce verticalmente, sua largura acompanha a superview e o contêiner de texto acompanha a largura da view de texto. O guia de montagem da Apple foi arquivado, então o exemplo usa APIs Swift atuais e seleciona explicitamente o TextKit 2 por meio de NSTextView(usingTextLayoutManager: true). A Apple documenta essa escolha de inicializador na apresentação sobre TextKit. Guia da Apple sobre views de texto com rolagem, Novidades no TextKit e nas views de texto

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
            }
        }
    }
}

Substitua o ponto de entrada e a view de conteúdo gerados pelo app por este segundo arquivo. Mantenha apenas uma declaração @main no destino. Todos os rótulos e textos são fictícios, e o exemplo funciona inteiramente dentro da janela do próprio app.

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)
    }
}

Esses arquivos foram inspecionados, mas não foram compilados nem executados aqui: o ambiente disponível é Linux e não tem executável Swift nem frameworks do macOS. Compile e confira o app no Xcode usando a sequência de verificação abaixo.

Como funcionam o dimensionamento e as configurações de quebra de linha?

Aceite propostas finitas sem medir o documento

Para este editor, uma largura e uma altura propostas finitas tornam-se as dimensões preferidas da área de visualização. O valor padrão de 320 × 180 é uma escolha ilustrativa de design para dimensões não especificadas ou não finitas. Ele não corresponde a uma medição do texto. Zero continua sendo zero, para que uma consulta de dimensão mínima não se torne um mínimo positivo arbitrário.

O SwiftUI pode solicitar dimensões diferentes durante uma única passagem de layout. Entre as propostas padrão estão consultas de dimensão mínima, máxima e ideal. Este exemplo fornece intencionalmente um valor padrão finito para uma proposta sem limite; ele foi pensado para um painel de edição limitado, não como uma política de dimensionamento de uso geral para qualquer contêiner. Retornar nil selecionaria, em vez disso, o algoritmo de dimensionamento padrão do SwiftUI. Documentação da Apple sobre ProposedViewSize, Callback de dimensionamento do representable da Apple

O callback não altera o frame da view de rolagem, não substitui o texto nem grava estado do SwiftUI. Sua função é responder à solicitação de dimensionamento. Portanto, nesta implementação, o comprimento do texto não determina a altura externa do editor.

Faça a largura acompanhar a área de visualização e a altura acompanhar o conteúdo

As duas configurações de largura têm funções diferentes. A máscara de redimensionamento automático .width da view de texto interna faz com que sua largura responda às mudanças na superview. A propriedade widthTracksTextView = true do contêiner de texto faz com que sua largura de layout responda às mudanças na view de texto. As duas relações são importantes quando o editor fica mais estreito. Documentação da Apple sobre autoresizingMask, Documentação da Apple sobre widthTracksTextView

Na vertical, isVerticallyResizable = true permite que a view de documento mude de altura para se ajustar ao texto. A altura grande do contêiner de texto dá espaço para o layout vertical, enquanto heightTracksTextView = false evita vincular esse limite de layout a cada redimensionamento da view de documento. A view de rolagem continua sendo a área visível limitada. Documentação da Apple sobre redimensionamento vertical, Documentação da Apple sobre heightTracksTextView

Espere encontrar algum espaço entre a borda e o texto. O exemplo adiciona explicitamente um recuo de oito pontos ao contêiner de texto. O texto também pode ter recuo adicional do preenchimento de fragmentos de linha do contêiner. Portanto, nem a largura da área de visualização nem a largura da view de texto correspondem necessariamente à largura exata ocupada pelos caracteres. Documentação da Apple sobre textContainerInset

Mantenha as atualizações de texto separadas da negociação de tamanho

O coordenador encaminha as alterações de edição por meio de textDidChange, e o binding as conecta ao estado do elemento pai. updateNSView trata das alterações que vêm do SwiftUI. Sua verificação de igualdade evita substituir a string do editor quando o mesmo valor retorna após uma edição comum. Documentação da Apple sobre o delegate de alteração de texto, Documentação da Apple sobre Binding

Uma string recebida diferente é tratada como uma substituição completa. Se seu app também substituir texto programaticamente durante a edição, defina como preservar a seleção, o histórico de desfazer e a composição em andamento antes de ampliar este exemplo. A demonstração, por si só, só altera a string por meio do editor.

O exemplo marca seus tipos de interface e o coordenador com @MainActor. O guia de concorrência do Swift descreve o isolamento do ator principal como o mecanismo para proteger o estado da interface e exigir que o código anotado seja executado nesse ator. A linguagem de programação Swift: concorrência

Verifique o resultado e localize os problemas de layout restantes

Execute estas verificações no app de exemplo. Elas permitem avaliar separadamente o dimensionamento da área de visualização, a quebra de linha, a rolagem e a sincronização do estado, em vez de tratar todo sintoma como uma falha de frame do SwiftUI.

  1. Observe a borda do editor. A área inicial solicitada é de 420 × 180 pontos.
  2. Ative Editor compacto. A largura deve passar para 280 pontos, enquanto a altura permanece em 180 pontos. As frases devem se quebrar em mais linhas.
  3. Role pelos parágrafos fornecidos. O documento deve se mover dentro da mesma borda.
  4. Digite mais linhas. A contagem de caracteres deve mudar sem aumentar a altura solicitada do editor.
  5. Exclua o texto. A área de visualização deve manter as dimensões solicitadas, mesmo que o documento tenha pouco conteúdo.
Sintoma Primeira verificação
A borda tem o tamanho correto, mas as linhas longas não são quebradas Verifique a máscara de redimensionamento automático da largura do documento e widthTracksTextView
Adicionar texto amplia o layout ao redor Confirme que o representable retorna a view de rolagem e não retorna a altura do conteúdo por meio de sizeThatFits
Não é possível chegar às linhas posteriores Verifique a rolagem vertical, o redimensionamento vertical do documento e o limite de altura do contêiner de texto
O editor continua estreito em um layout pai diferente Examine a largura proposta pelo elemento pai; o callback aceita propostas finitas, sem inventar espaço adicional
A posição do texto muda durante atualizações de estado sem relação com ele Verifique se há atribuições incondicionais a textView.string

Essas verificações formam uma sequência de diagnóstico derivada das relações de dimensionamento acima; não são resultados de testes de execução relatados.

O que muda com altura baseada no conteúdo ou em versões antigas do macOS?

Um editor que se expande para mostrar todas as linhas tem um objetivo diferente. Ele precisa calcular a altura com base na largura proposta, além de definir uma política para a altura máxima. Simplesmente ativar o redimensionamento vertical do documento interno não comunica uma nova altura preferida ao SwiftUI. Da mesma forma, aceitar a altura proposta, como faz este exemplo, não calcula uma altura determinada pelo texto.

Ao adicionar a medição, preserve o mecanismo de texto escolhido. A Apple explica que acessar a propriedade layoutManager de uma instância de NSTextView pode acionar o modo de compatibilidade com TextKit 1. A alternativa documentada para o uso do TextKit 2, referente aos limites de uso do contêiner de texto, é NSTextLayoutManager.usageBoundsForTextContainer. Este editor com área limitada não precisa de nenhuma dessas APIs de medição. Orientações da Apple para migração do TextKit

O limite de versão também importa. NSViewRepresentable está disponível a partir do macOS 10.15, mas seu callback sizeThatFits requer o macOS 13. Para destinos de implantação anteriores, mantenha a configuração do documento com rolagem e use o comportamento de dimensionamento padrão do representable mais antigo, com prioridades de dimensionamento do AppKit ou um contêiner projetado para essa versão. Não espere que esse callback controle o layout nessas versões; valide essa implementação separadamente. Documentação da Apple sobre representables, Disponibilidade do callback de dimensionamento da Apple

Para um editor limitado no Mac, deixe claras as responsabilidades: o SwiftUI aloca o retângulo visível, a view de rolagem contém o documento que cresce e o contêiner de texto acompanha a largura disponível. Essa configuração oferece uma forma concreta de diagnosticar o dimensionamento do frame sem incluir a medição do texto em um editor de altura fixa.

Fontes