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
CAMetalLayeren 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
- Compose Multiplatform — JetBrains official dev guide: la guía definitiva para configurar el compilador, habilitar el plugin gráfico y desplegar tu primera UI unificada en emuladores y dispositivos físicos.
- Kotlin Multiplatform — Project Structure Discovery: documentación oficial de JetBrains sobre cómo se estructuran las dependencias de los módulos de UI declarativa hacia las librerías nativas y Gradle.
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.












