eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArquitectura
La superficie declarativa (escribir sin new)
Edit this page
9 min read
Una pantalla son expresiones C# corrientes: ningún lenguaje de marcado, ninguna ceremonia de builder, y ningún new:
1
2
3
4
5
6
7
8
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S4, children: [
Text($"Count: {_count}", TypeRole.Display, context.Theme.TextPrimary),
Row(gap: Space.S3, children: [
Button("Up", onPressed: () => SetState(() => _count++)),
Button("Reset", Variant.Outline, onPressed: () => SetState(() => _count = 0)),
]),
]);
Cada nombre de ahí es un método factory llamado exactamente como el tipo que devuelve. No hay ningún import que escribir: el SDK pone la superficie del framework al alcance en todos los archivos de tu proyecto, y tus propios componentes se suman a ella automáticamente (ver más abajo).
Como los estilos son valores tipados y no cadenas de CSS, el compilador verifica toda la interfaz, layout y estilos incluidos, y la misma clase se renderiza en la web y de forma nativa a través de Photon.
El contrato
•
Una factory se llama exactamente como su tipo. Column, Text, Button.
•
Refleja un constructor parámetro por parámetro (mismos nombres, mismo orden, mismos valores por defecto), así que los argumentos nombrados pasan entre new X(…) y X(…) sin cambio.
•
Después de los parámetros reflejados, una factory puede tomar una cola semántica opcional: parámetros que corresponden cada uno a una propiedad pública init-only del nodo (mismo nombre, mismo tipo) y que se aplican con un inicializador de objeto. Así es como una pantalla declarativa declara lo que los constructores deliberadamente no llevan: `Pressable(child, onPressed, label:, selected:, disabled:, pressedBackground:, expanded:), Link(destination, child, label:, current:), y TextEntry(value, onChanged, label:, placeholder:, disabled:, obscure:)`. La maquinaria de composición y de modales (Role, Mixed, InitialFocus) se queda solo en el inicializador a propósito: pertenece a los componentes dueños de esos patrones.
•
Los nodos contenedores toman un parámetro children final, escrito como una expresión de colección.
•
No hay sobrecargas. La superficie se transpila a un gemelo en JavaScript, y los métodos de JS no pueden sobrecargarse, así que cada tipo tiene UNA factory canónica.
El layout son parámetros, no un inicializador
Desde 0.2.0-preview.13
1
2
3
Row(gap: Space.S3, main: MainAlign.SpaceBetween, cross: CrossAlign.Start, children: [ … ])
Column(gap: Space.S2, wrap: true, runGap: Space.S4, children: [ … ])
Text("42", TypeRole.Display, align: TextAlignment.Center, tabular: true)
La semántica también son parámetros
Desde 0.2.0-preview.45
Un botón de solo icono que VoiceOver puede nombrar, un elemento de navegación que DECLARA que está seleccionado, el enlace a la página en la que está el lector, todo alcanzable sin salir de la forma de factory:
1
2
3
Pressable(Icon(Icons.Search), onPressed: Search, label: "Search", selected: true)
Link("/scan", Icon(Icons.Home), label: "Home", current: true)
TextEntry(_password, OnPassword, label: "Password", obscure: true)
Un control que se pulsa mientras el teclado se queda donde está, el botón de una barra de formato al lado del texto que formatea o una fila de sugerencias bajo un campo, lo dice con canRequestFocus: false: la pulsación corre, y el foco se queda donde estaba.
1
Pressable(Icon(Icons.Check), onPressed: Apply, label: "Apply", canRequestFocus: false)
Desde 0.2.0-preview.61
El layout también son parámetros
Un contenedor que llena a su padre (el layout más común que existe) ya no necesita new: Column, Row, Grid, Stack, ScrollView y ListView toman width y height antes de children (un SizeValue; dejarlos fuera es Hug, exactamente lo que significa un inicializador sin ellos), y Pressable toma su role compuesto. Contado por una aplicación real después de migrar toda su UI: 49 sitios que se habían quedado imperativos por un ancho, 3 por un rol.
1
2
3
Row(gap: Space.S2, width: SizeValue.Fill, children: [ … ])
Column(height: SizeValue.Fill, children: [ ScrollView(list, height: SizeValue.Fill) ])
Pressable(Text("Overview"), Select, selected: true, role: PressableRole.Radio)
Desde 0.2.0-preview.46
Y los controles de configuración
Tres más del mismo informe, y la misma regla. Un Switch se anuncia y se vuelve inerte mientras se aplica un lote; un Checkbox se vuelve inerte también; y SegmentedControl no tenía factory ninguna, así que una tira de pestañas se quedaba imperativa por un solo botón.
1
2
3
Switch(on: item.Enabled, onChanged: Toggle, label: item.Name, disabled: applying)
Checkbox(includeSystem, onChanged: Flip, label: "Include system files", disabled: scanning)
SegmentedControl(["Apps", "Login items"], selected, onChanged: Pick, stretch: false)
Una lista de switches sin nombres es una lista de controles idénticos para la tecnología de apoyo, y por eso label es el primero de ellos.
Desde 0.2.0-preview.46
Un enlace necesita un sitio donde llegar
Bookmark nombra un lugar de una pantalla al que un enlace puede llegar: Link("#features", …) llega al nodo que dice Bookmark = "features".
1
Box(SectionStyle, children: [ … ]) with { Bookmark = "features" }
Deliberadamente no es Key, y la confusión le costó a un sitio toda la navegación de su landing más el raíl de "En esta página" de sus docs: una Key es identidad para el reconciliador, única solo entre HERMANOS, y en una lista es típicamente "1", "2", "3", y proyectar eso sobre un nombre de pantalla entera choca en la primera lista repetida. Un nodo puede llevar ambas.
Nada valida el valor más allá de que no esté vacío, a propósito: los bookmarks se GENERAN con frecuencia (un raíl de docs saca el slug de los encabezados que encuentra), y rechazar un duplicado en tiempo de build haría fallar una página porque la fuente que renderiza ganó un encabezado repetido.
En la web se vuelve el id del elemento, y el realizador le da espacio donde aterrizar: un navegador desplaza un destino hasta arriba del todo, así que bajo una cabecera fija llegaría escondido. El desplazamiento se mide desde la cabecera fija que la aplicación ya declaró, nunca un número repetido en la página: un sitio tiene una nav de 60dp en su landing y una topbar de 56dp en sus docs y no declara ninguna de las dos. En Photon es inerte hoy: un bookmark es un sitio al que desplazarse, y el desplazamiento programático todavía está por llegar.
Desde 0.2.0-preview.46
Como Stack ganó dos mandos antes de children, su children pasa a ir nombrado, Stack(Alignment.Center, children: [ … ]), la regla que todos los demás contenedores ya seguían.
Row y Column toman main, cross, wrap, runGap y padding; Text toma align, mono, tabular y styleOverride. Estas eran propiedades init-only, así que fijar una significaba un inicializador de objeto, lo que significa new, y new es exactamente lo que esta superficie quita. Una fila que tenía que centrar su contenido se caía de la superficie por completo y había que escribirla a la antigua.
Ancho, alto, fondo y radio de esquina deliberadamente no son parámetros de un flex: un flex que lleve eso es un Box envolviendo un flex, y las propiedades ya lo dicen.
styleOverride en Text es la salida de una escala tipográfica cerrada. Los peldaños son el default correcto y un diseño que se sale de ellos en todas partes ha dejado de tener una escala, pero una escala cerrada sin salida se sortea anidando un HtmlElement crudo, que es peor, porque solo funciona en un destino.
ROMPE EN LA 0.2.0-PREVIEW.13 children va al final (el contrato de los contenedores), así que los mandos se sitúan entre él y gap: Column(Space.S3, [ … ]) pasa a ser Column(Space.S3, children: [ … ]). Una palabra, y ya es la forma normal de la superficie.
Las propiedades init más raras conservan la forma constructor + inicializador; las factories son azúcar sobre los mismos tipos, nunca una segunda API:
1
2
Box(new BoxStyle { Padding = EdgeInsets.All(Space.S4), Background = theme.Surface },
Text("Still the same Box", TypeRole.BodyM))
Los registros de valor (GridTrack, DialogAction, NavItem) deliberadamente no tienen factories: son datos, y un new(…) con tipo inferido ya se lee bien.
Tus propios componentes se suman
Define un componente; el build genera su factory. Nada que registrar, nada que importar:
1
2
3
4
5
6
7
8
// Components/StatTile.cs
public sealed class StatTile : StatelessComponent
{
public StatTile(string label, string value) { Label = label; Value = value; }
public string Label { get; init; }
public string Value { get; init; }
public override VisualNode Build(ComponentContext context) => /* … */;
}
1
2
3
4
5
// Pages/HomePage.cs, sin using, sin new
Row(gap: Space.S3, children: [
StatTile("Count", $"{_count}"),
StatTile("Doubled", $"{_count * 2}"),
])
Un generador de código lee la compilación, encuentra los componentes, y escribe una clase estática AppUI más el global using static que la pone al alcance. Se mantiene al paso de tus componentes porque se genera a partir de ellos.
Las páginas no reciben factory. Una [Page] se alcanza por su ruta, nunca se compone a mano.
Qué constructor se refleja
El más ancho, la misma regla que aplica el transpilador cuando colapsa las sobrecargas de constructor, para que la factory y el constructor emitido nunca discrepen. Cuando el más ancho no es el que quieres ofrecer, elige otro:
1
2
3
4
5
6
public sealed class Badge : StatelessComponent
{
[UiFactory]
public Badge(string label) { … } // ← este recibe la factory
public Badge(string label, int count, bool dot) { … }
}
Diagnósticos
Código
Severidad
Significado
EQ3101
Error
Dos constructores de un componente están marcados con [UiFactory]. Una elección necesita un único ganador.
EQ3102
Advertencia
Dos componentes comparten nombre, así que solo uno puede ser dueño de esa factory. Renombra uno, o construye el otro con new.
Cómo apagarlo
1
2
3
<PropertyGroup>
<EQuanticGenerateFactorySurface>false</EQuanticGenerateFactorySurface>
</PropertyGroup>
Tus componentes entonces se componen con new, exactamente como antes.
La única arista afilada: una factory ensombrece su tipo
Un método llamado como un tipo ensombrece ese tipo en cualquier archivo donde la superficie esté al alcance. Solo muerde en los tipos que llegan por un using, lo que significa los del framework, no los tuyos:
1
2
Spacer.Fixed(34) // ✗ CS0119: `Spacer` se liga al método factory
Panel.Empty("x") // ✓ tu propio tipo, declarado en tu propio namespace, gana
C# resuelve los nombres declarados en el namespace actual antes que los imports using static, así que tus propios componentes nunca quedan ensombrecidos por sus propias factories, y nunca necesitas el apaño de abajo.
Los estáticos propios del framework que quedan detrás de un nombre de factory reciben una factory con un nombre propio:
En lugar de
Escribe
Spacer.Fixed(34)
Gap(34)
Badge.AsDot(variant)
DotBadge(variant)
new Icon(packGlyph)
Glyph(packGlyph)
Desde 0.2.0-preview.7 (la fila de Glyph; las otras dos desde la 0.2.0-preview.2)
Un test de conformidad recorre cada factory, busca estáticos en el tipo que ensombrece, y falla nombrándolos hasta que cada uno tiene una factory con nombre, así que un cuarto no puede aparecer sin que se note.
Glyph está ahí por una razón algo distinta de las otras dos, y vale saber cuál: Icon tiene dos constructores, uno que toma el enum Icons curado del framework, otro que toma el IconGlyph que entrega un paquete de iconos. El Icon(...) reflejado solo puede ser uno de ellos, y aquí no hay sobrecargas, así que un glifo de pack no tenía ninguna entrada a un archivo que importara esta superficie: solo se podía dibujar con new, en el único sitio donde el framework te promete que nunca lo necesitas.
1
2
3
4
using eQuantic.UI.MaterialSymbols;
Glyph(MaterialSymbolsIcons.PlayArrowRounded) // el catálogo de cualquier paquete de iconos
Icon(Icons.Check) // el conjunto curado
Cómo llega al navegador
La superficie generada se escribe a disco (EmitCompilerGeneratedFiles) porque eqc lee archivos, no la compilación de C#: una factory que llama tu página tiene que ser parte de lo que ve el transpilador, o la llamada se resuelve a nada y el JavaScript emitido se degrada en silencio. Después se transpila a un módulo como cualquier otra clase, así que AppUI.statTile(…) existe en el bundle al lado de UI.column(…).
LAS SOBRAS DE UN GENERADOR SE VAN CON ÉL El compilador no elimina ningún archivo generado, así que un componente que borres, o un generador que renombres o quites, dejaría su última salida ahí para que eqc la lea: un tipo fantasma, o una segunda copia de uno vivo. El SDK los quita él mismo: después de una compilación que corre, un archivo generado que esa compilación no escribió se borra, y el log del build lo nombra. No hace falta ningún dotnet clean.
Ver también
•
Componentes write-once: la arquitectura sobre la que se asienta la superficie
•
Componentes: el catálogo al que corresponde cada factory
•
Design System: los tokens tipados de los que están hechos los argumentos