El desarrollo de aplicaciones multiplataforma ha evolucionado enormemente en los últimos años, y Kotlin Multiplatform junto con Compose Multiplatform están liderando esta transformación. Estas tecnologías permiten crear aplicaciones modernas, eficientes y nativas para múltiples plataformas desde un solo código base. Este artículo explora sus ventajas, compatibilidad y cómo comenzar.
Kotlin Multiplatform (KMP) es una tecnología que permite compartir lógica entre diferentes plataformas, como Android, iOS, escritorio y web, utilizando Kotlin como lenguaje principal. Por su parte, Compose Multiplatform extiende la funcionalidad de Jetpack Compose, el popular framework de interfaz de usuario para Android, al ámbito multiplataforma.
Aunque Compose Multiplatform se basa en Jetpack Compose, hay diferencias importantes:
Desarrollo eficiente:
Experiencia de usuario consistente:
Ecosistema Kotlin:
Compose Multiplatform simplifica la creación de aplicaciones móviles para Android e iOS. Al reutilizar la lógica y los componentes visuales, puedes garantizar consistencia en el diseño y comportamiento en ambas plataformas, reduciendo significativamente el tiempo de desarrollo.
Compose Multiplatform también es compatible con aplicaciones web y de escritorio:
Compose vs Flutter:
Compose vs React Native:
Con Kotlin Multiplatform y Compose Multiplatform, puedes configurar rápidamente un proyecto que comparta la lógica y los componentes de UI entre Android, iOS y escritorio. El flujo de trabajo se simplifica al dividir el proyecto en módulos comunes y específicos de cada plataforma. Para este ejemplo particular tomaremos composables y lógica previamente desarrollados del curso v . Aquí vas a ver como con unos sencillos pasos podemos reutilizar Compose para otras plataformas
Nota: Debemos resolver conflictos. Cada maquina es independiente de las cosas que sucedan y no hace parte del alcance del articulo explicar como resolverlos.
Debemos generar el proyecto multiplataforma a través de Kotlin Multiplatform Wizard y seleccionar las plataformas que queremos incluir: Android, iOS y Desktop. Selecciona descargar y extrae el .zip descargado a una carpeta.
Una vez abres el proyecto con Android Studio ubicando la carpeta(puede tomar unos minutos en estar listo). Vas a ver un readme que explica el propósito de cada carpeta.
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop.
* `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- `commonMain` is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only theplatform indicated inthefolder name.
For example, if you want to use Apple’s CoreCrypto forthe iOS part of your Kotlin app,
`iosMain` would be therightfolderfor such calls.
* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
En este caso nos concentraremos en la carpeta commonMain donde colocaremos toda la lógica compartida entre UI y lógica de negocio. Si quisiéramos colocar algo especifico, por ejemplo en iOS deberíamos incluirlo en la carpeta iosMain.
Nota: Importante colocar la forma de visualizar el proyecto en modo Project y no Android para navegar de forma sencilla.
En este momento vamos a migrar los archivos del proyecto del curso Curso de Jetpack Compose en Android y los acomodaremos en esta estructura de carpetas.
//FakeTaskLocalDataSource.ktobject FakeTaskLocalDataSource: TaskLocalDataSource {
privateval _tasksFlow = MutableStateFlow<List<Task>>(emptyList())
init {
_tasksFlow.value = completedTask + pendingTask
}
overrideval tasksFlow: Flow<List<Task>>
get() = _tasksFlow
override suspend funaddTask(task: Task) {val tasks = _tasksFlow.value.toMutableList()
tasks.add(task)
delay(1000L)
_tasksFlow.value = tasks
}
override suspend funupdateTask(updatedTask: Task) {val tasks = _tasksFlow.value.toMutableList()
val taskIndex = tasks.indexOfFirst { it.id == updatedTask.id }
if (taskIndex != -1) {
tasks[taskIndex] = updatedTask
delay(1000L)
_tasksFlow.value = tasks
}
}
override suspend funremoveTask(task: Task) {val tasks = _tasksFlow.value.toMutableList()
tasks.remove(task)
delay(1000L)
_tasksFlow.value = tasks
}
override suspend fundeleteAllTasks() {
delay(1000L)
_tasksFlow.value = emptyList()
}
override suspend fungetTaskById(taskId: String): Task? {
delay(1000L)
return _tasksFlow.value.find { it.id == taskId }
}
}
enumclassCategory{
WORK,
PERSONAL,
SHOPPING,
OTHER;
}
...
data classTask(
val id: String,
val title:String,
val description:String?,
val isCompleted:Boolean = false,
val category: Category? = null
)
interface TaskLocalDataSource{
val tasksFlow: Flow<List<Task>>
suspend funaddTask(task: Task)
suspend funupdateTask(updatedTask: Task)
suspend funremoveTask(task: Task)
suspend fundeleteAllTasks()
suspend fungetTaskById(taskId: String): Task?
}
val completedTask = mutableListOf<Task>()
.apply {
repeat(20){
add(
Task(
id = it.toString(),
title = "Task $it",
description = "Description $it",
category = WORK,
isCompleted = false
)
)
}
}
val pendingTask = mutableListOf<Task>()
.apply {
repeat(20){
add(
Task(
id = (it+30).toString(),
title = "Task $it",
description = "Description $it",
category = OTHER,
isCompleted = true
)
)
}
}
@Composable
fun SectionTitle(
modifier: Modifier = Modifier,
title: String
) {
Box {
Text(
text = title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
modifier = modifier
.padding(8.dp)
)
}
}
...
@Composable
fun SummaryInfo(
modifier: Modifier = Modifier,
date: String = "March 9, 2024",
tasksSummary: String = "5 incomplete, 5 completed"
) {
Column (
modifier = modifier
.padding(16.dp)
){
Text(
text = date,
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold
)
Text(
text = tasksSummary,
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onSurface,
)
}
}
....
@Composable
fun TaskItem(
modifier: Modifier = Modifier,
onClickItem:(String) -> Unit,
onDeleteItem:(String) -> Unit,
onToggleCompletion:(Task) -> Unit,
task: Task,
) {
Row (
modifier = modifier
.clickable {
onClickItem(task.id)
}
.background(
color = MaterialTheme.colors.surface,
)
.padding(horizontal = 8.dp)
,
verticalAlignment = Alignment.CenterVertically
){
Checkbox(
checked = task.isCompleted,
onCheckedChange = {
onToggleCompletion(
task
)
},
)
Column (
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
4.dp
),
modifier = Modifier.padding(
8.dp
).weight(
1f
)
){
Text(
text = task.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.subtitle1.copy(
textDecoration = if(task.isCompleted) TextDecoration.LineThrough else TextDecoration.None
),
color = MaterialTheme.colors.primary
)
if(!task.isCompleted){
task.description?.let {
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
}
task.category?.let {
Text(
text = it.toString(),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
}
}
}
Box {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete Task",
tint = MaterialTheme.colors.surface,
modifier = Modifier
.padding(8.dp)
.clickable {
onDeleteItem(task.id)
}
)
}
}
}
.......
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
) {
var isMenuExtended by remember { mutableStateOf(false) }
var state by remember { mutableStateOf(HomeDataState()) }
val coroutineScope = rememberCoroutineScope()
val fakeTaskLocalDataSource = FakeTaskLocalDataSource
LaunchedEffect(true){
fakeTaskLocalDataSource.tasksFlow.collect{
val completedTask = it.filter { task -> task.isCompleted }
val pendingTask = it.filter { task -> !task.isCompleted }
state = HomeDataState(
date = "March 9, 2024",
summary = "${completedTask.size} completed, ${pendingTask.size} pending",
completedTask = completedTask,
pendingTask = pendingTask
)
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
text = "Todo App",
color = MaterialTheme.colors.surface,
fontWeight = FontWeight.Bold
)
},
actions = {
Box (
modifier= Modifier.padding(8.dp).clickable {
isMenuExtended = true
}
){
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Add Task",
tint = MaterialTheme.colors.onSurface,
)
DropdownMenu(
expanded = isMenuExtended,
modifier = Modifier.background(
color = MaterialTheme.colors.surface
),
onDismissRequest = { isMenuExtended = false }
) {
DropdownMenuItem(
content ={
Text(
text = "delete all",
color = MaterialTheme.colors.onSurface
)
},
onClick = {
}
)
}
}
}
)
},
content = { paddingValues ->
LazyColumn (
modifier = Modifier.padding( paddingValues = paddingValues )
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(
8.dp
)
){
item {
SummaryInfo(
date = state.date,
tasksSummary = state.summary
)
}
stickyHeader{
SectionTitle(
modifier = Modifier
.fillParentMaxWidth()
.background(
color = MaterialTheme.colors.surface
),
title = "Completed tasks"
)
}
items(
items = state.completedTask,
key = { task -> task.id }
){ task ->
TaskItem(
modifier = Modifier
.clip(
RoundedCornerShape(8.dp)
)
.animateItem(),
task = task,
onClickItem = {
},
onDeleteItem = {
coroutineScope.launch {
fakeTaskLocalDataSource.removeTask(task)
}
},
onToggleCompletion = {
coroutineScope.launch {
fakeTaskLocalDataSource.updateTask(it.copy(isCompleted = !it.isCompleted))
}
}
)
}
stickyHeader{
SectionTitle(
modifier = Modifier.background(
color = MaterialTheme.colors.surface
).fillParentMaxWidth(),
title = "Pending tasks"
)
}
items(
items = state.pendingTask,
key = { task -> task.id }
){ task ->
TaskItem(
modifier = Modifier
.clip(
RoundedCornerShape(8.dp)
)
.animateItem(),
task = task,
onClickItem = { },
onDeleteItem = {
coroutineScope.launch {
fakeTaskLocalDataSource.removeTask(task)
}
},
onToggleCompletion = {
coroutineScope.launch {
fakeTaskLocalDataSource.updateTask(it.copy(isCompleted = !it.isCompleted))
}
}
)
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = { }
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add Task")
}
}
)
}
data class HomeDataState(
val date:String = "",
val summary:String = "",
val completedTask:List<Task> = emptyList(),
val pendingTask:List<Task> = emptyList(),
)
Lo único que debemos hacer ahora es colocar nuestro composable principal en el archivo App.kt para que los demás plataformas puedan invocarlo de forma global al proyecto.
@Composable
@Preview
funApp() {
MaterialTheme {
HomeScreen()
}
}
Las diferencias principales de cambios que se tienen que hacer en comparación con el proyecto del curso de Android residieron simplemente en la clase de MaterialTheme, tuvimos que buscar tokens que se adaptaran de una mejor forma y cambios en componentes como el checkbox para traer la pantalla principal o HomeScreen.
Ya podemos instalar el proyecto en cada una de las plataformas.
Android
iOS
Desktop
Compose Multiplatform está redefiniendo el desarrollo de aplicaciones multiplataforma al combinar la eficiencia del código compartido con el rendimiento de componentes nativos. Su flexibilidad y compatibilidad hacen que sea una opción ideal tanto para desarrolladores nuevos como experimentados.
Si buscas una solución moderna para crear aplicaciones multiplataforma, Compose Multiplatform es una apuesta segura así que te invito a aprender más de este tema en nuestro Curso de Jetpack Compose en Android
¿Listo para comenzar? ¡Nos vemos en el curso!