Posts

NSTextTable en Swift: tablas reales dentro de NSAttributedString

Arturo Rivas Arias

Durante años, representar una tabla dentro de texto enriquecido en iOS significaba recurrir a HTML, alinear contenido manualmente con tabuladores o construir una interfaz específica con UITableView o UICollectionView. Con el SDK de iOS 27, Apple ha llevado a UIKit NSTextTable, NSTextTableBlock y el resto de APIs de TextKit necesarias para representar tablas como parte real de un NSAttributedString.

La diferencia conceptual es importante: NSTextTable no es una nueva vista ni pretende competir con UITableView. La tabla forma parte del modelo tipográfico del documento. Sus filas y columnas se integran en el propio texto enriquecido, de forma que pueden convivir con párrafos, enlaces, imágenes, listas y otros atributos dentro del mismo NSAttributedString.

La estructura puede resultar extraña al principio porque una tabla no almacena directamente sus celdas. NSTextTable define las características globales —número de columnas, algoritmo de distribución, colapso de bordes o tratamiento de celdas vacías— mientras que cada celda se representa mediante un NSTextTableBlock. Ese bloque se asocia a un párrafo mediante NSMutableParagraphStyle.textBlocks.

NSAttributedString
    ↓
Párrafo
    ↓
NSMutableParagraphStyle
    ↓
textBlocks
    ↓
NSTextTableBlock
    ↓
NSTextTable

Crear la tabla es sencillo. En este ejemplo definimos tres columnas, hacemos que los bordes adyacentes se dibujen como uno solo y usamos un diseño fijo para tener control explícito sobre sus proporciones:

let table = NSTextTable()
table.numberOfColumns = 3
table.layoutAlgorithm = .fixed
table.collapsesBorders = true
table.hidesEmptyCells = false

Cada celda necesita conocer la tabla a la que pertenece, su fila y columna iniciales y cuántas filas o columnas ocupa. Esto permite reproducir de forma nativa conceptos equivalentes a rowspan y colspan de HTML:

let cell = NSTextTableBlock(
    table: table,
    startingRow: 0,
    rowSpan: 1,
    startingColumn: 0,
    columnSpan: 2
)

NSTextTable ofrece dos algoritmos de distribución. Con .automatic, TextKit calcula el ancho de las columnas en función del contenido y del espacio disponible. Con .fixed, la distribución depende de los anchos definidos explícitamente en las celdas de la primera fila. El primero encaja bien en documentos donde el contenido manda; el segundo es mejor cuando necesitamos columnas estables y predecibles.

cell.setContentWidth(40, type: .percentage)

El aspecto visual se configura en los propios bloques. NSTextTableBlock hereda de NSTextBlock, por lo que dispone de propiedades para fondo, padding, borde, margen, dimensiones mínimas y máximas y alineación vertical. Los tamaños pueden expresarse como puntos o porcentajes.

cell.backgroundColor = .secondarySystemBackground
cell.setBorderColor(.separator)
cell.setWidth(1, type: .absolute, for: .border)
cell.setWidth(8, type: .absolute, for: .padding)

En la versión 27 de los sistemas también podemos personalizar individualmente cada lado del bloque mediante las variantes que reciben un CGRectEdge. Esto resulta útil, por ejemplo, para destacar únicamente la línea inferior de una cabecera:

cell.setWidth(
    2,
    type: .absolute,
    for: .border,
    rectEdge: .maxYEdge
)
cell.setBorderColor(.systemBlue, rectEdge: .maxYEdge)

Un ejemplo algo más completo permite ver cómo encajan todas las piezas. En lugar de construir una tabla genérica, imaginemos un informe generado por una herramienta interna que muestra varios módulos de una app y el tiempo que ha tardado en compilarlos:

import UIKit

@available(iOS 27.0, *)
func makeBuildReport() -> NSAttributedString {
    let rows = [
        ("Networking", "Correcto", "1,8 s"),
        ("Persistence", "Correcto", "2,4 s"),
        ("FeatureProfile", "Avisos", "4,1 s")
    ]

    let table = NSTextTable()
    table.numberOfColumns = 3
    table.layoutAlgorithm = .fixed
    table.collapsesBorders = true

    let result = NSMutableAttributedString()
    let widths: [CGFloat] = [45, 30, 25]

    func appendCell(
        _ text: String,
        row: Int,
        column: Int,
        isHeader: Bool = false
    ) {
        let cell = NSTextTableBlock(
            table: table,
            startingRow: row,
            rowSpan: 1,
            startingColumn: column,
            columnSpan: 1
        )

        cell.setContentWidth(widths[column], type: .percentage)
        cell.setWidth(1, type: .absolute, for: .border)
        cell.setWidth(6, type: .absolute, for: .padding)
        cell.setBorderColor(.separator)
        cell.backgroundColor = isHeader
            ? .secondarySystemBackground
            : .systemBackground

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.textBlocks = [cell]

        let attributes: [NSAttributedString.Key: Any] = [
            .font: isHeader
                ? UIFont.preferredFont(forTextStyle: .headline)
                : UIFont.preferredFont(forTextStyle: .body),
            .foregroundColor: UIColor.label,
            .paragraphStyle: paragraphStyle
        ]

        result.append(
            NSAttributedString(
                string: "\(text)\n",
                attributes: attributes
            )
        )
    }

    ["Módulo", "Estado", "Tiempo"]
        .enumerated()
        .forEach { column, title in
            appendCell(title, row: 0, column: column, isHeader: true)
        }

    for (index, item) in rows.enumerated() {
        let row = index + 1
        appendCell(item.0, row: row, column: 0)
        appendCell(item.1, row: row, column: 1)
        appendCell(item.2, row: row, column: 2)
    }

    return result
}

La línea aparentemente menos importante del ejemplo es en realidad la que conecta toda la estructura: paragraphStyle.textBlocks = [cell]. TextKit analiza los bloques asociados a párrafos consecutivos para reconstruir la tabla durante el layout. No necesitamos insertar una vista por celda ni mantener manualmente una jerarquía paralela.

Este diseño tiene otra consecuencia interesante: una celda es fundamentalmente un párrafo dentro de una región de layout. El contenido puede seguir usando atributos de texto normales, como distintas fuentes, colores, enlaces o adjuntos. La tabla añade estructura alrededor del texto, pero no sustituye el modelo de NSAttributedString.

También es posible crear celdas que ocupen varias columnas. Por ejemplo, podríamos añadir al informe anterior una fila de título que se extendiera sobre las tres columnas:

let titleCell = NSTextTableBlock(
    table: table,
    startingRow: 0,
    rowSpan: 1,
    startingColumn: 0,
    columnSpan: 3
)

En ese caso, las filas siguientes deberían comenzar en el índice adecuado para no solapar posiciones. El mismo mecanismo funciona verticalmente mediante rowSpan.

Mostrar el resultado en UIKit no requiere un componente especial. Basta con asignar el texto atribuido a un UITextView; el sistema de texto interpreta automáticamente la información almacenada en los estilos de párrafo y realiza el layout de las celdas:

@available(iOS 27.0, *)
func configure(_ textView: UITextView) {
    textView.attributedText = makeBuildReport()
    textView.isEditable = false
    textView.isScrollEnabled = true
    textView.backgroundColor = .systemBackground
}

La llegada de estas APIs a UIKit también reduce una diferencia histórica con AppKit, donde NSTextTable existe desde hace muchos años. Para aplicaciones que trabajan con documentos compartidos entre macOS y iOS, disponer ahora del mismo modelo conceptual facilita mucho la reutilización de código y de estructuras de texto enriquecido entre plataformas Apple.

Los casos de uso naturales son precisamente aquellos donde la tabla es parte de un documento: informes, facturas, recibos, editores de texto enriquecido, mensajes formateados o contenido que posteriormente se exportará. La ventaja frente a una colección de vistas es que la información estructural permanece unida al resto del texto.

Eso no convierte NSTextTable en una alternativa general a UITableView o UICollectionView. Para miles de elementos, reutilización de celdas, carga progresiva, interacción compleja, selección o interfaces que se comporten como una hoja de cálculo, las vistas tradicionales siguen siendo la solución adecuada. NSTextTable resuelve un problema distinto: representar datos tabulares dentro de un documento.

En SwiftUI tampoco aparece un nuevo componente Table orientado a este sistema de TextKit. Cuando necesitamos mostrar todas las capacidades de un NSAttributedString basado en UIKit, la integración más directa consiste en empaquetar un UITextView con UIViewRepresentable:

import SwiftUI
import UIKit

@available(iOS 27.0, *)
struct RichTextTableView: UIViewRepresentable {
    let attributedText: NSAttributedString

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.isEditable = false
        textView.isScrollEnabled = false
        textView.backgroundColor = .clear
        textView.textContainerInset = .zero
        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        textView.attributedText = attributedText
    }
}

La clave para decidir si esta API encaja en un proyecto es pensar primero en el contenido y no en la interfaz. Si la tabla debe comportarse como parte de un documento, copiarse junto al texto o mantener su formato dentro de un NSAttributedString, NSTextTable es el modelo correcto. Si lo que necesitamos es una interfaz interactiva formada por filas y celdas reutilizables, seguimos estando en el terreno de las vistas.

NSTextTable puede parecer una incorporación pequeña frente a otras novedades de iOS 27, pero cubre una carencia histórica de UIKit. Por primera vez podemos modelar tablas reales dentro de texto enriquecido en iPhone y iPad utilizando las mismas abstracciones de TextKit que ya existían en macOS, sin recurrir a HTML, tabuladores ni layouts hechos a mano.