Plataformas
iOS · iPadOS
La plataforma de referencia — los 25 primitivos sobre vistas de UIKit de verdad, y el puñado de cosas que UIKit puso más difíciles de lo que parecen.
La plataforma contra la que se mide todo lo demás. Los veinticinco primitivos son vistas reales de UIKit, todos los eventos se entregan, y funcionan tanto los módulos nativos como los plugins.
an ios # examples/hello-angular en el simuladoran ios examples/controls # los controles del sistemaan dev # lo mismo, vigilando, con recarga en calienteNo hace falta nada más: aarch64-apple-ios-sim está en cualquier toolchain
estable y el SDK viene con Xcode. No hay .xcodeproj — un proyecto de Xcode
solo añadiría un fichero de dos mil líneas que nadie puede revisar en un diff.
Cómo está montado
Sección titulada «Cómo está montado»Un solo crate, an-ios, sirve a tres familias: iOS, tvOS y visionOS. Lo que las
separa es un enum y un puñado de cfg — mira tvOS y
visionOS para saber qué pierde o gana cada una. En
iOS no falta nada: la lista de «aquí no está disponible» está vacía.
El shell son cinco ficheros Swift y unos cientos de líneas. main.swift llama a
UIApplicationMain sin storyboard; el app delegate crea una UIWindow con los
límites de la pantalla y un único view controller raíz. En iOS no hay scene
delegate — ese fichero es enteramente de visionOS.
El FFI son seis funciones: an_runtime_new, _eval, _reload,
_set_viewport, _frame, _free. Los plugins añaden cuatro más, y esas son
globales al proceso y no al runtime.
Dos hilos
Sección titulada «Dos hilos»El hilo principal es el dueño de cada UIView — an_runtime_new devuelve nulo
si no se le llama ahí. El motor de JS, el árbol sombra y taffy viven en un
worker con una pila de 8 MB, y ese número está medido y no elegido: el
router de Angular necesita algo más de 3 MB para completar una navegación, y a
2 MB la transición avanza siete eventos y se para. El hilo principal de iOS
tiene 1 MB que no puedes cambiar, y por eso el motor no puede vivir ahí.
En cada fotograma el hilo de UI espera como mucho 12 ms de los 16,6 ms la
respuesta del worker, luego monta lo que haya llegado y sigue. Si el worker
sigue ocupado no se encola ningún tick nuevo, o la cola crecería sin límite. El
reloj es un CADisplayLink en modo .common, y su marca de tiempo es el único
reloj de la app: los timers de JS avanzan con los fotogramas, no en un hilo
propio.
Los panics tienen un hook que escribe el sitio en stderr y hace flush a mano,
porque un panic dentro de una función extern "C" aborta y un mensaje
almacenado en buffer no se vería nunca.
El modelo de montaje
Sección titulada «El modelo de montaje»Una jerarquía real de UIKit: una vista nativa por nodo, insertSubview(at:)
para la estructura, y setFrame con el rectángulo que produjo taffy. Auto
Layout no se usa nunca — sería un segundo motor de layout compitiendo con el
primero.
Toda vista creada recibe translatesAutoresizingMaskIntoConstraints = true, y
esa línea tiene una historia. Antes era false, lo cual era inofensivo hasta
que el primer control con restricciones intrínsecas —una UISearchBar, un
UIDatePicker— encendió Auto Layout para toda la ventana y puso a cero todos
los frames. El síntoma era la pantalla entera amontonada en una esquina.
Una UIScrollView recibe su contentSize del core, que lo calcula. Una stack
view recorta a sus límites, pone la pantalla que entra encima independientemente
del índice en el árbol, y retiene la que sale hasta que termina su animación.
En qué se convierte cada primitivo
Sección titulada «En qué se convierte cada primitivo»El mapeo completo está en Componentes. Cuatro decisiones merecen explicación:
an-tab-bares unUITabBarControllerentero, no unaUITabBarsuelta. Desde iOS 26 una barra suelta se dibuja a través de su propio proveedor visual, y en iPad aparece dos veces — una arriba y otra en el frame que le dio el layout. Se fuerza también el modo.tabBar, porque.automaticpuede convertirla en una barra lateral en iPad.an-selectes unUIButtonconshowsMenuAsPrimaryAction, no unUIPickerView. La picker view es la rueda a pantalla completa, que es otra cosa.an-map-viewes unMKMapViewde verdad. La clase está declarada a mano, porque el crate generado de MapKit solo cubre macOS.an-alertyan-modalmontan un marcador oculto y presentan un controlador real. Un modal es unUIViewControllery no una vista superpuesta porque sin él UIKit no sabe que hay algo modal delante: VoiceOver seguía leyendo lo que había detrás, y el orden de presentación frente a unUIAlertControllerdependía del orden de creación de las vistas.
Los iconos son SF Symbols pedidos por su nombre. No se empaqueta nada.
Los radios de esquina no uniformes no tienen API en UIKit, así que el contorno
se dibuja como un UIBezierPath y se instala como máscara de shape layer — y se
redibuja en cada cambio de tamaño, porque una máscara no se estira.
El área segura
Sección titulada «El área segura»Suscribirse a (safeArea) registra el nodo e informa de inmediato; a partir de
ahí los safeAreaInsets del contenedor se releen en cada layout, y solo sale un
evento cuando los cuatro números cambian de verdad.
<an-safe-area> los aplica como padding, no como margen, y el motivo vale
la pena: una vista que se apartara con un margen dejaría de estar bajo la muesca,
entonces informaría de cero, volvería, y haría eso para siempre.
Dos batallitas que lleva el código
Sección titulada «Dos batallitas que lleva el código»Los rasgos de teclado van por el runtime, no por setters generados.
[keyboardType], [returnKeyType], [autoCapitalize] y [autoCorrect] se
aplican buscando la implementación con class_getMethodImplementation. En iOS
26, -[UITextField setKeyboardType:] no está en la tabla de métodos de la
clase — UIKit lo resuelve de forma perezosa — así que respondsToSelector:
dice que sí, class_getInstanceMethod dice que no, la capa de binding se cree
al segundo y aborta. Todas las apps con un campo de texto reventaban al
arrancar.
El vídeo necesita un bucle de reintento. Llamar a play() sobre un
reproductor que aún no ha cargado deja la velocidad a cero para siempre, sin
error y con una capa negra, así que la llamada se reemite en cada fotograma
hasta que prende. La vista del reproductor también hay que redimensionarla en
flush, porque se crea después de fijar el frame.
Gestos y eventos
Sección titulada «Gestos y eventos»Todo lo que hay en Componentes se entrega en iOS. El mecanismo depende del nodo: target-action para los controles, un delegate para el scroll, gesture recognisers para las vistas.
(press)en un botón esTouchUpInside. En una tele no — mira tvOS.- Los eventos de una barra de búsqueda se enganchan a su campo de texto interno, no a la barra.
(refresh)es unUIRefreshControlde verdad.(back)en unan-stack-viewes unUIScreenEdgePanGestureRecognizeren el borde izquierdo, y solo dispara enEnded— un arrastre cancelado no debe navegar. El host solo informa; deshacer la navegación es cosa del router.- Los toques fuerzan
userInteractionEnableda activo, porqueUILabelyUIImageViewvienen con él apagado.
En iOS nada avisa al suscribirse. Un nombre de evento que el host no
reconoce se ignora en silencio, deliberadamente: una plantilla puede llevar un
(click) heredado de la web, y eso no es motivo para hacer ruido. Los avisos al
suscribirse son todos de tvOS y visionOS.
La hoja de un modal, y dónde descansa
Sección titulada «La hoja de un modal, y dónde descansa»[presentation]="'sheet'" da un pageSheet con tirador. Dónde puede descansar
lo dice [ios].detents, y va en el objeto de iOS porque un detente es idea de
UIKit: el Dialog de Android mide lo que mide su contenido y no hay ninguna
lista de paradas que darle.
<an-modal [visible]="open()" [presentation]="'sheet'" [ios]="{ detents: [180, 'large'] }" />La lista admite los tres de UIKit y ninguno más: 'medium' es .medium(),
'large' es .large(), y un número es .custom(resolver:) — una altura en
puntos, medida desde abajo, de iOS 16 en adelante. Una altura mayor de lo que la
hoja puede llegar a ser se baja hasta ese máximo en lugar de tirarla, porque al
resolver se le pregunta otra vez en cada rotación y «600 puntos» en un móvil
tumbado significa «hasta arriba». El orden es el tuyo y se respeta.
No decir nada sigue siendo medio y grande, que es lo que sugiere el sistema. Una entrada que no es ninguno de los dos nombres ni una altura mayor que cero se descarta con un aviso en el log, una vez por valor: el tipo de la plantilla ya las rechaza, así que llegar ahí significa un objeto montado en tiempo de ejecución. Una lista vacía no se le pasa a UIKit, que responde a eso lanzando.
Una lista atada a una señal se vuelve a aplicar sobre una hoja que ya está levantada, así que se puede añadir o quitar un detente sin cerrarla.
Cualquier cosa que no sea una hoja se presenta como overFullScreen con una
cobertura vertical, elegida porque el core ya ha maquetado el contenido a
pantalla completa, así que no hay que recolocar nada. Por eso mismo una hoja
que descansa en un detente pequeño enseña la parte de arriba de una maqueta a
pantalla completa: el contenido no se vuelve a maquetar a la altura de la hoja.
En tvOS no hay detentes: UISheetPresentationController está marcado
API_UNAVAILABLE(tvos), y pedirlos lo dice una vez en el log.
Medición del texto
Sección titulada «Medición del texto»boundingRectWithSize: con origen de fragmento de línea e interlineado de la
fuente. Los pesos CSS 100..900 se mapean sobre la escala -1..1 de UIKit en nueve
pasos. boundingRect no sabe nada de lineHeight ni de numberOfLines, así
que el número de líneas se deriva y se remultiplica después, y el resultado se
redondea hacia arriba para que no se recorte el último glifo.
La caché no es una optimización: el layout pide el mínimo de contenido, el máximo y el tamaño final, por nodo y por fotograma. La clave son el texto, el tamaño, el peso, la cursiva, la familia, el espaciado entre letras y el ancho disponible redondeado a un octavo de punto, y se vacía cuando cambian la escala de la pantalla o el ajuste de tipografía dinámica.
Los tamaños de los controles se miden una vez al arrancar en el hilo principal,
construyendo cada control y preguntando sizeThatFits. Se registran once; un
slider, una barra de progreso, una tab bar, una barra de búsqueda y un control
segmentado se estiran al ancho disponible.
Compilar y ejecutar
Sección titulada «Compilar y ejecutar»cargo build --target aarch64-apple-ios-sim -p an-iosxcrun swiftc -target arm64-apple-ios17.0-simulator … -lan_ios→ build/ios/<AppName>.appEl plist se comprueba contra angular-native.json antes de compilar nada:
no merece la pena quemar medio minuto de cargo y swiftc para decir que el nombre
no cuadra. La cobertura de plugins se comprueba lo primero de todo. Las fuentes
Swift —las del shell, las compartidas, las de cada plugin y el registro
generado— van en una sola invocación de swiftc, ordenadas, para que un plugin
vea AnPlugin sin importar nada y la línea de comandos no cambie con el orden
del sistema de ficheros.
Los entitlements, cuando un plugin los pide, se incrustan dentro del binario
con -sectcreate, no se ponen en una firma. En el simulador eso es lo que
funciona: keychain-access-groups es un entitlement restringido y macOS se
niega a ejecutar un binario que lo lleve en una firma sin un perfil de
aprovisionamiento. El síntoma era que la app no arrancaba, con una denegación
que no menciona los entitlements por ninguna parte. Xcode hace lo mismo para el
simulador.
El lanzamiento es simctl boot → abrir Simulator → bootstatus -b → terminar →
desinstalar → instalar → lanzar. La espera importa: instalar sobre un simulador
a medio arrancar se cuelga en silencio. La desinstalación también: instalar
encima de una app existente no reemplaza el bundle de forma fiable, y la app
arranca con el código viejo. El dispositivo se encuentra parseando
simctl list devices available -j como JSON, porque simctl imprime el udid
antes del nombre y hacer grep instala en el simulador equivocado.
No hay firma, y no hay dispositivo físico. El target de Rust es el del
simulador, el SDK es iphonesimulator, y el camino de lanzamiento es simctl
de punta a punta. Un dispositivo real necesita la otra ruta —una identidad y un
perfil— y esa no está aquí.
Recursos dentro del bundle
Sección titulada «Recursos dentro del bundle»Un <an-image [source]="'logo.png'"> —una ruta sin esquema— es un fichero que
ha viajado con la app, y sale de resources/, en el proyecto:
mi-app/ ios/Info.plist lo escribe an add ios; tuyo a partir de ahí resources/logo.png tuyo también; se copia al bundle con este mismo nombre src/Está al lado de ios/ y no dentro de src/assets/ ni de public/ por un
motivo: esos dos son de la compilación web, angular.json decide qué va en
ellos, y an ha prometido no tocar nunca angular.json. Además metería en un
binario de teléfono —que no tiene navegador que las necesite— todas las
favicons y todas las fuentes web. Dentro del monorepo el mismo directorio cuelga
del ejemplo: examples/controls/resources/logo.png, que es de donde sale la A
rosa de arriba de la captura de los controles.
an ios copia el árbol entero a la raíz del .app, junto a main.js, y
conserva los subdirectorios: resources/icons/logo.png se pide como
icons/logo.png. Los ficheros ocultos se saltan, así que el .DS_Store que el
Finder deja en cualquier directorio que se haya abierto no llega nunca a un
bundle firmado. La copia va antes de la firma, porque una firma cubre todos
los ficheros del bundle y uno añadido después es uno que installd rechaza.
Dos cosas pueden salir mal, y ninguna de las dos es silenciosa:
- Un recurso cuyo nombre es uno de los que escribe la propia compilación
—
main.js,Info.plist,dev-server.txt, el ejecutable— para la compilación, diciendo qué fichero. Si no, la copia reemplazaría el código de la app y en el dispositivo no habría nada que explicara por qué arrancó a ninguna parte. - Un nombre sin nada detrás se avisa una vez en el dispositivo, con el
nombre y con el sitio del que tendría que haber salido. No puede ser una
comprobación de compilación: solo el dispositivo sabe qué cadenas ha
producido de verdad una plantilla, y la mitad son calculadas. Lo que no puede
ser es un
an-imagevacío, que se parece exactamente a una imagen que aún está cargando, a un color igual que el fondo o a un marco de altura cero.
La búsqueda es primero UIImage(named:) —así siguen funcionando un catálogo de
assets y los nombres del propio sistema, y UIKit cachea lo que encuentra— y
después el fichero bajo la ruta de recursos del bundle, que es lo que hace que
un nombre con una barra dentro se resuelva siquiera.
A un iPhone de verdad, y a la tienda
Sección titulada «A un iPhone de verdad, y a la tienda»an ios --physical # firmado, instalado con devicectlan ios --archive # .xcarchive y .ipaLas dos son la misma compilación con otro destino: aarch64-apple-ios en lugar
del target del simulador, el perfil incrustado en el bundle, y una firma real
sobre todo el conjunto. Los entitlements se mudan también —en el simulador viven
dentro del binario, en un dispositivo viven en la firma— y se toman de lo que
concede el perfil de aprovisionamiento, porque el sistema no le da a una app
nada que su perfil no lleve.
Ninguna de las dos la ha ejecutado nadie de este proyecto contra un dispositivo físico ni contra una cuenta de Apple Developer. Lo que sí está comprobado es que se paran antes de compilar cuando falta una credencial, y dicen cuál. Mira Firma y distribución, que es explícita sobre qué caminos se han ejecutado y cuáles están escritos a partir de la documentación de Apple.
No hay un camino de código separado para iPadOS. Es iOS, con tres sitios donde se reconoce al iPad, y los tres existen porque sin ellos se rompía:
UIDeviceFamily = [1, 2]en el plist. Sin eso la app se declara solo para iPhone, un iPad la ejecuta en una ventana de compatibilidad de 320×480 escalada, y presentar cualquier controlador del sistema hace que iOS quite el escalado, con lo que el contenido aparece diminuto en una esquina.- Una hoja de acciones necesita un origen en iPad. El popover se ancla al
centro inferior del contenedor cuando el idiom es
.pad, y deliberadamente no se ancla en iPhone, donde eso le daría un pico a la hoja. Sin el ancla UIKit no avisa — revienta. - La tab bar. El controlador, y forzar el modo
.tabBar, los dicta el comportamiento del iPad.
La multitarea no se maneja explícitamente. No hay manifiesto de escenas, ni
UIRequiresFullScreen, ni manejo de clases de tamaño o de trait collections. El
redimensionado funciona solo de forma genérica: los nuevos límites pasan por
an_runtime_set_viewport en cada layout, y an-safe-area vuelve a informar en
el siguiente. Las orientaciones soportadas son vertical y las dos horizontales.
Lo que falta
Sección titulada «Lo que falta»- No hay input accessory view. El teclado en sí está resuelto — va en el
inset inferior, en movimiento, y
<an-safe-area>viaja con él; mira El área segura y el teclado. Lo que no hay forma de pedir es la barra de encima: un botón Hecho, una flecha al siguiente campo, cualquier cosa para la que sirveinputAccessoryView. - Seis sitios que avisan una vez, todos en accesibilidad: un rol
desconocido, una clave de estado desconocida, un rol para el que UIKit no
tiene rasgo,
checked: 'mixed'—UIKit solo conoce marcado y sin marcar, así que el valor se deja vacío en lugar de redondearlo— yexpandedybusy, que no tienen rasgo ninguno de los dos. Qué se aplica y qué se rechaza está en Accesibilidad en Apple.