Posts

DataDetector en iOS 26: detección semántica de texto con una API moderna de Swift

Arturo Rivas Arias

Detectar un enlace, una fecha o un número de teléfono dentro de un texto no es algo nuevo en las plataformas de Apple. NSDataDetector lleva años resolviendo este problema, pero lo hace con una API heredada de Objective-C: máscaras de bits, NSRange, resultados polimórficos y propiedades opcionales cuyo significado depende del tipo encontrado.

Con iOS 26, Apple ha modernizado por fin esta tarea mediante el framework DataDetection y la nueva API DataDetector. El cambio no se limita a sustituir tipos antiguos por sintaxis Swift. Ahora la detección es asíncrona, los resultados están tipados semánticamente y los rangos apuntan directamente a los índices del texto original.

De NSDataDetector a una extensión de StringProtocol

En la API anterior era necesario crear una instancia de NSDataDetector, indicar los tipos mediante una máscara, convertir los límites de String a NSRange y después interpretar cada NSTextCheckingResult.

import Foundation

let note = "Documentación disponible en https://example.com/manual"
let checkingTypes = NSTextCheckingResult.CheckingType.link.rawValue
let detector = try NSDataDetector(types: checkingTypes)
let range = NSRange(note.startIndex..., in: note)

for result in detector.matches(in: note, range: range) {
    if result.resultType == .link {
        print(result.url as Any)
    }
}

La nueva API se expone como una extensión de StringProtocol. No se construye un detector: se solicita al propio texto una secuencia de coincidencias.

import DataDetection

let note = "Documentación disponible en https://example.com/manual"

for await match in note.dataDetectorMatches([.link]) {
    if case let .link(link) = match.details {
        print(link)
    }
}

dataDetectorMatches(_:options:) devuelve un AsyncSequence. Esto permite empezar a procesar resultados sin esperar necesariamente a que termine el análisis completo y encaja con el modelo moderno de concurrencia de Swift. Apple advierte, además, de que la detección puede consumir bastantes recursos, por lo que no conviene realizarla como trabajo síncrono dentro de una actualización de interfaz ni sobre bloques grandes de texto que no la necesiten.

Tres piezas de información por coincidencia

Cada DataDetector.Match reúne tres elementos distintos:

  • range indica dónde aparece la coincidencia dentro del texto original mediante un Range<String.Index>?.
  • details contiene el valor semántico asociado, no solo los caracteres visibles.
  • preferredHighlightStyle sugiere cómo podría presentarse el resultado en la interfaz.

La separación entre el rango y los detalles es importante. El rango sirve para resaltar o extraer el fragmento original, mientras que details es la fuente correcta para trabajar con el dato interpretado. Un teléfono, por ejemplo, puede incluir formato, etiqueta o extensión que no deberían deducirse únicamente a partir de la subcadena visible.

import DataDetection

func inspect(_ text: String) async {
    for await match in text.dataDetectorMatches(.all) {
        let fragment = match.range.map { String(text[$0]) } ?? ""

        switch match.details {
        case let .emailAddress(value):
            print("Correo:", value.emailAddress)

        case let .phoneNumber(value):
            print("Teléfono:", value.phoneNumber)

        case let .moneyAmount(value):
            print("Importe:", String(describing: value))

        default:
            print("Otro dato:", fragment)
        }
    }
}

SemanticDetails es una enumeración cuyos casos llevan valores asociados. De esta forma, el compilador ayuda a distinguir un correo de una cantidad de dinero y evita el patrón anterior, donde era posible consultar por error una propiedad que no correspondía al resultType recibido.

Qué tipos puede reconocer

DataDetector.MatchType es un OptionSet, de modo que se pueden combinar únicamente las categorías útiles para cada funcionalidad. La API contempla enlaces, correos electrónicos, teléfonos, eventos de calendario, direcciones postales, importes monetarios, medidas, números de vuelo, localizadores de envíos e identificadores de pago. También existe .all para solicitar todos los tipos.

let usefulTypes: DataDetector.MatchType = [
    .calendarEvent,
    .postalAddress,
    .moneyAmount,
    .measurement
]

let briefing = "El montaje será mañana a las 8:30 en Gran Vía 32. " +
    "El material pesa 18 kg y el presupuesto máximo es de 450 €."

for await match in briefing.dataDetectorMatches(usefulTypes) {
    guard let range = match.range else { continue }
    print(briefing[range], match.details)
}

En producción suele ser preferible seleccionar categorías concretas en lugar de recurrir siempre a .all. Detectar un localizador de envío no aporta nada si la aplicación no puede ofrecer una acción relacionada con él. Limitar los tipos reduce trabajo innecesario y hace más fácil definir qué comportamiento corresponde a cada resultado.

El contexto cambia el significado

Expresiones como «mañana», «el próximo viernes» o «a las nueve» no tienen un valor absoluto. Su interpretación depende de la fecha del documento, la zona horaria, el idioma y la región. DataDetector.Options permite proporcionar estas pistas al sistema.

import DataDetection
import Foundation

var options = DataDetector.Options()
options.documentDate = .now
options.documentTimeZone = TimeZone(identifier: "Europe/Madrid")
options.documentLanguageCode = "es"
options.documentRegion = "ES"

let text = "La revisión del prototipo será mañana a las 9:30."

for await match in text.dataDetectorMatches(
    [.calendarEvent],
    options: options
) {
    if case let .calendarEvent(event) = match.details {
        print(event)
    }
}

Estas opciones son pistas, no reglas estrictas de análisis. La detección de lenguaje natural es deliberadamente conservadora y puede descartar una coincidencia cuando no tiene suficiente certeza. También puede producir resultados distintos según el idioma, la región o la ambigüedad de la frase.

Detectar no significa validar

DataDetector no debe utilizarse para validar los campos de un formulario. Su objetivo es localizar entidades probables dentro de lenguaje natural, no decidir si un valor cumple las reglas de negocio de una aplicación.

Para validar una URL resulta más apropiado intentar construir un URL; para fechas con un formato conocido se deben emplear FormatStyle o ParseStrategy; y para identificadores propios del negocio conviene usar un analizador específico. En muchos casos, la validación definitiva tendrá que realizarse también en el servidor.

La diferencia conceptual es sencilla: la detección intenta averiguar si una frase parece contener una entidad; la validación determina si un valor concreto es admisible para una operación.

Integración sin bloquear la interfaz

En una aplicación SwiftUI puede resultar tentador analizar el contenido directamente cada vez que cambia un TextEditor. Esto provocaría ejecuciones repetidas y podría trasladar trabajo costoso al actor principal. Una solución más controlada consiste en cancelar el análisis anterior, introducir un pequeño retraso y realizar el escaneo fuera del actor principal.

import DataDetection
import Foundation
import Observation

struct DetectedFragment: Identifiable, Sendable {
    let id = UUID()
    let text: String
    let description: String
}

@available(iOS 26.0, *)
enum SemanticScanner {
    @concurrent
    static func scan(_ text: String) async -> [DetectedFragment] {
        var fragments: [DetectedFragment] = []
        let types: DataDetector.MatchType = [
            .calendarEvent,
            .moneyAmount,
            .measurement
        ]

        for await match in text.dataDetectorMatches(types) {
            guard !Task.isCancelled else { break }
            guard let range = match.range else { continue }

            fragments.append(
                DetectedFragment(
                    text: String(text[range]),
                    description: String(describing: match.details)
                )
            )
        }

        return fragments
    }
}

@available(iOS 26.0, *)
@MainActor
@Observable
final class BriefingAnalyzer {
    private(set) var fragments: [DetectedFragment] = []
    private var analysisTask: Task<Void, Never>?

    func analyze(_ text: String) {
        analysisTask?.cancel()

        analysisTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }

            let result = await SemanticScanner.scan(text)

            guard !Task.isCancelled else { return }
            fragments = result
        }
    }
}

El retardo actúa como debounce y evita iniciar un análisis por cada pulsación. El atributo @concurrent, disponible en Swift 6.2, fuerza la ejecución de scan(_:) fuera del actor principal. Como el trabajo conserva la relación con la tarea almacenada, analysisTask?.cancel() también permite que el escáner detecte la cancelación durante su recorrido. La asignación final a fragments vuelve a realizarse en MainActor.

En textos extensos también conviene limitar el fragmento analizado o dividirlo en unidades coherentes, como párrafos.

Una pista de presentación, no una decisión de diseño

preferredHighlightStyle puede devolver .hidden, .url o .regular. El sistema indica así si recomienda ocultar el resaltado, mostrarlo como enlace o aplicar un tratamiento visual más discreto.

@available(iOS 26.0, *)
func styleName(for match: DataDetector.Match) -> String {
    switch match.preferredHighlightStyle {
    case .hidden:
        "Sin resaltado"
    case .url:
        "Estilo de enlace"
    case .regular:
        "Resaltado normal"
    @unknown default:
        "Estilo desconocido"
    }
}

La propiedad no aplica estilos automáticamente ni obliga a convertir cada coincidencia en un elemento interactivo. La aplicación sigue siendo responsable de decidir qué se puede pulsar, qué acción se ejecuta y cómo se comunica mediante accesibilidad. Una cantidad de dinero detectada en una nota puede merecer un resaltado, pero no necesariamente una acción.

Compatibilidad con versiones anteriores

La API moderna está disponible a partir de iOS 26 y las versiones 26 de iPadOS, macOS, Mac Catalyst, watchOS, tvOS y visionOS. Si el proyecto mantiene una versión mínima anterior, NSDataDetector continúa siendo necesario.

Una buena estrategia consiste en esconder ambas implementaciones detrás de una interfaz propia. La capa moderna puede devolver todos los tipos semánticos nuevos, mientras que la implementación heredada ofrece el subconjunto que NSDataDetector sabe reconocer. No conviene fingir que ambas APIs tienen exactamente las mismas capacidades.

import Foundation

protocol TextEntityDetecting: Sendable {
    func detect(in text: String) async -> [DetectedFragment]
}

enum LegacyTextDetector {
    @concurrent
    static func scan(_ text: String) async -> [DetectedFragment] {
        let types: NSTextCheckingResult.CheckingType = [
            .link,
            .phoneNumber
        ]

        guard let detector = try? NSDataDetector(types: types.rawValue) else {
            return []
        }

        let fullRange = NSRange(text.startIndex..., in: text)

        return detector.matches(in: text, range: fullRange).compactMap { match in
            guard let range = Range(match.range, in: text) else { return nil }
            let fragment = String(text[range])

            let description = switch match.resultType {
            case .link:
                match.url?.absoluteString ?? fragment
            case .phoneNumber:
                match.phoneNumber ?? fragment
            default:
                fragment
            }

            return DetectedFragment(
                text: fragment,
                description: description
            )
        }
    }
}

struct AppTextDetector: TextEntityDetecting {
    func detect(in text: String) async -> [DetectedFragment] {
        if #available(iOS 26.0, *) {
            await SemanticScanner.scan(text)
        } else {
            await LegacyTextDetector.scan(text)
        }
    }
}

La implementación heredada puede concentrar las conversiones entre String.Index y NSRange, evitando que esos detalles contaminen el resto de la funcionalidad. Cuando iOS 26 pase a ser la versión mínima del proyecto, bastará con retirar esa rama.

Una modernización que va más allá de la sintaxis

DataDetector sustituye un modelo centrado en Objective-C por una API que encaja de forma natural con Swift: AsyncSequence, rangos nativos, enumeraciones con valores asociados y opciones explícitas de contexto. Además, amplía los casos de uso con importes, medidas, localizadores de envíos e identificadores de pago.

Su mayor valor aparece en notas, mensajes, resultados de OCR, documentos y editores donde el texto libre puede transformarse en información útil. Utilizado con tipos acotados, fuera del actor principal y sin confundir detección con validación, permite construir experiencias similares a las de Mail o Mensajes sin mantener expresiones regulares frágiles ni depender en toda la aplicación de una API diseñada para otra época.