Subsecciones de Documentation

Service and Process Programming

Initial steps

Before you start with this course, you need to download and install some required software:

  • Java JDK, follow the steps provided here to download and setup JDK.
  • IntelliJ, follow the steps provided here to download the LATEST version of IntelliJ IDEA (Community).
  • Besides, if you want to learn more about Java programming language, you can follow the contents of this course.


Autor/a: Javier Carrasco Última modificación: 04/09/2025

Subsecciones de SPP

Unit 1: Java Reinforcement

1. Important Java concepts

In this part, we are going to have an overview of some important Java concepts that you should be familiar with. If you feel that you need to go more in depth with some of these concepts (or some others) regarding Java, you can have a look at the complete Java course.

Object Oriented Programming

Estimated time: 2,5 hours

  • In these slides, you have a quick overview of some basic concepts regarding OOP: how to define classes and objects in Java, and the different relationships between classes: associations, inheritance…
  • Here you can find the proposed exercises associated to the slides.

Collection management

Estimated time: 2 hours

  • In these slides, you have a quick overview of some concepts regarding collections: lists, maps, trees and so on.
  • Here you can find the proposed exercises associated to the slides.

I/O management

Estimated time: 2,5 hours

  • In these slides, you have a quick overview of how to deal with text files and the filesystem in Java.
  • Here you can find the proposed exercises associated to the slides.

2. Functional programming

In this part, we are going to learn some concepts regarding functional programming, a declarative paradigm to face some programs.

Lambda expressions

Estimated time: 1 hour

  • In this document, you have a quick overview of what functional programming is, and its main principles.
  • In this document, you can learn about lambda expressions. What they are and how to implement them in Java. Do Exercises 1 and 2 to practice with these expressions.

Stream management

Estimated time: 2 hours

  • In this document, you will learn how to manage collections with streams. There are some basic concepts regarding intermediary and final operations with streams, that you can practice with Exercises 1 and 2. Then, you can get more in depth with some advanced concepts and Exercise 3.

3. JavaFX application development

In this part, we are going to learn how to implement GUIs (Graphical User Interfaces) using JavaFX.

JavaFX fundamentals

  • In this document, you have a quick overview about how to create JavaFX projects from IntelliJ, and how to run and test your first project, including the additional software that you may need to install and setup.
  • Once that you have setup JavaFX in IntelliJ, in this document you have an overview of the most important elements in JavaFX applications: you will read about the different containers and controls available in JavaFX, how to place them in our application, and how to synchronize them with our Java code. You have Exercises 1 and 2 to start placing components in different applications. Then, you will learn how to manage events to respond to user interaction. Exercises 3 to 6 help you create JavaFX applications including events (Exercise 3 is a YouTube tutorial to implement a simple calculator using JavaFX). Finally, you will learn some additional elements, such as dialogs, to show popups or alerts to the user. Exercises 7 and 8 let you practice with these dialogs.

Advanced JavaFX features

  • In this document, you will learn some advanced concepts regarding JavaFX applications, although some of them will not be explained in class:
    • First of all, you will learn how to add a window closing event to our application, to prevent user from closing without saving changes.
    • Next section talks about how to add CSS styles to our JavaFX application. This section will not be explained in class.
    • Section 3 is a really important one. It shows how to develop applications with multiple views, and how to swap views in the same stage, or between different stages. Exercise 2 lets you practice with this concept.
    • Section 4 explains how to add different charts to our applications. Exercise 3 is a simple exercise to add a bar chart.
    • Section 5 shows how to use some advanced controls, such as tables, which are an important element in applications that need to show large amounts of information. We will also learn how to work with dates and images in JavaFX applications.
    • Finally, section 6 is a step-by-step project to practice with some of the most important concepts seen so far. Try to follow the steps given to build the HealthyMenu application. You can also follow this video to complete it. Then, do Exercise 4 to finish it.
    • Optionally, there’s an additional section that explains how to add animations to JavaFX projects. We are not going to cover this section in class.

Autor/a: Javier Carrasco Última modificación: 04/09/2025

Unit 2: Concurrent Programming

1. First steps with threads

In this part, we are going to learn the basics of concurrent programming in Java, focusing on how to create and manage multiple threads in an application.

Introduction to concurrent programming

In this document, you have an introduction to concurrent programming. You will learn the concept of process and thread, their similarities and differences, and the most basic principles of concurrent programming.

  • In this document, you can learn how to instantiate external processes in Java (for instance, open a Notepad instance in Windows). You have Exercise 1 and 2 to practice with processes.

Basic thread management

  • In this document, you will learn how to create threads in Java programs in some different ways, and how to perform some basic tasks with them, such as pause them or stop them, along with accessing to some basic information like the thread name or status. You have Exercises 1 to 4 to practice with these basic steps. Also you have some additional exercises.

Thread coordination and synchronization

  • In this document, you will learn some techniques to coordinate and synchronize multiple threads:
    • First of all, you will learn how to join threads, so that a thread must wait for another thread to finish before it starts. Do Exercises 1 and 2 to practice with this concept.
    • Then, you will learn the need of synchronizing threads when they must access a shared value. Try to copy the example shown in the contents to see the problem in action, and then try to apply the synchronization mechanisms mentioned. Do Exercises 3 and 4 to practice with this.
    • Next step is not compulsory. It talks about how to establish thread priorities in some different ways, according to your operating system. This way, some threads can run faster than another threads, if they have higher priorities. Exercises 5 and 6 are focused on this concept.
    • The last section introduces you to the producer-consumer problem, in which some threads (consumers) must wait another threads (producers) to produce something before consuming it, and these threads (producers) must wait for the others (consumers) to consume before producing next items. There is a sample with shared data that you can try to copy and test, and then you can try Exercise 7 to deal with this problem.

2. Advanced thread management

In this part we are going to learn some advanced strategies for thread management, such as using thread executors, atomic variables and, concurrent collections.

Advanced thread coordination and synchronization

Estimated time: 3 hours

  • In this document, you have some more recent and advanced strategies to deal with threads:
    • First of all, you will learn about thread executors, which let us manage a bunch of threads as a pool, so that an object is in charge of starting threads and managing statistics about current active threads and so on. There are no exercises about this section, but you will see some examples about how to use executors in later documents.
    • Then, the document talks about two alternatives to launch threads if we want to return something (callables), or if we don’t want to block main thread waiting for other threads to finish (completable futures). Exercises 1 and 2 let you practice these concepts. Also, you can download the source code of the examples provided in this section to try them.
    • Next section is optional for this module, and it talks about how to use Lock interface and some subtypes of it, such as ReadWriteLock to easily synchronize object access, even if we want to have two types of threads over this object: one for reading values and another one for writing or updating values. You can do Exercises 3 and 4 to practice with this.
    • Finally, there’s a section to read about the Fork/Join framework, which let us divide a complex task among multiple threads (fork) and wait for them to finish and show the results (join). You can do Exercise 5 of this subsection.

Threads and shared data

Estimated time: 2 hours

  • In this document, you will see several strategies for handling shared data in threads, depending on whether you want to manage simple values, arrays, or collections. You will learn how to use atomic variables for simple data, as well as how to use special thread-safe collection types. Additionally, you will learn the difference between synchronized and concurrent collections, an essential concept in terms of performance. You can complete Exercises 1 and 2.

3. Threads in JavaFX applications

In this part, we are going to learn how to use threads in JavaFX applications, as there are certain aspects we need to consider that were not necessary in console applications.

Estimated time: 3 hours

  • In this document, you will find the contents of this session:
    • First, the problem is introduced: we cannot access graphical elements, such as text fields or labels, from secondary threads in JavaFX — only from the main JavaFX application thread.
    • Next, we explore how to solve this issue using the Platform.runLater method, which allows secondary threads to delegate tasks (such as updating UI controls) to the main thread. This approach is suitable if your application does not require advanced thread management. You can complete Exercise 1 to practice this technique.
    • Then, the document covers the JavaFX concurrency framework and the various interfaces and classes it provides for handling threads in a more robust and professional way. You can use Service instances to launch background tasks and respond to events based on whether the task completed successfully, was cancelled, or failed. To gain hands-on experience with different types of services — such as simple services and scheduled services — you can complete Exercises 2 and 3.

Autor/a: Javier Carrasco Última modificación: 05/09/2025

Unit 3: Basic Client-Server Communications - Sockets

1. First steps with Java sockets

In this part, we are going to learn how to communicate between two parts of an application using Java sockets. These parts can run on the same machine or (more commonly) on different machines connected via a network or the Internet.

Estimated time: 2,5 hours

  • In this document, you will find all the content for this session:
    • First, you will be introduced to Java sockets and the two main types: TCP and UDP.
    • Next, you will learn how to implement basic client and server sockets using both the TCP and UDP protocols, with simple examples for each. Exercises 1 and 2 will allow you to practice these basic connections.
    • Finally, you will explore how to use threads in socket applications to handle multiple client connections simultaneously. Exercise 3 guides you through adding threading to your client-server socket applications.

2. Some advanced concepts about sockets

In this part, you will learn advanced strategies for working with Java sockets.

Estimated time: 4 hours

  • In this document, you will find all the content for this session:
    • First, you will learn how to serialize complex objects in socket applications. To do this, you will need to create a third project — separate from the client and server projects — to store the data that will be shared between them. This shared project must then be linked to both the client and server projects. Object serialization can be performed using either TCP or UDP sockets. Exercises 1 and 2 will allow you to practice serialization with each protocol.
    • Next, you will explore multicast sockets and their main purpose: sending messages simultaneously to all clients connected to a multicast group. To practice with multicast sockets, complete Exercises 3 and 4.

Autor/a: Javier Carrasco Última modificación: 05/09/2025

Subsecciones de U4: Service Development and Access

Annex II.1. Configuring a Secure FTP on Our Linux Server

1. Installing vsftpd

$ sudo apt-get install vsftpd

2. Opening the Firewall

$ ufw status

If the firewall is active, you will have to open some ports. If it is inactive, you won’t have any problem with the FTP server (but there might be a problem with the security of your server). If you need to open ports in your firewall for the FTP server: 20, 21 for FTP, and 990 to enable TLS.

$ sudo ufw allow 20,21,990/tcp

Additionally, we will need to open some ports for passive mode, for example, from 40000 to 50000.

$ sudo ufw allow 40000:50000/tcp

3. Preparing the User Directory

Now, we are going to create a specific user for FTP and configure its home directory to be secure, with no chance to exit to the main directories of the system.

First, we add a user for FTP connections.

$ sudo adduser maricheloftp

You will be asked for a password and some other information. We only need to enter the password; the other information can remain empty.

Vsftpd jails local users in their home directory, and it may not be writable by the user when using shell connections instead of FTP. To avoid this and continue to secure our FTP connections, we are going to create a specific directory for FTP connections where the user, through FTP, will be unable to exit this directory.

$ sudo mkdir /home/maricheloftp/ftp
$ sudo chown nobody:nogroup /home/maricheloftp/ftp
$ sudo chmod a-w /home/maricheloftp/ftp

If we verify the permissions:

$ sudo ls -la /home/maricheloftp/ftp
dr-xr-xr-x 3 nobody       nogroup      4096 Feb 21 17:24 .
drwxr-xr-x 3 maricheloftp maricheloftp 4096 Feb 21 18:48 ..

Now, we are going to create a directory for uploading files.

$ sudo mkdir /home/maricheloftp/ftp/files
$ sudo chown maricheloftp:maricheloftp /home/maricheloftp/ftp/files
$ sudo ls -la /home/maricheloftp/ftp
dr-xr-xr-x 3 nobody       nogroup      4096 Feb 21 17:24 .
drwxr-xr-x 3 maricheloftp maricheloftp 4096 Feb 21 18:48 ..
drwxr-xr-x 2 maricheloftp maricheloftp 4096 Feb 21 18:57 files

And here, we can create a file to test our server.

$ echo "test file" | sudo tee /home/maricheloftp/ftp/files/test.txt

4. Configuring vsftpd

$ sudo nano /etc/vsftpd.conf

Here, you have to change or uncomment the following lines:

anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YES

And add these ones (to enable passive FTP ports):

pasv_min_port=40000
pasv_max_port=50000

and these ones:

user_sub_token=$USER
local_root=/home/$USER/ftp

These last two lines will ensure that local users, when connecting through FTP, will access directly to the ftp directory and not to their home directory.

To allow FTP access only to some users, we add these lines:

userlist_enable=YES
userlist_file=/etc/vsftpd.userlist
userlist_deny=NO

We create the file vsftpd.userlist with the names of the users that will be allowed to use FTP.

$ echo "maricheloftp" | sudo tee -a /etc/vsftpd.userlist

Restart the service.

$ sudo service vsftpd restart

To check if the service is running okay, we can see its status with:

$ sudo service vsftpd status

Enter an editor with the information; to exit, press q.

Testing FTP Access

You can test the FTP access with the ftp command available in Linux and Windows. For Mac, you can use Finder and the option Go and Connect to a Server.

$ ftp maricheloftp@ftp.marich

elo.es
$ ftp ftp.marichelo.es

You will be asked for the password, and you can list with ls and change directory with cd. If you try to connect without inserting a user or using a valid user but not the one included in the vsftpd.userlist file, the connection will be refused.

Now, you can check your Java sources to connect to an FTP server.

6. Securing our FTP server

FTP does not encrypt data transactions, including user credentials, to avoid this hole in security we are going to enable TLS/SSL to provide encryption. First, we have to create the SSL certificates in our server to use them with vsftpd. Here we use openssl to create a certificate valid for 1 year and both the private key and the certificate will be located in the same file. This is only one instruction, only one line.

$ sudo openssl req -x509 -nodes -days 365 -newkey 
rsa:2048 -keyout /etc/ssl/private/vsftpd.pem -out 
/etc/ssl/private/vsftpd.pem

You will be asked about your address information that will be incorporated in your certificate request.

Once you have created the certificates, we will open the file vsftpd.conf again. We will comment these lines:

#rsa_cert_file=/etc/ssl/certs/ssl-cer-snakeoil.pem
#rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key

And we will add these ones:

rsa_cert_file=/etc/ssl/private/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.pem

Now, we are going to force the use of SSL always in our FTP server. Change ssl_enable to YES:

ssl_enable=YES

Next, add the following lines to explicitly deny anonymous conections over SSL and require SSL for both data transfer and logins:

allow_anon_ssl=NO
force_local_data_ssl=YES
force_local_logins_ssl=YES

With the following lines we will configure our server to use TLS:

ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NO

And finally, we will add these 2 options to don’t require reuse of ssl because it can break many FTP clients and to use key lenghts equal or greater than 128 bits:

require_ssl_reuse=NO
ssl_ciphers=HIGH

Now we will close the configuration file and restart the service. Now, if we try to to connect with the command ftp we will have a message saying that non-anonymous sessions must use encryption.

To try it now we will need a FTP client that supports TLS as FileZilla or our Java program using the class FTPSClient.


Autor/a: Mari Chelo Rubio, Javier Carrasco Última modificación: 05/09/2025

Unit 3. Basic Client-Server Communications

3.3. FTP connections. FTP clients

In this section we are going to see how to connect to a FTP server using a Java application. So, we need to implement an FTP client for this purpose. To access a FTP server through Java we’ll use Apache’s FTPClient class which is included in Apache’s Commons Net library. To do this, we will have to create a Maven or Gradle project in order to add this library in the dependencies, or download the corresponding JAR and include it in our project.

3.3.1. Connection

The most simple way to connect to an existing FTP server through FTP protocol (you can connect to an FTP server through HTTP protocol also) is opening a connection like this:

 1FTPClient ftp = new FTPClient();
 2try {
 3    // Important (before connecting)
 4    ftp.setControlEncoding("UTF-8"); 
 5	ftp.connect("172.16.208.128");
 6	System.out.print(ftp.getReplyString());
 7    // If the server is in another network...
 8	ftp.enterLocalPassiveMode(); 
 9
10	if(!FTPReply.isPositiveCompletion(ftp.getReplyCode())) {
11		ftp.disconnect();
12		System.err.println("Error connecting to FTP");
13	}
14} catch (IOException e) {
15} finally {
16	if (ftp.isConnected()) {
17		try {
18			ftp.disconnect();
19		} catch (IOException ioe) {}
20	}
21}

As you can see, we open a connection specifying the server IP or domain name (localhost if it’s running on the same machine). Then we print the response from the server and check the reply code (indicating an error or success) using the class FTPReply to know if the connection was successful or not.

Importante

The FTP server can close a connection if it has been inactive for a period of time, so it’s not guaranteed the connection will still be open anytime.

3.3.2. File listing

To list the contents of a directory, first we’ll have to log in, and then we can use the listFiles() method that returns an array of FTPFile objects, representing each one of them a file (or subdirectory, or link) inside the directory:

1if(ftp.login("arturo", "arturo")) {
2    FTPFile[] files = ftp.listFiles();
3    for(FTPFile file: files) {
4		String type = file.isDirectory()?
5   			"Directory":file.isSymbolicLink()?
6   					"Link":"File";
7        System.out.println(type + " -> " + file.getName());
8    }
9}

3.3.3. Uploading files

Before uploading a file to the server we must know if that file is in binary or text format and set the FTP default file type to be transferred previous to send it. This is accomplished with setFileType(int type) method. Valid values are FTP.ASCII_FILE_TYPE (default) or FTP.BINARY_FILE_TYPE (recommended if you don’t know the type of a file).

Other useful methods are changeWorkingDirectory(String pathname) that changes the current working directory in the FTP server and changeToParentDirectory() which changes to the parent directory.

 1public static void uploadFile(boolean isText, String filePath, 
 2    String nameInServer) {
 3    // This is a method we have created to open a connection
 4	if(!connect()) { 
 5		System.err.println("Cannot upload file, error connecting!");
 6		return;
 7	}
 8		
 9	try(FileInputStream in = new FileInputStream(filePath)) {
10		ftp.setFileTransferMode(
11			isText?FTP.ASCII_FILE_TYPE:FTP.BINARY_FILE_TYPE);
12        if(!ftp.storeFile(nameInServer, in)) {
13            System.err.println("Error uploading file " + filePath +
14                " (" + ftp.getReplyString() + ")");
15        } else {
16            System.out.println("File " + filePath +
17            " uploaded with name " + nameInServer);	
18        }
19    } catch (IOException e) {
20        System.err.println("Error uploading file " + filePath
21                + e.getMessage());
22    }
23}

3.3.4. Downloading files

To download a file is a very similar process but using a FileOutputStream on the local filename and the method retrieveFile.

 1public static void downloadFile(boolean isText, String nameInServer, 
 2    String nameLocal) {
 3	if(!connect()) {
 4		System.err.println("Cannot download file, error connecting!");
 5		return;
 6	}
 7	try(FileOutputStream out = new FileOutputStream(nameLocal)) {
 8		ftp.setFileTransferMode(isText? 
 9            FTP.ASCII_FILE_TYPE:FTP.BINARY_FILE_TYPE);
10        if(!ftp.retrieveFile(nameInServer, out)) {
11            System.err.println("Error downloading file " + nameInServer +
12                " (" + ftp.getReplyString() + ")");
13        } else {
14            System.out.println("File " + nameInServer +
15                " downloaded with name " + nameLocal);	
16        }
17	} catch (IOException e) {
18        System.err.println("Error downloading file " + nameInServer +
19            e.getMessage());
20	}
21}

3.3.5. Other operations

There are other useful operations that can be done from the FTP client, such as:

  • rename(String from, String to): Changes a remote file’s name.
  • deleteFile(String pathname): Deletes a remote file.
  • removeDirectory(String pathname): Deletes a directory, only if it’s empty.
  • makeDirectory(String pathname): Creates a new subdirectory.
  • printWorkingDirectory(): Gets the name of the current working directory.
  • listNames() Gets only the names of the list of files inside the current directory.

Exercise 6

Create a JavaFX application called FTPManager. It will be a simple FTP client, which you’ll use to connect to a server (can be localhost or a virtual machine IP), list its contents on a ListView and do several actions. The application aspect should be more or less like this:

FTP Manager FTP Manager

As you can see, once you enter a server address, a login name and password, and click the Connect button, the application will list the working directory contents. The other buttons will perform the following actions:

  • Upload: Will open a FileChooser dialog to select a file that will be uploaded to the FTP server (current directory). Dowload/Enter: Only active when something in the list is selected. Two situations can happen
    • If the selected item is a directory, the application will change to that directory listing its files.
    • If it’s a normal file, the application will open a DirectoryChooser dialog to select a local directory and download the file there. Go up: Will change to the parent directory and list its contents. Delete: Only active when something in the list is selected. Will delete the file.

There will be a label where you’ll show every action’s result, whether it’s a success or a failure. When it’s needed, refresh the list’s contents.

3.4. FTPS Connections (FTP Secure)

FTPS adds layers of security to standard FTP connections through the use of SSL (Secure Sockets Layer) or TLS (Transport Layer Security). To access an FTPS server through Java, we will use Apache’s FTPSClient class from the Apache Commons Net library.

3.4.1. Connection

Connecting to an FTPS server is similar to connecting to an FTP server but using FTPSClient instead of FTPClient:

 1ftps = new FTPSClient("TLS",false);
 2try {
 3    ftps.setControlEncoding("UTF-8");
 4    //ftps.connect("54.38.240.72");
 5    ftps.connect("ftp.marichelo.es");
 6    ftps.login("maricheloftp", "marichelo");
 7
 8    if (!FTPReply.isPositiveCompletion(ftps.getReplyCode())) {
 9        ftps.disconnect();
10        System.err.println("Error connecting to FTPS");
11    } else {
12        ftps.enterLocalPassiveMode();
13        ftps.execPROT("P");
14        System.out.println("Connected");
15        //testing chroot
16        System.out.println(ftps.printWorkingDirectory());//wich directory we are connected
17        ftps.cdup(); // cd.. trying to exit our home directory
18        System.out.println(ftps.printWorkingDirectory());
19
20        FTPFile[] files = ftps.listFiles();  //listing the files of the working directory
21        for(FTPFile file: files) {
22            String type = file.isDirectory()?
23                    "Directory":file.isSymbolicLink()?
24                    "Link":"File";
25            System.out.println(type + " -> " + file.getName());
26        }
27    }
28} catch (IOException e) {
29    e.printStackTrace();
30} finally {
31    if (ftps.isConnected()) {
32        try {
33            ftps.logout();
34            ftps.disconnect();
35        } catch (IOException ex) {
36            ex.printStackTrace();
37        }
38    }
39}

Both uploading and downloading files are similar to FTPClient but using FTPSClient class instead.

3.5. SFTP Connections (SSH File Transfer Protocol)

SFTP is another protocol that provides a secure way to transfer files over an encrypted connection. To implement an SFTP client in Java, we can use the JSch library from com.jcraft.

3.5.1. Connection

Here’s a basic example of how to connect to an SFTP server using JSch:

 1import com.jcraft.jsch.JSch;
 2import com.jcraft.jsch.Session;
 3
 4JSch jsch = new JSch();
 5try {
 6    Session session = jsch.getSession("username", "yourserver.com", 22);
 7    session.setPassword("password");
 8
 9    // Configuration to not validate the host key
10    session.setConfig("StrictHostKeyChecking", "no");
11
12    session.connect();
13
14    // Connection established
15    session.disconnect();
16} catch (Exception e) {
17    e.printStackTrace();
18}

3.5.2. Listing Files, Uploading, and Downloading

For operations like listing files, uploading, and downloading, you’ll need to use the ChannelSftp class from JSch. Here’s a basic outline:

 1import com.jcraft.jsch.Channel;
 2import com.jcraft.jsch.ChannelSftp;
 3import com.jcraft.jsch.JSch;
 4import com.jcraft.jsch.Session;
 5
 6// Establish connection
 7Channel channel = session.openChannel("sftp");
 8channel.connect();
 9ChannelSftp sftpChannel = (ChannelSftp) channel;
10
11// List files
12Vector<ChannelSftp.LsEntry> list = sftpChannel.ls("/path/to/directory");
13for (ChannelSftp.LsEntry entry : list) {
14    System.out.println(entry.getFilename());
15}
16
17// Upload file
18sftpChannel.put("localfilepath", "remotefilepath");
19
20// Download file
21sftpChannel.get("remotefilepath", "localfilepath");
22
23// Close connection
24sftpChannel.exit();
25session.disconnect();

Autor/a: Mari Chelo Rubio, Javier Carrasco Última modificación: 05/09/2025

Unit 4. Developing and Accessing Services

4.6. Basic service access from Java

Nowadays, most applications rely on web services to access remote and centralized data stored in a remote server through the World Wide Web. A web service is a technology which uses a series of standards in order to communicate an application running in the server (using any technology like PHP, Node, Ruby, Java, .NET, …) with a client application that can be running in any device and be written also in any language. In this part we’ll mainly focus in accessing REST web services using Java as a client, relying on a server side implemented in Node.js from previous sections.

4.6.1. Opening and reading an HTTP connection

There are many ways to connect to a web via the HTTP protocol in Java. For instance, we can use the native classes derived from URLConnection, or an external library that simplifies the job.

Because one of the main uses of Java is for Android development, we’ll look at what’s recommended there. There’s a library called Apache Http Client, that was included in the Android libraries but it’s not supported anymore (and even if you still can use it, it’s not recommended). The recommended option is to use URLConnection class and its derivatives like HttpURLConnection and HttpsURLConnection.

4.6.1.1. Using URLConnection

This is the most low-level connection method, meaning that the programmer will be controlling every aspect of the connection but in contrast the resulting code will be larger and uglier. It’s recommended in Android because, if used properly, it’s the faster and the least memory, processor (and battery) consuming method.

The URL class is used to represent the remote resource in the World Wide Web we’ll be accessing.

1URL google = new URL("http://www.google.es");

This object will return a URLConnection object when we connect to it.

1URLConnection conn = google.openConnection();

To get the response body (content), the connection provides an InputStream for that purpose. It’s also recommended to retrieve the charset encoding from the response headers, in order to read everything (like accents) properly. We can use this static method for this purpose:

1// Get charset encoding (UTF-8, ISO,...)
2public static String getCharset(String contentType) {
3    for (String param : contentType.replace(" ", "").split(";")) {
4        if (param.startsWith("charset=")) {
5            return param.split("=", 2)[1];
6        }
7    }
8    return null; // Probably binary content
9}

This can be a basic way of connecting to a URL and gettint its contents:

 1public static void main(String[] args) {
 2    BufferedReader bufInput = null;
 3    try {
 4        URL google = new URL("http://www.google.es");
 5        URLConnection conn = google.openConnection();
 6        
 7        String charset = getCharset(conn.getHeaderField("Content-Type"));
 8        
 9        bufInput = new BufferedReader(
10                new InputStreamReader(conn.getInputStream(), charset));
11        
12        String line;
13        while((line = bufInput.readLine()) != null) {
14            System.out.println(line);
15        }
16    } catch (MalformedURLException e) {
17        ...
18    } catch (IOException e) { 
19        ...
20    } finally {
21        if(bufInput != null) {
22            try {
23                bufInput.close();
24            } catch (IOException e) {...}
25        }
26    }
27}

4.6.1.2. HttpURLConnection and following redirections

The class HttpURLConnection provides additional methods like following redirections automatically or getting the response code (such as 404 for “Not Found”). To get an HttpURLConnection and follow redirections automatically you should call this method:

1URL sanvi = new URL("http://iessanvicente.com");
2HttpURLConnection conn = (HttpURLConnection)sanvi.openConnection();
3conn.setInstanceFollowRedirects(true);

It doesn’t always work. For example, it may return code 301 (Moved Permanently) and thus you will not be redirected to the new location automatically. You can check what the response (and its headers) is by using available methods:

1System.out.println(conn.getResponseCode());
2System.out.println(conn.getResponseMessage());
3System.out.println(conn.getHeaderFields());

With this information, we could manage manually these redirections (with the risk of falling into a redirection loop), even if there are many, like this:

1URL url = new URL("http://iessanvicente.com");
2HttpURLConnection conn;
3do {
4    conn = (HttpURLConnection)url.openConnection();
5    if(conn.getResponseCode() == 301) {
6        url = new URL(conn.getHeaderField("Location"));
7    }
8} while(conn.getResponseCode() == 301);

Exercise 8

Create a console Java application named LinkSearch that will ask you for an address and print all the links (<a>) detected in the response. If you want, it’s a good idea to create an auxiliary class that extends from BufferedReader as we saw on Unit 1, to only filter those links from the output. This is part of the output that https://iessanvicente.com should show:

Exercise 8 Exercise 8

4.6.2. Basics of web service access

To access a REST web service we need a URL (which represents a resource being accessed), and an operation (GET, POST, PUT, DELETE) to do with that resource, along with additional data needed for the operation.

The simplest operation is GET, that is usually used for searching and getting information about something that already exists. In a GET operation, data (if necessary) is sent in the URL in two possible ways:

  • http://domain/resource?data1=value1&data2=value2.
  • http://domain/resource/value1/value2

This is a really basic Express service that will read two numbers passed in the url (GET) and will print (response) the result of that sum:

1app.get('/sum/:n1/:n2', (req, res) => {
2    let result = parseInt(req.params.n1) + parseInt(req.params.n2)
3    res.send("" + result);
4});

And this is how we can call it from Java and obtain the result:

 1private static String getSumFromService(int n1, int n2) {
 2    BufferedReader bufInput = null;
 3    String result;
 4    try {
 5        URL google = new URL("http://localhost/services/sum/" 
 6            + n1 + "/" + n2);
 7        URLConnection conn = google.openConnection();
 8        
 9        bufInput = new BufferedReader(
10                       new InputStreamReader(conn.getInputStream()));
11        result = bufInput.readLine();
12    } catch (IOException e) {
13        return "Error";
14    } finally {
15        if(bufInput != null) {
16            try {
17                bufInput.close();
18            } catch (IOException e) { return "Error"; }
19        }
20    }
21    
22    return result == null?"Error":result;
23}
24
25public static void main(String[] args) {
26    System.out.println(getSumFromService(3, 5));
27}

4.6.2.1. ServiceUtils class

In order to wrap all the code needed to connect to a web service and send/receive information to/from it, we are going to create our own class. We call it ServiceUtils, and its code is:

 1public class ServiceUtils {
 2
 3    private static String token = null;
 4
 5    public static void setToken(String token) {
 6        ServiceUtils.token = token;
 7    }
 8
 9    public static void removeToken() {
10        ServiceUtils.token = null;
11    }
12
13    public static String getCharset(String contentType) {
14        for (String param : contentType.replace(" ", "").split(";")) {
15            if (param.startsWith("charset=")) {
16                return param.split("=", 2)[1];
17            }
18        }
19
20        return null; // Probably binary content
21    }
22
23    public static String getResponse(String url, String data, 
24        String method) {
25
26        BufferedReader bufInput = null;
27        StringJoiner result = new StringJoiner("\n");
28        try {
29            URL urlConn = new URL(url);
30            HttpURLConnection conn = 
31                (HttpURLConnection) urlConn.openConnection();
32            conn.setReadTimeout(20000 /*milliseconds*/);
33            conn.setConnectTimeout(15000 /* milliseconds */);
34            conn.setRequestMethod(method);
35
36            conn.setRequestProperty("Host", "localhost");
37            conn.setRequestProperty("Connection", "keep-alive");
38            conn.setRequestProperty("Accept", "application/json");
39            conn.setRequestProperty("Origin", "http://localhost");
40            conn.setRequestProperty("Accept-Encoding", 
41                                    "gzip,deflate,sdch");
42            conn.setRequestProperty("Accept-Language", "es-ES,es;q=0.8");
43            conn.setRequestProperty("Accept-Charset", "UTF-8"); 
44            conn.setRequestProperty("User-Agent", "Java");
45
46            // If set, send the authentication token
47            if(token != null) {
48                conn.setRequestProperty("Authorization", 
49                                        "Bearer " + token);
50            }
51
52            if (data != null) {
53                conn.setRequestProperty("Content-Type", 
54                    "application/json; charset=UTF-8");
55                conn.setRequestProperty("Content-Length", 
56                    Integer.toString(data.length()));
57                conn.setDoOutput(true);
58                //Send request
59                DataOutputStream wr = 
60                    new DataOutputStream(conn.getOutputStream());
61                wr.write(data.getBytes());
62                wr.flush();
63                wr.close();
64            }
65
66            String charset = getCharset(
67                                conn.getHeaderField("Content-Type"));
68
69            if (charset != null) {
70                InputStream input = conn.getInputStream();
71                if ("gzip".equals(conn.getContentEncoding())) {
72                    input = new GZIPInputStream(input);
73                }
74
75                bufInput = new BufferedReader(
76                                    new InputStreamReader(input));
77
78                String line;
79                while((line = bufInput.readLine()) != null) {
80                    result.add(line);
81                }
82            }
83        } catch (IOException e) {
84        } finally {
85            if (bufInput != null) {
86                try {
87                    bufInput.close();
88                } catch (IOException e) { }
89            }
90        }
91
92        return result.toString();
93    }
94}

As you can see, we have the getCharset method explained before to get the charset encoding for the communication. The getResponse method will be used to send a request to a web service. It has 3 parameters: the url to connect, the data to send in the body of the request (or null if there’s no data), and the operation or method (GET, POST, PUT, DELETE). This response will be stored in a String that will be returned from this static method.

Also, some HTTP headers have been established so that the request is similar to what a web browser would send, like the origin domain (in this case localhost), the preferred language (Spanish), the possibility to compress data (gzip) in order to save bandwidth, a time out for the connection, or the data type used for communication (application/json).

There are also some other methods and attributes to deal with tokens for client authentication, so that we can store the token provided by the server in a static variable and send it back to the server in every request. But we are not going to use them for now.

You will be provided with this class in the Virtual Classroom, so that you can use it in the exercises to help you connect and get data from the web services more quickly.

4.6.3. JSON processing

Nowadays, most web services send and receive information in JSON format (XML is almost abandoned for this use). This format is native of JavaScript but most languages like Java have the necessary tools to process it.

The basic information about JSON and the available tools for each language can be found at http://www.json.org/. To process this information we can use the org.json API (also present in Android) or other options like Google’s GSON, but there are a lot of options. We will see here how to use GSON library.

4.6.3.1. Using GSON

Let’s see an example of how to use GSON library. First of all, we need to add that library to our Java project. You can download the latest version of the JAR file in the Maven repository, or the version that you will find in the Virtual Classroom. You must add the JAR file as a global or local library to your IntelliJ project, as you did with JavaFX in previous units.

Imagine that we receive this information from a web service in JSON format:

 1{
 2	"error":false,
 3	"person": {
 4		"name":"Peter",
 5		"age":30,
 6		"address":[
 7			{"city":"London","street":"Some street 24"},
 8			{"city":"New York","street":"Other street 12"}
 9		]
10	}
11}

GSON will try to automatically convert from JSON to a native Java object. So it will need a class that contains the same fields as the JSON response (same name). There’s no need to create any specific constructor. For the example above, we need a class called Address with two attributes called city (String) and street (String), and another class called Person with the attributes name (String), age (int) and address (of type Address).

Now we need an additional class that maps the initial JSON response format:

 1public class GetPersonResponse {
 2    boolean error;
 3    Person person;
 4    
 5    public boolean getError() {
 6        return error;
 7    }
 8    
 9    public Person getPerson() {
10        return person;
11    }
12}

If the field names are correctly set, it will map everything automatically:

 1public static void main(String[] args) {
 2
 3    String json = 
 4        ServiceUtils.getResponse("http://localhost/services/example", 
 5            null, "GET");
 6
 7    if(json != null) {
 8        Gson gson = new Gson();
 9        GetPersonResponse personResp = gson.fromJson(json, 
10            GetPersonResponse.class);
11        if(!personResp.getError()) {
12            System.out.println(personResp.getPerson().toString());
13            System.out.println(personResp.getPerson().getClass());
14        } else {
15            System.out.println("There was an error in the request");
16        }
17    }
18}

For this example, we would need to override the toString method in both classes Person and Address to show their data in an appropriate format. Note how we use the ServiceUtils class explained above to get a reponse, and then use GSON library to parse the response and store the corresponding data in the appropriate objects, according to GetPersonResponse class.

If a class property’s name doesn’t match the JSON field name, we can tell the GSON parser that it has to assign that field by using an annotation with that property:

1@SerializedName("error")
2boolean haserror; // In JSON it will be named "error"

You can learn more about GSON library in this GSON tutorial.

Exercise 9

Create a Java project called JsonParsing. Add the GSON library on it, and then implement a program (a console application, not a JavaFX one) that uses previous code (ServiceUtils, Person, Address and GetPersonResponse classes, apart from the main application) to connect to a server and retrieve a person information.

You can use the Node server provided to you in this session’s resources, and access the localhost/services/example URL to get the JSON data back. You can also edit the code of this server to change the URI or the port, if you want to.

4.6.4. Accessing web services from a different thread

Connecting to a web service and getting a response can be a costly operation, specially if the Internet connection is not the best and/or the server is overloaded. If we access a web service in the main thread, the application will be blocked (unresponsive) until we get the result.

The best way to deal with web services (or any other remote connection) is by using a separate thread to start the connection and then process the results when they’re received. If processing those results implies changing the view in a JavaFX application, we can use a Service or the Platform.runLater() method, like in this example that calls the sum service example shown in previous sections.

 1public class GetSumService extends Service<Integer> {
 2
 3    int n1, n2;
 4
 5    public GetSumService(int n1, int n2) {
 6        super();
 7        this.n1 = n1;
 8        this.n2 = n2;
 9    }
10
11    @Override
12    protected Task<Integer> createTask() {
13        return new Task<Integer>() {
14            @Override
15            protected Integer call() throws Exception {
16                BufferedReader bufInput = null;
17                Integer result = 0;
18                try {
19                    URL url = new URL("http://localhost/services/sum/" + 
20                        n1 + "/" + n2);
21                    URLConnection conn = url.openConnection();
22                    
23                    bufInput = new BufferedReader(
24                    new InputStreamReader(conn.getInputStream()));
25                    result = Integer.parseInt(bufInput.readLine()); 
26                } catch (IOException e) {} finally {
27                    if(bufInput != null) {
28                        try {
29                            bufInput.close();
30                        } catch (IOException e) {}
31                    }
32                }
33                
34                Thread.sleep(5000); // simulate a 5 seconds delay!
35                return result;
36            }
37        };
38    }
39    
40}

If this is the view…

WS WS

In the application’s controller, when we click “Add” button, we’ll create and start the service, and update the corresponding label when it’s finished:

 1private void sumNumbers(ActionEvent event) {
 2    gss = new GetSumService(
 3                  Integer.parseInt(num1.getText()), 
 4                  Integer.parseInt(num2.getText()));
 5    gss.start();
 6    addButton.setDisable(true);
 7    resultLabel.setVisible(false);
 8        
 9    gss.setOnSucceeded(e -> {
10        resultLabel.setText("Result: " + gss.getValue());
11        addButton.setDisable(false);
12        resultLabel.setVisible(true);
13    });
14}

Exercise 10

Create a JavaFX project called FXWebServiceExample and create a JavaFX application similar to the one shown above. Use the GetSumService class to access the sum service and retrieve the sum of the two digits sent as parameters.

As in previous exercise, you can use the Node services provided to you in this session. In this case, you should access localhost/services/sum with the two parameters needed (for instance, localhost/services/sum/5/2 should return 7 as a result). You can also change the URI or port in the Node project, if you want to.


Autor/a: Mari Chelo Rubio, Javier Carrasco Última modificación: 05/09/2025

Programación Multimedia y Dispositivos Móviles


Aquí puedes encontrar la versión anterior de los apuntes utilizada para el módulo.


Autor/a: Javier Carrasco Última modificación: 28/08/2025

Subsecciones de PMDM

Tema 0: Introducción a Kotlin

Objetivos de este tema

  • Comprender los fundamentos del lenguaje Kotlin, sus características y ventajas frente a otros lenguajes como Java.
  • Escribir y ejecutar programas básicos en Kotlin, utilizando la función principal main.
  • Diferenciar entre variables mutables (var) e inmutables (val) y comprender su importancia para la gestión de la inmutabilidad.
  • Aplicar el manejo seguro de nulos (null safety) para prevenir errores comunes como NullPointerException.
  • Definir y utilizar clases, incluidas data class, sealed class, y clases con constructores primarios y secundarios.
  • Entender y utilizar objetos (object) y los companion object para crear singletons y miembros estáticos.
  • Declarar y trabajar con funciones, incluidas las funciones de extensión.
  • Gestionar colecciones (listas, mapas y conjuntos) tanto inmutables como mutables.
  • Crear y utilizar enumerados (enum) para definir conjuntos de constantes con propiedades asociadas.
  • Comprender el uso del operador Elvis (?:) y el manejo de estructuras de control como when.
  • Desarrollar un pensamiento crítico y seguro al escribir código Kotlin, utilizando prácticas modernas y efectivas.

0.1. Introducción

Kotlin es un lenguaje moderno y conciso, diseñado para ser seguro y fácil de usar. Es el lenguaje oficial de Android y combina perfectamente con Java.

0.2. Primer programa en Kotlin

1fun main(args: Array<String>) {
2    println("Hello World!")
3}
  • La función main es el punto de entrada.
  • println imprime un mensaje en consola.

0.3. Expresividad

Kotlin reduce de manera significativa el código repetitivo de Java.

Java:

 1public class Artista {
 2    private long id;
 3    private String nombre;
 4    private String url;
 5    private String mbid;
 6
 7    public long getId() { return id; }
 8    public void setId(long id) { this.id = id; }
 9
10    public String getNombre() { return nombre; }
11    public void setNombre(String nombre) { this.nombre = nombre; }
12
13    public String getUrl() { return url; }
14    public void setUrl(String url) { this.url = url; }
15
16    public String getMbid() { return mbid; }
17    public void setMbid(String mbid) { this.mbid = mbid; }
18
19    @Override
20    public String toString() {
21        return "Artista{id=" + id + ", nombre='" + nombre + '\'' +
22               ", url='" + url + '\'' + ", mbid='" + mbid + '\'' + '}';
23    }
24}

Kotlin:

1data class Artista(
2    var id: Long,
3    var nombre: String,
4    var url: String,
5    var mbid: String
6)
  • Una data class genera de manera automática los métodos toString, equals, hashCode, etc.

0.4. Variables y tipos

1val i: Int = 42      // Constante inmutable
2var mutableNum = 10  // Variable mutable
3val d: Double = i.toDouble()
4val c: Char = 'c'
5val iFromChar = c.code
  • val define constantes, var permite cambios.
  • Los tipos se infieren de manera automática, es decir, no siempre es necesario declararlos.

Operaciones bitwise (bit a bit)

1val bitwiseOr = FLAG1 or FLAG2
2val bitwiseAnd = FLAG1 and FLAG2
  • En Kotlin se usan operadores simbólicos.

Acceso y recorrido de cadenas

1val s = "Ejemplo"
2val c = s[3]  // Accede a 'm'
3val s2 = "Example"
4for (c2 in s2) print(c2)  // Recorre cada carácter

Null safety

En Java, el código generalmente es defensivo, por lo que se debe comprobar en todo momento que no se produzca un null y prevenir NullPointerException. Kotlin, en cambio, es null safety, lo que significa que se puede definir si un objeto puede o no ser null utilizando para ello el operador ?. Fíjate en los siguientes ejemplos.

 1fun main(args: Array<String>) {
 2    // No compilaría, Artista no puede ser nulo.
 3    var notNullArtista: Artista = null
 4
 5    // Artista puede ser nulo.
 6    val artista: Artista? = null
 7
 8    // No compilará, artista podría ser nulo.
 9    artista.toString()
10
11    // Mostrará por pantalla artista si es distinto de nulo.
12    artista?.toString()
13
14    // No necesitaríamos utilizar el operador ? si previamente
15    // comprobamos si es nulo.
16    if (artista != null) {
17        artista.toString()
18    }
19    // Esta operación la utilizaremos si estamos completamente seguros
20    // que no será nulo. En caso contrario se producirá una excepción.
21    artista!!.toString()
22
23    // También podríamos utilizar el operador Elvis (?:) para dar
24    // una alternativa en caso que el objeto sea nulo.
25    val nombre = artista?.nombre ?: "vacío"
26}
  • La doble exclamación (!!) se utiliza para indicar al compilador que ese objeto no será nulo, evitando así posibles comprobraciones.

0.5. Control de flujo en Kotlin

Kotlin incluye diversas estructuras de control de flujo, como todos los lenguajes, que permiten gestionar la ejecución del código según condiciones, repeticiones o casos específicos.

if - else

Forma básica:

1val a = 5
2val b = 10
3if (a > b) {
4    println("a es mayor que b")
5} else {
6    println("a no es mayor que b")
7}

Como expresión, devolviendo un valor:

1val max = if (a > b) a else b
2println("El máximo es $max")

when

La sentencia when podría decirse que es el equivalente de switch en Java, aunque con algunas diferencias:

  • when no necesita la sentencia break, switch sí.
  • when se puede utilizar para comprobar datos de un rango (1..6), switch no.
  • when es más flexible que switch.
  • when permite la verificación de tipos, switch no.
  • when permite diferentes tipos de verificación de tipos de datos, switch no.
  • switch tiene limitaciones y solo admite tipos primitivos, enum y string, when no.

Forma básica:

1val x = 3
2when (x) {
3    1 -> println("Uno")
4    2 -> println("Dos")
5    3 -> println("Tres")
6    else -> println("Otro número")
7}

Con múltiples condiciones y rangos:

1when (x) {
2    1, 2 -> println("Uno o Dos")
3    in 3..5 -> println("Entre 3 y 5")
4    else -> println("Otro")
5}

Como expresión (devolviendo un valor):

1val mensaje = when (x) {
2    1 -> "Uno"
3    2 -> "Dos"
4    else -> "Otro"
5}
6println(mensaje)

for

Recorrer una lista:

1val lista = listOf("A", "B", "C")
2for (item in lista) {
3    println(item)
4}

Recorrer un rango:

1for (i in 1..5) {
2    println(i)
3}

Con índices:

1for ((index, value) in lista.withIndex()) {
2    println("Elemento $index: $value")
3}

while

1var contador = 0
2while (contador < 3) {
3    println("Contador: $contador")
4    contador++
5}

do-while

1var numero = 0
2do {
3    println("Número: $numero")
4    numero++
5} while (numero < 3)

Otras sentencias útiles

  • break: Sale de un bucle:
1for (i in 1..5) {
2    if (i == 3) break
3    println(i)
4}
  • continue: Salta a la siguiente iteración:
1for (i in 1..5) {
2    if (i == 3) continue
3    println(i)
4}
  • return: Finaliza la ejecución de una función y devuelve un valor:
1fun obtenerValor(x: Int): String {
2    if (x < 0) return "Negativo"
3    return "No negativo"
4}

0.6. Clases y constructores

 1class Persona(nombre: String, apellido: String) {
 2    var nombre: String = nombre
 3        set(value) { field = if (value.isEmpty()) "Sin nombre" else value }
 4        get() = "Nombre: $field"
 5
 6    var apellido: String = apellido
 7        set(value) { field = if (value.isEmpty()) "Sin apellido" else value }
 8        get() = "Apellido: " + field.uppercase()
 9
10    var edad: Int = 0
11        set(value) { field = if (value < 0) 0 else value }
12
13    var anyo: Int = 0
14
15    constructor(nombre: String, apellido: String, edad: Int) : this(nombre, apellido) { this.edad = edad }
16    constructor(nombre: String, apellido: String, edad: Int, anyo: Int) : this(nombre, apellido, edad) { this.anyo = anyo }
17}
  • El uso de constructor permite definir constructores primarios y secundarios. El uso de this en el contructor tras los dos puntos (:) invoca al constructor en línea de la clase.
  • field accede al respaldo interno de la propiedad cuando se sobrecargan los getters y setters.

init

También se dispone en Kotlin del bloque init, es un inicializador que se ejecutará de manera automática al crearse una instancia de la clase, inmediatamente después del constructor primario. Es especialmente útil para realizar operaciones de inicialización complejas, validaciones o cálculos adicionales con los parámetros del constructor. Se puede declarar más de un bloque init, y se ejecutarán en el orden en que aparecen en el código. Aunque las propiedades pueden inicializarse directamente, init permite incluir lógica adicional.

 1class Persona(nombre: String, apellido: String) {
 2    var nombreCompleto: String
 3
 4    init {
 5        // Se ejecutará tras el constructor primario
 6        nombreCompleto = "$nombre $apellido"
 7        println("Inicializando Persona: $nombreCompleto")
 8    }
 9}
10
11fun main() {
12    val persona = Persona("Patricia", "Aracil")
13    println(persona.nombreCompleto)
14}

Salida esperada:

1Inicializando Persona: Patricia Aracil
2Patricia Aracil

Herencia

Por defecto, las clases en Kotlin son final, es decir, no se pueden heredar. Para permitir la herencia, se debe marcar explícitamente con open.

 1open class Persona(val nombre: String) {
 2    fun presentarse() = println("Hola, soy $nombre")
 3}
 4
 5class Estudiante(nombre: String, val curso: String) : Persona(nombre) {
 6    fun mostrarCurso() = println("Curso: $curso")
 7}
 8
 9fun main() {
10    val estudiante = Estudiante("Carlos", "2º DAM")
11    estudiante.presentarse()      // Hereda de Persona
12    estudiante.mostrarCurso()
13}
  • open class permite que otra clase herede de Persona.
  • Estudiante heredará las propiedades y funciones públicas de Persona.

0.7. Data classes y desestructuración

1data class Person(val name: String, val surname: String, val age: Int)
2val (nombre, apellido, edad) = Person("Javier", "Carrasco", 45)
  • La desestructuración permite extraer valores fácilmente.

0.8. Sealed classes

 1sealed class Vehiculo(var nRuedas: Int)
 2data class Motocicleta(var ruedas: Int = 2) : Vehiculo(ruedas)
 3data class Turismo(var ruedas: Int = 4, var puertas: Int = 2) : Vehiculo(ruedas)
 4
 5fun tipoVehiculo(vehiculo: Vehiculo): String {
 6    return when (vehiculo) {
 7        is Motocicleta -> "Es del tipo Motocicleta"
 8        is Turismo -> "Es del tipo Turismo"
 9    }
10}

Las sealed classes en Kotlin permiten restringir el número de subclases que una clase puede tener (heredar), asegurando así, que todas las subclases se declaren en el mismo archivo. Esto proporciona mayor seguridad en tiempo de compilación, ya que el compilador sabe todos los posibles tipos de subclases y puede verificar el uso exhaustivo de estas en expresiones como when. Son ideales para representar jerarquías cerradas, donde cada tipo o variante está bien definido y no se puede extender fuera del contexto previsto. Un ejemplo común es cuando se desea modelar un estado finito o conjunto limitado de resultados como una respuesta de red o un tipo de mensaje.

0.9. Pair

1val parUno = Pair("Hola", "Mundo")
2val parDos = Pair("Adiós amigos", 150)
3val (usuario, contrasenya) = Pair("javier", "kotlin")
  • Útil para devolver dos valores.

0.10. Funciones

1fun sayHello() = "Hi!"  // Compacta
2fun sayHello(name: String, surname: String) = "Hello $name $surname"

Extensiones

1fun Fragment.toast(message: CharSequence, duration: Int = Toast.LENGTH_SHORT) {
2    Toast.makeText(this.context, message, duration).show()
3}
  • Permite extender clases sin heredar.

0.11. Colecciones

Listas

1val list = listOf("A", "B", "C")  // Inmutable
2val mutableList = mutableListOf("A", "B")
3mutableList.add("C")

Mapas

1val map = mapOf(1 to "Uno", 2 to "Dos")
2val mutableMap = mutableMapOf(1 to "Uno")
3mutableMap[2] = "Dos"

Sets

1val set = setOf(1, 2, 3)
2val mutableSet = mutableSetOf(1, 2)
3mutableSet.add(3)

0.12. Enumerados

 1enum class DiasSemana(val numero: Int, val estado: String) {
 2    LUNES(1, "Trabajando"),
 3    MARTES(2, "Trabajando"),
 4    MIERCOLES(3, "Trabajando"),
 5    JUEVES(4, "Trabajando"),
 6    VIERNES(5, "Trabajando"),
 7    SABADO(6, "Descanso"),
 8    DOMINGO(7, "Descanso")
 9}
10DiasSemana.values().forEach { println("${it.numero} - ${it.name} - ${it.estado}") }

0.13. Objetos y Companion Object

Un object permite declarar un objeto como una única instancia (singleton) sin necesidad de definir una clase y crear instancias separadas. Es ideal para definir constantes, utilidades, o estructuras que no requieran múltiples copias. Los objetos también pueden tener propiedades, funciones, inicializadores (init) e incluso implementar interfaces. Además, se pueden utilizar para definir companion objects, que actuarán como miembros estáticos compartidos entre todas las instancias de una clase. Esto facilita organizar código relacionado y compartirlo de forma global, sin perder las ventajas del enfoque orientado a objetos de Kotlin.

1object MiObjeto {
2    val usuario = "Javier"
3    val base_URL = "https://miweb.com"
4    fun mostrar() = println("Función de MiObjeto")
5}

Los companion object son un objeto declarado dentro de una clase que permite definir miembros estáticos, es decir, propiedades y métodos compartidos por todas las instancias de la clase. Funciona como acompañante (de ahí el nombre) a la clase que lo contiene, y permite acceder a sus miembros directamente a través del nombre de la clase, sin necesidad de crear instancias de esta. Puede resultar útil para crear, constantes o utilidades comunes, manteniendo una sintaxis clara.

1class Empleados(val nombre: String, val apellido: String) {
2    var idEmpleado: Int
3    init { println("Init clase Empleado"); idEmpleado = numEmpleados++ }
4    companion object { var numEmpleados = 0 }
5}

0.14. Funciones avanzadas para colecciones en Kotlin

Kotlin ofrece potentes herramientas funcionales para manipular colecciones de forma eficiente, legible y concisa. A continuación, se muestran las funciones más relevantes con ejemplos detallados.

filter

Filtra elementos que cumplan una condición específica.

1val numeros = listOf(1, 2, 3, 4, 5)
2val pares = numeros.filter { it % 2 == 0 }
3println(pares) // [2, 4]

Descripción: Selecciona elementos para los que la condición es verdadera.

map

Transforma cada elemento.

1val nombres = listOf("Ana", "Luis", "Eva")
2val mayusculas = nombres.map { it.uppercase() }
3println(mayusculas) // [ANA, LUIS, EVA]

Descripción: Aplica una transformación a cada elemento.

sortedBy y sortedDescending

Ordena elementos por un criterio.

1val personas = listOf(Persona("Ana", "Pérez"), Persona("Luis", "Gómez"))
2val ordenadas = personas.sortedBy { it.nombre }

Descripción: Ordena ascendente o descendente por una clave.

groupBy

Agrupa elementos por una clave.

1val animales = listOf("gato", "perro", "gallina", "caballo")
2val agrupados = animales.groupBy { it.first() }

Descripción: Agrupa por el resultado de una función clave.

any y all

Comprueba condiciones.

1val edades = listOf(18, 20, 25)
2println(edades.any { it >= 21 }) // true
3println(edades.all { it >= 18 }) // true

Descripción: any verifica si alguno cumple, all si todos cumplen.

count

Cuenta elementos que cumplen una condición.

1println(edades.count { it >= 21 }) // 1

distinct y distinctBy

Elimina duplicados.

1val duplicados = listOf(1, 2, 2, 3, 3, 3)
2println(duplicados.distinct()) // [1, 2, 3]

take y drop

Selecciona o descarta elementos.

1val lista = listOf(1, 2, 3, 4, 5)
2println(lista.take(3)) // [1, 2, 3]
3println(lista.drop(2)) // [3, 4, 5]

zip y unzip

Combina listas.

1val nombres = listOf("Ana", "Luis")
2val edades = listOf(25, 30)
3val combinados = nombres.zip(edades)
4println(combinados) // [(Ana, 25), (Luis, 30)]

flatten

Aplana listas de listas, es decir, convierte una lista de listas (colección anidada) en una única lista.

1val listas = listOf(listOf(1, 2), listOf(3, 4))
2println(listas.flatten()) // [1, 2, 3, 4]

reduce y fold

Acumulan elementos.

1val suma = numeros.reduce { acc, num -> acc + num }
2val sumaConInicial = numeros.fold(10) { acc, num -> acc + num }

Ejemplo combinando funciones avanzadas

Ahora se combinarán varias funciones para transformar una lista de números:

 1val numeros = listOf(5, 3, 8, 1, 9, 2)
 2
 3val resultado = numeros
 4    .filter { it % 2 != 0 }           // Solo impares
 5    .sortedDescending()               // Orden descendente
 6    .map { it * 2 }                   // Multiplica por 2
 7    .take(2)                          // Toma los dos primeros
 8    .fold(0) { acc, num -> acc + num } // Suma acumulativa
 9
10println("Resultado: $resultado")
  • Lista inicial: [5, 3, 8, 1, 9, 2]
  • Paso 1 (filter): [5, 3, 1, 9]
  • Paso 2 (sortedDescending): [9, 5, 3, 1]
  • Paso 3 (map): [18, 10, 6, 2]
  • Paso 4 (take): [18, 10]
  • Paso 5 (fold): 0 + 18 + 10 = 28

Resultado final: 28

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 1: Introducción a Jetpack Compose

Objetivos de este tema

  • Conocer Jetpack Compose y las diferencias con el sistema tradicional basado en vistas (XML).
  • Identificar las ventajas de utilizar Jetpack Compose para el desarrollo de interfaces.
  • Explorar y comprender la relación entre Kotlin y Jetpack Compose.
  • Establecer la estructura básica de un proyecto Compose.
  • Distinguir el flujo de trabajo del compilador y el runtime en la composición de interfaces.
  • Construir interfaces básicas utilizando funciones Composables y layouts principales.
  • Entender el proceso de recomposición y cómo se gestiona el estado en Compose.

1.1. Fundamentos de Compose

1.1.1. ¿Qué es Jetpack Compose?

Jetpack Compose es el framework moderno de Android que permite construir interfaces de usuario de forma declarativa. Diseñado para escribir UI de forma más intuitiva, menos propenso a errores y totalmente integrable con el lenguaje de programación Kotlin.

Se basa en tres ideas clave:

  • UI declarativa: la idea es describir qué se quiere mostrar, en lugar de dibujarlo con XML.
  • Reactividad: se actualiza automáticamente cuando los datos cambian.
  • Menos código: no se utiliza XML ni findViewById.

1.1.2. Diferencias entre el sistema basado en vistas y Jetpack Compose

Sistema basado en vistas (XML) Jetpack Compose
XML separado de lógica Código unificado en Kotlin
findViewById necesario o ViewBinding No requiere vinculación manual
Inflado de vistas Composición directa en runtime
Mucho código Sintaxis más concisa
Acoplamiento más rígido Modularidad y reutilización nativa

1.1.3. Ventajas de Jetpack Compose

  • Declarativo: se define la UI como función del estado, esto significa que la interfaz de usuario se crea a partir del estado de los datos actuales, si estos cambian la UI se actualiza.
  • Menos código: elimina gran parte del boilerplate.
  • Testing más sencillo: las funciones Composables pueden probarse directamente.
  • Integración total con Kotlin.
  • Mejora el rendimiento en muchos casos gracias a la recomposición eficiente.
  • Animaciones fáciles de implementar.
  • Migración progresiva: puede utilizarse junto con el sistema de vistas tradicional.

Ejemplo 1.1. UI reactiva con estado

Este ejemplo muestra un saludo reactivo. Al pulsar el botón, el estado (nombre) cambia y Compose actualiza automáticamente la interfaz, mostrando “Hola, Patricia” sin que haya que modificar directamente el Text.

 1import android.os.Bundle
 2import androidx.activity.ComponentActivity
 3import androidx.activity.compose.setContent
 4import androidx.activity.enableEdgeToEdge
 5import androidx.compose.foundation.layout.Arrangement
 6import androidx.compose.foundation.layout.Column
 7import androidx.compose.foundation.layout.Spacer
 8import androidx.compose.foundation.layout.fillMaxSize
 9import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.Button
12import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.unit.dp
22
23class MainActivity : ComponentActivity() {
24    override fun onCreate(savedInstanceState: Bundle?) {
25        super.onCreate(savedInstanceState)
26        enableEdgeToEdge()
27        setContent {
28            MaterialTheme {
29                SaludoInteractivo()
30            }
31        }
32    }
33}
34
35@Composable
36fun SaludoInteractivo() {
37    var nombre by remember { mutableStateOf("Javier") }
38    Column(
39        modifier = Modifier
40            .fillMaxSize()
41            .padding(16.dp),
42        verticalArrangement = Arrangement.Center, // Centra verticalmente
43        horizontalAlignment = Alignment.CenterHorizontally // Centra horizontalmente
44    ) {
45        Text(
46            text = "Hola, $nombre", style = MaterialTheme.typography.headlineMedium
47        )
48
49        Spacer(modifier = Modifier.height(8.dp))
50
51        Button(onClick = { nombre = "Patricia" }) {
52            Text(text = "Cambiar nombre")
53        }
54    }
55}

1.2. Arquitectura de Compose

1.2.1. Componentes clave: Runtime, UI, Compiler

Jetpack Compose se divide en tres módulos que trabajan conjuntamente y con tareas claramente definidas:

  1. Compose Compiler: plugin del compilador de Kotlin que transforma las funciones etiquetadas como @Composable en código optimizado y eficiente para ser ejecutado sobre la plataforma Android. Además de inyectar la lógica interna necesaria para hacer que la UI sea reactiva y eficiente.
  2. Compose Runtime: el motor de ejecución que mantiene el estado, gestiona las recomposiciones y decide qué UI debe actualizarse. Es capaz de saber qué partes de la UI deben volver a generarse.
  3. Compose UI: contiene los elementos visuales como Text, Button, Row, Column, etc. Aquí está el trabajo directo del desarrollador para construir interfaces. A nivel interno trabaja con la librería gráfica de Android.

Estos tres módulos trabajan de forma desacoplada, lo que permite actualizar o extender cada parte por separado.

Modularidad y escalabilidad

Gracias a esta arquitectura desacoplada, Jetpack Compose es:

  • Extensible: puedes crear tus propios elementos UI (@Composable personalizados) o incluso reemplazar partes del runtime si lo necesitas.
  • Testable: puedes probar el runtime o la UI por separado.
  • Ligero y escalable: puedes incluir solo los módulos necesarios.

Ejemplo 1.2. Modularidad

El siguiente ejemplo permite añadir una TopAppBar estableciendo el título de la aplicación a través del recurso String.

 1import androidx.compose.material3.TopAppBar
 2
 3...
 4
 5@Composable
 6fun MyAppTopAppBar(topAppBarText: String) {
 7    TopAppBar(
 8        title = {
 9            Text(
10                text = topAppBarText,
11                textAlign = TextAlign.Left,
12                modifier = Modifier
13                    .fillMaxSize()
14                    .wrapContentSize(Alignment.CenterStart),
15            )
16        }
17    )
18}

Para mostrarla añade la llamada al método saludoInteractivo().

 1@Composable
 2fun SaludoInteractivo() {
 3    var nombre by remember { mutableStateOf("Javier") }
 4
 5    myAppTopAppBar(stringResource(R.string.app_name))
 6
 7    Column(
 8        ...
 9    )
10    ...
11}

Debes tener en cuenta que esta no será la mejor manera de mostrar una TopAppBar, pero de momento puede servir. Más adelante se verá el componente Scaffold.

1.3. Trabajo del compilador (IR y transformaciones)

1.3.1. Funcionamiento del compilador en Compose

Cuando se escribe una función etiquetada con @Composable, el compilador no la ejecuta tal cual. En lugar de eso, el plugin de Compose para Kotlin modifica esa función y añade el código adicional necesario para gestionar:

  • El control de recomposición
  • El seguimiento del estado
  • La eficiencia en la actualización de la UI

1.3.2. Composable Compiler Plugin

  • Se integra en el proceso de compilación de Kotlin.
  • Convierte funciones @Composableen llamadas más complejas que pueden ser monitorizadas por el runtime.
  • Inyecta parámetros invisibles como el Composer, que rastrea si una función necesita recomponerse o no.

Oberseva el siguiente método:

1@Composable
2fun Saludo(nombre: String) {
3    Text("Hola, $nombre")
4}

El resultado del compilador será algo parecido al siguiente método:

1fun Saludo(nombre: String, composer: Composer, changed: Int) {
2    if (composer.shouldRecompose(changed)) {
3        Text("Hola, $nombre")
4    }
5}

Este código resultado no se verá, pero es el encargado del funcionamiento óptimo y eficiente para Compose.

1.3.3. Transformacón en el Intermediate Representation (IR)

El compilador de Compose trabaja en la fase de IR (Intermediate Representation) de Kotlin, dónde se realizarán transformaciones como:

  • Inyección de lógica para recomposición condicional.
  • División de Composables en múltiples fases si contienen múltiples niveles de recomposición.
  • Manejo de claves y grupos para optimizar la reconstrucción de UI.

Imagen punto 1.3 Imagen punto 1.3

1.4. Conexión con el tiempo de ejecución

1.4.1. Cómo se interpretan y ejecutan los Composables

Cuando un método etiquetado como @Composable se llama desde setContent o desde otro Composable, no se dibujará directamente en pantalla, sino que entra en juego el runtime de Compose:

  1. Interpretará la estructura del árbol Composable.
  2. Evaluará si debe recomponer por cambios de estado.
  3. Utilizará el sistema de slots para decidir qué partes de la UI deberán redibujarse.

1.4.2. Sistema de slots y control de recomposición

El sistema de slots (Slot Table) es la estructura interna encargada de:

  • Representar cada nodo del árbol de la UI (Text, Button, Column…).
  • Registrar qué parte del árbol corresponde a qué parte del estado.
  • Guardar el orden y la identidad de cada elemento para poder hacer una recomposición eficiente.

Podría verse como un índice dinámico del árbol de UI.

La recomposición se produce cuando un valor observado cambia (por ejemplo, una variable del tipo remember { mutableStateOf(...) }), Compose marca los Composables afectados como sucios (dirty). En la siguiente fase del frame, solo esos Composables se vuelven a ejecutar.

El encargado de esto es el runtime, sin que el desarrollador tenga que intervenir manualmente.

¿Y cómo sabe Compose qué recomponer para ser eficiente?

  • Cada método Composable recibe información sobre su “posición” en el árbol.
  • El runtime asigna un grupo de recomposición a cada llamada Composable.
  • Si el estado relevante cambia, solo ese grupo se vuelve a ejecutar.

Esto hace que Compose sea más eficiente que sistemas anteriores.

Ejemplo 1.4. Recomposición

 1@Composable
 2fun Contador() {
 3    var valor by remember { mutableStateOf(0) }
 4
 5    Column {
 6        Text("Valor: $valor")
 7        Button(onClick = { valor++ }) {
 8            Text("Incrementar")
 9        }
10    }
11}

¿Qué ocurre en este ejemplo cuando se pulsa el botón?

  • Únicamente se recompondrá Text("Valor: $valor").
  • El botón permanece intacto.
  • Todo esto es decidido por el runtime con ayuda de la Slot Table.

Imagen punto 1.4 Imagen punto 1.4

1.5. Introducción a la UI de Compose

1.5.1. Estructura básica de un @Composable

En Jetpack Compose, la UI se construye a partir de los métodos marcados con la anotación @Composable. Deberás tener en cuenta que estos métodos:

  • No devuelven nada (Unit).
  • Describen cómo se mostrará la interfaz.
  • Se pueden ser anidar y reutilizar.

Un ejemplo básico:

1@Composable
2fun Saludo(nombre: String) {
3    Text(text = "Hola, $nombre")
4}

Para mostrar la composición de esta etiqueta (Text), deberá llamarse desde dentro de setContent{} en una Activity.

1setContent {
2    Saludo("Jetpack Compose")
3}

1.5.2. Layouts básicos en Compose

Jetpack Compose ofrece varios contenedores flexibles para la organización de los elementos de UI. Los tres layouts básicos son:

Colum

Este layout organiza los elementos verticalmente, uno debajo de otro.

1@Composable
2fun EjemploColumn() {
3    Column(modifier = Modifier.padding(16.dp)) {
4        Text("Primera línea")
5        Text("Segunda línea")
6    }
7}

Row

Este organiza los elementos horizontalmente, de izquierda a derecha.

1@Composable
2fun EjemploRow() {
3    Row(modifier = Modifier.padding(16.dp)) {
4        Text("Izquierda")
5        Spacer(modifier = Modifier.width(8.dp))
6        Text("Derecha")
7    }
8}

Box

Este layout superpone los elementos, estando siempre encima de todos el último.

 1@Composable
 2fun EjemploBox() {
 3    Box(modifier = Modifier.size(100.dp)) {
 4        Text("Fondo", modifier = Modifier.align(Alignment.Center))
 5        Box(modifier = Modifier
 6            .size(40.dp)
 7            .background(Color.Red)
 8            .align(Alignment.BottomEnd))
 9    }
10}

Puedes probarlo en Android Studio y añadir justo antes de la etiqueta @Composable la etiqueta @Preview(showBackground = true) para ver una previsualización sin necesidad de ejecutar la aplicación.

Imagen punto 1.5 Imagen punto 1.5

1.6. Composición y recomposición

1.6.1. ¿Qué es la recomposición?

La recomposición de Jetpack Compose es el proceso por el cual el sistema vuelve a ejecutar funciones @Composable con el fin de actualizar la interfaz de usuario (UI) como respuesta a cambios en el estado. Esto permite que la UI muestre siempre el estado actual de la aplicación.

Por ejemplo, si una variable de estado cambia, Compose identificará las partes de la UI que dependen de ese estado y volverá a ejecutar solo los métodos @Composable para refrescar la pantalla.

1.6.2. Triggers de recomposición

La recomposición en Jetpack Compose se activa cuando:

  • Se producen cambios en variables de estado: Al modificar una variable creada con mutableStateOf, Compose detecta el cambio y recompone los métodos que la utilizan.
  • Hay nuevos valores en parámetros de funciones @Composable: Al llamar a un método @Composable con diferentes argumentos, se considera que su entrada ha cambiado y se recompone.
  • Cambios en claves de listas: Al modificar la clave de un elemento en una lista, Compose puede recomponer ese elemento específico.

Destacar que Compose optimiza este proceso, recomponiendo únicamente las partes necesarias de la UI. Por ejemplo, si una lista de elementos cambia en orden pero no en contenido, Compose puede evitar recomponer los elementos que no han cambiado.

1.7. Estado y diferenciación inteligente

1.7.1. Estado observable

En Jetpack Compose, el estado representa cualquier dato que puede cambiar y debe reflejarse en la UI. Cuando un estado cambia, Compose vuelve a ejecutar los métodos @Composable que dependen de ese estado para actualizar la UI según corresponda.

Para gestionar el estado de una manera eficiente, Compose facilita varias APIs:

  • remember: Almacena un valor en memoria durante la composición, útil para conservar el estado entre recomposiciones.
1val contador = remember { mutableStateOf(0) }
  • mutableStateOf: Crea un objeto mutable que Compose observa, cuando cambia su valor se produce la recomposición.
1var texto by remember { mutableStateOf("") }
Declaración Reactivo Recomposición Adecuado para estado UI
by remember { mutableStateOf(false) } ✅ Sí ✅ Automática ✅ Sí
remember { false } ❌ No ❌ No automática ❌ No
  • derivedStateOf: Permite derivar un nuevo estado a partir de otros estados. Solo se actualizará cuando el resultado derivado cambie, lo que evita recomposiciones innecesarias.
1val esTextoLargo by remember {
2    derivedStateOf { texto.length > 10 }
3}

El uso de estas APIs pueden ayudar a optimizar la recomposición y evitar así, recomposiciones innecesarias.

Consejo, evitar operaciones complejas en métodos @Composable, los métodos @Composable deben ser rápidos y sin efectos secundarios. Las operaciones intensivas deben realizarse fuera de estas funciones y sus resultados deben pasarse como parámetros.

1.7.2. Diferenciación y control inteligente

Compose optimiza las recomposiciones mediante un sistema de diferenciación inteligente. Lo que significa que solo las partes de la UI que dependen de un estado que ha cambiado se vuelven a componer.

Para aprovechar esta diferenciación:

  • Minimiza el alcance del estado: Define los estados en los niveles más bajos posible del árbol de Composables, limitando así las recomposiciones.

  • Evita operaciones costosas en composables: Realiza cálculos intensivos fuera de las funciones @Composable y pasa los resultados como parámetros.

  • Usa remember y derivedStateOf adecuadamente: Estas funciones ayudan a conservar valores y evitar recomposiciones innecesarias.

Ejemplo 1.7. Recomposición

 1@Composable
 2fun EjemploEstado() {
 3    var texto by remember { mutableStateOf("") }
 4    val esTextoLargo by remember {
 5        derivedStateOf { texto.length > 10 }
 6    }
 7
 8    Column(modifier = Modifier.padding(16.dp)) {
 9        TextField(
10            value = texto,
11            onValueChange = { texto = it },
12            label = { Text("Ingrese texto") }
13        )
14        if (esTextoLargo) {
15            Text("El texto es largo")
16        }
17    }
18}

En este código de ejemplo, la variable esTextoLargo se actualiza únicamente cuando la longitud de texto cambie y supere la longitud establecida, evitando las recomposiciones innecesarias.

1.8. Naturaleza y propiedades de las funciones componibles

1.8.1. Reglas de los Composables

Las funciones @Composable son la parte principal de Jetpack Compose. Estas funciones deberán cumplir ciertas reglas que garanticen una UI eficiente y predecible:

  • Anotación obligatoria: Toda función que construya UI deberá estar anotada como @Composable.
  • No deben devolver valores: Como norma general, este tipo de funciones no devolverán ningún valor, deben describir como se mostrará la UI.
  • Llamadas a otras composables: Pueden llamar a otras funciones @Composable para construir interfaces más complejas.
  • Sin efectos secundarios: Deben ser “puras”, es decir, no deben modificar el estado global ni realizar operaciones que afecten fuera del alcance del método.

Estas reglas básicas garantizan que Compose pueda realizar una gestión eficientemente de la recomposición y mantener una UI coherente.

1.8.2. Efectos secundarios y pureza

En el paradigma declarativo de Compose, que las funciones @Composable sean puras es muy importante. Esto significa que, dadas las mismas entradas, siempre deben producir la misma salida sin causar efectos secundarios.

Imagen punto 1.8 Imagen punto 1.8

Si fuese necesario realizar operaciones que requieran efectos secundarios, Compose proporciona APIs específicas:

  • LaunchedEffect: Ejecuta una operación de suspensión cuando una clave específica cambia.
1LaunchedEffect(key1 = clave) {
2    // Operación de suspensión
3}
  • rememberUpdatedState: Permite acceder al valor más reciente de una variable dentro de un efecto.

  • DisposableEffect: Realiza una operación cuando el Composable entra en la composición y limpia cuando sale.

Estas herramientas permiten manejar efectos secundarios de una manera controlada, manteniendo la integridad del sistema de composición.

1.8.3. Buenas prácticas

Para escribir funciones @Composable de manera eficiente:

  • Mantén la pureza: No modifiques estados globales o realices operaciones que modifiquen elementos fuera del alcance de la función.

  • Descomposición en funciones pequeñas: Facilita la lectura y reutilización del código.

  • Evita recomposiciones innecesarias: Utiliza remember y derivedStateOf para memorizar valores y evitar recomposiciones innecesarias.

  • Utiliza las APIs adecuadas para manejar efectos secundarios: Como LaunchedEffect o DisposableEffect.

Seguir estas prácticas garantiza una UI eficiente y un código fácil de mantener.

1.9. Estrategias de migración desde Views

1.9.1. Interoperabilidad entre Views y Compose

Jetpack Compose se diseño para coexistir con el sistema tradicional de vistas, basado en Views (XML), esto permite una migración progresiva y controlada.

1.9.2. AndroidView: Incluir Views en Compose

Es posible reutilizar componentes existentes basados en Views dentro de una interfaz construida con Compose, para ello se utiliza el Composable AndroidView.

 1@Composable
 2fun VistaPersonalizada() {
 3    AndroidView(
 4        factory = { context ->
 5            TextView(context).apply {
 6                text = "Texto desde una View tradicional"
 7            }
 8        }
 9    )
10}

Puede resultar útil cuando se necesita incorporar widgets personalizados, o bibliotecas que no tienen un equivalente en Compose.

1.9.3. ComposeView: Incluir Compose en layouts de Views

Por otra lado, existe la posibilidad de insertar contenido de Compose en una jerarquía de Views existente, para lo que se utilizará ComposeView.

Vista XML:

1<androidx.compose.ui.platform.ComposeView
2    android:id="@+id/compose_view"
3    android:layout_width="match_parent"
4    android:layout_height="wrap_content" />

Desde Kotlin:

1val composeView = findViewById<ComposeView>(R.id.compose_view)
2composeView.setContent {
3    Text("Contenido de Compose dentro de una View")
4}

De esta manera, es posible introducir nuevas funcionalidades haciendo uso de Compose sin reescribir código de las pantallas existentes.

1.9.4. Migración progresiva

Si te ves en la situación de ralizar una migración a Jetpack Compose, se recomienda realizarla de manera progresiva, permitiendo que Compose y Views coexistan en el mismo proyecto hasta que la aplicación esté completamente migrada.

  1. Construir nuevas pantallas con Compose: Desarrolla las nuevas funcionalidades directamente con Compose, aprovechando sus beneficios desde el inicio.
  2. Identificar componentes reutilizables: Crea bibliotecas de componentes UI comunes en Compose, fomentando así la reutilización y manteniendo una fuente única de verdad.
  3. Reemplazar pantallas existentes gradualmente: Migra las pantallas existentes una a una, comenzando por las más sencillas, o aquellas que requieran cambios, asegurando una transición controlada.

Este enfoque mantiene la estabilidad de la aplicación mientras se produce el cambio a la nueva tecnología.

Consideraciones adicionales

  • Compatibilidad con temas y estilos: Asegúrarte que los temas definidos en Views son compatibles, o adaptados a Compose para mantener una apariencia coherente.
  • Gestión del ciclo de vida: Ten en cuenta el ciclo de vida de los componentes al integrar Compose y Views, especialmente en actividades y fragments.
  • Pruebas y depuración: Actualiza las pruebas existentes y considera nuevas estrategias de testing para componentes en Compose.

1.10. Ciclo de vida de una aplicación móvil

1.10.1. Ciclo de vida de una Activity

Las aplicaciones móviles en Android están sujetas a un ciclo de vida gestionado por el sistema operativo:

  • onCreate() -> Se inicializan componentes y UI.
  • onStart() -> La Activity es visible, pero no interactúa con el usuario todavía.
  • onResume() -> La Activity ya está en primer plano y permite interacciones con el usuario.
  • onPause() -> Pierde el foco, momento en el que se puede guardar datos o pausar tareas.
  • onStop() -> La Activity ya no es visible y se liberan recursos pesados.
  • onDestroy() -> Se destruye la Activity y se limpian los recursos finales.

Conocer el ciclo de vida es vital para manejar recursos, permisos y situaciones como rotaciones, cambios de configuración o interrupciones.

1.10.2. Ciclo de vida de un Composable

El runtime de Compose dispone su propio ciclo de vida, este se compone de tres fases fundamentales:

  1. Enter the Composition: punto de inicio, es cuando la función @Composable se ejecuta por primera vez.
  2. Recomposition: vuelve a ejecutarse si el estado cambia, actualizando solo aquello que es necesario.
  3. Leave the Composition: se elimina del árbol UI y se liberan los recursos asociados.

Imagen punto 1.10 Imagen punto 1.10

1.10.3. Relación entre ciclos

Aunque separados, estos ciclos interactúan entre sí en aplicaciones Compose:

  • Las recomposiciones ocurren dentro del contexto de la Activity que controla la Composition.
  • Si se destruye la Activity, se abandona la Composition y todos los efectos son cancelados(DisposableEffect, LaunchedEffect, etc.).
  • Para reaccionar a eventos del ciclo de vida de la Activity dentro de Compose, se puede observar Lifecycle usando APIs como LifecycleEventEffect o lifecycle.currentStateAsState() del módulo lifecycle-runtime-compose.

Ejemplo práctico

 1@Composable
 2fun CiclosDeVida() {
 3    // Se utiliza lifecycleOwner para observar el ciclo de vida de la actividad o fragmento.
 4    val lifecycleOwner = LocalLifecycleOwner.current
 5    // Se obtiene el estado actual del ciclo de vida como un estado Compose.
 6    val estado = lifecycleOwner.lifecycle.currentStateAsState()
 7
 8    // Se muestra el estado actual del ciclo de vida.
 9    Log.d("CiclosDeVida", "Estado del ciclo de vida: ${estado.value}")
10    Text(
11        text = "Estado del ciclo de vida: ${estado.value}",
12        modifier = Modifier.padding(16.dp)
13    )
14}

Una posible salida por el Logcat sería:

2025...-18617 CiclosDeVida            es.javiercarrasco.examplet01b        D  Estado del ciclo de vida: RESUMED
2025...-18617 CiclosDeVida            es.javiercarrasco.examplet01b        D  Estado del ciclo de vida: STARTED
2025...-18617 CiclosDeVida            es.javiercarrasco.examplet01b        D  Estado del ciclo de vida: CREATED
2025...-18617 CiclosDeVida            es.javiercarrasco.examplet01b        D  Estado del ciclo de vida: RESUMED

Fuentes


Autor/a: Javier Carrasco Última modificación: 29/08/2025

Tema 2: Interfaz de usuario

Objetivos de este tema

  • Comprender y aplicar modificadores de Jetpack Compose para ajustar el estilo, disposición y comportamiento de los elementos de la UI.
  • Diseñar y personalizar layouts adaptados a diferentes contextos mediante Modifier.layout, medidas intrínsecas y restricciones.
  • Analizar el proceso de renderizado en Compose.
  • Utilizar componentes gráficos como Canvas y graphicsLayer para enriquecer la experiencia visual con formas, transformaciones y animaciones.
  • Estructurar pantallas completas usando Scaffold, barras de herramientas (TopAppBar) y acciones flotantes (FAB).
  • Crear listas eficientes y reutilizables con LazyColumn y LazyRow (alternativa moderna a RecyclerView).
  • Implementar elementos interactivos esenciales como Snackbar, Toast, cuadros de diálogo (AlertDialog) y menús desplegables (Spinner).
  • Gestionar la navegación entre pantallas utilizando tanto múltiples Activities, como la solución moderna basada en Navigation Compose.
  • Evaluar cuándo conviene usar una arquitectura basada en múltiples actividades frente a una navegación controlada dentro de una única actividad.

2.1. Modificadores

Los modificadores en Jetpack Compose son objetos que permiten modificar, o extender el comportamiento y la apariencia de un elemento Composable, como puede ser su tamaño, padding, clics, animaciones o aspecto gráfico. Son un componente esencial para la creación de interfaces en Compose.

2.1.1. Sintaxis de encadenamiento

La sintaxis de los modificadores está basada en el encadenamiento de funciones, similar a la programación funcional. Se aplica utilizando el operador punto(.) sobre el parámetro modifier que acepta cada Composable.

1Text(
2    text = "Hola mundo",
3    modifier = Modifier
4        .padding(16.dp)
5        .background(Color.LightGray)
6        .clickable { /* acción */ }
7)

Este código de ejemplo, muestra un Text que tiene un padding, un fondo gris y responde al clic.

  • Todos los modificadores devuelven un nuevo Modifier, lo que permite que su encadenamiento sea fluido.
  • Como se verá a continuación, el orden importa.

2.1.2. Orden de aplicación y optimización

Destacar que en Compose, el orden de los modificadores afectará directamente al resultado visual y funcional.

1// El padding se aplica antes que el fondo.
2Modifier
3    .padding(16.dp)
4    .background(Color.Red)

Utilizando este orden se quedará el fondo ajustado al contenido sin incluir el padding. Sin embargo:

1// El fondo se aplica antes del padding.
2Modifier
3    .background(Color.Red)
4    .padding(16.dp)

En este caso el fondo abarca también el espacio del padding, ya que se aplicará primero.

Información

Los modificadores se aplicarán de arriba hacia abajo según el orden escrito, es decir, de izquierda a derecha en el renderizado visual.

Optimización

Jetpack Compose está diseñado para optimizar los modificadores comunes, como padding, size, offset o background, pero:

  • Aplicar muchos modificadores anidados de manera innecesaria puede aumentar el número de nodos en la jerarquía (LayoutNode).
  • Es recomendable agrupar modificadores relacionados y evitar repeticiones.

2.1.3. Modificadores internos vs personalizados

Modificadores internos

Los modificadores internos son los que proporciona Compose, entre los más comunes:

  • padding()
  • background()
  • fillMaxWidth(), height(), size()
  • clickable()
  • offset()
  • graphicsLayer()

Totalmente optimizados y recomendados cuando se ajustan a las necesidades.

Modificadores personalizados

Es posible crear tus propios modificadores cuando sea necesario encapsular lógica de presentación, o comportamiento, para simplificar, mejorar la legibilidad y reutilizar código. Por ejemplo:

1fun Modifier.tarjetaRedonda(): Modifier = this
2    .padding(8.dp)
3    .clip(RoundedCornerShape(16.dp))
4    .background(Color.White)

Este modificador personalizado se utilizaría de la siguiente forma:

1Box(modifier = Modifier.tarjetaRedonda())
Consejo

Puedes también crear modificadores más complejos utilizando funciones como Modifier.drawBehind, Modifier.composed, o incluso Modifier.pointerInput para gestos personalizados.

Ejemplo 2.1. Modificadores

El siguiente ejemplo muestra el encadenamiento de modificadores, el orden en padding y background y un modificador personalizado (tarjetaRedonda).

Para verlo en funcionamiento puedes pegar este código en cualquier @Composable de Android Studio o en el componente de vista previa (@Preview).

 1@Composable
 2fun ModificadoresDemo() {
 3    var isHovered by remember { mutableStateOf(false) }
 4
 5    Column(
 6        modifier = Modifier
 7            .fillMaxSize()
 8            .padding(16.dp),
 9        verticalArrangement = Arrangement.spacedBy(20.dp)
10    ) {
11        Text("1. Orden: Padding antes de Background")
12        Box(
13            modifier = Modifier
14                .padding(16.dp)
15                .background(Color.Red)
16        ) {
17            Text(
18                "Texto con padding interno",
19                modifier = Modifier.padding(8.dp)
20            )
21        }
22
23        Text("2. Orden: Background antes de Padding")
24        Box(
25            modifier = Modifier
26                .background(Color.Green)
27                .padding(16.dp)
28        ) {
29            Text(
30                "Texto con fondo más grande",
31                modifier = Modifier.padding(8.dp)
32            )
33        }
34
35        Text("3. Modificador personalizado: tarjetaRedonda")
36        Box(
37            modifier = Modifier.tarjetaRedonda()
38        ) {
39            Text(
40                "Texto dentro de tarjeta",
41                modifier = Modifier.padding(16.dp)
42            )
43        }
44
45        val context = LocalContext.current
46        Text("4. Con `clickable` (logcat)")
47        Box(
48            modifier = Modifier
49                .tarjetaRedonda()
50                .clickable {
51                    Log.d("Compose", "Tarjeta clicada")
52                    Toast.makeText(context, "Tarjeta clicada", Toast.LENGTH_SHORT).show()
53                }
54        ) {
55            Text(
56                "Haz clic en esta tarjeta",
57                modifier = Modifier.padding(16.dp)
58            )
59        }
60    }
61}
62
63// Modificador personalizado
64fun Modifier.tarjetaRedonda(): Modifier = this
65    .padding(8.dp)
66    .clip(RoundedCornerShape(16.dp))
67    .background(Color.White)
68    .border(2.dp, Color.Gray, RoundedCornerShape(16.dp))

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente código:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaModificadores() {
4    ModificadoresDemo()
5}
Información

En este ejemplo se introduce el concepto de Context, su uso en Compose varía. Se utiliza LocalContext.current, que permite acceder al contexto de Android dentro de un método Composable.

2.2. Layout personalizado con Modifier.layout

Modifier.layout permite crear Composables con disposición personalizada, es decir, que no dependen de los layouts predeterminados como Column, Row o Box, sino que definen sus propias medidas y ubicación del contenido.

2.2.1. Cómo crear layouts con reglas propias

Conceptos clave

  • Modifier.layout { measurable, constraints -> ... } permite un control total sobre cómo se mide y posiciona un composable hijo.
  • Trabaja directamente con el ciclo de composición y disposición: Measurable.measure() → Placeable.place().
  • Se pueden implementar reglas personalizadas: alineaciones, offsets, centrar, limitar tamaño, aplicar rotaciones manuales, etc.

Estructura general de uso

1Modifier.layout { measurable, constraints ->
2    val placeable = measurable.measure(constraints)
3    layout(placeable.width, placeable.height) {
4        placeable.place(x = 0, y = 0)
5    }
6}
Detalles importantes
  • measurable: representa el contenido hijo.
  • constraints: definen el tamaño máximo y mínimo permitido.
  • placeable: resultado de medir el hijo.
  • layout(width, height): define el tamaño del layout padre.
  • place(x, y): define la posición del hijo.

Recomendaciones de uso

Tipo Control Uso
Column / Row Medio Layouts comunes
Box Bajo Superposición simple
Modifier.layout Alto Casos muy personalizados

Ejemplos prácticos

2.3. Árbol de Layout y fases de renderizado

En este punto tratará de entenderse como funciona internamente el sistema de renderizado en Jetpack Compose a través del layout tree, o LayoutNode, y las tres fases clave del proceso de renderizado: composición, disposición y dibujo.

2.3.1. ¿Qué es el Layout Tree?

En Jetpack Compose, cada elemento visible es representado como un nodo dentro del layout tree, o LayoutNode. Este árbol definerá:

  • La jerarquía visual de la UI.
  • Cómo se calculan y distribuyen tamaños.
  • El orden en que se dibujan y posicionan los elementos.
Column
 ├── Text("Título")
 ├── Row
 │    ├── Icon
 │    └── Text("Etiqueta")
 └── Button("Aceptar")

Cada nodo tiene una relación padre-hijo, lo que permite al sistema navegar, componer y organizar la interfaz.

2.3.2. Fases de renderizado: composición, disposición, dibujo

Los tres pasos fundamentales que realiza Jetpack Compose para renderizar la UI:

Fase 1: Composición (measure)

  • Cada nodo hijo se compone en función de una serie de restricciones (Constraints) que vienen impuestas por el padre.
  • Debe decidirse el tamaño que debe ocupar el elemento.

Por ejemplo: Un Text dentro de un Box será compuesto con un ancho máximo igual al del elemento contenedor, el Box.

1val placeable = measurable.measure(constraints)

Fase 2: Disposición (place)

  • Una vez compuesto, cada Placeable se colocará en una posición concreta (x, y) dentro del contenedor padre.
  • Se decide dónde irá ubicado el hijo.
1placeable.place(x, y)

Fase 3: Dibujo (draw)

  • Para finalizar, el sistema dibujará los elementos en pantalla.
  • Se aplicarán colores, bordes, sombras, imágenes, animaciones, etc.

Se realiza después de componer y ubicar. En esta última fase se puede intervenir utilizando modificadores como:

1Modifier.drawBehind { ... }
Esquema del ciclo simplificado
Renderizado (UI declarativa)
 Composición (measure)
 Disposición (place)
    Dibujo (draw)

2.3.3. Herramientas relacionadas

  • Layout Inspector (Android Studio): permite ver el LayoutNode en tiempo real.
  • Modifier.layout: permite intervenir directamente en measure y place.
  • Modifier.drawBehind, Modifier.graphicsLayer: permiten intervenir en la fase de dibujo.

2.4. Intrínsecos y restricciones

Es necesario conocer que son las medidas intrínsecas en Jetpack Compose para saber cuándo es útil utilizarlas y cómo se pueden gestionar restricciones de tamaño, utilizando Composables como BoxWithConstraints.

2.4.1. Las medidas intrínsecas

Las medidas intrínsecas son una forma de calcular el tamaño mínimo o máximo que necesita un composable sin tener en cuenta las restricciones del padre. Se pueden usar para ajustar el layout en función del contenido, en lugar de limitarse por fillMaxWidth, wrapContentHeight, etc.

En Jetpack Compose, se pueden utilizar:

  • Modifier.width(IntrinsicSize.Min)
  • Modifier.width(IntrinsicSize.Max)
  • Modifier.height(IntrinsicSize.Min)
  • Modifier.height(IntrinsicSize.Max)

El siguiente ejemplo asegura que ambos textos tengan la altura del más alto, gracias a IntrinsicSize.Min.

 1@Composable
 2fun DemoIntrinsicSize() {
 3    Row(
 4        modifier = Modifier.height(IntrinsicSize.Min)
 5    ) {
 6        Text(
 7            text = "Texto alto",
 8            modifier = Modifier
 9                .background(Color.Red)
10                .padding(8.dp)
11        )
12        HorizontalDivider(
13            color = Color.Black,
14            modifier = Modifier
15                .fillMaxHeight()
16                .width(1.dp)
17        )
18        Text(
19            text = "Texto más corto",
20            modifier = Modifier
21                .background(Color.Green)
22                .padding(8.dp)
23        )
24    }
25}

Imagen punto 2.4 Imagen punto 2.4

2.4.2. Limitaciones de las medidas intrínsecas

  • Coste alto de rendimiento: requieren múltiples pases de medición, por lo que se deben evitar en listas largas o layouts complejos.
  • Es preferible el uso de BoxWithConstraints o Modifier.layout cuando el tamaño se predecible o haya que adaptarlo manualmente.

2.4.3. Uso de BoxWithConstraints

BoxWithConstraints permite acceder y modificar las restricciones de tamaño desde dentro del composable, lo que facilita la creación de interfaces adaptativas.

 1@SuppressLint("UnusedBoxWithConstraintsScope")
 2@Composable
 3fun CajaResponsiva() {
 4    BoxWithConstraints(
 5        modifier = Modifier.fillMaxWidth(),
 6        contentAlignment = Alignment.Center
 7    ) {
 8        if (maxWidth < 300.dp)
 9            Text("Pantalla pequeña")
10        else Text("Pantalla grande")
11    }
12}

Se puede usar maxWidth, minWidth, maxHeight y minHeight para realizar evaluaciones lógicas en tiempo de composición.

Diferencias

Enfoque Uso
IntrinsicSize.Min / Max Ajusta el tamaño según el contenido.
BoxWithConstraints Adapta la UI al tamaño del contenedor (responsive).

Para resumir, las medidas intrínsecas permiten que un composable se adapte a su contenido, debiéndose utilizar con cuidado por razones de rendimiento. BoxWithConstraints es preferible para layouts adaptativos o responsive.


Ejemplos prácticos

Ejemplo práctico 3 BoxWithConstraints adaptativo

2.5. Lienzo y gráficos

Ahora se verá cómo utilizar Canvas en Jetpack Compose para dibujar elementos gráficos personalizados como líneas, formas y colores, y comprender cómo se integra esta fase con el ciclo de composición.

2.5.1. ¿Qué es Canvas?

Canvas es un composable especial que permite dibujar directamente sobre la pantalla utilizando para ello primitivas gráficas:

  • Líneas
  • Rectángulos
  • Círculos
  • Texto
  • Imágenes y gradientes

Esta API es similar a la de Canvas tradicional de Android (vistas), pero adaptada a Compose y Kotlin.

2.5.2. Uso básico de Canvas

El siguiente código dibuja un rectángulo azul de 100x100 en la posición (20,20).

 1import androidx.compose.foundation.Canvas
 2...
 3
 4@Composable
 5fun EjemploCanvas() {
 6    Canvas(modifier = Modifier.size(200.dp)) {
 7        drawRect(
 8            color = Color.Blue,
 9            topLeft = Offset(20f, 20f),
10            size = Size(100f, 100f)
11        )
12    }
13}

Imagen punto 2.5 Imagen punto 2.5

2.5.3. Primitivas

Método Descripción
drawRect() Dibuja un rectángulo con tamaño y posición.
drawCircle() Dibuja un círculo dado centro y radio.
drawLine() Dibuja una línea entre dos puntos.
drawPath() Dibuja una figura compleja con líneas y curvas.
drawText() Dibuja texto (requiere herramientas adicionales).

Ejemplo 2.5. Canvas sencillo

 1@Composable
 2fun EjemploCanvasSencillo() {
 3    Canvas(modifier = Modifier.size(200.dp)) {
 4        // Fondo gris claro
 5        drawRect(Color.LightGray, size = size)
 6
 7        // Círculo rojo en el centro
 8        drawCircle(
 9            color = Color.Red,
10            radius = 50f,
11            center = center
12        )
13
14        // Línea diagonal azul
15        drawLine(
16            color = Color.Blue,
17            start = Offset(0f, 0f),
18            end = Offset(size.width, size.height),
19            strokeWidth = 4f
20        )
21    }
22}
23```~
24
25Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye `Modifier.size(200.dp)` por `Modifier.fillMaxSize()`,
26
27Visualización del ejemplo:
28
29```kotlin { lineNos="inline" title="Kotlin" }
30@Preview(showBackground = true)
31@Composable
32fun VistaPreviaCanvas() {
33    EjemploCanvasSencillo()
34}

Como puedes observar, el uso de Canvases puramente decorativo, para dibujos personalizados, gráficos o animaciones. Debes tener en cuenta que el orden de las operaciones es importante, cada llamada a un draw... se superpone a las anteriores. Puedes combinar Canvas con otros layouts y modificadores (padding, clip, etc.).


Ejemplos prácticos

2.6. Capas de dibujo y graphicsLayer

El modificador graphicsLayer en Jetpack Compose permite transformaciones a nivel de capa modificando propiedades como rotación, escala, alfa, traslación, etc.

2.6.1. ¿Qué es graphicsLayer?

  • graphicsLayer crea una capa de composición separada para el elemento, permitiendo así transformaciones y efectos visuales que no afectan a otros nodos.
  • Es ideal para animaciones, efectos complejos o para optimizar redibujados cuando se aplican múltiples transformaciones.

Propiedades comunes

Propiedad Descripción
alpha Opacidad (0.0 = transparente, 1.0 = opaco)
rotationX, rotationY, rotationZ Rotación en grados
scaleX, scaleY Escalado
translationX, translationY Traslación en píxeles
shadowElevation Sombra en píxeles (solo para elevación Z)
cameraDistance Distancia de cámara para efectos 3D

Ejemplo 2.6. Rotar y escalar un Box

 1@Composable
 2fun EjemploGraphicsLayer() {
 3    Box(
 4        modifier = Modifier
 5            .size(200.dp)
 6            .graphicsLayer(
 7                rotationZ = 45f,       // Rotación 45 grados
 8                scaleX = 1.5f,         // Escala 1.5x en X
 9                scaleY = 1.5f,         // Escala 1.5x en Y
10                alpha = 0.8f,          // Opacidad 80%
11                shadowElevation = 16f  // Sombra
12            )
13            .background(Color.Red),
14        contentAlignment = Alignment.Center
15    ) {
16        Text("Transformado", color = Color.White, fontSize = 16.sp)
17    }
18}
19
20@Preview(showBackground = true)
21@Composable
22fun VistaPreviaGraphicsLayer() {
23    EjemploGraphicsLayer()
24}

2.6.2. Animaciones con graphicsLayer

Se puede utilizar animateFloatAsState para animar propiedades de la capa, como la rotación.

 1@Composable
 2fun EjemploAnimacionGraphicsLayer() {
 3    var rotar by remember { mutableStateOf(false) }
 4    val rotacionAnimada by animateFloatAsState(targetValue = if (rotar) 360f else 0f)
 5
 6    Box(
 7        modifier = Modifier
 8            .size(200.dp)
 9            .graphicsLayer(
10                rotationZ = rotacionAnimada,
11                scaleX = 1.2f,
12                scaleY = 1.2f
13            )
14            .background(Color.Blue)
15            .clickable { rotar = !rotar },
16        contentAlignment = Alignment.Center
17    ) {
18        Text("Haz clic", color = Color.White)
19    }
20}

Ejemplos prácticos

2.7. Estructura visual con Scaffold y TopAppBar

El componente Scaffold se utiliza para estructurar pantallas completas en Compose, organizando elementos como TopAppBar, BottomAppBar, FloatingActionButton y el contenido principal de la UI mediante secciones.

2.7.1. ¿Qué es Scaffold?

Scaffold es un contenedor base que permite estructurar la pantalla mediante secciones predefinidas.

Permite organizar los elementos comunes de una app:

  • Barra superior (topBar)
  • Barra inferior (bottomBar)
  • Botón flotante (floatingActionButton)
  • Snackbar (snackbarHost)
  • Contenido principal (content)
1Scaffold(
2    topBar = { TopAppBar(...) },
3    bottomBar = { BottomAppBar(...) },
4    floatingActionButton = { FloatingActionButton { ... } },
5    content = { paddingValues ->
6        // Contenido principal con padding
7    }
8)

2.7.2. Uso básico de Scaffold con TopAppBar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploScaffoldBasico() {
 4    Scaffold(
 5        topBar = {
 6            TopAppBar(
 7                title = { Text("Mi App") },
 8                colors = topAppBarColors(
 9                    containerColor = MaterialTheme.colorScheme.primaryContainer,
10                    titleContentColor = MaterialTheme.colorScheme.primary,
11                )
12            )
13        },
14        content = { padding ->
15            Box(
16                modifier = Modifier
17                    .fillMaxSize()
18                    .padding(padding),
19                contentAlignment = Alignment.Center
20            ) {
21                Text("Contenido principal")
22            }
23        }
24    )
25}

2.7.3. Añadiendo BottomAppBar y FloatingActionButton

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploScaffoldBasico() {
 4    Scaffold(
 5        topBar = {
 6            TopAppBar(
 7                title = { Text("Mi App") },
 8                colors = topAppBarColors(
 9                    containerColor = MaterialTheme.colorScheme.primaryContainer,
10                    titleContentColor = MaterialTheme.colorScheme.primary,
11                )
12            )
13        },
14        bottomBar = {
15            BottomAppBar {
16                Text("Barra inferior", modifier = Modifier.padding(16.dp))
17            }
18        },
19        floatingActionButton = {
20            FloatingActionButton(onClick = { /* Acción */ }) {
21                Icon(Icons.Default.Add, contentDescription = "Añadir")
22            }
23        },
24        floatingActionButtonPosition = FabPosition.End,
25    ) { innerPadding ->
26        Column(
27            modifier = Modifier.padding(innerPadding),
28            verticalArrangement = Arrangement.spacedBy(16.dp),
29        ) {
30            Text(
31                modifier = Modifier.padding(8.dp),
32                text = """
33                    This is an example of a scaffold. It uses the Scaffold composable's parameters to create a screen with a simple top app bar, bottom app bar, and floating action button.
34
35                    It also contains some basic inner content, such as this text.
36
37                    You have pressed the floating action button 3 times.
38                """.trimIndent(),
39            )
40        }
41    }
42}

2.7.4. Organización de pantallas por secciones

  • Scaffold divide cada parte de la UI en una sección, facilitando la legibilidad y el mantenimiento del código.
  • Permite diseñar pantallas complejas con contenido adaptado a dispositivos (tablets, móviles).
Sección Uso
topBar Barra superior (menú, título, acciones)
bottomBar Barra inferior (navegación, información)
floatingActionButton Botón flotante para acción principal
snackbarHost Mostrar notificaciones flotantes
content Contenido principal de la pantalla

Ejemplos prácticos

Ejemplo práctico 7 Uso básico de Scaffold

2.8. Elementos básicos de la UI

Este punto tratará los componentes básicos de Jetpack Compose, haciendo una correlación con alugnos de sus equivalente en vistas (XML) de TextView, EditText, CheckBox, RadioButton, ImageView, controlando su estado, estilo e interacción.

2.8.1. Texto o etiqueta: Text (equivalente a TextView)

1@Composable
2fun EjemploText() {
3    Text(
4        text = "Hola, Compose!",
5        style = MaterialTheme.typography.titleLarge,
6        color = Color(0xFF1E88E5),
7        modifier = Modifier.fillMaxWidth().padding(8.dp)
8    )
9}

Se configura el estilo y color utilizando MaterialTheme y Color.

2.8.2. Cuadro de texto: TextField (equivalente a EditText)

 1@Composable
 2fun EjemploTextField() {
 3    var nombre by remember { mutableStateOf("") }
 4
 5    Column(modifier = Modifier.wrapContentHeight().padding(16.dp)) {
 6        TextField(
 7            modifier = Modifier.fillMaxWidth(),
 8            value = nombre,
 9            onValueChange = { nombre = it },
10            label = { Text("Nombre") },
11            placeholder = { Text("Escribe tu nombre") } // Equivalente a hint en XML
12        )
13        Spacer(Modifier.height(16.dp))
14        Text("Hola, $nombre")
15    }
16}

El estado está gestionado con remember y mutableStateOf, se usa de label para asignarle una etiqueta al campo y placeholder para indicar las instrucciones, sería el equivalente a hint para las vistas XML. El input es reactivo al estado, apareciendo el texto en un Text separado por un Spacer.

Puedes cambiar el estilo del cuadro de texto utilizando OutlinedTextField en lugar de TextField.

2.8.3. Casilla: Checkbox

 1@Composable
 2fun EjemploCheckbox() {
 3    var marcado by remember { mutableStateOf(false) }
 4
 5    Row(
 6        verticalAlignment = Alignment.CenterVertically,
 7        modifier = Modifier.fillMaxWidth().padding(16.dp)
 8    ) {
 9        Checkbox(
10            checked = marcado,
11            onCheckedChange = { marcado = it }
12        )
13        Spacer(Modifier.width(8.dp))
14        Text(text = if (marcado) "Marcado" else "No marcado")
15    }
16}

Actualiza el estado al marcar la casilla y se puede añadir texto descriptivo junto con un Text.

2.8.4. Botón de opción: RadioButton

 1@Composable
 2fun EjemploRadioButton() {
 3    val opciones = listOf("Opción A", "Opción B", "Opción C")
 4    var seleccion by remember { mutableStateOf(opciones[0]) }
 5
 6    Column(Modifier
 7        .selectableGroup()
 8        .padding(16.dp)) {
 9        opciones.forEach { texto ->
10            Row(
11                verticalAlignment = Alignment.CenterVertically,
12                modifier = Modifier
13                    .fillMaxWidth()
14                    .selectable(
15                        selected = (texto == seleccion),
16                        onClick = { seleccion = texto },
17                        role = Role.RadioButton
18                    )
19                    .padding(8.dp)
20            ) {
21                RadioButton(
22                    selected = (texto == seleccion),
23                    onClick = null
24                )
25                Spacer(Modifier.width(8.dp))
26                Text(text = texto)
27            }
28        }
29        Text("Seleccionado: $seleccion", modifier = Modifier.padding(top = 8.dp))
30    }
31}

Se hace uso de selectableGroup para accesibilidad y agrupación y muestra la opción seleccionada.

2.8.5. Interruptor: Switch

 1@Composable
 2fun EjemploSwitch() {
 3    var activado by remember { mutableStateOf(false) }
 4
 5    Row(
 6        verticalAlignment = Alignment.CenterVertically,
 7        modifier = Modifier.fillMaxWidth().padding(16.dp)
 8    ) {
 9        Switch(
10            checked = activado,
11            onCheckedChange = { activado = it }
12        )
13        Spacer(Modifier.width(8.dp))
14        Text(text = if (activado) "Activado" else "Desactivado")
15    }
16}

Alterna valores booleanos de forma visual y accesible.

2.8.6. Imagen: Image (equivalente a ImageView)

 1@Composable
 2fun EjemploImage() {
 3    Image(
 4        painter = painterResource(id = R.drawable.ic_launcher_foreground),
 5        contentDescription = "Ejemplo de imagen",
 6        modifier = Modifier
 7            .size(150.dp)
 8            .clip(RoundedCornerShape(8.dp))
 9            .border(2.dp, Color.Gray, RoundedCornerShape(8.dp)),
10        contentScale = ContentScale.Crop
11    )
12}

Se pueden cargar imágenes desde recursos (R), además, se puede recortar (clip), redondear esquinas y aplicar borde.

2.8.7. Cargar imágenes desde URL con librerías externas

Para estos casos es necesario el uso de Internet, por lo que la aplicación deberá tener declarado dicho permiso en el Manifest. Además se añade un segundo permiso para permitir a estas librerías hacer comprobaciones del estado de la red.

1<uses-permission android:name="android.permission.INTERNET" />
2<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

GlideImage

GlideImage pertenece a la librería Glide, actualmente la integración oficial con Jetpack Compose está en fase beta experimental, por lo que su uso puede ser algo impredecible.

En primer lugar se deberá añadir la siguiente dependencia al Gradle y sincronizar.

1// Glide for image loading
2implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1")

Para aplicar Glide, añade el siguiente método Composable:

 1@OptIn(ExperimentalGlideComposeApi::class)
 2@Composable
 3fun ImagenConGlide(imageUrl: String) {
 4    GlideImage(
 5        model = imageUrl,
 6        contentDescription = "Imagen con Glide",
 7        modifier = Modifier
 8            .padding(16.dp)
 9            .size(200.dp)
10            .clip(RoundedCornerShape(12.dp)),
11        contentScale = ContentScale.Crop
12    )
13}

Como puedes observar, se carga la imagen en model, se añade una descripción para la imagen y se modifica el contenedor de la imagen con el padding, el tamaño y se recorta (clip) redondeando las esquinas. Por último se escala la imagen.

Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.

1ImagenConGlide(imageUrl = "https://via.placeholder.com/300")

AsyncImage

Otra opción para cargar imágenes desde URL en Jetpack Compose es usando la librería Coil y el Composable AsyncImage. En primer lugar se deberá añadir las siguientes dependencias al Gradle y sincronizar.

1// Coil for image loading
2implementation("io.coil-kt.coil3:coil-compose:3.2.0")
3implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")

El método Composable para cargar una imagen desde una URL puede ser como se muestra a continuación:

 1@Composable
 2fun ImagenConAsyncImage(imageUrl: String) {
 3    AsyncImage(
 4        model = ImageRequest.Builder(LocalContext.current)
 5            .data(imageUrl)
 6            .crossfade(true)
 7            .build(),
 8        contentDescription = "Imagen con Coil",
 9        placeholder = painterResource(R.drawable.loading),
10        error = painterResource(R.drawable.error),
11        contentScale = ContentScale.Crop,
12        modifier = Modifier
13            .padding(16.dp)
14            .size(200.dp)
15            .clip(RoundedCornerShape(12.dp))
16            .border(2.dp, Color.Gray, RoundedCornerShape(12.dp))
17    )
18}

Esta es una versión más compleja, ya que se utiliza ImageRequest para habilitar crossfade, utilizando LocalContext.current para construirlo. Además, se especifica placeholder y error. A diferencia de Glide, se puede añadir un borde a la imagen.

Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.

1ImagenConAsyncImage(imageUrl = "https://via.placeholder.com/300")

Conclusiones

  • Coil actualmente tiene una integración estable, uso sencillo, tamaño reducido y una API moderna para Compose.
  • Glide tiene mejor rendimiento para GIFs o caching, y también tiene integración con Compose, pero hay que tener en cuenta que está en beta y puede requerir cambios futuros. Es más pesado que Coil, pero tiene mejor rendimiento en listas.

2.8.8. Desplazamiento del contenido: ScrollView

El desplazamiento de contenido en Compose puede aplicarse directamente sobre Column y Row con los modificadores verticalScroll y horizontalScroll, que equivalen al clásico ScrollView en XML.

Un scroll de este tipo, no es lazy, como se verá más adelante, carga todo el contenido.

 1Column(
 2    modifier = Modifier
 3        .fillMaxSize()
 4        .verticalScroll(rememberScrollState())
 5        .padding(innerPadding)
 6) {
 7    ...
 8}
 9```~
10
11```kotlin { lineNos="inline" title="Kotlin" }
12Row(
13    modifier = Modifier
14        .fillMaxWidth()
15        .horizontalScroll(rememberScrollState())
16        .padding(16.dp)
17) {
18    ...
19}

El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.

2.9. Listas con LazyColumn y LazyRow

Para crear listas eficientes y escalables en Jetpack Compose se utiliza LazyColumn y LazyRow, estos vienen a ser los equivalentes modernos a RecyclerView.

2.9.1. Diferencias clave respecto a RecyclerView

Si conocéis RecyclerView para vistas XML, la siguiente tabla os aclarará algunos conceptos.

Apartado RecyclerView LazyColumn / LazyRow
Arquitectura Basado en ViewHolder + Adapter Declarativo, sin adapter
Layout XML + inflado manual Composable
Ciclo de vida Fragment / Activity Composable puro
Reutilización Sí, con pool de vistas Sí, de forma implícita y lazy
Configuración Compleja (LayoutManager, Adapter) Muy simple (items o itemsIndexed)
Escalabilidad Muy buena Excelente para listas dinámicas

2.9.2. Estructura básica con items

Por ejemplo, se crea un companion que se pasará en la llamada al método desde onCreate() que contrendrá la lista de datos.

1class MainActivity : ComponentActivity() {
2    companion object {
3        val itemsList = List(100) { "Item #$it" }
4    }
5    ...
6}

El Composable que permite crear una lista básica utilizando items puede ser como se muestra:

 1@Composable
 2fun ExampleLazyColum(itemsList: List<String>) {
 3    LazyColumn(
 4        modifier = Modifier.fillMaxSize(),
 5        verticalArrangement = Arrangement.spacedBy(8.dp) // Espacio entre los elementos.
 6    ) {
 7        items(itemsList) { item ->
 8            Text(text = item)
 9        }
10    }
11}

Se suele utilizar para listas de elementos de tipo simple o complejo, y puede usarse con o sin key para mejorar el rendimiento.

2.9.3. Utilizando itemsIndexed

Añade la siguiente lista al companion:

1val dias = listOf("Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo")

El Composable utilizando itemsIndexed podría ser como se muestra:

1fun ExampleLazyColum2(dias: List<String>) {
2    LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
3        itemsIndexed(dias) { index, dia ->
4            Text("[$index] Día: $dia")
5        }
6    }
7}

Esta versión permite acceder al índice y al valor en cada iteración. Perfecto para numeraciones, saltos condicionales o detección del último elemento.

2.9.4. Delegación de eventos y selección de ítems

Para este ejemplo, se añade al companion una nueva lista.

 1val frutas = listOf(
 2    "Manzana",
 3    "Pera",
 4    "Naranja",
 5    "Plátano",
 6    "Fresa",
 7    "Kiwi",
 8    "Mango",
 9    "Piña",
10    "Uva",
11    "Sandía"
12)

Y se crea el siguiente método Composable.

 1@Composable
 2fun ListaConSeleccion(frutas: List<String>) {
 3    var seleccionada by remember { mutableStateOf<String?>(null) }
 4
 5    LazyColumn {
 6        items(frutas) { fruta ->
 7            Text(
 8                text = fruta,
 9                modifier = Modifier
10                    .fillMaxWidth()
11                    .clickable { // Acción al hacer clic.
12                        seleccionada = fruta
13                        Log.d("Seleccionada", "Fruta seleccionada: $seleccionada")
14                    }
15                    .background(if (seleccionada == fruta) Color.LightGray else Color.Transparent)
16                    .padding(16.dp)
17            )
18        }
19    }
20}

Se delega el evento de clic utilizando Modifier.clickable, y se gestiona el estado de selección en la vista, sin necesidad de adapter, simplemente controlando la selección en la propiedad background.

2.9.5. Optimización de listas grandes

Si la lista a utilizar tiene elementos con IDs únicos se utilizará items(..., key = { it.id }):

1items(listaUsuarios, key = { it.id }) { usuario -> ... }
  • Utiliza remember y derivedStateOf para evitar recomposiciones innecesarias.
  • Controla la composición de elementos pesados o animados.

2.9.6. Ejemplo completo con LazyColumn

Crea la siguiente lista en el companion de la clase:

 1val usuarios = listOf(
 2    "Ana",
 3    "Luis",
 4    "Carlos",
 5    "Lucía",
 6    "María",
 7    "Javier",
 8    "Patricia",
 9    "Sofía",
10    "Pedro",
11    "Laura",
12    "David",
13    "Isabel"
14)

Siguiendo los ejemplos anteriores, se creará el siguiente Composable.

 1@Composable
 2fun ListaUsuarios(usuarios: List<String>) {
 3    val context = LocalContext.current
 4
 5    LazyColumn(
 6        contentPadding = PaddingValues(12.dp),
 7        verticalArrangement = Arrangement.spacedBy(8.dp)
 8    ) {
 9        items(usuarios, key = { it }) { nombre ->
10            Card(
11                modifier = Modifier
12                    .fillMaxWidth()
13                    .clickable {
14                        Toast.makeText(
15                            context,
16                            "Usuario seleccionado: $nombre",
17                            Toast.LENGTH_SHORT
18                        ).show()
19                    }
20                    .padding(4.dp)
21            ) {
22                Row {
23                    Image(
24                        painter = painterResource(id = R.drawable.ic_launcher_foreground),
25                        contentDescription = "Imagen de usuario",
26                        modifier = Modifier
27                            .wrapContentSize()
28                            .size(50.dp)
29                    )
30                    Text(
31                        text = nombre,
32                        modifier = Modifier.fillMaxSize().padding(16.dp),
33                        style = MaterialTheme.typography.bodyLarge
34                    )
35                }
36            }
37        }
38    }
39}
  • En este caso se ha utilizado items para crear una lista de tarjetas con los nombres de los usuarios.
  • Cada tarjeta es clicable y muestra un Toast al seleccionarla, recuerda que hay que recuperar el contexto actual para poder mostrarlo.
  • El uso de key = { it } asegura que cada elemento tenga una clave única, si por ejemplo, Ana estuviese duplicada, se produciría el siguiente error:
java.lang.IllegalArgumentException: Key "Ana" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.

2.9.7. LazyRow para scroll horizontal

1LazyRow {
2    items(listOf("🍎", "🍊", "🍌", "🍇", "🍏", "🍋", "🥑", "🍉", "🍓")) { fruta ->
3        Text(fruta, fontSize = 48.sp, modifier = Modifier.padding(8.dp))
4    }
5}

2.10. Notificaciones visuales: Snackbar y Toast

En este punto se verá cómo mostrar notificaciones visuales al usuario utilizando Snackbar (propio de Compose y Material Design) y Toast (clásico de Android), viendo su uso e implementación de forma correcta en Compose.

2.10.1. Diferencias clave entre Snackbar y Toast

Características Snackbar Toast
Visibilidad Dentro de la UI (Scaffold) Flotante, fuera del árbol Compose
Interactivo ✅ Soporta acciones (con botón) ❌ No permite interacción
Estilo Material ✅ Integra con temas de Compose Material ❌ Estilo clásico de Android
Control desde Compose ✅ Totalmente declarativo ⚠️ Necesita Context
Uso recomendado Mensajes importantes o con acción Mensajes breves e informativos

2.10.2. Mostrar un Snackbar con acción

 1@Composable
 2fun SnackbarConAccionEjemplo() {
 3    val snackbarHostState = remember { SnackbarHostState() }
 4    val scope = rememberCoroutineScope()
 5
 6    Scaffold(
 7        snackbarHost = { SnackbarHost(snackbarHostState) },
 8        floatingActionButton = {
 9            FloatingActionButton(onClick = {
10                scope.launch {
11                    val resultado = snackbarHostState.showSnackbar(
12                        message = "Se ha borrado un elemento",
13                        actionLabel = "Deshacer",
14                        duration = SnackbarDuration.Short
15                    )
16                    // Manejo del resultado del Snackbar.
17                    if (resultado == SnackbarResult.ActionPerformed) {
18                        Log.d("SNACKBAR", "El usuario pulsó Deshacer")
19                    }
20                }
21            }) {
22                Icon(Icons.Default.Delete, contentDescription = "Eliminar")
23            }
24        }
25    ) { innerPadding ->
26        Box(
27            modifier = Modifier
28                .padding(innerPadding)
29                .fillMaxSize(),
30            contentAlignment = Alignment.Center
31        ) {
32            Text("Haz clic en el FAB para mostrar el Snackbar.")
33        }
34    }
35}

SnackbarHostState mantiene el estado del Snackbar, con el método showSnackbar() se lanza una corutina para mostrarlo en pantalla. A continuación, se comprueba (if) si el usuario pulsa la acción con SnackbarResult y la constante ActionPerformed, para comprobar que el usuario no la puede utilizarse Dismissed.

1if (resultado == SnackbarResult.Dismissed) {
2    Log.d("SNACKBAR", "El usuario descartó el Snackbar")
3}

2.10.3. Mostrar un Toast en Compose

 1@Composable
 2fun ToastEjemplo() {
 3    val context = LocalContext.current
 4
 5    Button(onClick = {
 6        Toast.makeText(context, "Mensaje desde Toast", Toast.LENGTH_SHORT).show()
 7    }) {
 8        Text("Mostrar Toast")
 9    }
10}

Para este case es neceario usar LocalContext.current para acceder a un Context que pueda ser utilizado. El Toast se muestra como en el sistema clásico de vistas de Android y no depende de Scaffold.

Ejemplo 2.10. Combinando ambos

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploSnackbarYToast() {
 4    val context = LocalContext.current
 5    val snackbarHostState = remember { SnackbarHostState() }
 6    val scope = rememberCoroutineScope()
 7
 8    Scaffold(
 9        snackbarHost = { SnackbarHost(snackbarHostState) },
10        topBar = {
11            TopAppBar(title = { Text("Notificaciones") })
12        },
13        content = { padding ->
14            Column(
15                modifier = Modifier
16                    .fillMaxSize()
17                    .padding(padding)
18                    .padding(16.dp),
19                verticalArrangement = Arrangement.spacedBy(16.dp) // Espacio entre los elementos.
20            ) {
21                Button(
22                    modifier = Modifier.fillMaxWidth(),
23                    onClick = {
24                        scope.launch {
25                            snackbarHostState.showSnackbar("Esto es un Snackbar")
26                        }
27                    }) {
28                    Text("Mostrar Snackbar")
29                }
30
31                Button(
32                    modifier = Modifier.fillMaxWidth(),
33                    onClick = {
34                        Toast.makeText(context, "Esto es un Toast", Toast.LENGTH_SHORT).show()
35                    }) {
36                    Text("Mostrar Toast")
37                }
38            }
39        }
40    )
41}

Recomendaciones

  • Utiliza Snackbar dentro de Scaffold.
  • Usa Toast para mostrar información rápida.
  • No muestres múltiples Toast seguidos (no cancelables por el usuario).
  • Se recomienda observar el resultado del Snackbar si se utilizan acciones.

2.11. Menús en Jetpack Compose

Ahora se verá como implementar diferentes tipos de menús en Jetpack Compose, desde los más simples hasta los personalizados o en cascada, tratando de comprender cuándo usar cada uno.

2.11.1. Menú básico: DropdownMenu

 1@Composable
 2fun MenuBasico() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val context = LocalContext.current // Contexto para mostrar Toast.
 5
 6    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 7        IconButton(onClick = { expanded = !expanded }) {
 8            Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
 9        }
10        DropdownMenu(
11            expanded = expanded,
12            onDismissRequest = { expanded = false }
13        ) {
14            DropdownMenuItem(text = { Text("Opción 1") }, onClick = { /*...*/ })
15            DropdownMenuItem(text = { Text("Opción 2") }, onClick = {
16                Toast.makeText(context, "Opción 2 seleccionada", Toast.LENGTH_SHORT).show()
17                expanded = false // Cierra el menú al seleccionar una opción.
18            })
19        }
20    }
21}

Imagen punto 2.11.1. Imagen punto 2.11.1.

Este menú está formado por DropdownMenu, el menú en sí, y DropdownMenuItem, las opciones del menú. El popup aparecerá anclado al elemento que dispara el menú, en este caso el IconButton.

2.11.2. Menú en overflow de TopAppBar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun TopAppBarConMenu() {
 4    var showMenu by remember { mutableStateOf(false) }
 5
 6    Scaffold(
 7        topBar = {
 8            TopAppBar(
 9                title = { Text("AppBar con Menú") },
10                actions = {
11                    IconButton(onClick = { /* acción principal */ }) {
12                        Icon(Icons.Default.Share, contentDescription = "Compartir")
13                    }
14                    IconButton(onClick = { showMenu = !showMenu }) {
15                        Icon(Icons.Default.MoreVert, contentDescription = "Más")
16                    }
17                    DropdownMenu(
18                        expanded = showMenu,
19                        onDismissRequest = { showMenu = false }
20                    ) {
21                        DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
22                        DropdownMenuItem(text = { Text("Eliminar") }, onClick = { /* ... */ })
23                    }
24                }
25            )
26        }
27    ) { innerPadding ->
28        /* ... */
29    }
30}

Este patrón es ideal para pantallas con un menú de opciones en la barra superior. Si observas el código, es el mismo que el utilizado para el menú básico.

Ahora bien, lo ideal sería reutilizar código, algo bastante habitual en los menús, creando un método que contenga la estructura del menú, dejando el Scaffold más limpio. En primer lugar se crearía el Composable que crea el menú.

 1@Composable
 2fun AppBarOverflowMenu(onSave: () -> Unit) { // Callback para guardar.
 3    var showMenu by remember { mutableStateOf(false) }
 4
 5    IconButton(onClick = { /* acción principal */ }) {
 6        Icon(Icons.Default.Share, contentDescription = "Compartir")
 7    }
 8    IconButton(onClick = { showMenu = !showMenu }) {
 9        Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
10    }
11    DropdownMenu(
12        expanded = showMenu,
13        onDismissRequest = { showMenu = false }
14    ) {
15        DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
16        DropdownMenuItem(text = { Text("Eliminar") }, onClick = {
17            showMenu = false // Cierra el menú al seleccionar una opción.
18            onSave() // Llama al callback para guardar.
19        })
20    }
21}

En este código se introduce la delegación del callback para hacerlo más reutilizable. El Scaffold quedaría más limpio, extrayendo el callback, además, se lleva fuera, dejándolo más sencillo.

 1setContent {
 2    Example_t02_11Theme {
 3        Scaffold(
 4            topBar = {
 5                TopAppBar(
 6                    title = { Text("Menús") },
 7                    actions = {
 8                        AppBarOverflowMenu(onSave = showToast)
 9                    }
10                )
11            }
12        )
13        { innerPadding ->
14            /* ... */
15        }
16    }
17}

El objeto showToast quedaría como una propiedad de la clase MainActivity.

1class MainActivity : ComponentActivity() {
2    private val showToast: () -> Unit = {
3        Toast.makeText(this, "Guardado correctamente", Toast.LENGTH_SHORT).show()
4    }
5    ...
6}

Ventajas de este sistema o esquema de uso:

  • Reusabilidad: El menú está encapsulado y puede utilizarse en varias pantallas.
  • Claridad: Separa la lógica del menú, facilitando su lectura y pruebas.
  • Flexibilidad: Se pueden pasar callbacks (onSave) para definir acciones desde cada pantalla.

2.11.3. Menú largo y scrollable

 1@Composable
 2fun MenuLargo() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val opciones = List(50) { "Opción ${it + 1}" }
 5    val context = LocalContext.current // Contexto para mostrar Toast.
 6
 7    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 8        // Botón para mostrar el menú desplegable.
 9        IconButton(onClick = { expanded = !expanded }) {
10            Icon(Icons.Default.MoreVert, contentDescription = "Más opciones")
11        }
12        DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
13            opciones.forEach { opc ->
14                DropdownMenuItem(text = { Text(opc) }, onClick = {
15                    Toast.makeText(context, "$opc seleccionada", Toast.LENGTH_SHORT).show()
16                    expanded = false // Cierra el menú al seleccionar una opción.
17                })
18            }
19        }
20    }
21}

Si el menú sobrepasa el espacio disponible, automáticamente se activa el scroll.

2.11.4. Menú con iconos y divisores

 1@Composable
 2fun MenuConDetalles() {
 3    var expanded by remember { mutableStateOf(false) }
 4
 5    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 6        IconButton(onClick = { expanded = !expanded }) {
 7            Icon(Icons.Default.MoreVert, contentDescription = "Menú")
 8        }
 9        DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
10            DropdownMenuItem(text = { Text("Perfil") },
11                leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
12                onClick = { /*...*/ })
13            HorizontalDivider(
14                modifier = Modifier.padding(horizontal = 8.dp), // Espacio horizontal (opcional).
15                thickness = 1.dp, // Espacio entre los elementos del menú (opcional).
16                color = Color.Red // Color del divisor (opcional).
17            )
18            DropdownMenuItem(text = { Text("Configuración") },
19                leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
20                onClick = { /*...*/ })
21        }
22    }
23}

Esta puede ser una buena opción para menús organizados, con secciones y elementos visuales.

2.11.5. Menú expuesto (Spinner): ExposedDropdownMenuBox

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun MenuSpinner() {
 4    val opciones = listOf("Rojo", "Verde", "Azul", "Amarillo")
 5    var expanded by remember { mutableStateOf(false) }
 6    var seleccion by remember { mutableStateOf(opciones[0]) }
 7
 8    ExposedDropdownMenuBox(
 9        expanded = expanded,
10        onExpandedChange = { expanded = !expanded },
11        modifier = Modifier.fillMaxWidth()
12    ) {
13        TextField(
14            value = seleccion,
15            onValueChange = {},
16            readOnly = true,
17            label = { Text("Color favorito") },
18            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
19            modifier = Modifier
20                .fillMaxWidth()
21                .menuAnchor(MenuAnchorType.PrimaryEditable, true)
22        )
23        ExposedDropdownMenu(
24            expanded = expanded,
25            onDismissRequest = { expanded = false }
26        ) {
27            opciones.forEach { color ->
28                DropdownMenuItem(
29                    text = { Text(color) },
30                    onClick = {
31                        seleccion = color
32                        expanded = false
33                    }
34                )
35            }
36        }
37    }
38}

Permite mostrar el elemento seleccionado en un TextField o OutlinedTextField, puede venir bien para selección de una dimensión fija.

2.11.6. Menú expuesto editable (autocomplete)

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun SpinnerAutocomplete() {
 4    val opciones = listOf("Alicante", "Barcelona", "Bilbao", "Madrid", "Valencia", "Zaragoza")
 5    var expanded by remember { mutableStateOf(false) }
 6    var text by remember { mutableStateOf("") }
 7    val filtradas = opciones.filter { it.contains(text, true) }
 8
 9    ExposedDropdownMenuBox(
10        expanded = expanded && filtradas.isNotEmpty(),
11        onExpandedChange = { expanded = !expanded }) {
12        OutlinedTextField(
13            value = text,
14            onValueChange = {
15                text = it
16                expanded = true
17            },
18            singleLine = true,
19            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
20            label = { Text("Ciudad") },
21            modifier = Modifier
22                .fillMaxWidth()
23                .menuAnchor(MenuAnchorType.PrimaryEditable, true) // Ancla el menú al campo de texto.
24        )
25        ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
26            filtradas.forEach { ciudad ->
27                DropdownMenuItem(text = { Text(ciudad) }, onClick = {
28                    text = ciudad
29                    expanded = false
30                })
31            }
32        }
33    }
34}

Este menú permite escribir para filtrar las opciones, muy útil para selecciones extensas. También se conoce como autocompletar.

2.11.7. Menú en cascada: CascadeDropdownMenu

Estos son un tipo de menú que actualmente no se encuentran en el core de Compose, pero pueden añadirse utilizando la librería cascade-compose.

1implementation("me.saket.cascade:cascade-compose:2.3.0")

Este tipo de menú puede ser una buena opción para añadirlo a la TopAppBar.

 1@Composable
 2fun CascadeMenu() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val context = LocalContext.current
 5
 6    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 7        IconButton(onClick = { expanded = !expanded }) {
 8            Icon(Icons.Default.MoreVert, contentDescription = "Menú cascada")
 9        }
10
11        CascadeDropdownMenu(
12            expanded = expanded,
13            onDismissRequest = { expanded = false }
14        ) {
15            // Item principal con submenú
16            DropdownMenuItem(
17                text = { Text("Opciones Avanzadas ▸") },
18                children = {
19                    DropdownMenuItem(
20                        text = { Text("Sub‑opción 1") },
21                        onClick = {
22                            expanded = false
23                            Toast.makeText(context, "Sub‑opción 1", Toast.LENGTH_SHORT).show()
24                        }
25                    )
26                    DropdownMenuItem(
27                        text = { Text("Sub‑opción 2") },
28                        onClick = {
29                            expanded = false
30                            Toast.makeText(context, "Sub‑opción 2", Toast.LENGTH_SHORT).show()
31                        }
32                    )
33                }
34            )
35            // Otro item principal
36            DropdownMenuItem(
37                text = { Text("Acerca de") },
38                onClick = {
39                    expanded = false
40                    Toast.makeText(context, "Acerca de", Toast.LENGTH_SHORT).show()
41                }
42            )
43        }
44    }
45}

Las ventajas de utilizar esta librería, principalmente, es la simplificación a la hora de crear menús jerárquicos. Además, añade animaciones en la expansión y contracción de los menús y es compatible con Material Design y Compose.


Ejemplos prácticos

2.12. Cuadros de diálogo (AlertDialog)

Este punto pretendre introducir la creación y gestión de cuadros de diálogo en Jetpack Compose con AlertDialog (MaterialAlertDialog versión para vistas), y entender cómo personalizarlos para diferentes contextos: confirmación, alerta, formulario, etc.

Los diálogos son un componente modal que interrumpe el flujo de la interfaz para presentar información importante o solicitar una acción del usuario.

2.12.1. Cuadro de diálogo informativo

El siguiente código muestra un cuadro de diálogo muy simple, de uso informativo.

 1val openInfoDialog = remember { mutableStateOf(false) }
 2
 3/* ... */
 4
 5Button(onClick = { openInfoDialog.value = true }) {
 6    Text("Mostrar info")
 7}
 8
 9if (openInfoDialog.value) {
10    AlertDialog(
11        onDismissRequest = { openInfoDialog.value = false }, // Se cierra el diálogo al tocar fuera de él.
12        title = { Text("Información") },
13        text = { Text("Esta es una notificación informativa.") },
14        confirmButton = {
15            TextButton(onClick = { openInfoDialog.value = false }) {
16                Text(LocalContext.current.getString(android.R.string.ok))
17            }
18        }
19    )
20}

2.12.2. Cuadro de diálogo básico

El siguiente código muestra el método Composable para mostrar un cuadro de diálogo, además, se añaden los parámetros necesarios para delegar las acciones, un posible icono y el texto. Esto hará del componente más reutilizable.

 1@Composable
 2fun AlertDialogExample(
 3    onDismissRequest: () -> Unit,
 4    onConfirmation: () -> Unit,
 5    dialogTitle: String,
 6    dialogText: String,
 7    icon: ImageVector = Icons.Default.Warning,
 8) {
 9    AlertDialog(
10        icon = { Icon(icon, contentDescription = "Example Icon") },
11        title = { Text(text = dialogTitle) },
12        text = { Text(text = dialogText) },
13        properties = DialogProperties(
14            dismissOnBackPress = false, // Se evita el cierre al presionar atrás.
15            dismissOnClickOutside = false // Se evita el cierre al tocar fuera del diálogo.
16        ),
17        onDismissRequest = { onDismissRequest() }, // Se llama a la función de cierre del diálogo si no está bloqueado el cierre.
18        confirmButton = {
19            TextButton(onClick = { onConfirmation() }) {
20                Text(LocalContext.current.getString(android.R.string.ok))
21            }
22        },
23        dismissButton = {
24            TextButton(onClick = { onDismissRequest() }) {
25                Text(LocalContext.current.getString(android.R.string.cancel))
26            }
27        }
28    )
29}

El uso de este cuadro de diálogo será como se muestra a continuación.

 1@ExperimentalMaterial3Api
 2@Composable
 3fun PantallaPrincipal() {
 4    val openAlertDialog = remember { mutableStateOf(false) }
 5
 6    Scaffold(
 7        topBar = {
 8            TopAppBar(
 9                title = { Text("Cuadros de diálogo") },
10                colors = topAppBarColors(
11                    containerColor = MaterialTheme.colorScheme.primaryContainer,
12                    titleContentColor = MaterialTheme.colorScheme.primary,
13                )
14            )
15        },
16        modifier = Modifier.fillMaxSize()
17    ) { innerPadding ->
18        Column(
19            modifier = Modifier
20                .fillMaxSize()
21                .padding(innerPadding),
22            horizontalAlignment = Alignment.CenterHorizontally,
23            verticalArrangement = Arrangement.Center
24        ) {
25            Button(onClick = { openAlertDialog.value = true }) {
26                Text("Mostrar diálogo")
27            }
28
29            if (openAlertDialog.value) {
30                AlertDialogExample(
31                    onDismissRequest = { openAlertDialog.value = false },
32                    onConfirmation = {
33                        openAlertDialog.value = false
34                        println("Confirmación recibida")
35                    },
36                    dialogTitle = "Título del Diálogo",
37                    dialogText = "Este es un ejemplo de cuadro de diálogo en Jetpack Compose.",
38                    icon = Icons.Default.Info
39                )
40            }
41        }
42    }
43}

Observa como se dejan los callbacks preparados para que hagan las acciones según la situación.

2.12.3. Cuadro de diálogo personalizado

Ahora se verá como mostrar un cuadro de diálogo personalizado, añadiendo elementos más allá de texto.

 1@Composable
 2fun CustomDialog(onSave: (String) -> Unit) {
 3    val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
 4    var texto by remember { mutableStateOf("") }
 5
 6    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 7        Button(onClick = { abierto.value = true }) {
 8            Text("Nuevo elemento")
 9        }
10
11        if (abierto.value) {
12            AlertDialog(
13                onDismissRequest = { abierto.value = false },
14                title = { Text("Crear elemento") },
15                text = {
16                    Column {
17                        OutlinedTextField(
18                            label = { Text("Introduce un nombre") },
19                            value = texto,
20                            onValueChange = { texto = it },
21                            singleLine = true
22                        )
23                    }
24                },
25                confirmButton = {
26                    TextButton(onClick = {
27                        if (texto.isNotBlank()) {
28                            onSave(texto)
29                            abierto.value = false
30                            texto = ""
31                        }
32                    }) {
33                        Text("Guardar")
34                    }
35                },
36                dismissButton = {
37                    TextButton(onClick = {
38                        abierto.value = false
39                        texto = ""
40                    }) {
41                        Text(LocalContext.current.getString(R.string.cancel))
42                    }
43                }
44            )
45        }
46    }
47}

Como puedes observar en este código, se ha optado por una estrategia diferente, el propio método se encarga de gestionar el estado de visibilidad del cuadro de diálogo y de mostrar el botón que desencadena la acción. Se propaga el dato recogido mediante el callback onSave.

1CustomDialog(
2    onSave = { newItem ->
3        println("Nuevo elemento guardado: $newItem")
4    }
5)

Pero… según la documentación oficial “si quieres crear un diálogo más complejo, quizás con formularios y varios botones, debes usar Dialog con contenido personalizado” aquí

Si se adapta al uso de Dialog, el método CustomDialog podría quedar así:

 1@Composable
 2fun CustomDialog(onSave: (String) -> Unit) {
 3    val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
 4    var texto by remember { mutableStateOf("") }
 5
 6    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 7        Button(onClick = { abierto.value = true }) {
 8            Text("Nuevo elemento")
 9        }
10
11        if (abierto.value) {
12            Dialog(onDismissRequest = { abierto.value = false }) {
13                Column(
14                    modifier = Modifier.padding(16.dp),
15                    horizontalAlignment = Alignment.CenterHorizontally
16                ) {
17                    Card(
18                        modifier = Modifier.padding(16.dp),
19                        shape = RoundedCornerShape(16.dp),
20                    ) {
21                        Column(
22                            modifier = Modifier.wrapContentSize(),
23                            verticalArrangement = Arrangement.Center,
24                            horizontalAlignment = Alignment.CenterHorizontally,
25                        ) {
26                            Spacer(Modifier.height(16.dp))
27                            Text("Crear nuevo elemento")
28                            OutlinedTextField(
29                                label = { Text("Introduce un nombre") },
30                                value = texto,
31                                onValueChange = { texto = it },
32                                singleLine = true,
33                                modifier = Modifier.fillMaxWidth(0.9f) // Ajusta el ancho del campo de texto (%)
34                            )
35                            Row(
36                                modifier = Modifier.fillMaxWidth(),
37                                horizontalArrangement = Arrangement.Center,
38                            ) {
39                                TextButton(
40                                    onClick = { abierto.value = false },
41                                    modifier = Modifier.padding(8.dp),
42                                ) {
43                                    Text(LocalContext.current.getString(R.string.cancel))
44                                }
45                                TextButton(
46                                    onClick = {
47                                        if (texto.isNotBlank()) {
48                                            onSave(texto)
49                                            abierto.value = false
50                                            texto = ""
51                                        }
52                                    },
53                                    modifier = Modifier.padding(8.dp)
54                                ) {
55                                    Text("Guardar")
56                                }
57                            }
58                        }
59                    }
60                }
61            }
62        }
63    }
64}

2.12.4. Selección de hora: TimePicker

El siguiente código muestra una forma sencilla de crear un cuadro de diálgo para la selección de una hora.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun DialogoSeleccionHora() {
 4    var mostrarDialogo by remember { mutableStateOf(false) }
 5    val timePickerState = rememberTimePickerState() // Hora actual del sistema.
 6    var horaSeleccionada by remember { mutableStateOf("") }
 7
 8    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 9        Button(onClick = { mostrarDialogo = true }) {
10            Text("Seleccionar hora")
11        }
12
13        Spacer(Modifier.height(8.dp))
14        Text(text = "Hora seleccionada: $horaSeleccionada")
15
16        if (mostrarDialogo) {
17            AlertDialog(
18                onDismissRequest = { mostrarDialogo = false },
19                confirmButton = {
20                    TextButton(onClick = {
21                        val h = timePickerState.hour.toString().padStart(2, '0')
22                        val m = timePickerState.minute.toString().padStart(2, '0')
23                        horaSeleccionada = "$h:$m"
24                        mostrarDialogo = false
25                    }) { Text("Aceptar") }
26                },
27                dismissButton = {
28                    TextButton(onClick = { mostrarDialogo = false }) {
29                        Text("Cancelar")
30                    }
31                },
32                title = { Text("Selecciona la hora") },
33                text = { TimePicker(state = timePickerState) }
34            )
35        }
36    }
37}

2.12.5. Selección de fecha: DatePicker

Este caso es similar al anterior pero, en esta ocasión, para seleccionar una fecha.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun DialogoSeleccionFecha() {
 4    var mostrarDialogo by remember { mutableStateOf(false) }
 5    val datePickerState = rememberDatePickerState() // Fecha actual del sistema.
 6    var fechaSeleccionada by remember { mutableStateOf("") }
 7
 8    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 9        Button(onClick = { mostrarDialogo = true }) {
10            Text("Seleccionar fecha")
11        }
12
13        Spacer(Modifier.height(8.dp))
14        Text("Fecha seleccionada: $fechaSeleccionada")
15
16        if (mostrarDialogo) {
17            AlertDialog(
18                onDismissRequest = { mostrarDialogo = false },
19                confirmButton = {
20                    TextButton(onClick = {
21                        datePickerState.selectedDateMillis?.let { millis ->
22                            val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
23                            val fecha = Instant.ofEpochMilli(millis) // Convertir milisegundos a Instant.
24                                .atZone(ZoneId.systemDefault()) // Convertir a zona horaria local.
25                                .toLocalDate() // Obtener la fecha local.
26                            fechaSeleccionada = formatter.format(fecha)
27                        }
28                        mostrarDialogo = false
29                    }) { Text("Aceptar") }
30                },
31                dismissButton = {
32                    TextButton(onClick = { mostrarDialogo = false }) {
33                        Text("Cancelar")
34                    }
35                },
36                title = { Text("Selecciona la fecha") },
37                text = { DatePicker(state = datePickerState) }
38            )
39        }
40    }
41}

2.12.6. Buenas prácicas

Recomendación Explicación
Control con remember Utiliza variables de estado para controlar la visibilidad del diálogo
Confirmación explícita Utiliza botones confirm/dismiss con acciones claras
Diálogo no bloqueante Usa onDismissRequest para permitir al usuario cerrar tocando fuera
Evita usarlo en recomposiciones frecuentes Solo muestra el diálogo cuando el estado lo indique

Ejemplos prácticos

Ejemplo práctico 10 Login en un cuadro de diálgo

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Subsecciones de Tema 2: Interfaz de usuario

Ejemplo práctico 1: Centrar un Text usando Modifier.layout

Objetivo

Aprende a crear un layout personalizado que centre un composable hijo (en este caso, un Text) manualmente, sin usar Box o Arrangement.Center.

Estructura del Modifier.layout

 1Modifier.layout { measurable, constraints ->
 2    // Se mide el hijo con las restricciones.
 3    val placeable = measurable.measure(constraints)
 4
 5    // Se calcula el tamaño total del padre.
 6    val width = constraints.maxWidth
 7    val height = constraints.maxHeight
 8
 9    // se calcula la posición centrada.
10    val x = (width - placeable.width) / 2
11    val y = (height - placeable.height) / 2
12
13    // Se devuelve el layout y se coloca el hijo.
14    layout(width, height) {
15        placeable.place(x, y)
16    }
17}

Ejemplo completo

Se crea el método composable:

 1@Composable
 2fun LayoutPersonalizadoDemo() {
 3    Box(
 4        modifier = Modifier
 5            .wrapContentHeight() // Se ajusta al contenido
 6            .background(Color(0xFFEFEFEF)) // Fondo gris claro
 7            .centroManual() // Modificador personalizado
 8    ) {
 9        Text(
10            text = "Texto centrado con layout personalizado",
11            fontSize = 18.sp,
12            color = Color.Black
13        )
14    }
15}

A continuación, se crea el modificador personalizado centroManual():

 1fun Modifier.centroManual(): Modifier = this.then(
 2    Modifier.layout { measurable, constraints ->
 3        val placeable = measurable.measure(constraints)
 4
 5        val width = constraints.maxWidth
 6        val height = constraints.maxHeight
 7
 8        val x = (width - placeable.width) / 2
 9        val y = (height - placeable.height) / 2
10
11        layout(width, height) {
12            placeable.place(x, y)
13        }
14    }
15)

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaLayoutPersonalizado() {
4    LayoutPersonalizadoDemo()
5}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 2: Limitar al 50% del espacio disponible usando Modifier.layout

Objetivo

Se creará un modificador personalizado llamado limitarAnchoAl50Porciento() que limite el ancho del hijo al 50% del ancho máximo disponible. Además, colocará el hijo centrado horizontalmente dentro del espacio total y mantendrá la altura original del hijo.

Modificador personalizado

 1fun Modifier.limitarAnchoAl50Porciento(): Modifier = this.then(
 2    Modifier.layout { measurable, constraints ->
 3        // Se calcula el 50% del ancho disponible.
 4        val anchoDisponible = constraints.maxWidth
 5        val anchoLimitado = anchoDisponible / 2
 6
 7        // Se crean nuevas restricciones con ancho máximo reducido.
 8        val newConstraints = constraints.copy(maxWidth = anchoLimitado)
 9
10        // Se mide el hijo con esas restricciones.
11        val placeable = measurable.measure(newConstraints)
12
13        // La altura del padre será la del hijo, ancho será el original.
14        layout(anchoDisponible, placeable.height) {
15            // Se centra horizontalmente
16            val x = (anchoDisponible - placeable.width) / 2
17            placeable.place(x, 0)
18        }
19    }
20)

A continuación, se creará el método composable:

 1@Composable
 2fun LayoutAnchoLimitadoDemo() {
 3    Box(
 4        modifier = Modifier
 5            .fillMaxHeight()
 6            .background(Color(0xFFEFEFEF))
 7            .limitarAnchoAl50Porciento()
 8    ) {
 9        Text(
10            text = "Ancho limitado al 50%",
11            fontSize = 16.sp,
12            color = Color.Black,
13            modifier = Modifier
14                .background(Color.Yellow)
15                .padding(8.dp)
16        )
17    }
18}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaAnchoLimitado() {
4    LayoutAnchoLimitadoDemo()
5}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 3: BoxWithConstraints adaptativo

Objetivo

Este ejemplo muestra un texto diferente según si el ancho del contenedor es mayor o menor a 300.dp. Además, el fondo cambia de color para mayor visibilidad.

Composable EjemploBoxWithConstraints

 1@SuppressLint("UnusedBoxWithConstraintsScope")
 2@Composable
 3fun EjemploBoxWithConstraints() {
 4    BoxWithConstraints(
 5        modifier = Modifier
 6            .fillMaxWidth()
 7            .height(200.dp)
 8            .background(Color.LightGray)
 9    ) {
10        val isPantallaGrande = maxWidth > 300.dp
11
12        Box(
13            modifier = Modifier
14                .fillMaxSize()
15                .background(if (isPantallaGrande) Color.Cyan else Color.Magenta),
16            contentAlignment = Alignment.Center
17        ) {
18            Text(
19                text = if (isPantallaGrande) "Pantalla ancha" else "Pantalla estrecha",
20                fontSize = 20.sp,
21                color = Color.White
22            )
23        }
24    }
25}

Para visualizar el ejemplo en la vista previa de Android Studio añade los siguientes métodos, ajustando los anchos de pantalla:

 1@Preview(showBackground = true, widthDp = 400)
 2@Composable
 3fun VistaPreviaPantallaAncha() {
 4    EjemploBoxWithConstraints()
 5}
 6
 7@Preview(showBackground = true, widthDp = 250)
 8@Composable
 9fun VistaPreviaPantallaEstrecha() {
10    EjemploBoxWithConstraints()
11}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 4: Barra de progreso circular personalizada

Objetivo

Este ejemplo muestra cómo crear una barra de progreso circular usando Canvas.

Composable BarraProgresoCircular

 1@Composable
 2fun BarraProgresoCircular(progreso: Float) {
 3    Canvas(modifier = Modifier.size(150.dp)) {
 4        // Fondo del círculo (gris)
 5        drawCircle(
 6            color = Color.LightGray,
 7            radius = size.minDimension / 2,
 8            center = center,
 9            style = Stroke(width = 20f)
10        )
11
12        // Progreso (azul)
13        drawArc(
14            color = Color.Blue,
15            startAngle = -90f,
16            sweepAngle = 360 * progreso,
17            useCenter = false,
18            style = Stroke(width = 20f, cap = StrokeCap.Round),
19            size = size
20        )
21    }
22}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente métodos:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaBarraProgreso() {
4    BarraProgresoCircular(progreso = 0.65f) // 65% de progreso
5}

Para dotar de movimiento a la barra de progreso, se añadirá el siguiente código en el método onCreate(). Además, se podrá ver una breve introducción al uso de Scaffold, Toolbar y FloatingActionButton.

 1override fun onCreate(savedInstanceState: Bundle?) {
 2    super.onCreate(savedInstanceState)
 3    enableEdgeToEdge()
 4    setContent {
 5        Examplet02Theme {
 6            var progreso by remember { mutableStateOf(0.0f) }
 7
 8            Scaffold(
 9                modifier = Modifier.fillMaxSize(),
10                topBar = {
11                    TopAppBar(
12                        title = { Text("Scaffold con Canvas") }
13                    )
14                },
15                floatingActionButton = {
16                    FloatingActionButton(
17                        onClick = {
18                            progreso += 0.1f
19                            if (progreso > 1.01f) progreso = 0f
20                        }
21                    ) {
22                        Icon(Icons.Default.Refresh, contentDescription = "Incrementar Progreso")
23                    }
24                }
25            ) { padding ->
26                Box(
27                    modifier = Modifier
28                        .fillMaxSize()
29                        .padding(padding),
30                    contentAlignment = Alignment.Center
31                ) {
32                    BarraProgresoCircular(progreso = progreso)
33                }
34            }
35        }
36    }
37}

Para verla en acción deberás lanzar la aplicación contra un emulador o un dispositivo físico.

Ejemplo práctico 4 Ejemplo práctico 4


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 5: Fondo decorativo con formas

Objetivo

Este ejemplo crea un fondo decorativo con círculos y líneas, ideal para personalizar pantallas.

Composable FondoDecorativo

 1@Composable
 2fun FondoDecorativo(modifier: Modifier = Modifier) {
 3    Canvas(modifier = modifier.fillMaxSize()) {
 4        val ancho = size.width
 5        val alto = size.height
 6
 7        // Fondo general
 8        drawRect(Color(0xFFEFEFEF))
 9
10        // Círculo azul en esquina superior izquierda
11        drawCircle(
12            color = Color.Blue,
13            radius = ancho / 4,
14            center = Offset(ancho / 4, alto / 4)
15        )
16
17        // Línea diagonal decorativa
18        drawLine(
19            color = Color.Magenta,
20            start = Offset(0f, alto),
21            end = Offset(ancho, 0f),
22            strokeWidth = 10f
23        )
24
25        // Pequeños círculos decorativos
26        for (i in 1..5) {
27            drawCircle(
28                color = Color.Green,
29                radius = 20f,
30                center = Offset(ancho * i / 6, alto * i / 6)
31            )
32        }
33    }
34}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

 1@Preview(showBackground = true)
 2@Composable
 3fun VistaPreviaFondoDecorativo() {
 4    Box(modifier = Modifier.size(300.dp, 300.dp)) {
 5        FondoDecorativo()
 6        Text(
 7            text = "Contenido",
 8            modifier = Modifier.align(Alignment.Center),
 9            color = Color.Black,
10            fontSize = 18.sp
11        )
12    }
13}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 6: Animación con graphicsLayer y Scaffold

Objetivo

Este ejemplo se creará un Scaffold con TopAppBar y FloatingActionButton. Se creará un composable que anime su rotación y escala usando graphicsLayer y un botón flotante que inicie o detenga la animación.

Composable EjemploAvanzadoGraphicsLayer

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploAvanzadoGraphicsLayer() {
 4    var animar by remember { mutableStateOf(false) }
 5    val rotacion by animateFloatAsState(
 6        targetValue = if (animar) 360f else 0f,
 7        animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
 8    )
 9    val escala by animateFloatAsState(
10        targetValue = if (animar) 1.5f else 1f,
11        animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
12    )
13
14    Scaffold(
15        topBar = {
16            TopAppBar(title = { Text("GraphicsLayer Avanzado") })
17        },
18        floatingActionButton = {
19            FloatingActionButton(onClick = { animar = !animar }) {
20                Icon(Icons.Default.PlayArrow, contentDescription = "Animar")
21            }
22        }
23    ) { padding ->
24        Box(
25            modifier = Modifier
26                .fillMaxSize()
27                .padding(padding),
28            contentAlignment = Alignment.Center
29        ) {
30            Box(
31                modifier = Modifier
32                    .size(150.dp)
33                    .graphicsLayer(
34                        rotationZ = rotacion,
35                        scaleX = escala,
36                        scaleY = escala,
37                        alpha = 0.8f,
38                        shadowElevation = 16f
39                    )
40                    .background(Color(0xFF6200EE)),
41                contentAlignment = Alignment.Center
42            ) {
43                Text("Animado", color = Color.White, fontSize = 18.sp)
44            }
45        }
46    }
47}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaEjemploAvanzado() {
4    EjemploAvanzadoGraphicsLayer()
5}

En resumen, se coloca un Scaffold con barra superior y un FAB, se crea un Box central ocupando toda la pantalla que rota 360 grados y se escala a 1.5x cuando se pulsa el FAB. El estado animar controla si se inicia o detiene la animación. Por último, se utiliza animateFloatAsState para interpolar suavemente.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 7: Uso básico de Scaffold

Objetivo

En este ejemplo se utiliza Material 3 para añadir un Scaffold con TopAppBar, BottomAppBar, y FloatingActionButton. Además, se utiliza un Snackbar mediante SnackbarHostState y cambio de contenido al pulsar el FAB, mostrando Snackbar cuando el contador alcanza el máximo (5).

Composable ScaffoldMaterial3ConSnackbar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun ScaffoldMaterial3ConSnackbar() {
 4    val snackbarHostState = remember { SnackbarHostState() }
 5    val scope = rememberCoroutineScope()
 6    var contador by remember { mutableStateOf(0) }
 7
 8    Scaffold(
 9        snackbarHost = { SnackbarHost(snackbarHostState) },
10        topBar = {
11            TopAppBar(
12                title = { Text("Mi App Simple M3") },
13                colors = topAppBarColors(
14                    containerColor = MaterialTheme.colorScheme.primaryContainer,
15                    titleContentColor = MaterialTheme.colorScheme.primary,
16                )
17            )
18        },
19        bottomBar = {
20            BottomAppBar {
21                IconButton(onClick = { /* Acción 1 */ }) {
22                    Icon(Icons.Default.Home, contentDescription = "Home")
23                }
24                Spacer(Modifier.weight(1f))
25                IconButton(onClick = { /* Acción 2 */ }) {
26                    Icon(Icons.Default.Favorite, contentDescription = "Favoritos")
27                }
28            }
29        },
30        floatingActionButton = {
31            ExtendedFloatingActionButton(
32                onClick = {
33                    if (contador < 5) {
34                        contador++
35                        Log.d("ScaffoldM3", "Contador incrementado: $contador")
36                    } else {
37                        scope.launch {
38                            snackbarHostState.showSnackbar(
39                                "Conteo máximo alcanzado",
40                                actionLabel = "Reiniciar",
41                                duration = SnackbarDuration.Short
42                            ).let { result ->
43                                if (result == SnackbarResult.ActionPerformed) {
44                                    contador = 0
45                                }
46                            }
47                        }
48                    }
49                }
50            ) {
51                Text("Sumar")
52            }
53        },
54        floatingActionButtonPosition = FabPosition.End
55    ) { innerPadding ->
56        Box(
57            modifier = Modifier
58                .fillMaxSize()
59                .padding(innerPadding),
60            contentAlignment = Alignment.Center
61        ) {
62            Text(
63                text = "Conteo: $contador",
64                fontSize = 24.sp,
65                fontWeight = FontWeight.Bold
66            )
67        }
68    }
69}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPrevia() {
4    ScaffoldMaterial3ConSnackbar()
5}

En resumen, se utiliza remember { SnackbarHostState() } para evitar que se cree una nueva en cada recomposición. El Scaffold recibe el snackbarHost, enlazado con el SnackbarHostState creado.

El FAB incrementa contador que, al llegar a 5, mostrará un Snackbar con opción “Reiniciar” y resetea el contador si se pulsa la acción.

innerPadding hace que el contenido central respete las barras del Scaffold.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 8: Menú básico en TopAppBar con devolución de selección vía callback

Objetivo

Este ejemplo trata de plantear una posible solución a problemas que pueden plantearse durante el desarrollo de aplicaciones móviles. La idea es crear un componente para montar una TopAppBar con un menú, evaluando la selección del usuario mediante un único callback, comprobando la respuesta producida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks.

Recursos en string.xml

Tratará de evitarse lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_8</string>
 3
 4    <string name="txt_welcome">Selecciona una opción del menú</string>
 5
 6    <string name="txt_option_title">Más opciones</string>
 7
 8    <string name="txt_option_share">Compartir</string>
 9    <string name="txt_option_save">Guardar</string>
10    <string name="txt_option_logout">Cerrar sesión</string>
11
12    <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13    <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14    <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>

Sealed Class

Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso.

 1sealed class OpcionMenu {
 2    object Compartir : OpcionMenu()
 3    object Guardar : OpcionMenu()
 4    object Logout : OpcionMenu()
 5
 6    override fun toString(): String {
 7        return when (this) {
 8            Compartir -> "Compartir"
 9            Guardar -> "Guardar"
10            Logout -> "Cerrar sesión"
11        }
12    }
13}

Compose TopBarConMenu

Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun TopBarConMenu(
 4    onOpcionSeleccionada: (OpcionMenu) -> Unit
 5) {
 6    var expanded by remember { mutableStateOf(false) }
 7    val context = LocalContext.current
 8
 9    TopAppBar(
10        title = { Text("TopAppBar con Menú") },
11        colors = topAppBarColors(
12            containerColor = MaterialTheme.colorScheme.primaryContainer,
13            titleContentColor = MaterialTheme.colorScheme.primary,
14        ),
15        actions = {
16            IconButton(onClick = { expanded = true }) {
17                Icon(Icons.Default.MoreVert, contentDescription = context.getString(R.string.txt_option_title))
18            }
19            DropdownMenu(
20                expanded = expanded,
21                onDismissRequest = { expanded = false }
22            ) {
23                DropdownMenuItem(
24                    text = { Text(context.getString(R.string.txt_option_share)) },
25                    onClick = {
26                        expanded = false
27                        onOpcionSeleccionada(OpcionMenu.Compartir)
28                    }
29                )
30                DropdownMenuItem(
31                    text = { Text(context.getString(R.string.txt_option_save)) },
32                    onClick = {
33                        expanded = false
34                        onOpcionSeleccionada(OpcionMenu.Guardar)
35                    }
36                )
37                DropdownMenuItem(
38                    text = { Text(context.getString(R.string.txt_option_logout)) },
39                    onClick = {
40                        expanded = false
41                        onOpcionSeleccionada(OpcionMenu.Logout)
42                    }
43                )
44            }
45        }
46    )
47}

Resultado de la MainActivity

Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5
 6        setContent {
 7            ExampleT2_8Theme {
 8                PantallaPrincipal()
 9            }
10        }
11    }
12}
13
14@Preview(showBackground = true)
15@Composable
16fun PantallaPrincipal() {
17    val context = LocalContext.current
18    var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
19
20    Scaffold(
21        topBar = {
22            TopBarConMenu { opcion ->
23                mensaje = when (opcion) {
24                    is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
25                    is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
26                    is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
27                }
28            }
29        },
30        modifier = Modifier.fillMaxSize()
31    ) { innerPadding ->
32        Box(
33            modifier = Modifier
34                .padding(innerPadding)
35                .fillMaxSize(),
36            contentAlignment = Alignment.Center
37        ) {
38            Text(
39                text = mensaje,
40                fontSize = 18.sp
41            )
42        }
43    }
44}

El uso de este esquema permite el tipado seguro, evitando así errores de escritura en las cadenas, es escalable, está integrado con when lo que permite una evaluación exhaustiva y permite la reutilización, ya que la acción se realizará en el when, y no en el método encargado de montar el menú.

Puede ser más óptimo, por ejemplo, añadiendo propiedades como label o icon dentro de la sealed class.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 9: Menú con DropdownMenu en BottomAppBar

Objetivo

Este ejemplo es una variante del anterior. Se sustituirá la TopAppBar con un menú por una BottomAppBar, evaluando la selección del usuario mediante un único callback, comprobando la respuesta recibida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks. Se mantendrá la misma estructura de sealed class, añadiendo label e icon.

Recursos en string.xml

Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_8</string>
 3
 4    <string name="txt_welcome">Selecciona una opción del menú</string>
 5
 6    <string name="txt_option_title">Más opciones</string>
 7
 8    <string name="txt_option_share">Compartir</string>
 9    <string name="txt_option_save">Guardar</string>
10    <string name="txt_option_logout">Cerrar sesión</string>
11
12    <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13    <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14    <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>

Sealed Class

Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso. Como se ha comentado, se añadirán dos nuevas propiedades a la clase label e icon.

1sealed class OpcionMenu(val label: String, val icon: ImageVector) {
2    object Compartir : OpcionMenu("Compartir", Icons.Default.Share)
3    object Guardar : OpcionMenu("Guardar", Icons.Default.Add)
4    object Logout : OpcionMenu("Cerrar sesión", Icons.AutoMirrored.Filled.ExitToApp)
5
6    companion object {
7        val todas = listOf(Compartir, Guardar, Logout)
8    }
9}

Compose BottomAppBarConMenu

Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt. Esta versión está mejorada con respecto al ejemplo anterior, se crea un bucle para mostrar las opciones que se vayan añadiendo en la sealed class.

 1@Composable
 2fun BottomAppBarConMenu(
 3    onOpcionSeleccionada: (OpcionMenu) -> Unit
 4) {
 5    var expanded by remember { mutableStateOf(false) }
 6
 7    BottomAppBar(
 8        actions = {
 9            IconButton(onClick = { expanded = !expanded }) {
10                Icon(Icons.Default.MoreVert, contentDescription = "Menú inferior")
11            }
12
13            DropdownMenu(
14                expanded = expanded,
15                onDismissRequest = { expanded = false }
16            ) {
17                OpcionMenu.todas.forEach { opcion ->
18                    DropdownMenuItem(
19                        text = { Text(opcion.label) },
20                        leadingIcon = { Icon(opcion.icon, contentDescription = null) },
21                        onClick = {
22                            expanded = false
23                            onOpcionSeleccionada(opcion)
24                        }
25                    )
26                }
27            }
28        }
29    )
30}

Resultado de la MainActivity

Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5        setContent {
 6            ExampleT2_9Theme {
 7                PantallaPrincipal()
 8            }
 9        }
10    }
11}
12
13@Preview(showBackground = true)
14@Composable
15fun PantallaPrincipal() {
16    val context = LocalContext.current
17    var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
18
19    Scaffold(
20        bottomBar = {
21            BottomAppBarConMenu { opcion ->
22                mensaje = when (opcion) {
23                    is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
24                    is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
25                    is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
26                }
27            }
28        },
29        modifier = Modifier.fillMaxSize()
30    ) { innerPadding ->
31        Box(
32            modifier = Modifier
33                .fillMaxSize()
34                .padding(innerPadding),
35            contentAlignment = Alignment.Center
36        ) {
37            Text(mensaje)
38        }
39    }
40}

Como puedes ver, BottomAppBar permite el uso de actions igual que la TopAppBar. También es posible implementar este menú utilizando la sección floatingActionButton y utilizando FloatingActionButton, y se puede combinar ambas barras (topBar y bottomBar) en el mismo Scaffold.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 10: Login en un cuadro de diálgo

Objetivo

Este ejemplo permite crear un AlertDialog personalizado para solicitar usuario y contraseña.

Recursos en string.xml

Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_10</string>
 3
 4    <string name="txt_title">Login</string>
 5    <string name="txt_user">Usuario</string>
 6    <string name="txt_password">Contraseña</string>
 7
 8    <string name="txt_login_error">Credenciales incorrectas</string>
 9    <string name="txt_login_ok">Credenciales correctas</string>
10</resources>

Compose LoginDialog

Se creará el siguiente compose para mostrar el cuadro de diálogo personalizado, en esta ocasión se utiliza AlertDialog ya que la personalización es mínima y simplifica el código, pero para los cuadros de diálogo personalizados se recomienda el uso de Dialog.

 1@Composable
 2fun LoginDialog(onLogin: (String, String) -> Unit = { _, _ -> }) {
 3    val ctxt = LocalContext.current
 4    val openDialog = remember { mutableStateOf(false) }
 5    var user by remember { mutableStateOf("") }
 6    var pass by remember { mutableStateOf("") }
 7
 8    Box(
 9        modifier = Modifier.fillMaxSize(),
10        contentAlignment = Alignment.Center
11    ) {
12        Button(onClick = { openDialog.value = true }) {
13            Text(text = ctxt.getString(R.string.txt_title))
14        }
15
16        if (openDialog.value) {
17            AlertDialog(
18                onDismissRequest = { openDialog.value = true }, // Se mantiene el diálogo abierto.
19                title = { Text(text = ctxt.getString(R.string.txt_title)) },
20                text = {
21                    Column {
22                        OutlinedTextField(
23                            value = user,
24                            onValueChange = { user = it },
25                            singleLine = true,
26                            label = { Text(ctxt.getString(R.string.txt_user)) }
27                        )
28                        OutlinedTextField(
29                            value = pass,
30                            onValueChange = { pass = it },
31                            singleLine = true,
32                            label = { Text(ctxt.getString(R.string.txt_password)) },
33                            visualTransformation = PasswordVisualTransformation()
34                        )
35                    }
36                },
37                confirmButton = {
38                    TextButton(onClick = {
39                        if (user.isNotBlank() && pass.isNotBlank()) {
40                            onLogin(user, pass)
41                            openDialog.value = false
42                            // Limpiar los campos después del inicio de sesión.
43                            user = ""
44                            pass = ""
45                        }
46                    }) {
47                        Text(ctxt.getString(android.R.string.ok))
48                    }
49                },
50                dismissButton = {
51                    TextButton(onClick = {
52                        openDialog.value = false
53                        user = ""
54                        pass = ""
55                    }) {
56                        Text(ctxt.getString(android.R.string.cancel))
57                    }
58                }
59            )
60        }
61    }
62}

Observa el uso de visualTransformation en el OutlinedTextField, este permite la ocultación del password.

Compose MainScreen

Siguiendo con la reutilización y actualización del estado, se creará el siguiente composable para mostrar el botón de login o el mensaje de login correcto.

 1@Composable
 2fun MainScreen() {
 3    var isLoggedIn by remember { mutableStateOf(false) }
 4    val ctxt = LocalContext.current
 5
 6    if (!isLoggedIn) {
 7        // Se muestra el diálogo de inicio de sesión
 8        LoginDialog(
 9            onLogin = { user, pass ->
10                // Aquí se maneja la lógica de inicio de sesión
11                // Por ejemplo, verificar las credenciales
12                if (user == "admin" && pass == "1234") {
13                    isLoggedIn = true // Simulación de inicio de sesión exitoso
14                    println("Inicio de sesión correcto. Usuario: $user, Contraseña: $pass")
15                } else {
16                    // Se muestra un mensaje de error o manejar el fallo de inicio de sesión
17                    Toast.makeText(
18                        ctxt,
19                        ctxt.getString(R.string.txt_login_error),
20                        Toast.LENGTH_SHORT
21                    ).show()
22                }
23            }
24        )
25    } else {
26        // Contenido principal de la aplicación
27        Text(text = ctxt.getString(R.string.txt_login_ok))
28    }
29}

Resultado de la MainActivity

Ahora, la actividad principal tendrá el siguiente aspecto.

 1class MainActivity : ComponentActivity() {
 2    @OptIn(ExperimentalMaterial3Api::class)
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        enableEdgeToEdge()
 6        setContent {
 7            ExampleT2_10Theme {
 8                Scaffold(
 9                    topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) }
10                ) { innerPadding ->
11                    Box(
12                        modifier = Modifier
13                            .fillMaxSize()
14                            .padding(innerPadding),
15                        contentAlignment = Alignment.Center
16                    ) {
17                        MainScreen()
18                    }
19                }
20            }
21        }
22    }
23}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 3: Intents y permisos

Objetivos de este tema

  • Comprender las diferentes estrategias de navegación en aplicaciones Android.
  • Dominar el uso de la clase Intent para la comunicación entre componentes.
  • Aplicar técnicas modernas para el envío de datos entre actividades y la recepción de resultados.
  • Gestionar adecuadamente los permisos peligrosos en Android.
  • Gestionar la navegación con ViewModel en el contexto de MVVM y Clean Architecture.
  • Integrar el patrón ViewModel en la gestión de la navegación y el estado de los permisos.
  • Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.

3.1. Creación y navegación entre actividades

Para crear una Activity nueva en el modelo de vistas se utiliza una clase conocida como Intent, esto pueden ser de dos formas:

  • Explícitos, indicarán que deben lanzar exactamente, su uso típico es ejecutar diferentes componentes internos de una aplicación. Por ejemplo, una actividad (ventana nueva).
  • Implícitos, se utilizan para lanzar tareas abstractas, del tipo “quiero hacer una llamada” o “quiero hacer una foto”. Estas peticiones se resuelven en tiempo de ejecución, por lo que el sistema buscará los componentes registrados para la tarea pedida, si encontrase varias, el sistema preguntará al usuario que componente prefiere.

3.1.1. Crear nuevas Activity

Para crear una Activity es necesrio extender ComponentActivity y usar startActivity(Intent(this, OtraActivity::class.java)). Este sistema se utiliza para lanzar nuevas actividades en el sistema de vistas, y sería un Intent explícito_.

3.1.2. Enviar datos con Intent

Para añadir datos a la llamada se usará intent.putExtra("clave", valor), y se recibirá en OtraActivity utilizando intent.getXXXExtra("clave"). Como en la creación, se utiliza para lanzar nuevas actividades en el sistema de vistas.

3.1..3. Recibir resultados

Para recuperar datos de OtraActivity se recomienda utilizar la API moderna, creando el siguiente callback, sustituyendo la versión anterior que utilizaba onActivityResult(). A este método también se le conoce como Intent por contrato.

1val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
2    if (result.resultCode == Activity.RESULT_OK) { /* … */ }
3}
4launcher.launch(Intent(...))

3.2. Gestión de permisos

Cuando se quiere lanzar una tarea que no es propia de la app se tendrá que tratar con otro tema, los permisos. El tratamiento de los permisos en Android cambió a partir de la API 23, hasta entonces, se concedían durante la instalación. Ahora, debe concederse de manera explícita aquellos considerados peligrosos, ya no se pide permiso durante el proceso de instalación sino en tiempo de ejecución.

A raíz de este cambio se produce una clasificación de los permisos, básicamente se distinguirán tres tipos de permisos según sea su nivel de peligrosidad.

  • Permisos normales: estos se utilizan cuando la aplicación necesita acceder a recursos o servicios fuera del ámbito de la app, donde no existe riesgo para la privacidad del usuario o para el funcionamiento de otras aplicaciones, por ejemplo, cambiar el uso horario.
    Si se declara en el manifest de la aplicación uno de estos permisos, el sistema otorgará automáticamente permiso para su uso durante la instalación. Además de no preguntarse al usuario por ellos, estos no podrán revocarlos.
  • Permisos de firma: estos son concedidos durante la instalación de la app, pero sólo cuando la aplicación que intenta utilizar el permiso está firmada por el mismo certificado que la aplicación que define el permiso. Estos permisos no se suelen utilizar con aplicaciones de terceros, es decir, se utilizan entre aplicaciones del mismo desarrollador.
  • Permisos peligrosos: estos permisos involucran áreas potencialmente peligrosas, como son la privacidad del usuario, la información almacenada por los usuarios o la interacción con otras aplicaciones. Si se declara la necesidad de uso de uno de estos permisos, se necesitará el consentimiento explícito del usuario, y se hará en tiempo de ejecución. Hasta que no se conceda el permiso, la app no podrá hacer uso de esa funcionalidad. Por ejemplo, acceder a los contactos.

Puedes encontrar todos los permisos que se pueden utilizar en la documentación de Google para Android. El valor que necesites añadir al manifest lo encontrarás en Constant Value, y Protection level indica el tipo de permiso que es.

INTERNET
public static final String INTERNET
Allows applications to open network sockets.
Protection level: normal
Constant Value: "android.permission.INTERNET"

Los permisos normales no requieren de una solicitud al usuario para poder funcionar, es por eso que se comenzarán por los permisos peligrosos, concretamente, uno de los más habituales, el uso de la cámara de fotos del dispositivo. Según la documentación de Google, cuando uno se plantea la gestión de permisos debe plantearse el siguiente flujo de trabajo para una correcta gestión.

Imagen punto 3.2 Imagen punto 3.2

Como estamos introduciendo el uso de Jetpack Compose, una de las cosas que cambia con respecto al sistema de vistas es la gestión de permisos, para ello se hará una primera aproximación a ViewModel.

3.2.1. ¿Qué es un ViewModel?

Un ViewModel es un componente de Architecture Components de Android Jetpack que permite almacenar y gestionar datos relacionados con la UI, de forma que sobreviven a cambios de configuración (como rotaciones de pantalla). Sus principales características son:

  • Separar la lógica de negocio de la UI, manteniendo los Composables atómicos y centrados en la presentación.
  • Mantener la consistencia del estado tras representaciones de activities o fragments.
  • Diseñado para integrarse fácilmente con librerías como Hilt, Navigation Compose y funciones de flujo de datos como StateFlow o LiveData.

En Jetpack Compose, los ViewModel se crearán e inyectarán en Composables utilizando las funciones viewModel() o hiltViewModel().

3.2.2. Integrar permisos con ViewModel

Se creará una nueva clase (PermissionHandlerViewModel) que extenderá (heredará) de ViewModel y centralizará la lógica para solicitar y comprobar el estado del permiso.

 1class PermissionHandlerViewModel : ViewModel() {
 2    data class PermissionUiState(
 3        val granted: Boolean = false,
 4        val showRationale: Boolean = false,
 5        val permanentlyDenied: Boolean = false
 6    )
 7
 8    // MutableStateFlow to hold the UI state, we use backing property.
 9    private val _uiState = MutableStateFlow(PermissionUiState())
10    val uiState: StateFlow<PermissionUiState> = _uiState.asStateFlow()
11
12    // Function to update the UI state based on permission results.
13    fun onPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
14        _uiState.update {
15            it.copy(
16                granted = granted,
17                showRationale = !granted && shouldShowRationale,
18                permanentlyDenied = !granted && !shouldShowRationale
19            )
20        }
21    }
22}

El método onPermissionResult actualizará el estado según la respuesta del usuario:

  • granted: permiso concedido.
  • showRationale: denegado con posible explicación.
  • permanentlyDenied: denegación del permiso sin posibilidad de volver a preguntar.

El uso de MutableStateFlow en el ViewModel es para representar un estado que pueda sobrevivir a cambios de configuración, integrarse con flujos de datos y mantenerse testable y encapsulado. Se expone como StateFlow para su consumo externo, esta técnica se conoce como backing. En Compose, se usa collectAsState() para convertirlo en un estado observable y disparar la recomposición.

Ahora se añadirá al Manifest el permiso para poder utilizar la cámara de fotos del dispositivo.

1<uses-permission android:name="android.permission.CAMERA" />
2<uses-feature android:name="android.hardware.camera" android:required="true" />

Para este tipo de acciones, es necesario establecer en el Manifest lo que se conoce como queries, estas permiten indicar al sistema operativo que la aplicación va ha necesitar una aplicación de terceros. La siguiente querie se utiliza para indicar que la aplicación va a necesitar el uso de la cámara de fotos.

1<queries>
2    <intent>
3        <action android:name="android.media.action.IMAGE_CAPTURE" />
4    </intent>
5</queries>

A continuación, se creará el siguiente Composable para crear la pantalla principal, esto es meramente estético.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Preview(showBackground = true)
 3@Composable
 4fun MainScreen() {
 5    val ctxt = LocalContext.current
 6
 7    Scaffold(
 8        topBar = {
 9            TopAppBar(
10                title = { Text(ctxt.getString(R.string.app_name)) },
11                colors = topAppBarColors(
12                    containerColor = MaterialTheme.colorScheme.primaryContainer,
13                    titleContentColor = MaterialTheme.colorScheme.primary
14                )
15            )
16        }
17    ) { innerPadding ->
18        Column(
19            modifier = Modifier
20                .padding(innerPadding)
21                .fillMaxWidth()
22        ) {
23            OpenCamera()
24        }
25    }
26}

Ahora se creará el método OpenCamera() que será el encargado de gestionar los permisos y mostrar la UI según la respuesta del usuario.

 1@Composable
 2fun OpenCamera(viewModel: PermissionHandlerViewModel = viewModel()) {
 3    val permissionState = viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
 4    val ctxt = LocalContext.current
 5    // Este callback se usa para solicitar el permiso de cámara.
 6    val requestPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
 7        viewModel.onPermissionResult(
 8            granted, ActivityCompat.shouldShowRequestPermissionRationale(
 9                ctxt as Activity, Manifest.permission.CAMERA
10            )
11        )
12    }
13
14    // Observamos el estado del permiso y actuamos en consecuencia.
15    LaunchedEffect(permissionState) {
16        when {
17            permissionState.value.granted -> {
18                // Aquí abrimos la cámara; por simplicidad indicamos con un log
19                Log.d("CameraPermission", "Acceso a cámara concedido")
20                // Podrías lanzar una navegación o mostrar vista de cámara
21            }
22
23            permissionState.value.showRationale -> {
24                // Mostrar diálogo explicativo
25            }
26
27            permissionState.value.permanentlyDenied -> {
28                // Mostrar diálogo con opción a abrir ajustes
29            }
30
31            else -> {
32                // Primer lanzamiento: solicitamos el permiso
33                requestPermission.launch(Manifest.permission.CAMERA)
34            }
35        }
36    }
37
38    // Aquí se muestra la UI dependiendo del estado del permiso.
39    when {
40        permissionState.value.granted -> {
41            Text("Pulsa el botón para abrir un intent")
42            Button(
43                onClick = {
44                    Log.d("DEBUG", "Botón pulsado")
45
46                    val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
47                    if (cameraIntent.resolveActivity(ctxt.packageManager) != null)
48                        ctxt.startActivity(cameraIntent)
49                    else Log.e("DEBUG", "No hay aplicación que pueda manejar la cámara")
50                },
51                modifier = Modifier.padding(8.dp).fillMaxWidth()
52            ) {
53                Text(text = "Abrir la cámara")
54            }
55        }
56
57        permissionState.value.showRationale -> {
58            Text("Se necesita acceso a la cámara de fotos")
59            Toast.makeText(
60                ctxt,
61                "Es necesario tener acceso a la cámara de fotos",
62                Toast.LENGTH_LONG
63            ).show()
64        }
65
66        permissionState.value.permanentlyDenied -> {
67            Text("Permiso denegado permanentemente")
68            Button(
69                onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
70                    ctxt.startActivity(
71                        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
72                            data = Uri.fromParts("package", ctxt.packageName, null)
73                        }
74                    )
75                }, modifier = Modifier.padding(8.dp).fillMaxWidth()
76            ) {
77                Text("Abrir ajustes")
78            }
79        }
80
81        else -> {
82            Text("Solicitando permiso para acceder a la cámara")
83            // Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
84        }
85    }
86}

Deberás añadir la siguiente dependencia para simplificar la creación y uso de ViewModel al build.gradle.kts (Module :app). Recuerda sincronizar el proyecto para que surta efecto.

1implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")
  • Como habrás observado, ViewModel centraliza todo el estado de los permisos (concedido, con justificación, denegado permanentemente).
  • El Composable observa ese estado con collectAsState() y lanza acciones según el estado:
    • Solicitar permiso.
    • Mostrar explicaciones.
    • Abrir ajustes del sistema si hay denegación permanente.
    • Ejecutar acceso a la cámara cuando esté concedido.
  • viewModel() es la forma recomendada en Compose para instanciar ViewModels vinculados al ciclo de vida (debes incluir la biblioteca).
  • No necesitas hacer nada en el Activity ni pasar parámetros adicionales.
  • Asegura la consistencia del estado y facilitando además la integración con el patrón MVVM y clean architecture.

3.3. Intents implícitos

Pero, para abrir los Intents implícitos, se sigue utilizando la clase Intent, a continuación, se muestran algunos ejemplos sencillos, empezando por aquellos que no requieren permiso del usuario.

3.3.1. Abrir una URL en el navegador del dispositivo

En este es necesario establecer en el Manifest la queries para indicar que la aplicación va a necesitar un navegador web.

 1<?xml version="1.0" encoding="utf-8"?>
 2<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 3
 4    <!-- Comprueba que existe un navegador en el sistema -->
 5    <queries>
 6        <intent>
 7            <action android:name="android.intent.action.VIEW" />
 8            <category android:name="android.intent.category.BROWSABLE" />
 9            <data android:scheme="https" />
10        </intent>
11    </queries>
12
13    <application...></application>
14</manifest>

El código para lanzar el Intent podría ser como el que se muestra a continuación.

 1Button(
 2    onClick = {
 3        Log.d("DEBUG", "Botón pulsado")
 4
 5        // Intent para abrir un navegador web
 6        Intent(Intent.ACTION_VIEW, "https://www.javiercarrasco.es".toUri()).apply {
 7            if (this.resolveActivity(ctxt.packageManager) != null)
 8                ctxt.startActivity(this)
 9            else Log.d("DEBUG", "Hay un problema para encontrar un navegador.")
10        }
11    },
12    modifier = Modifier
13        .padding(8f.dp)
14        .fillMaxWidth()
15) {
16    Text("Abrir navegador")
17}

Puedes refactorizar el código y llevarte el código para crear el Intent en un método a parte, al que se le pase el contexto y la URL que quieras abrir.

 1fun openWebPage(ctxt: Context, url: String) {
 2    // Intent para abrir un navegador web
 3    Intent(Intent.ACTION_VIEW, url.toUri()).apply {
 4        addCategory(Intent.CATEGORY_BROWSABLE) // Añade categoría para navegadores.
 5        flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea.
 6        if (this.resolveActivity(ctxt.packageManager) != null)
 7            ctxt.startActivity(this)
 8        else Log.d("DEBUG", "Hay un problema para encontrar un navegador.")
 9    }
10}

3.3.2. Marcar un número de teléfono

Este no necesita crear una query, se entiende que el dispositivo está preparado, y no necesita solicitar al usuario permiso explícito.

1fun openDialer(ctxt: Context, phoneNumber: String) {
2    // Intent para abrir la aplicación de teléfono
3    Intent(Intent.ACTION_DIAL, "tel:$phoneNumber".toUri()).apply {
4        addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5        flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6        ctxt.startActivity(this)
7    }
8}

3.3.3. Abrir una aplicación de mapas

Tampoco requiere query ni permiso específico ya que no se está utilizando geolocalización, para lo que sí sería necesario.

1fun openMap(ctxt: Context, geo: String) { // geo: "geo:0,0?q=Alicante"
2    // Intent para abrir la aplicación de teléfono
3    Intent(Intent.ACTION_VIEW, geo.toUri()).apply {
4        addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5        flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6        ctxt.startActivity(this)
7    }
8}

3.3.4. Escribir un correo electrónico

En primer lugar se creará la siguiente query en el Manifest.

1<!-- Comprueba que existe una aplicación de correo electrónico -->
2<queries>
3    <intent>
4        <action android:name="android.intent.action.SENDTO" />
5        <category android:name="android.intent.category.DEFAULT" />
6        <data android:scheme="mailto" />
7    </intent>
8</queries>

Un posible método para componer un correo podría ser como el siguiente.

 1fun composeMail(ctxt: Context, email: String, subject: String, body: String) {
 2    // Intent para enviar un correo electrónico
 3    Intent(Intent.ACTION_SENDTO).apply {
 4        data = "mailto:".toUri() // Asegura que solo se manejen aplicaciones de correo
 5        putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) // Destinatario del correo
 6        // putExtra(Intent.EXTRA_CC, arrayOf(emailsCC)) // Destinatarios en copia (opcional)
 7        putExtra(Intent.EXTRA_SUBJECT, subject)
 8        putExtra(Intent.EXTRA_TEXT, body)
 9
10        addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
11        flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
12
13        if (this.resolveActivity(ctxt.packageManager) != null)
14            ctxt.startActivity(Intent.createChooser(this, "Enviar correo..."))
15        else Log.d("DEBUG", "Hay un problema para enviar el correo electrónico.")
16    }
17}

3.3.5. Crear una alarma

El siguiente ejemplo necesita establecer el permiso correspondiente para poder crear una alarma en el despertador, en el manifest deberás añadir la siguiente línea. Este permiso está catalogado como normal, por tanto no se necesita pedir permiso al usuario.

1<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />

También habrá que añadir la query para buscar el tipo de aplicación necesaria.

1<!-- Comprueba que existe una aplicación para establecer alarmas -->
2<queries>
3    <intent>
4        <action android:name="android.intent.action.SET_ALARM" />
5        <category android:name="android.intent.category.DEFAULT" />
6    </intent>
7</queries>

Un posible método para establecer una alarma en la aplicación de reloj podría ser el siguiente.

 1fun setAlarm(ctxt: Context, mensaje: String, hora: Int, minuto: Int) {
 2    Log.d("SetAlarm", "Estableciendo alarma: $mensaje a las $hora:$minuto")
 3
 4    Intent(AlarmClock.ACTION_SET_ALARM).apply {
 5        putExtra(AlarmClock.EXTRA_MESSAGE, mensaje)
 6        putExtra(AlarmClock.EXTRA_HOUR, hora)
 7        putExtra(AlarmClock.EXTRA_MINUTES, minuto)
 8
 9        if (this.resolveActivity(ctxt.packageManager) != null) {
10            ctxt.startActivity(this)
11        } else {
12            Log.d("DEBUG", "Hay un problema para establecer la alarma.")
13            Toast.makeText(
14                ctxt,
15                "No se pudo establecer la alarma, comprueba que tienes una aplicación de reloj instalada.",
16                Toast.LENGTH_LONG
17            ).show()
18        }
19    }
20}

A continuación, se muestra el uso de otro Intent que sí requieren permiso del usuario, haciendo uso de la clase vista en el punto anterior. Recuerda añadir la dependencia "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1" al Gradle.

3.3.6. Realizar una llamada telefónica

El siguiente Intent, a diferencia del anterior, sí requiere permiso expreso por parte del usuario, ya que se va a producir una acción considerada peligrosa.

En primer lugar habrá que indicar en el Manifest el uso del permiso en cuestión, y la necesidad del componente hardware necesario para realizar la acción.

1<uses-permission android:name="android.permission.CALL_PHONE" />
2<uses-feature android:name="android.hardware.telephony" android:required="false" />

La adaptación del método para realizar la llamada, controlando el estado de los permisos podría quedar como se muestra a continuación.

 1@Composable
 2fun CallPhone(phoneNumber: String, viewModel: PermissionHandlerViewModel = viewModel()) {
 3    val permissionState =
 4        viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
 5    val ctxt = LocalContext.current
 6    // Este callback se usa para solicitar el permiso de cámara.
 7    val requestPermission =
 8        rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
 9            viewModel.onPermissionResult(
10                granted, ActivityCompat.shouldShowRequestPermissionRationale(
11                    ctxt as Activity, Manifest.permission.CALL_PHONE
12                )
13            )
14        }
15
16    // Se observa el estado del permiso y actuamos en consecuencia.
17    LaunchedEffect(permissionState) {
18        when {
19            permissionState.value.granted -> {
20                // Aquí abrimos la cámara; por simplicidad indicamos con un log
21                Log.d("CallPermission", "Acceso a llamar concedido")
22            }
23
24            else -> {
25                // Primer lanzamiento: solicitamos el permiso
26                requestPermission.launch(Manifest.permission.CALL_PHONE)
27            }
28        }
29    }
30
31    // Aquí se muestra la UI dependiendo del estado del permiso.
32    when {
33        permissionState.value.granted -> {
34            Text("Pulsa el botón para abrir un intent")
35            Button(
36                onClick = {
37                    Log.d("DEBUG", "Botón pulsado")
38
39                    // Intent para realizar una llamada telefónica
40                    Intent(Intent.ACTION_CALL, "tel:$phoneNumber".toUri()).apply {
41
42                        addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
43                        flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
44
45                        // Nota: ACTION_CALL requiere el permiso CALL_PHONE en el manifiesto
46                        if (this.resolveActivity(ctxt.packageManager) != null)
47                            ctxt.startActivity(this)
48                        else Log.d("DEBUG", "Hay un problema para realizar la llamada.")
49                    }
50                },
51                modifier = Modifier.padding(8.dp).fillMaxWidth()
52            ) {
53                Text(text = "Realizar llamada telefónica")
54            }
55        }
56
57        permissionState.value.showRationale -> {
58            Text("Se necesita acceso para realizar llamadas telefónicas")
59            // Solicitar nuevamente el permiso.
60            Button(
61                onClick = { // Se solicita el permiso de llamada telefónica.
62                    requestPermission.launch(Manifest.permission.CALL_PHONE)
63                },
64                modifier = Modifier.padding(8.dp).fillMaxWidth(),
65                colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
66            ) {
67                Text("Solicitar permiso")
68            }
69
70            Toast.makeText(
71                ctxt,
72                "Es necesario tener acceso para realizar llamadas telefónicas",
73                Toast.LENGTH_LONG
74            ).show()
75        }
76
77        permissionState.value.permanentlyDenied -> {
78            Text("Permiso denegado permanentemente")
79            Button(
80                onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
81                    ctxt.startActivity(
82                        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
83                            data = Uri.fromParts("package", ctxt.packageName, null)
84                        }
85                    )
86                }, modifier = Modifier.padding(8.dp).fillMaxWidth()
87            ) {
88                Text("Abrir ajustes")
89            }
90        }
91
92        else -> {
93            Text("Solicitando permiso para realizar llamadas telefónicas")
94            // Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
95        }
96    }
97}

Ejemplos prácticos

Ejemplo práctico 11 Recuperar la imagen capturada desde la cámara de fotos

3.4. Creación y navegación entre actividades

Jetpack Compose apuesta por el uso de una sola Activity con múltiples pantallas (Composable), pero, en algunas situaciones es útil o necesario utilizar varias actividades: interoperabilidad con vistas heredadas, flujos aislados o necesidades de integración específicas.

3.4.1. Crear nuevas Activity

En Android, una actividad representa una pantalla completa. Crear una nueva Activity en un proyecto con Jetpack Compose es algo más sencillo que hacerlo en el sistema basado en vistas, ya que no tienes que crear el XML que la represente. El primer paso será crear una nueva clase. Puedes utilizar la opción File > New > Compose > Empty Activity. Tras eliminar algo de boilerplate code, podría quedar así.

 1class SecondActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5        setContent {
 6            Text(
 7                "Second Activity",
 8                modifier = Modifier.padding(32.dp)
 9            )
10        }
11    }
12}

Esta nueva actividad quedará registrada en el Manifest.

1<activity
2    android:name=".SecondActivity"
3    android:exported="false"
4    android:label="@string/title_activity_second"
5    android:theme="@style/Theme.DocumentationT3_4" />

3.4.2. Enviar datos con Intent

Una vez preparada una actividad principal sencilla con un botón que traslade al usuario a la segunda actividad (SecondActivity.kt)…

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5        setContent {
 6            DocumentationT3_4Theme {
 7                MainScreen()
 8            }
 9        }
10    }
11}
12
13@OptIn(ExperimentalMaterial3Api::class)
14@Preview(showBackground = true)
15@Composable
16fun MainScreen() {
17    val ctxt = LocalContext.current
18    Scaffold(
19        topBar = {
20            TopAppBar(
21                title = { Text(ctxt.getString(R.string.app_name)) },
22                colors = topAppBarColors(
23                    containerColor = MaterialTheme.colorScheme.primaryContainer,
24                    titleContentColor = MaterialTheme.colorScheme.primary,
25                )
26            )
27        }
28    ) { innerPadding ->
29        Column(modifier = Modifier.padding(innerPadding)) {
30            Text(
31                modifier = Modifier.padding(5.dp).fillMaxWidth(),
32                text = "Main Activity"
33            )
34            Button(
35                modifier = Modifier.padding(5.dp).fillMaxWidth(),
36                onClick = {
37                    /* Navigate to SecondActivity */
38                }) {
39                Text(text = "Ir a la segunda pantalla")
40            }
41        }
42    }
43}

…pueden pasarse datos de una Activity a otra usando el sistema de Intent. El onClick del botón podría ser como se muestra a continuación.

1/* Navigate to SecondActivity */
2Intent(ctxt, SecondActivity::class.java).apply {
3    putExtra("nombre", "Javier")
4    putExtra("edad", 48)
5
6    ctxt.startActivity(this)
7}

Ahora, en la segunda actividad (SecondActivity.kt) se pueden recoger los datos y mostrarlos en un Text por ejemplo.

 1class SecondActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5        setContent {
 6            // Se recuperan los datos enviados desde MainActivity.
 7            val nombre = intent.getStringExtra("nombre") ?: ""
 8            val edad = intent.getIntExtra("edad", 0)
 9
10            Column(modifier = Modifier.padding(32.dp)) {
11                Text("Second Activity")
12                Spacer(modifier = Modifier.height(8.dp))
13                if (nombre.isNotEmpty())
14                    Text("Nombre: $nombre")
15                if (edad > 0)
16                    Text("Edad: $edad")
17            }
18        }
19    }
20}
Consejo

Utiliza constantes o companion object para definir las claves (“nombre”, “edad”), esto te evitará posibles errores de escritura.

3.4.3. Recibir resultados con ActivityResultLauncher

Desde la API 30 de Android (Jetpack Activity 1.2.0), se utiliza la nueva API para recibir resultados, evitando el uso obsoleto de onActivityResult.

El primer paso será crear en un Composable el launcher encargado de lanzar la nueva actividad y recoger el resultado devuelto.

 1val launcher = rememberLauncherForActivityResult(
 2    contract = ActivityResultContracts.StartActivityForResult()
 3) { result ->
 4    if (result.resultCode == Activity.RESULT_OK) {
 5        println("Resultado recibido de SecondActivity")
 6        val data = result.data?.getStringExtra("resultado")
 7
 8        if (data != null) {
 9            // Aquí puedes manejar el resultado que viene de SecondActivity
10            // Por ejemplo, mostrar un Toast o actualizar la UI
11            println("Resultado recibido: $data")
12        }
13    }
14}

El segundo paso será lanzar el Intent, el método completo quedaría como se muestra a continuación.

 1@Composable
 2fun NavigateToSecondActivity(nombre: String, edad: Int) {
 3    val ctxt = LocalContext.current
 4    val launcher = rememberLauncherForActivityResult(
 5        contract = ActivityResultContracts.StartActivityForResult()
 6    ) { result ->
 7        if (result.resultCode == Activity.RESULT_OK) {
 8            println("Resultado recibido de SecondActivity")
 9            val data = result.data?.getStringExtra("resultado")
10
11            if (data != null) {
12                // Aquí puedes manejar el resultado que viene de SecondActivity
13                // Por ejemplo, mostrar un Toast o actualizar la UI
14                println("Resultado recibido: $data")
15            }
16        }
17    }
18
19    Button(
20        modifier = Modifier.padding(5.dp).fillMaxWidth(),
21        onClick = {
22            /* Navigate to SecondActivity */
23            val intent = Intent(ctxt, SecondActivity::class.java)
24            intent.putExtra("nombre", nombre)
25            intent.putExtra("edad", edad)
26
27            launcher.launch(intent)
28        }) {
29        Text(text = "Ir a la segunda pantalla")
30    }
31}

En SecondActivity.kt se añadirá un botón, por ejemplo, que se encargue de realizar el retorno con el paso de información.

 1Button(
 2    onClick = {
 3        // Se crea un Intent para devolver el resultado a MainActivity.
 4        val resultIntent = Intent().apply {
 5            putExtra("resultado", "Hola $nombre, tienes $edad años")
 6        }
 7        setResult(Activity.RESULT_OK, resultIntent)
 8        finish() // Finaliza SecondActivity y devuelve el resultado.
 9    }
10) {
11    Text("Devolver resultado")
12}

El uso de esta API es compatible con Compose y es segura para el ciclo de vida.

3.4.4. ¿Cuándo usar varias Activity?

En Jetpack Compose se recomiendoa el uso múltiples actividades solo si:

  • Se esta integrando módulos legacy con Compose.
  • Se necesita un fuerte aislamiento entre pantallas (como flujos separados de autenticación o ajustes).
  • Se necesitas interoperar con componentes que requieran una Activity concreta (por ejemplo, bibliotecas de terceros).

En todos los demás casos, se recomienda usar una sola actividad y Navigation Compose para gestionar pantallas.

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 4: Navegación en Jetpack Compose

Objetivos de este tema

  • Conocer las alternativas de navegación en Android: actividades múltiples vs pantallas en Compose.
  • Aprender a crear y vincular actividades, enviar datos y recibir resultados.
  • Introducción a Navigation Compose: NavHost, NavController, rutas y argumentos.
  • Gestionar la navegación con ViewModel en el contexto de MVVM y Clean Architecture.
  • Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.

4.1. Introducción a Navigation Compose

Navigation Compose es una extensión de la Jetpack Navigation Architecture Component, está adaptada para trabajar con Jetpack Compose, y permite gestionar la navegación entre pantallas o destinos (screens) de forma declarativa, simplificando así el manejo de la pila de navegación y mejorando la estructura del proyecto.

Gracias a la integración con Jetpack Compose, Navigation Compose facilita la navegación entre diferentes Composables sin necesidad de usar múltiples actividades o fragmentos, algo muy habitual en aplicaciones tradicionales que utilizan vistas basadas en XML.

4.1.1. Beneficios frente a múltiples actividades

El uso de Navigation Compose frente al uso de múltiples actividades ofrece varias ventajas:

  1. Menor complejidad del proyecto

    • En vez de tener múltiples Activity, todo el flujo de navegación se maneja desde una única Activity que contiene los Composable destino.
    • Esto reduce la complejidad del proyecto y mejora la mantenibilidad del código.

    Con múltiples Activity:

    1val intent = Intent(this, DetailsActivity::class.java)
    2startActivity(intent)

    Con Navigation Compose:

    1navController.navigate("details")
  2. Mejor experiencia de usuario

    • La navegación entre pantallas es más rápida y fluida, ya que no se necesitan procesos con alto coste como puede ser el inicio de nuevas Activity.
    • Se evitan recreaciones innecesarias de componentes.
  3. Uso eficiente de recursos

    • Menos uso de memoria y recursos del sistema, ya que no se crean múltiples contextos o ciclos de vida de Activity.
  4. Navegación declarativa

    • Se adapta perfectamente al paradigma declarativo de Jetpack Compose, permitiendo definir la navegación de forma clara y sencilla en el código.

    Ejemplo de declaración:

    1NavHost(navController, startDestination = "home") {
    2    composable("home") { HomeScreen() }
    3    composable("profile") { ProfileScreen() }
    4}
  5. Mejor gestión del estado

    • Facilita la gestión del estado compartido entre pantallas dentro de una misma Activity, lo cual es más complicado cuando se usan múltiples actividades.

    Ambas pantallas compartirán el mismo SharedViewModel:

    1val viewModel: SharedViewModel = viewModel()
    2
    3composable("screenA") {
    4    ScreenA(viewModel)
    5}
    6
    7composable("screenB") {
    8    ScreenB(viewModel)
    9}

    Cuando tienes múltiples Activity, compartir datos entre ellas requiere serializar objetos, usar Bundle, Intent, o incluso patrones como ViewModel compartidos con un alcance específico.

Comparativa rápida

Característica Múltiples Activity Navigation Compose
Cantidad de archivos Mayor Menor
Gestión del estado Compleja Más simple
Velocidad de navegación Más lenta Más rápida
Consumo de recursos Alto Bajo
Integración con Jetpack Compose No nativa Integación total

4.2. Definición de destinos (Composable)

En Jetpack Compose, los destinos (Composables) son las pantallas o vistas a las que se puede navegar dentro de una aplicación. La navegación se gestiona mediante la librería Navigation Component, pero adaptada para Compose, para ello se añadirá la dependencia al Gradle.

1// Navigation Compose
2implementation("androidx.navigation:navigation-compose:2.9.2")

¿Cómo se define un destino?

Un destino se define dentro del NavHost, asociado a una ruta de navegación. Cada destino puede mostrar un Composable diferente, en el punto anterior ya se ha podido ver.

  • NavHost
    • Es el contenedor que gestiona los destinos de navegación.

    • Define qué Composable deberá mostrarse según la ruta.

    • Se crea mediante el componente NavHost proporcionado por la librería androidx.navigation:navigation-compose.

      Ejemplo básico de NavHost:

       1val navController = rememberNavController()
       2
       3NavHost(navController = navController, startDestination = "home") {
       4    composable("home") {
       5        HomeScreen(navController)
       6    }
       7    composable("detail") {
       8        DetailScreen()
       9    }
      10}

      Este ejemplo define dos destinos: “home” y “detail”, cada uno mostrando un Composable diferente.

4.2.1. Rutas con parámetros

Las rutas con parámetros permiten navegar a destinos dinámicos, pasando valores como parte de la ruta. Esto es útil para mostrar información específica, como un producto, usuario o noticia.

  • Rutas

    • Cada pantalla (destino) debe tener una ruta asociada, que será un identificador único (string).

    • Se pueden utilizar rutas simples como “home” o con parámetros como “details/{id}”.

      Sintaxis de ruta básica:

      1composable("aboutit"){ AboutIt() }

      Sintaxis de ruta con parámetro:

      1composable("detail/{id}")

      Este ejemplo muestra el parámetro {id}, que es dinámico y podrá recuperarse dentro del Composable.

      Navegar pasando el parámetro ID:

      1// Desde una pantalla anterior
      2navController.navigate("detail/123")

      Pasar el parámetro al destino:

      1composable("detail/{id}") { backStackEntry ->
      2    val idProducto = backStackEntry.arguments?.getString("id")
      3    idProducto?.let {
      4        DetailScreen(id = it.toInt())
      5    }
      6}

      !> Importante: Deberás asegúrate de convertir el valor si fuese necesario a otro tipo, como Int.

  • NavController

    • Es el encargado de gestionar la navegación entre destinos.

    • Se obtiene con rememberNavController() y se pasa al NavHost.

    • Se utiliza para navegar entre pantallas, por ejemplo: navController.navigate("details").

      Navegación desde un botón:

      1Button(onClick = { navController.navigate("aboutit") }) {
      2    Text("Acerca de...")
      3}

Resumen rápido

Concepto Descripción
NavController  Controla la navegación entre destinos.
NavHost  Contenedor que define las pantallas y sus rutas.
composable()  Define una pantalla dentro del NavHost.
Rutas Identificadores únicos que representan cada pantalla.

4.2.2. Paso de datos entre pantallas

El paso de datos entre pantallas es algo muy común en las aplicaciones. En Jetpack Compose, esto se puede hacer de varias formas, pero la más común es mediante parámetros en la ruta o usando argumentos explícitos.

Ejemplo completo: paso de datos entre pantallas

Pantalla principal (lista de productos):

 1@Composable
 2fun HomeScreen(navController: NavHostController) {
 3    val productos = listOf("Producto 1", "Producto 2", "Producto 3", "Producto 4", "Producto 5")
 4
 5    Column(
 6        modifier = Modifier.fillMaxSize().padding(16.dp),
 7        verticalArrangement = Arrangement.spacedBy(8.dp)
 8    ) {
 9        Button(onClick = { navController.navigate("aboutit") }) {
10            Text("Acerca de...")
11        }
12        LazyColumn(
13            contentPadding = PaddingValues(8.dp),
14            verticalArrangement = Arrangement.spacedBy(8.dp)
15        ) {
16            items(productos) { producto ->
17                Card(
18                    modifier = Modifier.fillMaxWidth()
19                        .clickable {
20                            navController.navigate("detail/${productos.indexOf(producto) + 1}")
21                        }.padding(4.dp)
22                ) {
23                    Text(
24                        text = producto,
25                        modifier = Modifier.padding(16.dp)
26                    )
27                }
28            }
29        }
30    }
31}

Pantalla detalle (recibe el ID):

1@Composable
2fun DetailScreen(navBackStackEntry: NavBackStackEntry) {
3    val idProducto = navBackStackEntry.arguments?.getString("id")
4
5    Text(
6        text = "Mostrando detalles del producto con ID: $idProducto",
7        modifier = Modifier.padding(16.dp)
8    )
9}

Pantalla “Acerca de”:

1@Composable
2fun AboutIt() {
3    Text(
4        text = "App creada por Javier Carrasco para la documentación de T4.2",
5        modifier = Modifier.padding(16.dp)
6    )
7}

El NavHost en el método Composable Navigation() quedaría así:

 1@Composable
 2fun Navigation() {
 3    // Aquí se definirían las rutas de navegación.
 4    // Por ejemplo, usando NavHost y composable.
 5    val navController: NavHostController = rememberNavController()
 6
 7    NavHost(navController = navController, startDestination = "home") {
 8        composable("home") { HomeScreen(navController) }
 9        composable("aboutit"){ AboutIt() }
10        composable("detail/{id}") { backStackEntry ->
11            // Aquí se recibe el parámetro id de la ruta.
12            val idProducto = backStackEntry.arguments?.getString("id")
13            // Se puede usar el idProducto para mostrar detalles específicos.
14            idProducto?.let {
15                DetailScreen(backStackEntry)
16            }
17        }
18    }
19}
Información

Se utiliza idProducto?.let para comprobar que se pasa el parámetro y no sea nulo.

Por último, la clase MainActivity podría quedar así:

 1class MainActivity : ComponentActivity() {
 2    @OptIn(ExperimentalMaterial3Api::class)
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        enableEdgeToEdge()
 6        setContent {
 7            DocumentationT4_2Theme {
 8                Scaffold(
 9                    topBar = {TopAppBar(title = { Text("Documentación T4.2") })},
10                    modifier = Modifier.fillMaxSize()
11                ) { innerPadding ->
12                    Column(Modifier.padding(innerPadding).fillMaxSize()) {
13                        Navigation()
14                    }
15                }
16            }
17        }
18    }
19}

Si buscas más separación entre destinos cuando la complejidad de estos aumenta, puedes llevarte los métodos HomeScreen() y DetailsScreen() a ficheros separados.

Resumen rápido

Concepto Descripción
Destino (composable)  Es una pantalla que se muestra al navegar, definida con composable("ruta").
Ruta con parámetro Se define como "ruta/{param}" y se recupera con backStackEntry.arguments.
Paso de datos Se realiza a través de parámetros en la ruta o mediante argumentos extras.
Integración Se puede usar en MVVM para cargar datos desde ViewModel, ROOM o Retrofit2 (se verá en próximos temas).

4.3. Navegación controlada con NavController

Como ya se ha comentado, la navegación en Jetpack Compose entre pantallas se gestiona mediante la librería Navigation Compose, la cual permite crear una jerarquía de pantallas y navegar entre ellas de forma sencilla.

Para controlar la navegación, se utiliza el objeto NavController, que da acceso a métodos como navigate(), popBackStack() o navigateUp(). Estos métodos permiten gestionar la pila de navegación y el comportamiento del botón de retroceso del dispositivo.

4.3.1. navigate(), popBackStack(), navigateUp()

  • navigate()

    El método navigate() se utiliza para ir de una pantalla a otra. Primero se deberá definir las rutas de navegación, y luego usar navigate() pasando la ruta destino. Ya has tenido contacto con este método en puntos anteriores.

    Ejemplo:

    1@Composable
    2fun MyNavigation() {
    3    val navController: NavHostController = rememberNavController()
    4
    5    NavHost(navController, startDestination = "pantalla1") {
    6        composable("pantalla1") { Pantalla1(navController) }
    7    }
    8}

    Código de Pantalla1:

     1@Composable
     2fun Pantalla1(navController: NavController) {
     3    Column(
     4        modifier = Modifier.fillMaxSize(),
     5        horizontalAlignment = Alignment.CenterHorizontally,
     6        verticalArrangement = Arrangement.Center
     7    ) {
     8        Text("Pantalla 1")
     9        Button(onClick = { navController.navigate("pantalla2") }) {
    10            Text("Ir a pantalla 2")
    11        }
    12    }
    13}
  • popBackStack()

    Este método eliminará la última pantalla del stack (pila) de navegación y vuelve a la pantalla anterior.

    Código de Pantalla2:

     1@Composable
     2fun Pantalla2(navController: NavController) {
     3    Column(
     4        modifier = Modifier.fillMaxSize(),
     5        horizontalAlignment = Alignment.CenterHorizontally,
     6        verticalArrangement = Arrangement.Center
     7    ) {
     8        Text("Pantalla 2")
     9        Button(onClick = { navController.popBackStack() }) {
    10            Text("Volver a la pantalla 1")
    11        }
    12    }
    13}

    Recuerda añadir el composable para Pantalla2 en el NavHost.

  • navigateUp()

    Este método funciona de forma similar a popBackStack(), pero se usa generalmente cuando hay una jerarquía anidada de pantallas, como en pantallas de detalles o en navegación por pestañas.

    Ejemplo:

    1Button(onClick = { navController.navigateUp() }) {
    2    Text("Navegar hacia arriba")
    3}
Información

navigateUp() puede no funcionar si no estás en una ruta con padre definido. Para la mayoría de casos, popBackStack() es suficiente.

4.3.2. Manejo del stack y el botón “atrás”

El stack de navegación es como una pila donde se van guardando las pantallas por las que se pasan. Cada vez que se utiliza navigate(), la nueva pantalla se apila encima. Al pulsar el botón “atrás”, se desapila la última pantalla.

Ejemplo del stack:

  • Inicialmente: pantalla1.
  • Navegas a pantalla2: el stack es [pantalla1, pantalla2].
  • Pulsas atrás: se elimina pantalla2 y vuelves a pantalla1.

Personalizar el comportamiento del botón “atrás”:

Por defecto, Android gestiona el botón de retroceso con el stack de navegación. Pero si quieres hacer algo especial al pulsarlo (como mostrar un diálogo antes de salir), se puede usar BackHandler para modificar el comportamiento básico.

Ejemplo:

 1@Composable
 2fun Pantalla2(navController: NavController) {
 3    val ctxt = LocalContext.current
 4
 5    // Manejo del botón de retroceso
 6    BackHandler {
 7        // Aquí se define lo que ocurre al pulsar atrás
 8        navController.popBackStack()
 9
10        Toast.makeText(ctxt, "Volviendo a pantalla 1", Toast.LENGTH_SHORT).show()
11        Log.d("Pantalla2", "Back button pressed")
12    }
13
14    /* ... */
15}
Información

BackHandler permite interceptar el evento del botón “atrás” y definir un comportamiento personalizado.

Resumen de los métodos

Método  Función
navigate(route) Va a otra pantalla.
popBackStack() Vuelve a la pantalla anterior.
navigateUp() Vuelve a la pantalla padre (si existe jerarquía).
BackHandler {} Controla el botón de retroceso del dispositivo.

Con NavController y sus métodos, tendrás el control total sobre la navegación de la app. Esto es fundamental cuando trabajas con arquitecturas como MVVM o Clean Architecture, ya que la navegación puede estar controlada desde el ViewModel.


Ejemplos prácticos

Ejemplo práctico 12 Aplicación con tres pantallas

4.4. Navegación con ViewModel y estado

En aplicaciones Android modernas que usan arquitecturas como MVVM (Modelo-Vista-ViewModel), es fundamental gestionar correctamente el estado de la navegación y compartirlo entre pantallas cuando sea necesario.

En Jetpack Compose se puede usar ViewModel para mantener el estado de la aplicación, incluso para cambiar de pantalla. Esto permite evitar que se pierdan datos al navegar o al rotar la pantalla.

4.4.1. Compartir estado entre pantallas

Imagina que tienes una pantalla de formulario (FormScreen) y otra de resumen (SummaryScreen). Quieres que los datos introducidos en el formulario estén disponibles en la pantalla de resumen.

Usar un ViewModel compartido

Un ViewModel puede ser compartido entre pantallas si se crea en un ámbito (scope) común, como el de la navegación completa.

Además de la librería de Navigation Compose será necesaria la librería para la gestión del ciclo de vida y el uso de ViewModel.

1implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")

Ejemplo: compartir datos entre pantallas

  1. Se define el ViewModel, en este caso en un fichero independiente.

    1class SharedViewModel : ViewModel() {
    2    var name = mutableStateOf("")
    3        private set
    4
    5    fun updateNombre(value: String) {
    6        name.value = value
    7    }
    8}
  2. Se definen las pantallas y rutas, también por separado.

    1sealed class Screen(val route: String) {
    2    object FormScreen : Screen("form")
    3    object SummaryScreen : Screen("summary")
    4}
  3. Configuración de la navegación.

    En el fichero MainActivity.kt, fuera de la clase, se crea el siguiente método para gestionar la navegación de la aplicación.

     1@Composable
     2fun MyAppNav() {
     3    val navController = rememberNavController()
     4    val sharedViewModel: SharedViewModel = viewModel()
     5
     6    NavHost(navController = navController, startDestination = Screen.FormScreen.route) {
     7        composable(Screen.FormScreen.route) {
     8            FormScreen(navController, sharedViewModel)
     9        }
    10        composable(Screen.SummaryScreen.route) {
    11            SummaryScreen(sharedViewModel)
    12        }
    13    }
    14}
    Información

    Aquí se crea el SharedViewModel fuera del NavHost o en un scope compartido, por lo que todas las pantallas tendrán acceso al mismo ViewModel.

  4. Pantalla formulario.

    Además de la gestión de la navegación, se ha añadido en el siguiente bloque la gestión del error del campo vacío.

     1@Composable
     2fun FormScreen(navController: NavController, viewModel: SharedViewModel) {
     3    var input by remember { mutableStateOf("") }
     4    var errorMessage by remember { mutableStateOf("") }
     5    var isError by remember { mutableStateOf(false) }
     6
     7    Column(modifier = Modifier.padding(16.dp)) {
     8        OutlinedTextField(
     9            value = input,
    10            onValueChange = { input = it; isError = false; errorMessage = "" },
    11            label = { Text("Introduce tu nombre") },
    12            modifier = Modifier.fillMaxWidth(),
    13            singleLine = true,
    14            trailingIcon = { // Icono de error opcional.
    15                if (isError)
    16                    Icon(Icons.Default.Info, contentDescription = "Error", tint = MaterialTheme.colorScheme.error)
    17            },
    18            supportingText = { // Texto de apoyo para mostrar mensajes de error.
    19                if (isError)
    20                    Text(errorMessage, color = MaterialTheme.colorScheme.error)
    21            },
    22            isError = isError // Control de error visual.
    23        )
    24        Button(onClick = {
    25            if (input.isNotEmpty()) {
    26                viewModel.updateNombre(input)
    27                navController.navigate(Screen.SummaryScreen.route)
    28            } else {
    29                // Aquí podrías mostrar un mensaje de error si el campo está vacío
    30                isError = true
    31                errorMessage = "El campo no puede estar vacío"
    32            }
    33        }) {
    34            Text("Siguiente")
    35        }
    36    }
    37}
  5. Pantalla resumen.

    1@Composable
    2fun SummaryScreen(viewModel: SharedViewModel) {
    3    Column(modifier = Modifier.padding(16.dp)) {
    4        Text("Tu nombre es: ${viewModel.name.value}")
    5    }
    6}

4.4.2. Scope adecuado del ViewModel por destino

En algunas ocasiones no se necesita que el ViewModel esté compartido entre todas las pantallas, sino que su alcance (scope) esté limitado a un destino específico de la navegación.

Jetpack Compose permite asociar un ViewModel a un destino específico usando el viewModel() dentro del composable.

¿Cuándo hacerlo? Cuando se necesita que el estado solo exista mientras se está en esa pantalla, y que se reinicie si se vuelve a ella más tarde.

Información

Nunca debe pasarse al ViewModel parámetros sin crear ViewModelProvider.Factory o añadir inyección de dependencias con Hilt.

Versión ViewModelProvider.Factory

  1. Se crea la clase DetalleViewModel que extenderá de ViewModel() y se añade el factory.

     1class DetalleViewModel(id: String) : ViewModel() {
     2    val itemId = id
     3    val contenido = mutableStateOf("Contenido del ítem $itemId")
     4}
     5
     6class DetalleViewModelFactory(private val id: String) : ViewModelProvider.Factory {
     7    override fun <T : ViewModel> create(modelClass: Class<T>): T {
     8        @Suppress("UNCHECKED_CAST")
     9        return DetalleViewModel(id) as T
    10    }
    11}
  2. Se crea la nueva ruta con composable, haciendo uso del factory creado en el NavHost.

    1composable("detalle/{id}") { backStackEntry ->
    2    val id = backStackEntry.arguments?.getString("id") ?: "default" // Valor por defecto si no se pasa un ID.
    3    val factory = DetalleViewModelFactory(id) // Crear una instancia del ViewModel con el ID recibido.
    4    val detalleViewModel: DetalleViewModel = viewModel(factory = factory) // Usar el factory para crear el ViewModel.
    5
    6    DetalleScreen(detalleViewModel)
    7}
  3. Se hace la llamada desde un botón (por ejemplo).

    1Button(
    2    onClick = {
    3        // Navegar a la pantalla de detalle con un ID ficticio
    4        navController.navigate("detalle/321")
    5    }
    6) { Text("Detalle item") }
  4. Pantalla detalle.

    1@Composable
    2fun DetalleScreen(viewModel: DetalleViewModel) {
    3    Column(modifier = Modifier.padding(16.dp)) {
    4        Text("ID: ${viewModel.itemId}")
    5        Text("Contenido: ${viewModel.contenido.value}")
    6    }
    7}

Resumen de scopes de ViewModel

Scope del ViewModel Uso
Compartido globalmente Para compartir datos entre pantallas (ej: formulario-resumen).
Por destino (composable) Para que el ViewModel solo viva mientras estás en esa pantalla.
Con clave única (por ejemplo por ID) Para tener un ViewModel diferente por cada ítem (ej: detalles de productos).

Ejemplos prácticos

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Subsecciones de Tema 4: Navegación en Jetpack Compose

Ejemplo práctico 12: Aplicación con tres pantallas

Objetivo

Se creará una aplicación con tres pantallas (Home, Detalle, Configuración) que permita navegar entre ellas utilizando botones. Además, desde Configuración, se podrá volver a Home eliminando del stack a Detalle.

Configuración del proyecto

En primer lugar, deberás añadir al build.gradle.kts (Module :app) la librería Navigation Compose. Recuerda sincronizar.

1implementation("androidx.navigation:navigation-compose:2.9.2")

Composable MyAppNav

Se crea el método encargado de controlar la navegación, básico, en este ejemplo no se utilizan rutas con parámetros.

 1@Composable
 2fun MyAppNav() {
 3    val navController: NavHostController = rememberNavController()
 4
 5    NavHost(navController, startDestination = "home") {
 6        composable("home") { Home(navController) }
 7        composable("detail") { Detail(navController) }
 8        composable("config") { Config(navController) }
 9    }
10}

Composable Home

Representa la pantalla inicial.

 1@Composable
 2fun Home(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Home Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = { navController.navigate("detail") }) { Text("Go to Detail") }
14    }
15}

Composable Detail

 1@Composable
 2fun Detail(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Detail Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = { navController.navigate("config") }) { Text("Go to Configuration") }
14    }
15}

Composable Config

 1@Composable
 2fun Config(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Configuration Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = {
14                navController.navigate("home") {
15                    popUpTo("home") // Elimina hasta "home"
16                    launchSingleTop = true // Evita duplicados
17                }
18            }) { Text("Go to Home") }
19    }
20}

La diferencia entre los Composables anteriores está en el navigate(), popUpTo() se encarga de limpiar la pila, puedes probarlo con el botón atrás cuando estés nuevamente en Home, la aplicación se cerrará en lugar de volver a la pantalla anterior.

MainActivity

La clase MainActivity podría quedar como se muestra a continuación.

 1class MainActivity : ComponentActivity() {
 2    @OptIn(ExperimentalMaterial3Api::class)
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        enableEdgeToEdge()
 6        setContent {
 7            ExampleT4_12Theme {
 8                Scaffold(
 9                    topBar = {
10                        TopAppBar(
11                            title = {
12                                Text(text = getString(R.string.app_name))
13                            },
14                            colors = topAppBarColors(
15                                containerColor = MaterialTheme.colorScheme.primaryContainer,
16                                titleContentColor = MaterialTheme.colorScheme.primary,
17                            )
18                        )
19                    },
20                    modifier = Modifier.fillMaxSize()
21                ) { innerPadding ->
22                    Column(
23                        modifier = Modifier.fillMaxSize().padding(innerPadding)
24                    ) {
25                        MyAppNav()
26                    }
27                }
28            }
29        }
30    }
31}

Código completo


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 13: Scope adecuado del ViewModel por destino con HILT

Objetivo

Se reproducirá la aplicación de ejemplo planteada en la documentación (punto 4.4.2.) utilizando inyección de dependencias con HILT.

Configuración del proyecto

La configuración de un proyecto Android Studio que haga uso de HILT es algo más compleja que añadir una simple librería, pero no es un inconveniente teniendo en cuenta la ayuda que proporciona. En primer lugar deberás añadir los plugins de KSP y HILT al build.gradle.kts (Project: ...) y sincronizar Gradle.

1plugins {
2    ...
3    id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false // KSP for annotation processing, used by libraries like Hilt.
4    id("com.google.dagger.hilt.android") version "2.57" apply false // Hilt for dependency injection.
5}

¿Qué es KSP? Es una herramienta que permite procesar anotaciones (@) de forma más eficiente que la anterior (KAPT) ya que está optimizada para Kotlin.

¿Cómo saber que versión utilizar? Para saber que versión debes utilizar, tendrás que consultar la URL, en la que deberás buscar tu versión de Kotlin según el archivo del proyecto libs.versions.toml en la propiedad kotlin. Por ejemplo, en este caso la versión es kotlin = "2.0.21", que coincide con el primer valor del plugin.

!> Cuidado con actualizar la versión de Kotlin, también deberás actualizar la versión del plugin KSP.

Ahora, en el build.gradle.kts (Module :app), en la sección de plugins añade los siguientes plugins. No sincronices todavía, no pasa nada, pero te dirá que falta la dependencia de HILT.

1plugins {
2    ...
3    id("com.google.devtools.ksp")
4    id("com.google.dagger.hilt.android")
5}

Siguiendo con este fichero, deberás añadir las siguientes librerías y, ahora sí, sincronizar.

1// Navigation Compose
2implementation("androidx.navigation:navigation-compose:2.9.2")
3
4// Hilt
5implementation("com.google.dagger:hilt-android:2.57")
6ksp("com.google.dagger:hilt-android-compiler:2.57") // For annotation processing.
7
8// Hilt integration with ViewModel for Compose
9implementation("androidx.hilt:hilt-navigation-compose:1.2.0")

Inicializa Hilt en la aplicación

Para inicializar HILT deberás crear una nueva clase que extienda de Application con la anotación @HiltAndroidApp.

1// App.kt
2
3@HiltAndroidApp
4class App: Application()

La anotación @HiltAndroidApp crea un contenedor de dependencias global asociado al ciclo de vida de la aplicación, permitiendo que cualquier Activity, Fragment, etc, pueda recibir dependencias de Hilt. Dicho de otro modo, convierte la aplicación en el punto central en el que HILT configura e inyecta las dependencias necesarias para el proyecto.

Ahora, para que sea la primera clase en crearse al lanzar la aplicación, deberás registrala en el AndroidManifest.xml.

1<application
2    android:name=".App"
3    android:allowBackup="true"
4    android:dataExtractionRules="@xml/data_extraction_rules"
5    ...>

Crea la siguiente sealed class para los destinos

1sealed class Screen(val route: String) {
2    object HomeScreen : Screen("home")
3    object DetailScreen : Screen("detail")
4}

Crea el ViewModel para el destino

Se crea el ViewModel adaptado al scope del destino, en este caso, el detalle.

1@HiltViewModel
2class DetalleViewModel @Inject constructor(
3    private val savedStateHandle: SavedStateHandle
4) : ViewModel() {
5
6    val id = savedStateHandle.get<String>("id") ?: "No ID"
7    val contenido = mutableStateOf("Contenido del ítem $id")
8}

Composable MyAppNav

Se crea el método encargado de controlar la navegación, en este caso, en la ruta detalle se utiliza la inyección con HILT.

 1@Composable
 2fun MyAppNav() {
 3    val navController = rememberNavController()
 4
 5    NavHost(navController = navController, startDestination = Screen.HomeScreen.route) {
 6        composable(Screen.HomeScreen.route) {
 7            HomeScreen(navController)
 8        }
 9        composable("detalle/{id}") { backStackEntry ->
10            val detalleViewModel: DetalleViewModel = hiltViewModel()
11            DetalleScreen(detalleViewModel)
12        }
13    }
14}

Composable Home

Representa la pantalla inicial.

 1@Composable
 2fun HomeScreen(navController: NavController) {
 3    Column(modifier = Modifier.padding(16.dp)) {
 4        Text("Home Screen")
 5        Button(
 6            onClick = {
 7                // Navegar a la pantalla de detalle con un ID ficticio
 8                navController.navigate("detalle/321")
 9            }
10        ) { Text("Detalle item") }
11    }
12}

Composable para el detalle

1@Composable
2fun DetalleScreen(viewModel: DetalleViewModel) {
3    Column(modifier = Modifier.padding(16.dp)) {
4        Text("ID: ${viewModel.id}")
5        Text("Contenido: ${viewModel.contenido.value}")
6    }
7}

MainActivity

La clase MainActivity podría quedar como se muestra a continuación. Observa la anotación @AndroidEntryPoint, esta le indica a Hilt que una clase de Android (como Activity, Fragment, View, etc.) será un punto de entrada para la inyección de dependencias. Básicamente habilita la inyección automática de dependencias en una clase de Android, gestionando su ciclo de vida y las instancias necesarias.

 1@AndroidEntryPoint
 2class MainActivity : ComponentActivity() {
 3    @OptIn(ExperimentalMaterial3Api::class)
 4    override fun onCreate(savedInstanceState: Bundle?) {
 5        super.onCreate(savedInstanceState)
 6        enableEdgeToEdge()
 7        setContent {
 8            ExampleT4_13Theme {
 9                Scaffold(
10                    topBar = {
11                        TopAppBar(
12                            title = { Text(getString(R.string.app_name)) },
13                            colors = topAppBarColors(
14                                containerColor = MaterialTheme.colorScheme.primaryContainer,
15                                titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
16                            )
17                        )
18                    },
19                    modifier = Modifier.fillMaxSize()
20                ) { innerPadding ->
21                    Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
22                        MyAppNav()
23                    }
24                }
25            }
26        }
27    }
28}

Código completo


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 5: Arquitectura MVVM y Clean Architecture

Objetivos de este tema

  • Entender la separación de responsabilidades en aplicaciones Android.
  • Implementar el patrón MVVM en proyectos Android con Jetpack Compose.
  • Integrar Clean Architecture con componentes como Room, Retrofit2 y ViewModel.
  • Aprender a usar corutinas de Kotlin para realizar operaciones asíncronas.
  • Aplicar inyección de dependencias con Koin/Hilt.
  • Realizar pruebas unitarias básicas en capas lógicas.

5.1. ¿Qué es MVVM?

MVVM (Model-View-ViewModel) es un patrón de arquitectura que separa la lógica de la interfaz de usuario (UI).

Componentes principales:

  • Modelo (Model): representará la capa de datos o lógica de negocio. Únicamente contendrá la información, no habrán métodos o acciones que manipulen los datos y, no tendrá ninguna dependencia de la vista.
  • Vista (View): será la parte encargada de representar la información al usuario. En el patrón MVVM, las vistas son activas, reaccionando a eventos o cambios de los datos (Jetpack Compose en este caso).
  • Modelo de vista (ViewModel): es el intermediario entre el modelo y la vista, mantiene el estado de la UI y contiene la lógica de negocio y abstracción de la interfaz. El enlace con la vista se realizará mediante el enlace de datos.

Ejemplo básico con Jetpack Compose:

Como se hace uso de viewModel() será necesaria añadir la siguiente dependencia:

1// ViewModel dependencies for Compose
2implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")

Contador básico:

 1class CounterViewModel : ViewModel() {
 2    // Técnica de backing con StateFlow para manejar el estado del contador.
 3    private val _count = MutableStateFlow(0) // MutableStateFlow para el estado del contador.
 4    val count: StateFlow<Int> = _count // Exponer el estado como StateFlow para que pueda ser observado por la UI.
 5
 6    fun increment() {
 7        _count.value++
 8    }
 9}
10
11@Composable
12fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
13    val count by viewModel.count.collectAsState()
14    Column {
15        Text("Contador: $count")
16        Button(onClick = viewModel::increment) {
17            Text("Incrementar")
18        }
19    }
20}

5.2. Separación de responsabilidades

  • View (Compose): Dibuja la UI, no contiene lógica.
  • ViewModel: Gestiona el estado y eventos de la UI.
  • Repository: Interactúa con fuentes de datos (API, BD).
  • Model/Domain: Reglas de negocio independientes del contexto.

5.3. ViewModel, Flow y LiveData

ViewModel

Se encarga de almacenar el estado de la UI y sobrevive a cambios de configuración (rotación de pantalla por ejemplo).

LiveData vs. Flow

  • LiveData: Observa los cambios en la UI (solo emite en contexto de Android).

     1class LiveDataViewModel : ViewModel() {
     2    private val _text = MutableLiveData("nombre")
     3    val text: LiveData<String> = _text
     4
     5    fun updateText() {
     6        _text.value = "Javier" // Actualizar el valor de LiveData.
     7    }
     8}
     9
    10@Composable
    11fun Greeting(viewModel: LiveDataViewModel = viewModel()) {
    12    // Usar observeAsState para observar cambios en LiveData.
    13    val currentText by viewModel.text.observeAsState()
    14
    15    Column {
    16        Text(text = "Hola $currentText!")
    17        Button(onClick = { viewModel.updateText() }) {
    18            Text("Actualizar nombre")
    19        }
    20    }
    21}

    Para utilizar observeAsState() deberás añadir la dependencia androidx.compose.runtime:runtime-livedata:1.9.0 que permite observar LiveData.

  • Flow: Colección asíncrona reactiva (ideal para Jetpack Compose).

     1class FlowViewModel : ViewModel() {
     2    private val _uiState = MutableStateFlow(0)
     3    val uiState: StateFlow<Int> = _uiState
     4
     5    // Ejemplo con corutina
     6    fun fetchData() {
     7        viewModelScope.launch {
     8            delay(1000)
     9            _uiState.emit(_uiState.value + 10)
    10        }
    11    }
    12}
    13
    14@Composable
    15fun FlowScreen(viewModel: FlowViewModel = viewModel()) {
    16    // Usar collectAsState para observar cambios en StateFlow.
    17    val state by viewModel.uiState.collectAsState()
    18
    19    Column {
    20        Text(text = "Estado actual: $state")
    21        Button(onClick = { viewModel.fetchData() }) {
    22            Text("Obtener datos")
    23        }
    24    }
    25}

5.4. Introducción a Clean Architecture

Si además de la aplicación del patrón MVVM, se aplican conceptos de Clean Architecture se conseguirá mayor independencia entre módulos y proyectos más compactos. El uso de Clean Architecture se basa en la estructuración del código por capas, donde cada una de estas capas se comunicará con sus capas más cercanas. Además, cada una de estas capas tendrá un único objetivo, separando responsabilidades. Esta combinación permitirá soportar el crecimiento de la aplicación de manera más fiable.

Las capas comunes de Clean Architecture son:

  1. Presentación, esta es la capa que interactúa directamente con el usuario.
  2. Casos de uso, capa que suele contener las acciones que el usuario puede activar.
  3. Dominio, contiene la lógica de negocio, suele contener los modelos, por ejemplo las clases SuperHero o Editorial.
  4. Datos, esta capa contiene las definiciones de la fuente de datos y cómo se utilizará. Puede no limitarse a una única fuente de datos. Se suele utilizar el patrón repositorio para decidir que fuente de datos (DataSource) utilizar.
  5. Framework, esta capa define las distintas fuentes de datos, por ejemplo, Room en modo local o una API de forma remota.

El diagrama clásico que representa la Clean Architecture creado por Robert C. Martin es posible que ya lo hayas visto.

Imagen punto 5.4 Imagen punto 5.4

5.4.1. Capas: Presentation, Domain, Data

Evidentemente, puede hacerse una libre interpretación de la arquitectura, eliminando capas, unificando, etc. Esto es así porque no es realmente una arquitectura como tal, sino una guía con recomendaciones a seguir. En Android es muy común unificar las capas, lo que además permitirá simplificar el modelo.

  • Capa presentación, donde se aplicará MVVM.
  • Capa dominio, contendrá el modelo de negocio y los casos de uso.
  • Capa datos, se utilizará el modelo repositorio y el acceso a datos.

La comunicación entre todos los componentes de las capas será la siguiente.

Imagen punto 5.4 Imagen punto 5.4

5.5. Uso de corutinas

Ya se ha hecho un uso básico de ellas en puntos anteriores, a grandes rasgos, manejan tareas asíncronas sin bloquear el hilo principal, engargado de gestionar la UI.

1viewModelScope.launch {
2    val data = withContext(Dispatchers.IO) {
3        apiService.fetchData()
4    }
5    _uiState.emit(data)
6}

Más adelante se hará un uso más detallado de ellas.

5.6. Pruebas unitarias básicas

En este punto se tratará de mostrar una prueba “básica” para evaluar el método fetchData() de la clase FlowViewModel(). En concreto se crearán pruebas utilizando JUnit, kotlinx-coroutines-test y Turbine (para probar Flow).

JUnit ya se encuentra añadida por defecto en el Gradle de los proyectos de Android Studio, por lo que habrá que añadir las dos que faltan al build.gradle.kts (Module: app).

1// Coroutines y para pruebas
2androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
3
4// Para utilizar InstantTaskExecutorRule
5androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
6
7// Para Turbine (test de Flows, test, awaitItem, cancelAndConsumeRemainingEvents)
8androidTestImplementation("app.cash.turbine:turbine:1.2.1")
Información

Turbine es clave para probar Flow de forma sencilla. Permite recolectar valores emitidos y hacer aserciones.

Se creará una clase en el directorio destinado a los androidTest.

 1import androidx.arch.core.executor.testing.InstantTaskExecutorRule
 2import app.cash.turbine.test
 3import kotlinx.coroutines.Dispatchers
 4import kotlinx.coroutines.ExperimentalCoroutinesApi
 5import kotlinx.coroutines.test.*
 6import org.junit.*
 7import org.junit.Assert.assertEquals
 8
 9// FlowViewModelTest.kt
10
11@OptIn(ExperimentalCoroutinesApi::class)
12class FlowViewModelTest {
13    // Regla para ejecutar tareas en el hilo inmediato (sincroniza LiveData)
14    @get:Rule
15    val instantTaskExecutorRule = InstantTaskExecutorRule()
16
17    // Disponemos de un TestDispatcher para controlar el tiempo
18    private lateinit var testDispatcher: TestDispatcher
19    private lateinit var viewModel: FlowViewModel
20
21    @Before
22    fun setUp() {
23        testDispatcher = UnconfinedTestDispatcher() // Permite controlar corutinas
24        Dispatchers.setMain(testDispatcher)
25        viewModel = FlowViewModel()
26    }
27
28    @After
29    fun tearDown() {
30        Dispatchers.resetMain()
31    }
32
33    @Test // Cuando se llama a fetchData, emite nuevo valor tras 1 segundo.
34    fun testCallfetchData() = runTest {
35        // GIVEN: Estado inicial es 0
36        assertEquals(0, viewModel.uiState.value)
37
38        // WHEN: Se llama a fetchData
39        viewModel.fetchData()
40
41        // THEN: Aún no se ha emitido nada (por el delay de 1 segundo)
42        assertEquals(0, viewModel.uiState.value)
43
44        // Avanzamos el tiempo virtual
45        advanceTimeBy(1100) // Simula algo más de 1 segundo
46
47        // Verificamos que el valor cambió a 10
48        assertEquals(10, viewModel.uiState.value)
49    }
50
51    @Test // uiState emite valores correctamente con Turbine.
52    fun testUiStateTurbine() = runTest {
53        // GIVEN: Recolectamos el Flow con Turbine
54        viewModel.uiState.test {
55            // THEN: Primer valor emitido debe ser 0
56            assertEquals(0, awaitItem())
57
58            // WHEN: Llamamos a fetchData
59            viewModel.fetchData()
60
61            // Y avanzamos el tiempo
62            advanceTimeBy(1000)
63
64            // THEN: Debe emitir 10
65            assertEquals(10, awaitItem())
66
67            // Finalizamos la recolección
68            cancelAndConsumeRemainingEvents()
69        }
70    }
71
72    @Test // fetchData puede llamarse múltiples veces y suma correctamente.
73    fun testCallfetchDataMultipleTurbine() = runTest {
74        viewModel.uiState.test {
75            assertEquals(0, awaitItem())
76
77            // Primera llamada
78            viewModel.fetchData()
79            advanceTimeBy(1000)
80            assertEquals(10, awaitItem())
81
82            // Segunda llamada
83            viewModel.fetchData()
84            advanceTimeBy(1000)
85            assertEquals(20, awaitItem())
86        }
87    }
88}

Resumen del test

  • runTest: Reemplaza a runBlocking. Permite controlar el tiempo con advanceTimeBy().
  • testDispatcher: Simula el lanzamiento de corutinas sin depender del tiempo real.
  • viewModel.uiState.test { ... }: Con Turbine, se puede recolectar los valores emitidos por el Flow.
  • awaitItem(): Espera a que se emita un valor (ideal para pruebas asíncronas).
  • advanceTimeBy(1000) : Simula que han pasado 1000 ms, haciendo que delay(1000) termine.

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 6: Persistencia con ROOM

Objetivos de este tema

  • Entender el propósito de ROOM como solución de persistencia local en Android.
  • Crear entidades , DAOs y una base de datos usando ROOM.
  • Integrar ROOM con el patrón MVVM y el componente ViewModel .
  • Realizar consultas básicas y avanzadas en ROOM.
  • Gestionar migraciones simples al evolucionar la base de datos.

6.1. Introducción a ROOM

ROOM es una biblioteca de persistencia oficial de Android (parte de Jetpack) que facilita el acceso a la base de datos SQLite. Proporciona una capa de abstracción sobre SQLite, permitiendo escribir consultas de forma más segura y con menos código que usando SQLiteDatabase directamente.

Ventajas de Room:

  • Tipado seguro: Detecta errores en tiempo de compilación.
  • Integración con LiveData y Coroutines: Perfecto para aplicar el patrón MVVM.
  • Sin boilerplate: No se necesita escribir manualmente ContentValues, Cursor, etc y otro código innecesario o redundante.
  • Migraciones controladas: Facilita el cambio de versiones de la base de datos.
Información

ROOM no se ejecuta en el hilo principal de la aplicación (UI). Siempre deberán utilizarse Coroutines, LiveData y/o Flow para evitar ANR (Application Not Responding).

6.2. Configuración de ROOM en el proyecto

En primer lugar, se añadirá el complemento KSP en el archivo build.gradle.kts (Project:), alineando la versión de KSP con la versión de Kotlin del proyecto. Puedes encontrar una lista de las actualizaciones en la página de GitHub de KSP.

1plugins {
2    ...
3    id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false
4}

A continuación, habilita KSP en el archivo build.gradle.kts (Module :app) a nivel del módulo:

1plugins {
2    ...
3    id("com.google.devtools.ksp")
4}

Para terminar, seguiendo con el archivo build.gradle.kts (Module :app), se añadirán las siguientes dependencias para poder hacer uso de ROOM.

 1// ROOM dependencies
 2implementation("androidx.room:room-runtime:2.7.2")
 3implementation("androidx.room:room-ktx:2.7.2") // Soporte para Coroutines y Kotlin Extensions.
 4ksp("androidx.room:room-compiler:2.7.2") // KSP para procesamiento de anotaciones.
 5
 6// ViewModel y LiveData
 7implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2")
 8implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
 9implementation("androidx.compose.runtime:runtime-livedata:1.9.0")
10
11// Navigation Compose
12implementation("androidx.navigation:navigation-compose:2.9.3")

Recuerda sincronicar el Gradle con cada paso.

6.3. Modelo

Para simplificar, se omite el dominio y directamente se creará un package model para alojar las data classes que representarán las tablas.

1import androidx.room.Entity
2import androidx.room.PrimaryKey
3
4// Editorial.kt
5@Entity(tableName = "editorial")
6data class Editorial(
7    @PrimaryKey(autoGenerate = true) val idEd: Int = 0,
8    val name: String
9)
  • tableName te permite cambiar el nombre de la tabla si no quieres que coincida con el nombre de la data class.
  • autoGenerate = true genera IDs automáticamente.
 1import androidx.room.Entity
 2import androidx.room.PrimaryKey
 3
 4// SuperHero.kt
 5@Entity(tableName = "superhero")
 6data class SuperHero(
 7    @PrimaryKey(autoGenerate = true)
 8    var idSuper: Int = 0,
 9    var superName: String,
10    var realName: String,
11    var favorite: Boolean = false,
12    var idEditorial: Int = 0
13)

La siguiente clase representa la relación entre SuperHero y Editorial con cardinalidad 1:1. Observa que las clases que representan una relación no llevan la etiqueta @Entity.

 1import androidx.room.Embedded
 2import androidx.room.Relation
 3
 4// SuperWithEditorial.kt
 5data class SuperWithEditorial(
 6    @Embedded val supers: SuperHero,
 7    @Relation(
 8        parentColumn = "idEditorial",
 9        entityColumn = "idEd"
10    )
11    val editorial: Editorial
12)
  • @Embedded se utiliza para incluir los campos de la entidad SuperHero en el objeto SuperWithEditorial.
  • @Relation se utiliza para definir la relación entre SuperHero y Editorial, especificando las columnas que se utilizan para enlazarlas.

6.4. Creación del DAO

El DAO(Data Access Object) define las operaciones que pueden realizarse sobre la base de datos (CRUD). Siguiendo con la ordenación según la Clean Architecture se creará el package data, dentro se creará la interface para el DAO.

 1import androidx.lifecycle.LiveData
 2import androidx.room.Dao
 3import androidx.room.Delete
 4import androidx.room.Insert
 5import androidx.room.OnConflictStrategy
 6import androidx.room.Query
 7import androidx.room.Transaction
 8import kotlinx.coroutines.flow.Flow
 9
10// SupersDAO.kt
11@Dao
12interface SupersDAO {
13    // Versión de consultas que devuelven un FLOW.
14    @Transaction // Permite obtener datos de varias tablas relacionadas con una sola consulta.
15    @Query("SELECT * FROM SuperHero ORDER BY superName")
16    fun getSuperHerosWithEditorials(): Flow<List<SuperWithEditorial>>
17
18    @Query("SELECT * FROM Editorial")
19    fun getAllEditorials(): Flow<List<Editorial>>
20
21    // Versión de consultas que devuelven un LIVEDATA (...LD).
22    @Transaction
23    @Query("SELECT * FROM SuperHero ORDER BY superName")
24    fun getSuperHerosWithEditorialsLD(): LiveData<List<SuperWithEditorial>>
25
26    @Query("SELECT * FROM Editorial")
27    fun getAllEditorialsLD(): LiveData<List<Editorial>>
28
29    // Resto de consultas.
30
31    @Query("SELECT * FROM SuperHero WHERE idSuper = :idSuper")
32    suspend fun getSuperById(idSuper: Int): SuperHero?
33
34    @Query("SELECT * FROM Editorial WHERE idEd = :editorialId")
35    suspend fun getEditorialById(editorialId: Int): Editorial?
36
37    @Insert(onConflict = OnConflictStrategy.REPLACE)
38    suspend fun insertEditorial(editorial: Editorial)
39
40    @Insert(onConflict = OnConflictStrategy.REPLACE)
41    suspend fun insertSuperHero(superHero: SuperHero)
42
43    @Delete
44    suspend fun deleteSuperHero(superHero: SuperHero): Int
45}
  • @Transaction permite obtener datos de varias tablas relacionadas con una sola consulta.
  • @Query permite realizar consultas SQL directamente sobre la base de datos.
  • @Insert permite insertar un nuevo registro en la base de datos. Devuelve como Long el ID del registro insertado, si se ha insertado correctamente.
    • El uso de onConflictStrategy.REPLACE permite reemplazar un registro existente si hay un conflicto de clave primaria. Si se intenta insertar un SuperHero o Editorial con un id que ya existe, se actualizará el registro existente, puede utilizarse para ahorrarse un método para actualizar (@Update).
  • @Delete permite eliminar un registro de la base de datos, devolvilendo como Int el número de filas afectadas.

Observa que se han duplicado dos métodos, esto se hace a modo didáctico para ilustrar el uso de Flow y LiveData.

6.5. Definición de la base de datos

Se creará a continuación una clase abstracta para definir la base de datos y conecta las entidades con los DAOs.

 1import android.content.Context
 2import androidx.room.Database
 3import androidx.room.Room
 4import androidx.room.RoomDatabase
 5
 6// AppDatabase.kt
 7@Database(
 8    entities = [SuperHero::class, Editorial::class],
 9    version = 1,
10    exportSchema = true // Importante para migraciones
11)
12abstract class AppDatabase : RoomDatabase() {
13    abstract fun supersDAO(): SupersDAO // Conexión con DAO de SuperHéroes.
14
15    companion object {
16        @Volatile
17        private var INSTANCE: AppDatabase? = null
18
19        fun getInstance(context: Context): AppDatabase {
20            return INSTANCE ?: synchronized(this) {
21                val instance = Room.databaseBuilder(
22                    context.applicationContext,
23                    AppDatabase::class.java,
24                    "SuperHeros.db"
25                ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
26                    .build()
27
28                INSTANCE = instance // Asigna la instancia a la variable volátil.
29                instance // Devuelve la instancia de la base de datos.
30            }
31        }
32    }
33}
  • @Volatile asegura visibilidad del hilo.
  • synchronized evita creación múltiple de la instancia de la base de datos.
  • exportSchema = true genera un JSON con el esquema (necesario para migraciones).
  • fallbackToDestructiveMigration(boolen) se utiliza durante la configuración de la base de datos permitiendo controlar cómo se manejarán las migraciones cuando no se ha definido una estrategia.
    • false desactiva la migración destructiva, si no hay una migración definida entre dos versiones del esquema, ROOM lanzará una excepción (IllegalStateException) en lugar de borrar y recrear la base de datos. Por defecto.
    • true facilita los cambios rápidos del esquema sin tener que escribir migraciones cada vez, borra y crea la base de datos. Solo en desarrollo.

6.6. Uso de ROOM con ViewModel

Para respetar el patrón MVVM, nunca se accederá a Room desde la UI. Se utilizará un ViewModel para iniciar la interacción Repository <-> Datasource <-> Framework. Al aplicar la capa intermedia (Repository - Datasource), inicialmente puede verse como una repetición o redundancia de código, pero tiene una lógica, y es separar la lógica de acceso al Framework del Repositoy, separando y facilitando así el acceso a distintas fuentes de datos.

Información

UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]

Comenzando por el Datasource se creará en el package data la siguiente clase:

 1import androidx.lifecycle.LiveData
 2import kotlinx.coroutines.flow.Flow
 3
 4// LocalDatasource.kt
 5class LocalDatasource(private val dao: SupersDAO) {
 6    // Version FLOW.
 7    val currentSupers: Flow<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorials()
 8    val currentEditorials: Flow<List<Editorial>> = dao.getAllEditorials()
 9
10    // Version LIVEDATA.
11    val currentSupersLD: LiveData<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorialsLD()
12    val currentEditorialsLD: LiveData<List<Editorial>> = dao.getAllEditorialsLD()
13
14    suspend fun deleteSuper(superHero: SuperHero): Int { // Returns the number of rows deleted.
15        return dao.deleteSuperHero(superHero)
16    }
17
18    suspend fun saveSuper(superHero: SuperHero) {
19        dao.insertSuperHero(superHero)
20    }
21
22    suspend fun getSuperById(superId: Int): SuperHero? = dao.getSuperById(superId)
23
24    suspend fun saveEditorial(editorial: Editorial) {
25        dao.insertEditorial(editorial)
26    }
27
28    suspend fun getEdById(editorialId: Int): Editorial? = dao.getEditorialById(editorialId)
29}

Se opta por el nombre LocalDatasource.kt porque es la clase que da acceso al almacenamiento local. Ahora se creará Repository.kt, que será en este caso muy similar, pero ya se verá su utilidad real.

 1import androidx.lifecycle.LiveData
 2import kotlinx.coroutines.flow.Flow
 3
 4// Reposity.kt
 5class Repository(private val localDatasource: LocalDatasource) {
 6    // Versión FLOW.
 7    val currentSupers: Flow<List<SuperWithEditorial>> = localDatasource.currentSupers
 8    val currentEditorials: Flow<List<Editorial>> = localDatasource.currentEditorials
 9
10    // Versión LIVEDATA.
11    val currentSupersLD: LiveData<List<SuperWithEditorial>> = localDatasource.currentSupersLD
12    val currentEditorialsLD: LiveData<List<Editorial>> = localDatasource.currentEditorialsLD
13
14    suspend fun deleteSuper(superHero: SuperHero): Int {
15        return localDatasource.deleteSuper(superHero)
16    }
17
18    suspend fun saveSuper(superHero: SuperHero) {
19        localDatasource.saveSuper(superHero)
20    }
21
22    suspend fun getSuperById(superId: Int): SuperHero? = localDatasource.getSuperById(superId)
23
24    suspend fun saveEditorial(editorial: Editorial) {
25        localDatasource.saveEditorial(editorial)
26    }
27
28    suspend fun getEdById(editorialId: Int): Editorial? = localDatasource.getEdById(editorialId)
29}

A continuación, se creará el ViewModel compartido entre pantallas dónde se establecerá la conexión a la base de datos y la interacción con el repositorio. La clase SupersViewModel.kt estará a la misma altural que la clase MainActivity.kt en la estructura de árbol del proyecto.

 1import android.app.Application
 2import androidx.lifecycle.AndroidViewModel
 3import androidx.lifecycle.LiveData
 4import androidx.lifecycle.viewModelScope
 5import kotlinx.coroutines.Deferred
 6import kotlinx.coroutines.async
 7import kotlinx.coroutines.flow.MutableStateFlow
 8import kotlinx.coroutines.flow.StateFlow
 9import kotlinx.coroutines.flow.catch
10import kotlinx.coroutines.launch
11
12// SupersViewModel.kt
13class SupersViewModel(application: Application) : AndroidViewModel(application) {
14    // Se inicializa el repositorio y el datasource.
15    private val repository: Repository
16    private val localDatasource: LocalDatasource
17
18    // Se exponen los StateFlow para que la UI observe los cambios.
19    private val _currentSupers = MutableStateFlow<List<SuperWithEditorial>>(emptyList())
20    val currentSupers: StateFlow<List<SuperWithEditorial>> = _currentSupers
21
22    private val _currentEditorials = MutableStateFlow<List<Editorial>>(emptyList())
23    val currentEditorials: StateFlow<List<Editorial>> = _currentEditorials
24
25    // Se exponen los LiveData según sea necesario.
26    val currentSupersLD: LiveData<List<SuperWithEditorial>>
27    val currentEditorialLD: LiveData<List<Editorial>>
28
29    init {
30        // Inicialización del repositorio y el datasource.
31        val database = AppDatabase.getInstance(application)
32        val dao = database.supersDAO()
33        localDatasource = LocalDatasource(dao)
34        repository = Repository(localDatasource)
35
36        // Carga inicial de superhéroes y editoriales, versión Flow.
37        loadSupers()
38        loadEditorials()
39
40        // Inicialización del LiveData para los superhéroes.
41        currentSupersLD = repository.currentSupersLD
42        currentEditorialLD = repository.currentEditorialsLD
43    }
44
45    // Se observan los StateFlow para que la UI pueda reaccionar a los cambios con Flow una vez
46    // que se hayan cargado los datos iniciales.
47    fun loadEditorials() {
48        viewModelScope.launch {
49            repository.currentEditorials
50                .catch { e -> e.printStackTrace() } // Manejo de errores.
51                .collect { editorials ->
52                    _currentEditorials.value = editorials // Actualiza el StateFlow con las editoriales.
53                }
54        }
55    }
56
57    fun loadSupers() {
58        viewModelScope.launch {
59            repository.currentSupers
60                .catch { e -> e.printStackTrace() } // Manejo de errores.
61                .collect { supers ->
62                    _currentSupers.value = supers // Actualiza el StateFlow con los superhéroes.
63                }
64        }
65    }
66
67    fun saveEditorial(editorial: Editorial) {
68        viewModelScope.launch {
69            repository.saveEditorial(editorial)
70        }
71    }
72
73    fun saveSuper(superHero: SuperHero) {
74        viewModelScope.launch {
75            repository.saveSuper(superHero)
76        }
77    }
78
79    suspend fun delSuper(superHero: SuperHero) : Int{
80        return deleteSuper(superHero).await()
81    }
82
83    // Esta función devuelve un Deferred para que se pueda esperar su resultado de forma asíncrona.
84    private fun deleteSuper(superHero: SuperHero): Deferred<Int> {
85        return viewModelScope.async {
86            repository.deleteSuper(superHero)
87        }
88    }
89
90    fun getSuperById(superId: Int): Deferred<SuperHero?> {
91        return viewModelScope.async { repository.getSuperById(superId) }
92    }
93
94    fun getEdById(editorialId: Int): Deferred<Editorial?> {
95        return viewModelScope.async { repository.getEdById(editorialId) }
96    }
97}

Este ViewModel muestra dos formas de recuperar la información de la BD, una mediante MutableStateFlow para la versión con Flows y otra utilizando LiveData, aquí por motivos didácticos se tienen las dos a la vez, no es lo habitual, siempre se elegirá una única forma de trabajar, se recomienda el uso de Flows para Jetpack Compose.

6.6.1. Comsumir los datos desde la UI

Versión para Flow

En la versión para Flows, puede obtenerse el flugo de datos de la siguiente manera:

 1@Composable
 2fun MainScreen(navController: NavController, viewModel: SupersViewModel) {
 3    val snackbarHostState = remember { SnackbarHostState() }
 4    val scope = rememberCoroutineScope()
 5
 6    // Se recolecta el StateFlow del ViewModel para observar el flujo de datos
 7    // de los superhéroes y las editoriales. Se puede usar collectAsState() o
 8    // collectAsStateWithLifecycle() para obtener el estado actual.
 9    val currentSupers by viewModel.currentSupers.collectAsStateWithLifecycle()
10    val currentEditorials by viewModel.currentEditorials.collectAsStateWithLifecycle()
11
12    ...
13}

Cuando se observa StateFlow o Flow se utilizan los método collectAsState() o collectAsStateWithLifecycle(), aquí tienes una comparativa entre ambos.

Característica collectAsState() collectAsStateWithLifecycle()
Ciclo de vida Siempre ecolecta, incluso estando en segundo plano Solo recolectará cuando el estado de vida sea STARTED (pantalla visible)
Consumo de recursos Puede consumir batería innecesariamente Más eficiente, pausa la recolección en segundo plano
Uso recomendado En apps simples o prototipos Recomendado para producción
Dependencia extra No necesita dependencias adicionales Necesita la librería androidx.lifecycle:lifecycle-viewmodel-compose

collectAsStateWithLifecycle() es la opción recomendada para aplicaciones reales y proyectos con ViewModel + Compose, permitiendo así una gestión eficiente del ciclo de vida.

Nota

Desde Compose BOM 2023.10.01 y Lifecycle 2.6.2+, Google añadió una serie de mejoras importantes, ahora, collectAsState() dentro de un @Composable respeta el ciclo de vida si se usa junto con ViewModel y StateFlow/MutableStateFlow.
Esto quiere decir que collectAsState() pausa la recolección cuando la pantalla no está en primer plano, igual que hacía collectAsStateWithLifecycle().

Una vez se obtienen los datos, ya se puede trabajar con ellos, por ejemplo, comprobar la existencia previa de editoriales para permitir añadir superhéroes.

 1LaunchedEffect(currentEditorials.isEmpty()) {
 2    delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
 3    if (currentEditorials.isEmpty()) {
 4
 5        snackbarHostState.showSnackbar(
 6            message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
 7            duration = SnackbarDuration.Short
 8        )
 9    }
10}

En este ejemplo se utiliza LaunchedEffect(key), esta es una función de Compose que permite lanzar una Coroutine cuando un componente se muestra (o vuelve a componerse bajo ciertas condiciones).

Se usa para ejecutar tareas asíncronas desde la UI, como:

  • Llamar a una función suspendida del ViewModel.
  • Mostrar un Snackbar.
  • Ejecutar una tarea o acción tras un evento (ej: después de guardar).

El parámetro key del método se utilizará de la siguiente manera:

  • Si el objeto pasado como key cambia, la acción se vuelve a ejecutar.
  • Si la clave es Unit, se ejecutará solo una vez al entrar en composición.

En el caso de currentSupers, que es una lista, se utilizará como tal:

1items(currentSupers) { oneSuper ->
2    ...
3}

Versión para LiveData

En el caso de utilizar LiveData los datos se observan.

1// Se recolecta el LiveData del ViewModel
2val currentSupersLD by viewModel.currentSupersLD.observeAsState()
3val currentEditorialsLD by viewModel.currentEditorialLD.observeAsState()

En este caso deberán realizar más comprobaciónes, controlando los posibles nulos.

 1if (currentEditorialsLD != null) {
 2    LaunchedEffect(currentEditorialsLD!!.isEmpty()) {
 3        delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
 4        if (currentEditorialsLD!!.isEmpty()) {
 5
 6            snackbarHostState.showSnackbar(
 7                message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
 8                duration = SnackbarDuration.Short
 9            )
10        }
11    }
12}

También para la lista de superhéroes.

1if (currentSupersLD != null) {
2    items(currentSupersLD!!, key = { it.supers.idSuper }) { oneSuper ->
3    ...
4    }
5}

Código completo

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Tema 7: Comunicación con APIs REST con Retrofit2

Objetivos de este tema

  • Entender qué es una API REST y cómo comunicarse desde app Android.
  • Configurar y usar Retrofit2 para realizar peticiones HTTPS desde una app Android.
  • Convertir respuestas JSON en objetos Kotlin usando Gson.
  • Gestionar errores en las llamadas a la API.
  • Integrar Retrofit2 con ViewModel, ROOM (para caché offline) y el patrón MVVM.
  • Usar corutinas para gestionar llamadas asíncronas sin bloquear la interfaz de usuario.

7.1. Introducción a APIs REST

Una API REST es un servicio que facilita una serie de mecanismos para obtener información de un cliente externo, generalmente una base de datos que nutra la aplicación. Las peticiones que pueden hacerse a una API REST son los siguientes:

  • GET, devuelven información, puede pasársele parámetros a través de la URL, pero es poco segura.
  • POST, similar a GET, pero más segura, los parámetros no se pasan en la URL.
  • PUT, se utilizará para crear registros en la base de datos.
  • DELETE, permite eliminar registros de la base de datos.

La información devuelva por una API REST estará por lo general en formato JSON. Como norma general, para poder modificar el contenido mediante una API REST, será necesario algún tipo de autenticación, aunque muchas son utilizadas como consulta (GET) y no requieren de este sistema de seguridad.

Ejemplo de una URL de API REST: https://jsonplaceholder.typicode.com/posts. Esta URL devuelve una lista de publicaciones en formato JSON.

7.2. Retrofit2

Retrofit2 es una librería de Square que convierte una API REST en una interfaz de Java o Kotlin, facilitando mucho las llamadas HTTP en Android de una manera relativamente sencilla. Esta biblioteca permite el consumo de APIs REST, además se combinará con corrutinas y con el uso de Flows.

7.2.1. Dependencias necesarias

 1// ViewModel
 2implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.3")
 3implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.3")
 4
 5// Retrofit2
 6implementation("com.squareup.retrofit2:retrofit:3.0.0")
 7
 8// Conversor para JSON (Gson)
 9implementation("com.squareup.retrofit2:converter-gson:3.0.0")
10
11// Corutinas (para llamadas asíncronas)
12implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")

Recuerda también añadir el permiso de uso de Internet al Manifest, importante para el consumo de API REST.

1<uses-permission android:name="android.permission.INTERNET" />

7.3. Modelo

Para obtener un listado de los posts mostrados por jsonplaceholder se creará el siguiente modelo. Se creará dentro del package model.

1// Post.kt
2data class Post(
3    val id: Int,
4    val userId: Int,
5    val title: String,
6    val body: String
7)

En este caso se trata de una data class sencilla, pero puedes encontrarte con JSONs más complejos. Existen en Android Studio un plugin que puede ayudarte para estas situaciones, JSON To Kotlin Class.

7.4. Configuración de Retrofit2

Para ordenar el código, la configuración de Retrofit2 se hará dentro del package data, concretamente se creará el fichero RetrofitClient.kt en el que se ubicará un object para tener una única instancia de Retrofit2 (patrón Singleton) y la interfaz para utilizar las anotaciones que definirán las peticiones a la API.

 1import retrofit2.Response
 2import retrofit2.Retrofit
 3import retrofit2.converter.gson.GsonConverterFactory
 4import retrofit2.http.GET
 5import retrofit2.http.Path
 6
 7// RetrofitClient.kt
 8object RetrofitClient {
 9    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
10
11    val apiService: ApiService by lazy {
12        Retrofit.Builder()
13            .baseUrl(BASE_URL)
14            .addConverterFactory(GsonConverterFactory.create())
15            .build()
16            .create(ApiService::class.java)
17    }
18}
19
20interface ApiService {
21    @GET("posts")
22    suspend fun getPosts(): Response<List<Post>>
23
24    @GET("posts/{id}")
25    suspend fun getPostById(@Path("id") id: Int): Response<Post>
26}
Información
  • suspend: permite usar corutinas (asíncrono).
  • Response<T>: incluye código de estado, mensaje y cuerpo. Ideal para manejar errores.
  • addConverterFactory(GsonConverterFactory.create()): convierte automáticamente el JSON a objetos Kotlin y viceversa. Retrofit no procesa JSON por sí solo, necesita un convertidor. El más usado es GsonConverter, importado al inicio del tema.

7.4.1. Manejo de errores y excepciones

Cuando se hacen llamadas a una API pueden producirse errores que hay que controlar:

  • No hay conexión a Internet.
  • Servidor caído (500).
  • Recurso no encontrado (404).
  • Respuesta vacía.

Se utilizará try-catch y se analizará el Response obtenido para gestionarlos.

7.5. Creación del flujo e integración con ViewModel

En aplicaciones profesionales, se recomienda guardar los datos de manera local con ROOM y utilizar la API solo si no hay datos en caché o si el usuario refresca.

Flujo recomendado (Clean Architecture + MVVM)

Información

UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]

Se creará en primer lugar la obtención de la información de la API, como ya se dispone del interface para la API, se pasará a crear el Datasource en el package data.

 1// RemoteDatasource.kt
 2class RemoteDatasource {
 3    // Servicio API utilizando Retrofit.
 4    private val apiService = RetrofitClient.apiService
 5
 6    // Funciones para obtener datos desde la API.
 7    suspend fun getPosts() = apiService.getPosts()
 8
 9    // Obtener un post por su ID.
10    suspend fun getPostById(id: Int) = apiService.getPostById(id)
11}

Observa como quedaría ahora el repositorio, controlando posibles errores y haciendo uso del Datasource remoto.

 1// Repository.kt
 2class Repository(private val remoteDatasource: RemoteDatasource) {
 3    // Manejo de errores básico con try-catch.
 4    // En caso de error, se devuelve una lista vacía.
 5    suspend fun getPosts(): List<Post>? {
 6        return try {
 7            val response = remoteDatasource.getPosts()
 8            if (response.isSuccessful) {
 9                val posts = response.body() ?: emptyList()
10                posts
11            } else {
12                Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
13                emptyList()
14            }
15        } catch (e: Exception) {
16            Log.e("Repository", e.message, e)
17            throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
18        }
19    }
20
21    // Obtener un post por su ID con manejo de errores.
22    // En caso de error, se devuelve null.
23    suspend fun getPostById(id: Int): Post? {
24        return try {
25            val response = remoteDatasource.getPostById(id)
26            if (response.isSuccessful) {
27                val post = response.body()
28                post
29            } else {
30                Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
31                null
32            }
33        } catch (e: Exception) {
34            Log.e("Repository", "Error fetching post by ID", e)
35            throw e
36        }
37    }
38}

El siguiente paso será crear el ViewModel que se encargará de facilitar la información a la UI.

 1import androidx.lifecycle.ViewModel
 2import androidx.lifecycle.viewModelScope
 3import kotlinx.coroutines.flow.MutableStateFlow
 4import kotlinx.coroutines.flow.StateFlow
 5import kotlinx.coroutines.launch
 6
 7class MainViewModel : ViewModel() {
 8    // Se inicializa el repositorio y el datasource.
 9    private val repository: Repository
10    private val remoteDatasource: RemoteDatasource
11
12    // Estado para la lista de posts, estado de carga y errores.
13    private val _posts = MutableStateFlow<List<Post>>(emptyList())
14    val posts: StateFlow<List<Post>> = _posts
15
16    // Estado de carga y errores.
17    private val _loading = MutableStateFlow(false)
18    val loading: StateFlow<Boolean> = _loading
19
20    // Estado de error.
21    private val _error = MutableStateFlow<String?>(null)
22    val error: StateFlow<String?> = _error
23
24    init {
25        remoteDatasource = RemoteDatasource()
26        repository = Repository(remoteDatasource)
27    }
28
29    fun fetchPosts() {
30        viewModelScope.launch {
31            _loading.value = true
32            _error.value = null
33
34            try {
35                val posts = repository.getPosts()
36                _posts.value = posts ?: emptyList()
37            } catch (e: Exception) {
38                _error.value = "ERROR: ${e.message}"
39            } finally {
40                _loading.value = false
41            }
42        }
43    }
44}
Información
  • Se utiliza StateFlow para exponer los datos a la UI.
  • viewModelScope.launch ejecuta la corutina en el contexto del ViewModel.

Para cargar los datos desde la UI, puede utilizarse un Composable como el siguiente:

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun PostScreen(viewModel: MainViewModel = viewModel()) {
 4    val posts: List<Post> by viewModel.posts.collectAsState()
 5    val loading by viewModel.loading.collectAsState()
 6    val error by viewModel.error.collectAsState()
 7
 8    Scaffold(
 9        topBar = { TopAppBar({ Text("Documentation T7.1") }) },
10        modifier = Modifier.fillMaxSize()
11    ) { paddingValues ->
12        Column(modifier = Modifier.padding(paddingValues)) {
13            if (loading) {
14                CircularProgressIndicator(
15                    modifier = Modifier
16                        .padding(16.dp)
17                        .align(Alignment.CenterHorizontally)
18                )
19            } else if (error != null) {
20                Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
21            } else {
22                LazyColumn {
23                    items(posts) { post ->
24                        Card(modifier = Modifier.padding(8.dp)) {
25                            Text("Título: ${post.title}", Modifier.padding(8.dp))
26                            Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
27                        }
28                    }
29                }
30            }
31
32            Button(
33                modifier = Modifier.fillMaxWidth().padding(8.dp),
34                onClick = { viewModel.fetchPosts() }) {
35                Text("Actualizar")
36            }
37        }
38    }
39}

Este Composable muestra en pantalla un botón que el usuario debe pulsar para cargar la información, si prefieres que se cargen automáticamente, basta con añadir un LaunchedEffect.

1LaunchedEffect(posts) {
2    if (posts.isEmpty() && !loading && error == null) {
3        viewModel.fetchPosts()
4    }
5}

7.5.1. PullToRefreshBox en Jetpack Compose

En una aplicación móvil es muy común deslizar hacia abajo (pull down) para que se actualice el contenido (por ejemplo, nuevos correos, noticias o publicaciones). En Android con vistas tradicionales (XML) se usaba SwipeRefreshLayout, pero en Jetpack Compose, desde 2024, se utiliza PullToRefreshBox, que es parte de Material 3 y ofrece una experiencia más fluida y moderna. En versiones anteriores, debía añadirse pullRefresh como un modificador de un contenedor Box.

Para aplicarlo, se modificará el Composable PostScreen creado en el punto anterior. Se reutilizará el estado loadingy se simplificará el código, ya no hará falta utilizar CircularProgressIndicator.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun PostScreen(viewModel: MainViewModel = viewModel()) {
 4    val posts: List<Post> by viewModel.posts.collectAsState()
 5    val loading by viewModel.loading.collectAsState() // Estado de carga.
 6    val error by viewModel.error.collectAsState()
 7
 8    // Estado del pull-to-refresh.
 9    val refreshState = rememberPullToRefreshState()
10
11    LaunchedEffect(posts) {
12        if (posts.isEmpty() && !loading && error == null) {
13            viewModel.fetchPosts()
14        }
15    }
16
17    Scaffold(
18        topBar = { TopAppBar({ Text("Documentation T7.1") }) },
19        modifier = Modifier.fillMaxSize()
20    ) { paddingValues ->
21        Column(modifier = Modifier.padding(paddingValues)) {
22            if (error != null) {
23                Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
24
25                Button(
26                    modifier = Modifier.fillMaxWidth().padding(8.dp),
27                    onClick = { viewModel.fetchPosts() }) {
28                    Text("Actualizar")
29                }
30            } else {
31                // Implementación de Pull to Refresh.
32                PullToRefreshBox(
33                    isRefreshing = loading, // Usa el estado de carga del ViewModel.
34                    state = refreshState, // Estado del pull-to-refresh.
35                    modifier = Modifier.fillMaxSize(),
36                    onRefresh = { viewModel.fetchPosts() } // Acción al refrescar.
37                ) {
38                    // Contenido que se puede refrescar
39                    LazyColumn {
40                        items(posts){ post ->
41                            Card(modifier = Modifier.padding(8.dp).fillMaxWidth()) {
42                                Text("Título: ${post.title}", Modifier.padding(8.dp))
43                                Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
44                            }
45                        }
46                    }
47                }
48            }
49        }
50    }
51}

En este caso se deja el botón para forzar la actualización en caso de producirse algún error.

7.6. Integración con ROOM

Para realizar la integración con ROOM, se seguirán los pasos vistos en el tema anterior para la configuración del proyecto (ver aquí).

7.6.1. Modelo

Se modificará la data class que representa el modelo para que pueda utilizarse con ROOM.

1// Post.kt
2@Entity(tableName = "posts")
3data class Post(
4    @PrimaryKey(autoGenerate = true) val id: Int,
5    val userId: Int,
6    val title: String,
7    val body: String
8)
Información
  • @Entity: indica que esta clase será una tabla en la base de datos.
  • @PrimaryKey: el campo id es la clave primaria (obligatorio en Room).

7.6.2. Configuración de la BD y DAO

También será necesario definir la base de datos y crear el DAO.

 1// AppDatabase.kt
 2@Database(
 3    entities = [Post::class],
 4    version = 1,
 5    exportSchema = true // Importante para migraciones
 6)
 7abstract class AppDatabase : RoomDatabase() {
 8    abstract fun postsDAO(): PostsDAO // Conexión con DAO de Posts.
 9
10    companion object {
11        @Volatile
12        private var INSTANCE: AppDatabase? = null
13
14        fun getInstance(context: Context): AppDatabase {
15            return INSTANCE ?: synchronized(this) {
16                val instance = Room.databaseBuilder(
17                    context.applicationContext,
18                    AppDatabase::class.java,
19                    "Posts.db"
20                ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
21                    .build()
22
23                INSTANCE = instance // Asigna la instancia a la variable volátil.
24                instance // Devuelve la instancia de la base de datos.
25            }
26        }
27    }
28}
 1// PostsDAO.kt
 2@Dao
 3interface PostsDAO {
 4    // Obtiene todos los posts como un Flow para observar cambios en tiempo real.
 5    @Query("SELECT * FROM posts")
 6    fun getPosts(): Flow<List<Post>>
 7
 8    // Inserta una lista de posts. Si ya existen, los reemplaza.
 9    @Insert(onConflict = OnConflictStrategy.REPLACE)
10    suspend fun insertAllPosts(posts: List<Post>)
11}
Información

Flow<List<Post>>: devuelve un flujo de datos que se actualizará automáticamente cuando los datos cambien en la base de datos (ideal para Jetpack Compose).

7.6.3. Datasource local

 1// LocalDatasource.kt
 2class LocalDatasource(private val dao: PostsDAO) {
 3
 4    // Obtiene todos los posts desde la base de datos local.
 5    fun getPosts(): Flow<List<Post>> = dao.getPosts()
 6
 7    // Inserta una lista de posts en la base de datos local.
 8    suspend fun insertAllPosts(posts: List<Post>) {
 9        dao.insertAllPosts(posts)
10    }
11}

Como se puede observar, la clase LocalDatasource.kt es bastante sencilla y separa la lógica del Datasource local del remoto.

7.6.4. Repository

Ahora el Repository deberá inyectar ambos Datasources.

1class Repository(
2    private val remoteDatasource: RemoteDatasource,
3    private val localDatasource: LocalDatasource
4) ...

Y el método getPosts() se modificará de la siguiente forma para almacenar los posts en la BD y devolver de esta cuando se produzca algún error de la API.

 1suspend fun getPosts(): List<Post>? {
 2    return try {
 3        val response = remoteDatasource.getPosts()
 4        if (response.isSuccessful) {
 5            val posts = response.body() ?: emptyList()
 6            // Almacenar los posts obtenidos en la base de datos local.
 7            localDatasource.insertAllPosts(posts)
 8            posts
 9        } else {
10            Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
11            localDatasource.getPosts().first() // Se obtienen los posts almacenados localmente.
12        }
13    } catch (e: Exception) {
14        Log.e("Repository", e.message, e)
15        val dbdata = localDatasource.getPosts().first()
16        if (dbdata.isNotEmpty())
17            dbdata
18        else throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
19    }
20}
Información
  • localDatasource.insertAllPosts(posts) guarda los datos obtenidos de la API en la BD, de esta manera, aunque el usuario esté sin internet, podrá ver los datos almacenados.
  • first() se utiliza para obtener el primer valor emitido por el flujo y luego cancelar la suscripción. Se utiliza cuando no se necesita observar cambios continuamente.

Observa que se controla en el bloque del catch si se debe lanzar la excepción o no, si hay datos en la BD no se lanzará.

7.6.5. ViewModel

Por último, habrá que modificar el ViewModel de la siguiente forma.

 1class MainViewModel(application: Application) : AndroidViewModel(application) {
 2    // Se inicializa el repositorio y el datasource.
 3    private val repository: Repository
 4    private val remoteDatasource: RemoteDatasource
 5    private val localDatasource: LocalDatasource
 6
 7    ...
 8
 9    init {
10        // Se inicializa la base de datos local y el DAO.
11        val database = AppDatabase.getInstance(application)
12        val dao = database.postsDAO()
13
14        remoteDatasource = RemoteDatasource()
15        localDatasource = LocalDatasource(dao)
16        repository = Repository(remoteDatasource, localDatasource)
17    }
18
19    ...
20}

Básicamente se añade el LocalDatasource y se establece la conexión a la BD en el contructor init. También se modifica la declaración de la clase.

Código completo

Fuentes


Autor/a: Javier Carrasco Última modificación: 28/08/2025

Tema 8: Multimedia en Android

Objetivos de este tema

  • Reproducir audio y video en una app Android usando MediaPlayer y ExoPlayer.
  • Capturar fotos y vídeos mediante la cámara del dispositivo.
  • Acceder a la galería de imágenes del dispositivo.
  • Usar del micrófono del disposivo.
  • Utilizar sensores básicos del dispositivo como el acelerómetro y el sensor de luz.
  • Integrar funcionalidades en una arquitectura moderna (MVVM + Clean Architecture) con Jetpack Compose.

8.1. Reproducción de audio y video

En Android hay diferentes maneras de reproducir contenido multimedia. Las más comunes son MediaPlayer (nativa) y ExoPlayer, esta última más moderna y recomendada para Compose.

8.1.1. MediaPlayer (API Nativa)

Esta clase está integrada en Android, por lo que no necesita dependencias externas, es sencilla de utilizar pero algo limitada y poco personalizable.

1val mediaPlayer = MediaPlayer.create(ctxt, R.raw.epic_cinematic)
2mediaPlayer.start() // Reproduce.
3mediaPlayer.pause() // Pausa la reproducción.
4mediaPlayer.stop() // Detiene.
5mediaPlayer.prepare() // Prepara el MediaPlayer para poder reproducirlo de nuevo.
6mediaPlayer.release() // // Libera recursos del MediaPlayer.

Código completo

Nota

MediaPlayer no está recomendado para reproducción en streaming o formatos complejos.

8.1.2. ExoPlayer

ExoPlayer es una biblioteca de código abierto desarrollada por Google, ahora ya forma parte de Jetpack Media3. Es más potente y flexible que MediaPlayer y se encuentra actualizada. Además de ser la recomendación actual.

Dependencias necesarias para incluir ExoPlayer en el proyecto.

1// ExoPlayer
2implementation("androidx.media3:media3-exoplayer:1.8.0")
3implementation("androidx.media3:media3-ui:1.8.0")
4implementation("androidx.media3:media3-common:1.8.0")

El siguiente Composable utiliza un AndroidView para incrustar el PlayerView de ExoPlayer.

 1import androidx.compose.foundation.layout.fillMaxWidth
 2import androidx.compose.runtime.Composable
 3import androidx.compose.runtime.remember
 4import androidx.compose.ui.Modifier
 5import androidx.compose.ui.platform.LocalContext
 6import androidx.compose.ui.viewinterop.AndroidView
 7import androidx.media3.common.MediaItem
 8import androidx.media3.exoplayer.ExoPlayer
 9import androidx.media3.ui.PlayerView
10
11// VideoPlayer.kt
12@Composable
13fun VideoPlayer(videoUrl: String) {
14    val ctxt = LocalContext.current
15
16    // Se crea la instancia de ExoPlayer.
17    val exoPlayer = remember {
18        ExoPlayer.Builder(ctxt).build().apply {
19            val mediaItem = MediaItem.fromUri(videoUrl)
20            setMediaItem(mediaItem)
21            prepare()
22            playWhenReady = true
23        }
24    }
25
26    // Integración de la vista nativa de Android (PlayerView) en Compose.
27    AndroidView(
28        factory = { ctx ->
29            PlayerView(ctx).apply {
30                player = exoPlayer
31                useController = true // muestra controles
32            }
33        },
34        modifier = Modifier.fillMaxWidth(),
35        onRelease = { playerView ->
36            playerView.player?.release()
37        }
38    )
39}

La llamada desde la pantalla que muestre el vídeo será simplemente pasándole una URL.

1@Composable
2fun VideoScreen() {
3    VideoPlayer(videoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
4}

Código completo

Puedes obtener más vídeos de muestra en este repositorio.

8.2. Captura de imágenes y vídeo

8.3. Galería de imágenes

8.4. Uso del micrófono

8.5. Sensores básicos

Fuentes


Autor/a: Javier Carrasco Última modificación: 28/08/2025

Versión anterior

Aquí puedes consultar la versión anterior de la documentación utilizada en el módulo PMDM de CFGS.