Etiqueta: UI

  • Unidad 4 — Tema 1: Compose Multiplatform: Diseña tu interfaz una vez, ejecútala en todas partes

    Unidad 4 — Tema 1: Compose Multiplatform: Diseña tu interfaz una vez, ejecútala en todas partes

    En el artículo anterior de la serie (Unidad 3 — Tema 3), logramos el gran hito de conectar todas las piezas de nuestra arquitectura: integramos Ktor para la red y Room para la persistencia offline-first bajo un grafo de dependencias unificado con Koin, moviendo toda la lógica a un ViewModel compartido. Ahora que los cimientos de la lógica de negocio y del estado están firmemente establecidos en el código común, es hora de abordar la capa de presentación visual. En este artículo daremos el salto a Compose Multiplatform para diseñar las pantallas de nuestra aplicación una única vez y ejecutarlas de forma nativa tanto en Android como en iOS.

    Al final de este artículo habrás construido un flujo completo de interfaz de usuario compartida en commonMain que incluye una pantalla de Login con estados reactivos y validación de credenciales, junto con un rediseño premium en formato tarjetas para nuestra lista de noticias de News Explorer, todo ello integrado bajo el contenedor nativo de iOS ComposeUIViewController. La app no solo compartirá datos; compartirá la experiencia visual al completo.

    1. El motor de Compose Multiplatform: Skia y Skiko bajo el capó

    Para comprender la potencia de Compose Multiplatform en iOS, es vital analizar cómo funciona su ciclo de renderizado a nivel arquitectónico. A diferencia de tecnologías como React Native o las vistas nativas de Android, que mapean componentes lógicos a vistas del sistema operativo (un Button de Compose se traduce a un android.widget.Button tradicional en Android clásico).

    JetBrains utiliza Skia (la biblioteca gráfica en C++ de código abierto que motoriza a Chrome, Android y Flutter) en combinación con Skiko (Kotlin bindings para Skia) para pintar cada píxel de la interfaz directamente en un lienzo acelerado por hardware. En iOS, Skiko crea un CAMetalLayer y utiliza la API gráfica de Metal de Apple para obtener un rendimiento óptimo de 60 o 120 FPS. Cuando escribes un Text o un Button en commonMain, el compilador no genera un elemento UILabel o UIButton de UIKit; en su lugar, el árbol de composición calcula la geometría y Skia dibuja las formas vectoriales de forma directa sobre la capa de metal del dispositivo.

    En Android, sin embargo, Compose Multiplatform no necesita Skia embebido, ya que utiliza el propio motor de renderizado y el pipeline gráfico de la JVM del sistema operativo Android, garantizando que el peso de la app no aumente innecesariamente. Esta arquitectura mixta combina lo mejor de ambos mundos: rendimiento nativo directo en Android y fidelidad visual matemática de pixel-perfect en iOS.

    Hay además un requisito práctico en iPhone: añade la clave CADisableMinimumFrameDurationOnPhone al Info.plist de la app iOS. La propia documentación de JetBrains la marca como necesaria para habilitar correctamente el renderizado de Compose Multiplatform en dispositivos iPhone; si no está presente, el arranque puede fallar.

    <key>CADisableMinimumFrameDurationOnPhone</key>
    <true/>

    2. Maquetando la pantalla de Login con estados y validaciones

    El primer paso para dotar de interactividad a nuestra aplicación es crear una pantalla de acceso que valide el formato del email y la longitud de la contraseña. Crearemos este archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/login/LoginScreen.kt:

    package dev.asiles.newsexplorer.ui.login
    
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.text.KeyboardOptions
    import androidx.compose.material3.*
    import androidx.compose.runtime.*
    import androidx.compose.runtime.saveable.rememberSaveable
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.text.input.KeyboardType
    import androidx.compose.ui.text.input.PasswordVisualTransformation
    import androidx.compose.ui.unit.dp
    
    @Composable
    fun LoginScreen(onLoginSuccess: () -> Unit) {
        var email by rememberSaveable { mutableStateOf("") }
        var password by rememberSaveable { mutableStateOf("") }
        var isLoading by rememberSaveable { mutableStateOf(false) }
        
        val isEmailValid = remember(email) {
            email.contains("@") && email.contains(".")
        }
        val isPasswordValid = remember(password) {
            password.length >= 6
        }
        val isFormValid = isEmailValid && isPasswordValid
    
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            contentAlignment = Alignment.Center
        ) {
            if (isLoading) {
                CircularProgressIndicator()
            } else {
                Column(
                    modifier = Modifier.fillMaxWidth(),
                    verticalArrangement = Arrangement.spacedBy(16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "Welcome to News Explorer",
                        style = MaterialTheme.typography.headlineMedium,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    Spacer(modifier = Modifier.height(16.dp))
    
                    OutlinedTextField(
                        value = email,
                        onValueChange = { email = it },
                        label = { Text("Email") },
                        isError = email.isNotEmpty() && !isEmailValid,
                        supportingText = {
                            if (email.isNotEmpty() && !isEmailValid) {
                                Text("Invalid email format")
                            }
                        },
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
                        modifier = Modifier.fillMaxWidth()
                    )
    
                    OutlinedTextField(
                        value = password,
                        onValueChange = { password = it },
                        label = { Text("Password") },
                        isError = password.isNotEmpty() && !isPasswordValid,
                        supportingText = {
                            if (password.isNotEmpty() && !isPasswordValid) {
                                Text("Password must be at least 6 characters")
                            }
                        },
                        visualTransformation = PasswordVisualTransformation(),
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                        modifier = Modifier.fillMaxWidth()
                    )
    
                    Spacer(modifier = Modifier.height(8.dp))
    
                    Button(
                        onClick = {
                            isLoading = true
                            // Simulamos una petición de red asíncrona
                            onLoginSuccess()
                        },
                        enabled = isFormValid,
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text("Sign In")
                    }
                }
            }
        }
    }

    En este bloque aplicamos el concepto de State Hoisting y la preservación del estado frente a la recreación de la vista o la rotación de pantalla mediante rememberSaveable. Las validaciones se optimizan con remember(state) para evitar recálculos en recomposiciones innecesarias.

    3. Diseño premium de la lista de noticias

    Una vez autenticado el usuario, debemos presentar el listado de artículos. Vamos a reescribir de forma estética la pantalla que creamos en la unidad anterior, convirtiendo la lista plana en un conjunto de tarjetas elegantes Material 3 que admiten scroll fluido. Crea o edita el archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/news/NewsListScreen.kt:

    package dev.asiles.newsexplorer.ui.news
    
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    import androidx.compose.material3.*
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.text.style.TextOverflow
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.compose.collectAsStateWithLifecycle
    import dev.asiles.newsexplorer.domain.model.Article
    import dev.asiles.newsexplorer.presentation.NewsViewModel
    import org.koin.compose.viewmodel.koinViewModel
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun NewsListScreen(
        viewModel: NewsViewModel = koinViewModel(),
        onBackToLogin: () -> Unit
    ) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("News Explorer") },
                    actions = {
                        TextButton(onClick = onBackToLogin) {
                            Text("Log Out", color = MaterialTheme.colorScheme.error)
                        }
                    }
                )
            }
        ) { padding ->
            Box(
                modifier = Modifier
                    .padding(padding)
                    .fillMaxSize()
                    .padding(horizontal = 16.dp)
            ) {
                if (uiState.isLoading && uiState.articles.isEmpty()) {
                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                } else {
                    LazyColumn(
                        verticalArrangement = Arrangement.spacedBy(16.dp),
                        contentPadding = PaddingValues(vertical = 16.dp),
                        modifier = Modifier.fillMaxSize()
                    ) {
                        uiState.error?.let { error ->
                            item {
                                Card(
                                    colors = CardDefaults.cardColors(
                                        containerColor = MaterialTheme.colorScheme.errorContainer
                                    ),
                                    modifier = Modifier.fillMaxWidth()
                                ) {
                                    Text(
                                        text = error,
                                        color = MaterialTheme.colorScheme.onErrorContainer,
                                        modifier = Modifier.padding(16.dp),
                                        style = MaterialTheme.typography.bodyMedium
                                    )
                                }
                            }
                        }
                        
                        items(uiState.articles, key = { it.url }) { article ->
                            ArticleCard(article)
                        }
                    }
                }
            }
        }
    }
    
    @Composable
    private fun ArticleCard(article: Article) {
        Card(
            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
            modifier = Modifier.fillMaxWidth()
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spaceBetween
                ) {
                    Text(
                        text = article.source,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary
                    )
                    Text(
                        text = article.publishedAt.take(10),
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = article.title,
                    style = MaterialTheme.typography.titleMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )
                if (article.description.isNotBlank()) {
                    Spacer(modifier = Modifier.height(6.dp))
                    Text(
                        text = article.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 3,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
        }
    }

    La estructura del proyecto en commonMain se simplifica al unificar la navegación. Vamos a actualizar la composición raíz en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/App.kt para gestionar el intercambio de pantallas de forma declarativa con una máquina de estados sencilla:

    package dev.asiles.newsexplorer.ui
    
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.runtime.*
    import androidx.compose.runtime.saveable.rememberSaveable
    import dev.asiles.newsexplorer.ui.login.LoginScreen
    import dev.asiles.newsexplorer.ui.news.NewsListScreen
    
    sealed interface Screen {
        object Login : Screen
        object NewsList : Screen
    }
    
    @Composable
    fun App() {
        var currentScreen by rememberSaveable { mutableStateOf<Screen>(Screen.Login) }
    
        MaterialTheme {
            when (currentScreen) {
                is Screen.Login -> LoginScreen(
                    onLoginSuccess = { currentScreen = Screen.NewsList }
                )
                is Screen.NewsList -> NewsListScreen(
                    onBackToLogin = { currentScreen = Screen.Login }
                )
            }
        }
    }

    4. Configuración del contenedor nativo en iOS: ComposeUIViewController

    En el mundo Apple, la UI se gestiona a través de controladores de vista (View Controllers). Para poder incrustar nuestro lienzo de Compose Multiplatform dentro de un ecosistema Swift nativo, JetBrains expone la función de fachada ComposeUIViewController, que hereda directamente del UIViewController del framework UIKit de Apple.

    Primero, en shared/src/iosMain/kotlin/dev/asiles/newsexplorer/MainViewController.kt instanciamos el punto de entrada de la UI unificada:

    package dev.asiles.newsexplorer
    
    import androidx.compose.ui.window.ComposeUIViewController
    import dev.asiles.newsexplorer.data.local.IosDatabaseFactory
    import dev.asiles.newsexplorer.di.initKoin
    import dev.asiles.newsexplorer.ui.App
    import org.koin.dsl.module
    
    fun MainViewController() = ComposeUIViewController(
        configure = {
            initKoin(
                platformModules = listOf(
                    module { single<DatabaseFactory> { IosDatabaseFactory() } }
                )
            )
        }
    ) {
        App()
    }

    En la aplicación de iOS nativa escrita en Xcode (iosApp), este controlador de vista se expone como una clase nativa de Objective-C/Swift. Mediante el protocolo UIViewControllerRepresentable de SwiftUI, convertimos el controlador gráfico de Compose en un componente compatible que SwiftUI sabe maquetar en su jerarquía declarativa:

    import SwiftUI
    import shared
    
    struct ContentView: View {
        var body: some View {
            ComposeView()
                .ignoresSafeArea(.all, edges: .bottom) // Evitamos solapamientos con la barra del sistema
        }
    }
    
    struct ComposeView: UIViewControllerRepresentable {
        func makeUIViewController(context: Context) -> UIViewController {
            return MainViewControllerKt.MainViewController()
        }
        
        func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
            // Reservado para actualizaciones dinámicas desde SwiftUI hacia Compose
        }
    }

    5. Enfoque senior: Compose Multiplatform vs. Vistas nativas

    Compartir el 100% de la interfaz gráfica a través de Compose Multiplatform es una decisión arquitectónica muy atractiva que reduce drásticamente el «time-to-market», pero un desarrollador senior debe evaluar cuidadosamente sus costes e implicaciones técnicas antes de dar el paso en un proyecto corporativo de gran escala.

    • Rendimiento Gráfico y Metal: Al pintar directamente sobre CAMetalLayer en iOS usando Skia, Compose ofrece un rendimiento visual espectacular a 120 FPS en pantallas con ProMotion. Sin embargo, esto requiere instanciar e inicializar la máquina de Skia en tiempo de ejecución. La huella inicial de memoria (RAM) y el arranque de la app pueden aumentar levemente (entre 10 y 15 MB de sobrecarga en iOS) en comparación con una UI puramente escrita en SwiftUI nativo que use los widgets nativos provistos por el kernel de Apple.
    • Accesibilidad y Árbol Semántico: Skia renderiza vectores gráficos en un lienzo plano. Para que herramientas como VoiceOver o el acceso mediante teclado funcionen en iOS, Skiko traduce en tiempo real el árbol de composición semántico de Compose a un árbol de nodos de accesibilidad nativos de Apple (UIAccessibilityElement). Aunque JetBrains ha mejorado esta traducción enormemente, si tu aplicación tiene requisitos extremadamente rigurosos de accesibilidad gubernamental o médica, las vistas nativas de SwiftUI siguen teniendo una integración directa y libre de wrappers de traducción intermedios.
    • Integración con el Teclado y Ciclo de Vida: La gestión del teclado en iOS (desplazamientos de foco, layouts ajustables cuando el teclado aparece en pantalla) se comporta de forma diferente a Android. Aunque Compose Multiplatform ofrece APIs para gestionar esto (WindowInsets.ime), en ocasiones la interacción con los gestos nativos de navegación lateral de iOS (swipe back) requiere configuraciones extras para evitar conflictos con los gestos de deslizamiento horizontales internos de Compose.
    • La decisión senior: Usa Compose Multiplatform si buscas paridad absoluta de diseño entre plataformas, si tu equipo tiene bases sólidas de Jetpack Compose y si la UI se enfoca principalmente en consumo de datos, formularios y listados. Opta por una fachada de UI separada (Compose en Android, SwiftUI en iOS compartiendo solo el ViewModel de commonMain) si tu aplicación móvil requiere widgets interactivos profundos del sistema (como Live Activities), APIs visuales altamente específicas de Apple, o si el diseño del producto debe ajustarse milimétricamente al «Human Interface Guidelines» de Apple de manera radical.

    Para profundizar

    Conclusión

    El diseño de interfaces mediante Compose Multiplatform representa el punto álgido del desarrollo multiplataforma: compartir la lógica visual y de navegación sin sacrificar el rendimiento, gracias al uso directo de APIs de bajo nivel como Metal y Skia. Esta homogeneidad arquitectónica hace que mantener pantallas se convierta en una tarea de un solo esfuerzo.

    Sin embargo, el lienzo del Canvas de Skia puede sentirse en ocasiones como una celda aislada si necesitamos consumir componentes extremadamente específicos del sistema operativo de Apple, tales como mapas de alta precisión de Apple Maps (MapKit) o flujos de realidad aumentada (ARKit). En el próximo artículo, Unidad 4 — Tema 2: Integrando SwiftUI y componentes nativos en Compose Multiplatform, aprenderemos a romper el sandbox del lienzo de Skia e incrustar de manera transparente componentes visuales de SwiftUI y UIKit directamente en el flujo declarativo de nuestro código común.