Documentation
Información
Esta web se encuentra en constante desarrollo, añadiendo nuevos contenidos para apoyar el proceso de aprendizaje.
Manuales, documentación y guías para el apoyo docente de diferentes módulos.
Esta web se encuentra en constante desarrollo, añadiendo nuevos contenidos para apoyar el proceso de aprendizaje.
Manuales, documentación y guías para el apoyo docente de diferentes módulos.
Before you start with this course, you need to download and install some required software:
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.
Estimated time: 2,5 hours
Estimated time: 2 hours
Estimated time: 2,5 hours
In this part, we are going to learn some concepts regarding functional programming, a declarative paradigm to face some programs.
Estimated time: 1 hour
Estimated time: 2 hours
In this part, we are going to learn how to implement GUIs (Graphical User Interfaces) using JavaFX.
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.
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 part we are going to learn some advanced strategies for thread management, such as using thread executors, atomic variables and, concurrent collections.
Estimated time: 3 hours
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.Estimated time: 2 hours
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
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.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.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 part, you will learn advanced strategies for working with Java sockets.
Estimated time: 4 hours
$ sudo apt-get install vsftpd$ ufw statusIf 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/tcpAdditionally, we will need to open some ports for passive mode, for example, from 40000 to 50000.
$ sudo ufw allow 40000:50000/tcpNow, 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 maricheloftpYou 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/ftpIf we verify the permissions:
$ sudo ls -la /home/maricheloftp/ftpdr-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/ftpdr-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 filesAnd here, we can create a file to test our server.
$ echo "test file" | sudo tee /home/maricheloftp/ftp/files/test.txt$ sudo nano /etc/vsftpd.confHere, you have to change or uncomment the following lines:
anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YESAnd add these ones (to enable passive FTP ports):
pasv_min_port=40000
pasv_max_port=50000and these ones:
user_sub_token=$USER
local_root=/home/$USER/ftpThese 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=NOWe 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.userlistRestart the service.
$ sudo service vsftpd restartTo check if the service is running okay, we can see its status with:
$ sudo service vsftpd statusEnter an editor with the information; to exit, press q.
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.esYou 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.
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.pemYou 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.keyAnd we will add these ones:
rsa_cert_file=/etc/ssl/private/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.pemNow, we are going to force the use of SSL always in our FTP server. Change ssl_enable to YES:
ssl_enable=YESNext, 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=YESWith the following lines we will configure our server to use TLS:
ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NOAnd 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=HIGHNow 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.
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.
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.
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.
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}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}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}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:
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
FileChooserdialog 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
DirectoryChooserdialog 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.
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.
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.
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.
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}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();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.
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.
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}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:
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:
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}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.
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.
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,AddressandGetPersonResponseclasses, 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.
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…
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
GetSumServiceclass 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.
In order to have a complete overview of how to deal with web services from a Java application, we’ll use an application called “Product Manager” that simulates the catalog of a computer shop, so that products are stored in a server with a MongoDB database and operations with those products will be done through web services in Express.js. In the database there is a collection for products and a collection for categories (each product belongs to a given category):
You can also check the structure of both collections (categories and products) by checking the model source files in the “models” subfolder of the project.
You should be able to download everything (JavaFX application project, and Node project for the server, including a database generator) from the Virtual Classroom to try this application. You can re-generate the database whenever you want with the database generator called db_generator.js within the Node project.
node db_generator.jsYou may need to run the file twice if you get some kind of error the first time you try it.
GET operations are intended to retrieve existing objects from the database (SELECT). In this case, there are 2 web services:
/category that gets all categories in the database. This is how it looks like in Node with Express:1app.get('/category', (req, res) => {
2 Category.find().then(result => {
3 res.send(result);
4 }).catch(error => {
5 res.send([]);
6 });
7});A service using the resource /product/:category that receives a category id and returns all products that belong to that category
1app.get('/product/:idCat', (req, res) => {
2 Product.find({category: req.params.idCat}).then(result => {
3 res.send(result);
4 }).catch (error => {
5 res.send([]);
6 });
7});When we receive a List of objects from Java, we need to use two special classes called com.google.gson.reflect.TypeToken and java.lang.reflect.Type if we intend to use GSON library. This is the JavaFX Service that will get the list of categories:
1public class GetCategories extends Service<List<Category>> {
2 @Override
3 protected Task<List<Category>> createTask() {
4 return new Task<List<Category>>() {
5 @Override
6 protected List<Category> call() throws Exception {
7 String json = ServiceUtils.getResponse(
8 "http://localhost:8080/category", null, "GET");
9 Gson gson = new Gson();
10 Type type = new TypeToken<List<Category>>(){}.getType();
11 List<Category> cats = gson.fromJson(json, type);
12 return cats;
13 }
14 };
15 }
16}And this is the service that will get the products from a category:
1public class GetProducts extends Service<List<Product>> {
2 String catId;
3
4 public GetProducts(String catId) {
5 this.catId = catId;
6 }
7
8 @Override
9 protected Task<List<Product>> createTask() {
10 return new Task<List<Product>>() {
11 @Override
12 protected List<Product> call() throws Exception {
13 String json = ServiceUtils.getResponse(
14 "http://localhost:8080/product/" + catId, null, "GET");
15 Gson gson = new Gson();
16 Type type = new TypeToken<List<Product>>(){}.getType();
17 List<Product> prods = gson.fromJson(json, type);
18 return prods;
19 }
20 };
21 }
22}To end this GET example, we’ll see how we request products when a new category is selected in the application:
1private void selectNewCategory(Category category, String selectAfter) {
2 getProds = new GetProducts(category.getId());
3 getProds.start();
4 getProds.setOnSucceeded(e -> {
5 currentProds =
6 FXCollections.observableArrayList(getProds.getValue());
7 productsTable.setItems(currentProds);
8 Optional<Product> selProd = currentProds.stream()
9 .filter(p -> p.getId().equals(selectAfter)).findFirst();
10 if(selProd.isPresent()) {
11 productsTable.getSelectionModel().select(selProd.get());
12 productsTable.scrollTo(selProd.get());
13 }
14 });
15}Exercise 11
Create a project named GetCompanies in JavaFX. It will use these two web services using GET:
- http://<server_address>/company: it will return all the companies with this JSON format:
1[
2 {
3 "_id": "1asdasrqwwr535q35a",
4 "cif": "C2314234Y",
5 "name": "Crazy Stuff Inc.",
6 "address": "Madness Street 15"
7 },
8 {
9 "_id": "2425ehpasuhrpasueg",
10 "cif": "T1342536Y",
11 "name": "Silly & Dumb LTD",
12 "address": "Idont Know Street, 1324"
13 },
14 ...
- http://<server_address>/company/{id}: it will return the information of a company in this format (if there’s information you won’t need, like companies id, just ignore it and don’t include it in Java’s class):
1{
2 "ok": true,
3 "error": "",
4 "company": {
5 "_id": "348w9ueasd90ays8s",
6 "cif": "V3241569E",
7 "name": "Maniacs International",
8 "address": "Happy Stress Street, 99",
9 "employees": [
10 {
11 "_id": "5sdaoishdaps8ys",
12 "nif": "46374869U",
13 "name": "Cocaine Rupert",
14 "age": "37",
15 "company": "348w9ueasd90ays8s"
16 },
17 {
18 "_id": "sdasd6asdas8y8fays",
19 "nif": "12425364K",
20 "name": "Happysad Windows",
21 "age": "47",
22 "company": "348w9ueasd90ays8s"
23 }
24 ]
25 }
26}First, it will load all companies on the top list from the appropriate web service. Then, when a user selects a company, it will retrieve its information and employees from the other web service, showing them in the bottom list. This application will look more or less like this:
You will be provided with a Node server with the services already implemented. It also includes a file called
db_generator.jsthat you can (must) run to create and fill the database (run it withnode db_generator.js). Then, launch the server (app.jsfile) and start creating your JavaFX client. You will also be provided with a JavaFX application skeleton to start with.
Using POST, data is sent in the body of the request. This is done by using the output stream of the connection to send that data. The format can be URL (key=value&key2=value2) or like in this case, JSON (in String format).
In our product manager example, we have defined a web service that gets the data about a product, processes the information and inserts this product in the database. It returns (prints) the id of the newly created product (or an empty string if there was an error).
This is the service in Java that will call this web service and return its response:
1public class AddProduct extends Service<String> {
2 Product prod;
3
4 public AddProduct(Product prod) {
5 this.prod = prod;
6 }
7
8 @Override
9 protected Task<String> createTask() {
10 return new Task<String>() {
11 @Override
12 protected String call() throws Exception {
13 Gson gson = new Gson();
14 String resp = ServiceUtils.getResponse(
15 "http://localhost:8080/product",
16 gson.toJson(prod), "POST");
17 return resp;
18 }
19 };
20 }
21}Finally, this is the code in the view’s controller to start this service and process its result:
1addProd = new AddProduct(newProd);
2addProd.start();
3
4addProd.setOnSucceeded(e -> {
5 String id = addProd.getValue();
6 if(!id.equals("")) { // Success
7 selectNewCategory(categories.stream()
8 .filter(c -> c.getId().equals(newProd.getIdCategory()))
9 .findFirst().get(), id);
10 showInfoMsg("New product added successfully.", false);
11 } else {
12 showInfoMsg("Error adding the product", true);
13 }
14});Exercise 12
Update the project GetCompanies from previous exercise and insert an “Add” button for adding employees. This button (only active when a company is selected) will open a new window containing a form to add a new employee.
To create new window using another FXML (or building the scene by code), you can do it like this:
1Stage stage = new Stage();
2stage.initModality(Modality.APPLICATION_MODAL);
3
4Parent root = FXMLLoader.load(getClass().getResource("AddEmployee.fxml"));
5
6Scene scene = new Scene(root);
7stage.setTitle("Add employee");
8stage.setScene(scene);
9stage.show();
10stage.setOnHidden((e) -> {
11 // Update the employees list somehow...
12});To cancel and close the window opened (at least using FXML):
1Stage window = (Stage) ((Node)event.getSource()).getScene().getWindow();
2window.close();This form will have to be sent to this web service by POST:
http://<server_address>/employee/{idCompany}And you need to send this information (there can be more fields and they’ll be ignored):
1{
2 "age": 32,
3 "nif": "12324354T",
4 "name": "Delete Meplease"
5}This web service will return a boolean flag indicating if everything went OK or not, and either the id of the new employee if everything was correct, or an error message if something went wrong:
1{
2 "ok": true,
3 "id": "1sdasp8yawasa8s2"
4}
5{
6 "ok": false,
7 "error": "Error adding the employee"
8}This HTTP method is a mix between GET and POST, and it’s usually equivalent to the UPDATE instruction in SQL. The information to get the object (id for example) that will be modified from the database is sent in the URL (like GET), and the data to modify from that object is sent in the body (like POST).
In our product manager example, we have a web service that will process the information and update an existing product. It just returns true or false indicating if the operation was successful or not. This is the JavaFX Service created to connect to this web service:
1public class UpdateProduct extends Service<Boolean> {
2 Product prod;
3
4 public UpdateProduct(Product prod) {
5 this.prod = prod;
6 }
7
8 @Override
9 protected Task<Boolean> createTask() {
10 return new Task<Boolean>() {
11 @Override
12 protected Boolean call() throws Exception {
13 Gson gson = new Gson();
14 String resp = ServiceUtils.getResponse(
15 "http://localhost:8080/product/" +
16 prod.getId(), gson.toJson(prod), "PUT");
17 return Boolean.parseBoolean(resp);
18 }
19 };
20 }
21}And finally, this is how we create and start the Service from the controller:
1updateProd = new UpdateProduct(newProd);
2updateProd.start();
3
4updateProd.setOnSucceeded(e -> {
5 if(updateProd.getValue()) { // Success
6 selectNewCategory(categories.stream()
7 .filter(c -> c.getId().equals(newProd.getIdCategory()))
8 .findFirst().get(), newProd.getId());
9 showInfoMsg("Product updated successfully.", false);
10 } else {
11 showInfoMsg("Error updating the product", true);
12 }
13});The DELETE operation works like GET (variables in URL), but instead of returning objects that meet some conditions, it removes the specified object(s) from the database. We have a web service ready in our product manager example, and this is the JavaFX Service that will connect to this web service:
1public class DeleteProduct extends Service<Boolean> {
2
3 String idProd;
4
5 public DeleteProduct(String idProd) {
6 this.idProd = idProd;
7 }
8
9 @Override
10 protected Task<Boolean> createTask() {
11 return new Task<Boolean>() {
12 @Override
13 protected Boolean call() throws Exception {
14 Gson gson = new Gson();
15 String resp = ServiceUtils.getResponse(
16 "http://localhost:8080/product/" + idProd,
17 null, "DELETE");
18 return Boolean.parseBoolean(resp);
19 }
20 };
21 }
22}And finally, how we create and start the service from the controller:
1deleteProd = new DeleteProduct(selectedProd.getId());
2deleteProd.start();
3
4deleteProd.setOnSucceeded(e -> {
5 if(deleteProd.getValue()) { // Success
6 currentProds.remove(selectedProd);
7 showInfoMsg("Product " + selectedProd.getReference() + " deleted.",
8 false);
9 } else {
10 showInfoMsg("Error removing the product", true);
11 }
12});Exercise 13
Update the GetCompanies project from previous exercises. You’ll have to add two more buttons:
Update: Only active when an employee is selected. It will open a window with a form (similar to Add) to edit an employee’s information. It will connect to the web service and send information by PUT method:
- http://<server_address>/employee/{id}
- The information sent in JSON will have the same format that when you add an employee (see exercise 12), and the response will be a JSON object with two fields:
ok(boolean) anderror(String).Delete: Will open a dialog to ask the user if he/she wants to delete the selected employee (see ProductManager example). If positive answer is given it will call this web service (using DELETE method):
- http://<server_address>/employee/{id}
- The response will be a JSON object with the same format as before (update)
You have information about token authentication in annexes of previous sections. The standard way to send this token is in a header called Authorization (although it could be different) with the prefix “Bearer “ before the encoded token (although it is up to you to define this prefix, as long as you are in charge of implementing both sides, client and server). This is how we would add this header in a request through a HttpURLConnection object (conn):
1if(token != null) {
2 conn.setRequestProperty("Authorization", "Bearer " + token);
3}You have this code already added to your ServiceUtils class, along with a couple of useful methods: setToken and removeToken, that let you set or remove the token from the requests performed by this class.
You have also information about sending images encoded in Base64 format from the client to the server in previous annexes, and how to process this image in the server. In order to get an image and convert its bytes into Base64 format in the client side, you may need to do something like this:
1class MyImage {
2
3 String name;
4 String data;
5
6 public MyImage(Path file) {
7 name = file.getFileName().toString();
8 byte[] bytes;
9 data = "";
10 try {
11 bytes = Files.readAllBytes(file);
12 data = Base64.getEncoder().encodeToString(bytes);
13 } catch (IOException ex) {
14 System.err.println("Error getting bytes: " + file.toString());
15 }
16 }
17}As you can see, we use Base64 class from Java API (package java.util) to encode the bytes of the image.
/uploadImg.1MyImage img = new MyImage(Paths.get("image.jpg"));
2Gson gson = new Gson();
3String json = gson.toJson(img, MyImage.class);
4String resp = ServiceUtils.getResponse("http://localhost:8080/uploadImg",
5 json, "POST");Exercise 14
Create a JavaFX project called PhotoUploader that will upload a photo with a title and a description to a web service using POST:
http://<server_address>/photo
The JSON response object will contain:
ok(boolean) anderror(String).This is the JSON format you’ll have to send:
1{
2 "name": "IMG_20130831_174624.jpg",
3 "title": "Lovely place",
4 "desc": "place description",
5 "data": "base 64 encoded data…."
6}Access this address in your browser to check all uploaded images and delete them:
http://<server_address>/photoplaces/
Aquí puedes encontrar la versión anterior de los apuntes utilizada para el módulo.
var) e inmutables (val) y comprender su importancia para la gestión de la inmutabilidad.NullPointerException.data class, sealed class, y clases con constructores primarios y secundarios.object) y los companion object para crear singletons y miembros estáticos.enum) para definir conjuntos de constantes con propiedades asociadas.?:) y el manejo de estructuras de control como when.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.
1fun main(args: Array<String>) {
2 println("Hello World!")
3}main es el punto de entrada.println imprime un mensaje en consola.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)data class genera de manera automática los métodos toString, equals, hashCode, copy, etc.1val i: Int = 42 // Constante inmutable
2var mutableNum = 10 // Variable mutable
3val d: Double = i.toDouble()
4val c: Char = 'c'
5val iFromChar = c.codeval define constantes, var permite cambios.1val bitwiseOr = FLAG1 or FLAG2
2val bitwiseAnd = FLAG1 and FLAG21val s = "Ejemplo"
2val c = s[3] // Accede a 'm'
3val s2 = "Example"
4for (c2 in s2) print(c2) // Recorre cada carácterEn 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}!!) se utiliza para indicar al compilador que ese objeto no será nulo, evitando así posibles comprobraciones.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 - elseForma 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")whenLa 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)forRecorrer 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}while1var contador = 0
2while (contador < 3) {
3 println("Contador: $contador")
4 contador++
5}do-while1var numero = 0
2do {
3 println("Número: $numero")
4 numero++
5} while (numero < 3)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} 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}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.initTambié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 AracilPor 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.Persona.1data class Person(val name: String, val surname: String, val age: Int)
2val (nombre, apellido, edad) = Person("Javier", "Carrasco", 45) 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.
1val parUno = Pair("Hola", "Mundo")
2val parDos = Pair("Adiós amigos", 150)
3val (usuario, contrasenya) = Pair("javier", "kotlin")1fun sayHello() = "Hi!" // Compacta
2fun sayHello(name: String, surname: String) = "Hello $name $surname"1fun Fragment.toast(message: CharSequence, duration: Int = Toast.LENGTH_SHORT) {
2 Toast.makeText(this.context, message, duration).show()
3}1val list = listOf("A", "B", "C") // Inmutable
2val mutableList = mutableListOf("A", "B")
3mutableList.add("C")1val map = mapOf(1 to "Uno", 2 to "Dos")
2val mutableMap = mutableMapOf(1 to "Uno")
3mutableMap[2] = "Dos"1val set = setOf(1, 2, 3)
2val mutableSet = mutableSetOf(1, 2)
3mutableSet.add(3) 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}") }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}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.
filterFiltra 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.
mapTransforma 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 sortedDescendingOrdena 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.
groupByAgrupa 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 allComprueba condiciones.
1val edades = listOf(18, 20, 25)
2println(edades.any { it >= 21 }) // true
3println(edades.all { it >= 18 }) // trueDescripción: any verifica si alguno cumple, all si todos cumplen.
countCuenta elementos que cumplen una condición.
1println(edades.count { it >= 21 }) // 1distinct y distinctByElimina duplicados.
1val duplicados = listOf(1, 2, 2, 3, 3, 3)
2println(duplicados.distinct()) // [1, 2, 3]take y dropSelecciona 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 unzipCombina listas.
1val nombres = listOf("Ana", "Luis")
2val edades = listOf(25, 30)
3val combinados = nombres.zip(edades)
4println(combinados) // [(Ana, 25), (Luis, 30)]flattenAplana 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 foldAcumulan elementos.
1val suma = numeros.reduce { acc, num -> acc + num }
2val sumaConInicial = numeros.fold(10) { acc, num -> acc + num }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")filter): [5, 3, 1, 9]sortedDescending): [9, 5, 3, 1]map): [18, 10, 6, 2]take): [18, 10]fold): 0 + 18 + 10 = 28Resultado final: 28
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:
findViewById.| 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 |
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}Jetpack Compose se divide en tres módulos que trabajan conjuntamente y con tareas claramente definidas:
@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.Estos tres módulos trabajan de forma desacoplada, lo que permite actualizar o extender cada parte por separado.
Gracias a esta arquitectura desacoplada, Jetpack Compose es:
@Composable personalizados) o incluso reemplazar partes del runtime si lo necesitas.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.
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:
@Composableen llamadas más complejas que pueden ser monitorizadas por el runtime.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.
El compilador de Compose trabaja en la fase de IR (Intermediate Representation) de Kotlin, dónde se realizarán transformaciones como:
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:
El sistema de slots (Slot Table) es la estructura interna encargada de:
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?
Esto hace que Compose sea más eficiente que sistemas anteriores.
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?
Text("Valor: $valor").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:
Unit).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}Jetpack Compose ofrece varios contenedores flexibles para la organización de los elementos de UI. Los tres layouts básicos son:
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}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}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.
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.
La recomposición en Jetpack Compose se activa cuando:
mutableStateOf, Compose detecta el cambio y recompone los métodos que la utilizan.@Composable: Al llamar a un método @Composable con diferentes argumentos, se considera que su entrada ha cambiado y se recompone.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.
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:
1val bandera = remember { false }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 |
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.
Evita 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.
Ejemplo práctico 1 Estado observable
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.
1@Composable
2fun EjemploEstado(paddingValues: PaddingValues) {
3 var texto by remember { mutableStateOf("") }
4 val esTextoLargo by remember {
5 derivedStateOf { texto.length > 10 }
6 }
7
8 Column(modifier = Modifier.padding(paddingValues)) {
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.
Las funciones @Composable son la parte principal de Jetpack Compose. Estas funciones deberán cumplir ciertas reglas que garanticen una UI eficiente y predecible:
@Composable.@Composable para construir interfaces más complejas.Estas reglas básicas garantizan que Compose pueda realizar una gestión eficientemente de la recomposición y mantener una UI coherente.
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.
Si fuese necesario realizar operaciones que requieran efectos secundarios, Compose proporciona APIs específicas:
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.
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.
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.
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.
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.
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.
Este enfoque mantiene la estabilidad de la aplicación mientras se produce el cambio a la nueva tecnología.
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.
El runtime de Compose dispone su propio ciclo de vida, este se compone de tres fases fundamentales:
Composable se ejecuta por primera vez.Aunque separados, estos ciclos interactúan entre sí en aplicaciones Compose:
DisposableEffect, LaunchedEffect, etc.).Lifecycle usando APIs como LifecycleEventEffect o lifecycle.currentStateAsState() del módulo lifecycle-runtime-compose. 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: RESUMEDLa documentación de Google para el desarrollo de aplicaciones Android es muy completa y de fácil consulta. Se comenzará por centrar la atención en la instalación de Android Studio1, desde este enlace, desde donde podrás descargar la última versión.
Las aplicaciones nativas para Android se desarrollan utilizando como lenguaje de programación Java, o Kotlin. Por tanto, será necesario tener instalado en el SO el software necesario para la ejecución de Java. Se hace referencia a la máquina virtual de Java (JVM) y su entorno de ejecución de Java (JRE). Puedes descargarlo desde la página de recursos técnicos de Oracle.
Una vez instalado Java en el ordenador, el siguiente paso será descargar Android Studio para el sistema operativo con el que se vaya a trabajar.
Una vez hayas descargado el instalador, ya puedes dar comienzo a la instalación del IDE en el sistema operativo. Como podrás ver a continuación, se trata de una instalación clásica guiada a través del asistente de instalación. Se han omitido algunos pasos del asistente, como el tipo de instalación, que suele seleccionarse standard, o la elección del tema, oscuro o claro.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Las imágenes mostradas en este apartado pueden no coincidir con las que se muestran en el proceso de instalación, ya que Google actualiza con frecuencia el asistente de instalación de Android Studio. Si tienes alguna duda durante el proceso de instalación, puedes consultar la documentación oficial.
Una vez finalizada la instalación, ya puedes ejecutar el IDE. En primer lugar podrás ver en la parte izquierda las opciones Projects, para crear y gestionar los proyectos que se vayan generando. La opción Customize, esta permite acceder de manera rápida a opciones visuales y de accesibilidad, pero también encontrarás el acceso a todas las opciones de configuración del IDE.
La opción Plugins, desde la cual pueden añadirse y eliminarse plugins al entorno de desarrollo.
Y por último Learn Android Studio, donde encontrarás enlaces a documentación, ejemplos y consejos sobre el IDE.
Ahora, se mostrará como añadir SDKs (Software Development Kit) de Android según se necesite. Tras la instalación, vendrá instalada la última versión disponible, pero si investigas un poco, verás que la última versión, no es siempre la más utilizada en el caso de Android. Desde Projects, selecciona la opción SDK Manager. Si la pantalla no aparece como la imagen siguiente, si no tienes proyectos creados, la encontrarás justo en el centro, en la opción More Actions.
De momento no es necesario añadir ningún API al IDE, se utilizará la última que contiene todo lo necesario para poder trabajar.
Si se necesitase añadir una API anterior, bastará con seleccionar la API deseada y pulsar el botón Apply, tras lo que se mostrará un mensaje informando de la descarga e instalación que va a producirse, deberás pulsar el botón OK para comenzar.
Seguidamente se deberá aceptar el contrato de licencia y pulsar el botón Next para comenzar con el proceso. A partir de aquí, Android Studio comenzará su trabajo, descargará e instalará los componentes seleccionados.
Una vez terminada la instalación, pulsa el botón Finish, seguidamente verás que aparecen marcadas las versiones instaladas. Ya podrías continuar preparando el entorno de trabajo.
Antes de comenzar a desarrollar, se terminará de configurar el entorno de desarrollo. Las pruebas, como bien sabrás, son una parte muy importante durante el desarrollo, es necesario saber si lo que se está haciendo funciona o no.
Se centrará la atención en el emulador AVD que incorpora Android Studio y en los dispositivos físicos. Existen otras alternativas en caso de no poder utilizar ninguno de estos dos medios, como las siguientes:
Como último recurso para realizar pruebas, se puede generar un archivo de instalación de Android, fichero del tipo .apk, y enviarlo al dispositivo, esto se mostrará más adelante.
Como ya se ha comentado, AVD (Android Virtual Device) es emulador que viene por defecto con Android Studio, este permitirá simular características físicas de dispositivos reales, ya bien sean teléfonos, tablets, incluso dispositivos Wear OS, Android TV, etc.
La recomendación de Google es crear un AVD por cada una de las imágenes disponibles del sistema, pero en este caso, con una nos bastará, quiero hacer hincapié en el espacio en disco, ya que si además de tener instaladas varias APIs, debe crearse una imagen AVD por cada una …
Para acceder al administrador AVD, se seguirán los mismos pasos que los utilizados para acceder al SDK Manager, pero esta vez, selecciona la opción Virtual Device Manager.
Una vez dentro, se mostraría el listado de los AVDs creados, en este caso al tratarse del primero, ese listado estará vacío y la única opción disponibles será Create Virtual Device… En versiones anteriores, en la parte inferior podías encontrar un enlace a Android Dashboards, donde podías encontrar estadísticas sobre los dispositivos utilizados, tamaños de pantalla, dispositivos que utilizan OpenGL, etc.
Una vez pulsada la opción Create Virtual Device, o el botón Create Device, se pasará a la selección del dispositivo, Category, en este caso se seleccionará Phone, y se seleccionará un modelo de la lista del centro, encontrarás modelos ofrecidos por Google (los Pixel), o modelos genéricos que encontrarás en la parte baja de la lista.
Se recomienda utilizar un model Pixel con Servicios de Google Play, estos, al igual que en los dispositivos reales, permiten hacer uso de funciones avanzadas que como desarrollador, facilitan el uso de ciertos servicios, como el localización, Places, Firebase, etc. Su uso también hará que se necesiten permisos de administrador para realizar ciertas modificaciones sobre el dispositivo emulado.
Se recomienda utilizar una imagen con Google Play y ABI x86_64, ya que ofrecen un mejor rendimiento. Si no se dispones de un equipo con procesador Intel o AMD, se puede utilizar una imagen ARM, pero el rendimiento será mucho peor.
El siguiente paso será seleccionar la imagen del sistema, al tratarse de la primera, verás que todas requieren descarga. Según el momento de instalación, la API a seleccionar puede variar. Se recomienda utilizar, para el curso, no la última, pero que esté al menos dos niveles por debajo, de esta forma se trabajará en un término medio. Deberás descargarla para continuar.
El siguiente paso será indicar el nombre del dispositivo, recomiendo indicar información de la API que tiene instalado para facilitar la elección, no será el caso ahora, pero se suelen tener más de dos o tres AVDs creados para las pruebas.
Una opción que puede venir bien es Enable Device Frame, por defecto viene activada, pero se recomienda desactivarla para aligerar levemente el uso del emulador, sobretodo si tu equipo no tiene suficiente memoria.
Una vez finalizado el proceso, el dispositivo aparecerá en el listado del Device Manager, ya solo faltará lanzarlo, utilizando el botón play que aparece en la columna Actions, para ver que funciona correctamente. Tras unos segundos de tensa espera, deberá cargarse el dispositivo en el emulador.
Una vez cargado, es posible que comience a actualizar aplicaciones como si de un dispositivo físico se tratase, aprovecha el momento para configurar el idioma, la cuenta de Google (opcional), etc.
Si el emulador no se inicia, puede ser por varios motivos, los más comunes son:
Es recomendable asignar al menos 2 GB de RAM al emulador para un rendimiento óptimo. Si el equipo tiene poca memoria, se puede asignar menos, pero el rendimiento será peor.
Se recomienda tener al menos dos o más AVDs creados, al menos uno con una versión baja de Android (API 29) y otro con una versión alta (API 30 o superior), para poder probar las aplicaciones en diferentes versiones del sistema operativo, y evitar problemas que pueden surgir por la emulación.
Otra opción para realizar las pruebas es utilizar un dispositivo físico, un teléfono o una tablet, que tenga instalado Android. Esta opción es la más recomendable, ya que se podrá probar la aplicación en un entorno real, con sus limitaciones y características propias.
Los dispositivos reales ya son otra historia, por un lado, ayuda ver tu aplicación funcionando en un dispositivo real, parece más profesional, además, te permite liberar memoria en el equipo al no tener que cargar un AVD.
Si utilizas macOS, no tienes que hacer nada, al menos nunca se me ha planteado lo contrario, pinchas el dispositivo, AS lo reconoce y a funcionar. Eso sí, necesitarás activar el modo desarrollador en tu dispositivo.
Si no es tu caso, y trabajas con Windows, debes instalar el driver USB, en primer lugar prueba con el Google USB Driver que puedes instalar desde el gestor Android SDK, el mismo que se utilizó para añadir las APIs, seguramente tendrás que reiniciar el sistema.
El driver de Google ha mejorado bastante y, salvo que tu dispositivo sea muy muy actual, no deberías tener problemas.
Si no funciona, prueba a buscar el driver específico para tu dispositivo, normalmente en la web del fabricante, o en foros especializados, para obtener el ADB driver para tu sistema operativo. Por ejemplo, para un móvil Samsung.
Si decides hacer uso de un dispositivo móvil, deberás activar el modo desarrollador para realizar pruebas y acceder a otras funciones, como la depuración, capturar pantalla e incluso grabarla.
Para acceder a las opciones para desarrolladores en un dispositivo Android, en primer lugar dirígete a los ajustes del dispositivo, busca la información del dispositivo y localiza el número de compilación. Una vez localizado el número de compilación, deberás pulsar sobre él siete veces, verás que aparece un mensaje indicando que las opciones para desarrollador están activadas.
Ahora, en la sección Sistema, en las opciones avanzadas deberás buscar Opciones para desarrolladores y activar Depuración por USB.
El dispositivo ya estaría listo para realizar las pruebas. Este proceso puede variar según el dispositivo, por ejemplo, en un dispositivo Redmi 9, deberás hacer las siete pulsaciones en la opción Versión de MIUI para activar las opciones para desarrolladores.
Una vez que el dispositivo está listo, con el modo desarrollador activado y la depuración por USB habilitada, conecta el dispositivo al ordenador mediante un cable USB. Asegúrate de utilizar un cable que permita la transferencia de datos, no todos los cables USB lo permiten.
Al conectar el dispositivo, es posible que aparezca una ventana emergente en el dispositivo solicitando permiso para permitir la depuración USB desde el ordenador. Asegúrate de aceptar esta solicitud para que Android Studio pueda comunicarse con el dispositivo.
| Función | Descripción | Ejemplo rápido |
|---|---|---|
remember { … } |
Guarda el estado en recomposición mientras el composable esté en el árbol. | val count = remember { mutableStateOf(0) } |
rememberSaveable { … } |
Igual que remember, pero persiste en recreaciones de actividad (rotación, proceso). |
val count = rememberSaveable { mutableStateOf(0) } |
| Función | Descripción | Ejemplo rápido |
|---|---|---|
rememberCoroutineScope() |
Devuelve un CoroutineScope ligado al ciclo de vida del composable. |
val scope = rememberCoroutineScope() |
rememberUpdatedState(value) |
Mantiene siempre la última versión de un valor dentro de efectos (LaunchedEffect, etc.). |
val onClick by rememberUpdatedState(newValue = action) |
| Función | Descripción | Ejemplo rápido |
|---|---|---|
rememberInfiniteTransition() |
Animaciones que se repiten indefinidamente. | val alpha by infinite.animateFloat(... ) |
rememberTransition(state) |
Transiciones entre estados definidos. | val transition = updateTransition(targetState, label="") |
rememberSplineBasedDecay() |
Animación tipo fling con decaimiento. | val decay = rememberSplineBasedDecay<Float>() |
| Función | Descripción | Ejemplo rápido |
|---|---|---|
rememberScrollState() |
Guarda la posición de scroll en Column / Row. |
val scroll = rememberScrollState() |
rememberLazyListState() |
Guarda el estado de scroll en LazyColumn/LazyRow. |
val listState = rememberLazyListState() |
rememberLazyGridState() |
Estado de scroll en LazyVerticalGrid o LazyHorizontalGrid. |
val gridState = rememberLazyGridState() |
rememberPagerState() |
Estado de un pager (HorizontalPager / VerticalPager). |
val pagerState = rememberPagerState() |
| Función | Descripción | Ejemplo rápido |
|---|---|---|
rememberDraggableState(onDelta) |
Controla los desplazamientos en gestos de arrastre. | val dragState = rememberDraggableState { delta -> … } |
rememberSwipeableState(initialValue) |
Estado de un componente deslizable (ej: BottomSheet). |
val sheetState = rememberSwipeableState(0) |
rememberPullRefreshState(refreshing, onRefresh) |
Estado para pull-to-refresh. | val refreshState = rememberPullRefreshState(refreshing, { … }) |
rememberNestedScrollInteropConnection() |
Conexión de scroll con vistas clásicas Android. | val connection = rememberNestedScrollInteropConnection() |
| Función | Descripción | Ejemplo rápido |
|---|---|---|
rememberSystemUiController() (Accompanist) |
Controla status bar y nav bar. | val controller = rememberSystemUiController() |
rememberInsetsPaddingValues() (Accompanist, deprecated) |
Padding según insets del sistema. | val padding = rememberInsetsPaddingValues() |
rememberHapticFeedback() |
Acceso al motor de vibración/háptica. | val haptic = LocalHapticFeedback.current |
rememberClipboardManager() |
Acceso al portapapeles del sistema. | val clipboard = LocalClipboardManager.current |
remember 1@Composable
2fun DemoRememberScreen() {
3 val count = rememberSaveable { mutableStateOf(0) }
4 val scrollState = rememberScrollState()
5 val scope = rememberCoroutineScope()
6 val transition = rememberInfiniteTransition()
7
8 val alpha by transition.animateFloat(
9 initialValue = 0f,
10 targetValue = 1f,
11 animationSpec = infiniteRepeatable(
12 animation = tween(1000),
13 repeatMode = RepeatMode.Reverse
14 )
15 )
16
17 Column(
18 modifier = Modifier
19 .fillMaxSize()
20 .verticalScroll(scrollState),
21 horizontalAlignment = Alignment.CenterHorizontally
22 ) {
23 Text("Contador: ${count.value}", modifier = Modifier.alpha(alpha))
24
25 Button(onClick = {
26 scope.launch { count.value++ }
27 }) {
28 Text("Incrementar")
29 }
30 }
31}Entender el funcionamiento de un observable y las diferencias entre utilizar remember { mutableStateOf(false) } y remember { false }.
La clase MainActivity es la típica configuración de una actividad con Jetpack Compose, donde se establece el tema y el Scaffold, desde donde se llama a la función composable RememberVsMutableStateDemo:
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 ExampleT1_01Theme {
7 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
8 RememberVsMutableStateDemo(Modifier.padding(innerPadding))
9 }
10 }
11 }
12 }
13}Observa el código del método RememberVsMutableStateDemo, en el se muestran dos bloques similares pero con comportamientos diferentes:
1@Composable
2fun RememberVsMutableStateDemo(modifier: Modifier = Modifier) {
3 Surface(modifier = modifier.fillMaxSize()) {
4 Column(
5 modifier = Modifier
6 .padding(16.dp)
7 .fillMaxSize(),
8 verticalArrangement = Arrangement.spacedBy(24.dp)
9 ) {
10
11 // 1. Estado observable: cambia y la UI se actualiza.
12 Card {
13 Column(
14 modifier = Modifier.padding(16.dp),
15 verticalArrangement = Arrangement.spacedBy(12.dp)
16 ) {
17 Text(
18 text = "Estado observable con mutableStateOf",
19 style = MaterialTheme.typography.titleMedium
20 )
21 var checked by remember { mutableStateOf(false) }
22 Row(
23 verticalAlignment = Alignment.CenterVertically,
24 horizontalArrangement = Arrangement.spacedBy(12.dp)
25 ) {
26 Switch(
27 checked = checked,
28 onCheckedChange = { checked = it }
29 )
30 Text(
31 if (checked) "Estado: ACTIVO (se recompone)" else "Estado: INACTIVO (se recompone)"
32 )
33 }
34 Text(
35 text = "Aquí usamos un State<Boolean>. Cambiar su valor notifica a Compose y la UI se recompone."
36 )
37 }
38 }
39
40 // 2. Valor recordado NO observable: no hay recomposición al reasignar la variable local.
41 Card {
42 Column(
43 modifier = Modifier.padding(16.dp),
44 verticalArrangement = Arrangement.spacedBy(12.dp)
45 ) {
46 Text(
47 text = "Valor recordado sin estado observable",
48 style = MaterialTheme.typography.titleMedium
49 )
50
51 // OJO: esto recuerda "false" una vez, pero NO es State<T>.
52 var naive = remember { false }
53
54 Row(
55 verticalAlignment = Alignment.CenterVertically,
56 horizontalArrangement = Arrangement.spacedBy(12.dp)
57 ) {
58 // La UI LEE 'naive' pero cambiarlo no provoca recomposición.
59 Switch(
60 checked = naive,
61 onCheckedChange = { newValue ->
62 // Esto cambia la variable local, pero NO notifica a Compose.
63 naive = newValue
64 println("Desde el Switch: $naive")
65 }
66 )
67 Text(
68 if (naive) "Valor leído: TRUE (no observable)" else "Valor leído: FALSE (no observable)"
69 )
70 }
71
72 Button(
73 onClick = {
74 // También “cambia” la variable local, pero la UI no se enterará.
75 naive = !naive
76 println("Desde el Button: $naive")
77 }
78 ) {
79 Text("Intentar alternar (no actualizará la UI)")
80 }
81
82 Text(
83 text = "Este bloque usa un Boolean 'recordado' pero no observable. " +
84 "Reasignarlo no dispara recomposición, por lo que la UI no refleja los cambios."
85 )
86 }
87 }
88
89 // Nota pedagógica opcional
90 AssistChip(
91 onClick = {},
92 label = { Text("Consejo: si necesitas UI reactiva, usa State<T> (p. ej., mutableStateOf).") }
93 )
94 }
95 }
96}Estado observable con mutableStateOf:
checked se define como var checked by remember { mutableStateOf(false) }.Switch y cambia su valor, la UI se recompone automáticamente para reflejar el nuevo estado.Switch muestra “Estado: ACTIVO” o “Estado: INACTIVO” según el valor de checked, y este texto se actualiza dinámicamente.checked cambia, Compose detecta el cambio y vuelve a ejecutar la función composable, actualizando la interfaz de usuario.Valor recordado sin estado observable:
naive se define como var naive = remember { false }.naive no provoca recomposición.Switch o el Button, la variable naive cambia internamente, pero la UI no se actualiza para reflejar estos cambios.Switch muestra “Valor leído: FALSE” o “Valor leído: TRUE” según el valor de naive, pero este texto no se actualizará nunca.Este ejemplo ilustra claramente la diferencia entre usar un estado observable (mutableStateOf) y un valor recordado no observable (remember { false }). Para que la UI sea reactiva y se actualice automáticamente cuando los datos cambian, es esencial utilizar estados observables en Jetpack Compose.
Modifier.layout, medidas intrínsecas y restricciones.Canvas y graphicsLayer para enriquecer la experiencia visual con formas, transformaciones y animaciones.Scaffold, barras de herramientas (TopAppBar) y acciones flotantes (FAB).LazyColumn y LazyRow (alternativa moderna a RecyclerView).Snackbar, Toast, cuadros de diálogo (AlertDialog) y menús desplegables (Spinner).Navigation Compose.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.
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.
Modifier, lo que permite que su encadenamiento sea fluido.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.
Los modificadores se aplicarán de arriba hacia abajo según el orden escrito, es decir, de izquierda a derecha en el renderizado visual.
Jetpack Compose está diseñado para optimizar los modificadores comunes, como padding, size, offset o background, pero:
LayoutNode).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.
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())Puedes también crear modificadores más complejos utilizando funciones como Modifier.drawBehind, Modifier.composed, o incluso Modifier.pointerInput para gestos personalizados.
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}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.
En este apartado se explicará cómo funcionan los layouts más comunes en Jetpack Compose, la creación de layouts personalizados utilizando Modifier.layout, y cuándo es adecuado utilizarlo frente a los layouts predeterminados como Column, Row o Box.
Los layouts en Jetpack Compose son componentes que organizan y posicionan otros Composables en la pantalla. Los layouts predeterminados más comunes son:
Column: organiza los elementos en una columna vertical.Row: organiza los elementos en una fila horizontal.Box: superpone elementos uno encima de otro.Estos layouts proporcionan una forma sencilla y eficiente de estructurar la interfaz de usuario. La estructura básica de un layout es la siguiente:
1Column(
2 modifier = Modifier.fillMaxSize(),
3 verticalArrangement = Arrangement.Center,
4 horizontalAlignment = Alignment.CenterHorizontally
5) {
6 Text("Elemento 1")
7 Text("Elemento 2")
8 Button(onClick = { /* Acción */ }) {
9 Text("Botón")
10 }
11}Entre las propiedades más comunes de los layouts se pueden encontrar:
| Propiedad | Descripción |
|---|---|
modifier |
Permite aplicar modificadores para ajustar tamaño, padding, etc. |
verticalArrangement |
Define cómo se distribuyen los elementos verticalmente (solo en Column). |
horizontalArrangement |
Define cómo se distribuyen los elementos horizontalmente (solo en Row). |
contentAlignment |
Define la alineación del contenido dentro del layout (solo en Box). |
weight |
Permite que un elemento ocupe un espacio proporcional dentro del layout. |
padding |
Añade espacio alrededor del layout o de los elementos dentro de él. |
Estos layouts son altamente personalizables mediante modificadores y permiten crear interfaces complejas de manera sencilla.
Modifier.layoutModifier.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.
Modifier.layout { measurable, constraints -> ... } permite un control total sobre cómo se mide y posiciona un composable hijo.Measurable.measure() → Placeable.place().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.| Tipo | Control | Uso |
|---|---|---|
Column / Row |
Medio | Layouts comunes |
Box |
Bajo | Superposición simple |
Modifier.layout |
Alto | Casos muy personalizados |
Ejemplo práctico 1 Centrar un Text usando Modifier.layout
Ejemplo práctico 2 Limitar al 50% del espacio disponible usando Modifier.layout
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.
En Jetpack Compose, cada elemento visible es representado como un nodo dentro del layout tree, o LayoutNode. Este árbol definerá:
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.
Los tres pasos fundamentales que realiza Jetpack Compose para renderizar la UI:
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)1placeable.place(x, y)Se realiza después de componer y ubicar. En esta última fase se puede intervenir utilizando modificadores como:
1Modifier.drawBehind { ... }Renderizado (UI declarativa)
↓
Composición (measure)
↓
Disposición (place)
↓
Dibujo (draw)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.
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): ajusta el ancho del componente al mínimo necesario para que su contenido no se recorte.Modifier.width(IntrinsicSize.Max): ajusta el ancho del componente al máximo permitido según el contenido más ancho de sus hijos.Modifier.height(IntrinsicSize.Min): ajusta la altura del componente al mínimo necesario para mostrar correctamente su contenido.Modifier.height(IntrinsicSize.Max): ajusta la altura del componente al máximo permitido según el hijo más alto o el contenido más grande.El siguiente ejemplo permite visualizar cómo las medidas intrínsecas afectan el tamaño de los contenedores en función del contenido.
1@Composable
2fun IntrinsicSizeExample() {
3 Row(
4 modifier = Modifier
5 .fillMaxWidth()
6 .padding(16.dp),
7 horizontalArrangement = Arrangement.SpaceEvenly
8 ) {
9 // 1. width(IntrinsicSize.Min)
10 Column(
11 modifier = Modifier
12 .width(IntrinsicSize.Min)
13 .background(Color(0xFFE1BEE7))
14 .padding(8.dp)
15 ) {
16 Text("Corto")
17 Text("Texto más largo")
18 }
19
20 // 2. width(IntrinsicSize.Max)
21 Column(
22 modifier = Modifier
23 .width(IntrinsicSize.Max)
24 .background(Color(0xFFBBDEFB))
25 .padding(8.dp)
26 ) {
27 Text("Corto")
28 Text("Texto más largo")
29 }
30
31 // 3. height(IntrinsicSize.Min)
32 Row(
33 modifier = Modifier
34 .height(IntrinsicSize.Min)
35 .background(Color(0xFFC8E6C9))
36 .padding(8.dp)
37 ) {
38 Text("Línea 1\nLínea 2")
39 HorizontalDivider(
40 color = Color.Black,
41 modifier = Modifier
42 .fillMaxHeight()
43 .width(2.dp)
44 )
45 Text("Texto más corto")
46 }
47
48 // 4. height(IntrinsicSize.Max)
49 Row(
50 modifier = Modifier
51 .height(IntrinsicSize.Max)
52 .background(Color(0xFFFFF9C4))
53 .padding(8.dp)
54 ) {
55 Text("Texto corto")
56 HorizontalDivider(
57 modifier = Modifier
58 .fillMaxHeight()
59 .width(2.dp),
60 thickness = DividerDefaults.Thickness, color = Color.Black
61 )
62 Text("Línea 1\nLínea 2\nLínea 3")
63 }
64 }
65}BoxWithConstraints o Modifier.layout cuando el tamaño se predecible o haya que adaptarlo manualmente.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.
| 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.
Ejemplo práctico 3 BoxWithConstraints adaptativo
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.
Canvas es un composable especial que permite dibujar directamente sobre la pantalla utilizando para ello primitivas gráficas:
Esta API es similar a la de Canvas tradicional de Android (vistas), pero adaptada a Compose y Kotlin.
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}| 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). |
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}Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye Modifier.size(200.dp) por Modifier.fillMaxSize(),
Visualización del ejemplo:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaCanvas() {
4 EjemploCanvasSencillo()
5}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.).
Ejemplo práctico 4 Barra de progreso circular personalizada
Ejemplo práctico 5 Fondo decorativo con formas
El modificador graphicsLayer en Jetpack Compose permite transformaciones a nivel de capa modificando propiedades como rotación, escala, alfa, traslación, etc.
graphicsLayer?graphicsLayer crea una capa de composición separada para el elemento, permitiendo así transformaciones y efectos visuales que no afectan a otros nodos.| 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 |
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}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}Ejemplo práctico 6 Animación con graphicsLayer y Scaffold
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.
Scaffold es un contenedor base que permite estructurar la pantalla mediante secciones predefinidas.
Permite organizar los elementos comunes de una app:
1Scaffold(
2 topBar = { TopAppBar(...) },
3 bottomBar = { BottomAppBar(...) },
4 floatingActionButton = { FloatingActionButton { ... } },
5 content = { paddingValues ->
6 // Contenido principal con padding
7 }
8) 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} 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}Scaffold divide cada parte de la UI en una sección, facilitando la legibilidad y el mantenimiento del código.| 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 |
Ejemplo práctico 7 Uso básico de Scaffold
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.
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.
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.
El cuadro de texto puede configurarse para distintos tipos de entrada, como correo electrónico, número, contraseña, etc., utilizando el parámetro keyboardOptions.
1import androidx.compose.ui.text.input.KeyboardType
2...TextField(
3 ...
4 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
5)Cuando se utiliza KeyboardType.Password, es recomendable añadir visualTransformation = PasswordVisualTransformation() para ocultar el texto introducido.
1import androidx.compose.ui.text.input.PasswordVisualTransformation
2...TextField(
3 ...
4 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
5 visualTransformation = PasswordVisualTransformation()
6)En definitiva, TextField es un componente versátil para entrada de texto en Compose, con múltiples opciones de personalización y gestión de estado. Su uso hace que aparezca el teclado virtual automáticamente al enfocarlo, pero en ocasiones puede ser necesario controlarlo manualmente mediante SoftwareKeyboardController, por ejemplo, para ocultarlo tras pulsar un botón. Para ello, se puede utilizar LocalSoftwareKeyboardController.current para obtener una instancia del controlador del teclado.
1import androidx.compose.ui.platform.LocalSoftwareKeyboardController
2...@Composable
3fun EjemploTextFieldConTeclado() {
4 val keyboardController = LocalSoftwareKeyboardController.current
5 var texto by remember { mutableStateOf("") }
6
7 Column(modifier = Modifier.padding(16.dp)) {
8 TextField(
9 value = texto,
10 onValueChange = { texto = it },
11 label = { Text("Escribe algo") },
12 modifier = Modifier.fillMaxWidth()
13 )
14 Spacer(Modifier.height(8.dp))
15 Button(onClick = {
16 keyboardController?.hide() // Oculta el teclado
17 }) {
18 Text("Ocultar teclado")
19 }
20 }
21}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.
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.
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.
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.
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 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")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")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.ScrollViewEl 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}1Row(
2 modifier = Modifier
3 .fillMaxWidth()
4 .horizontalScroll(rememberScrollState())
5 .padding(16.dp)
6) {
7 ...
8}El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.
Para crear listas eficientes y escalables en Jetpack Compose se utiliza LazyColumn y LazyRow, estos vienen a ser los equivalentes modernos 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 |
itemsPor 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.
itemsIndexedAñ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.
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.
Si la lista a utilizar tiene elementos con IDs únicos se utilizará items(..., key = { it.id }):
1items(listaUsuarios, key = { it.id }) { usuario -> ... }remember y derivedStateOf para evitar recomposiciones innecesarias.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}items para crear una lista de tarjetas con los nombres de los usuarios.Toast al seleccionarla, recuerda que hay que recuperar el contexto actual para poder mostrarlo.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.1LazyRow {
2 items(listOf("🍎", "🍊", "🍌", "🍇", "🍏", "🍋", "🥑", "🍉", "🍓")) { fruta ->
3 Text(fruta, fontSize = 48.sp, modifier = Modifier.padding(8.dp))
4 }
5}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.
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 |
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}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.
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}Snackbar dentro de Scaffold.Toast para mostrar información rápida.Toast seguidos (no cancelables por el usuario).Snackbar si se utilizan acciones.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.
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}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.
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:
onSave) para definir acciones desde cada pantalla. 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.
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.
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.
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.
CascadeDropdownMenuEstos 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.
Ejemplo práctico 8 Menú básico en TopAppBar con devolución de selección vía callback
Ejemplo práctico 9 Menú con DropdownMenu en BottomAppBar
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.
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}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.
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}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}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}| 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 |
Ejemplo práctico 10 Login en un cuadro de diálgo
Los Modifiers son elementos fundamentales en Jetpack Compose que permiten modificar la apariencia y el comportamiento de los componentes de la interfaz de usuario. A continuación, se describen algunas de las propiedades básicas de los Modifiers:
| Propiedad | Descripción | Ejemplo de uso |
|---|---|---|
padding() |
Añade espacio interno alrededor del contenido (dentro del contenedor). | Modifier.padding(16.dp) |
fillMaxWidth() |
Hace que el elemento ocupe todo el ancho disponible. | Modifier.fillMaxWidth() |
fillMaxHeight() |
Hace que el elemento ocupe toda la altura disponible. | Modifier.fillMaxHeight() |
fillMaxSize() |
Hace que el elemento ocupe todo el espacio disponible, tanto en ancho como en alto. | Modifier.fillMaxSize() |
size() |
Define un tamaño fijo para el elemento. | Modifier.size(100.dp) |
width() / height() |
Establecen un ancho o alto específico. | Modifier.width(200.dp) / Modifier.height(100.dp) |
wrapContentWidth() / wrapContentHeight() |
Ajustan el tamaño del elemento al contenido, solo en el eje indicado. | Modifier.wrapContentWidth() |
background() |
Pinta un color de fondo o un Brush. | Modifier.background(Color.LightGray) |
clip() |
Recorta el contenido a una forma (ej. CircleShape, RoundedCornerShape). | Modifier.clip(RoundedCornerShape(8.dp)) |
border() |
Dibuja un borde alrededor del elemento. | Modifier.border(2.dp, Color.Gray, RoundedCornerShape(8.dp)) |
offset() |
Desplaza el elemento en los ejes X e Y. | Modifier.offset(x = 10.dp, y = 4.dp) |
align() |
Alinea el elemento dentro de su contenedor padre. | Modifier.align(Alignment.CenterHorizontally) |
weight() |
Distribuye el espacio disponible entre los elementos de un Row o Column. | Modifier.weight(1f) |
clickable() |
Hace que el elemento responda a toques o clics. | Modifier.clickable { /* acción */ } |
combinedClickable() |
Permite manejar clics simples y largos en un mismo elemento. | Modifier.combinedClickable(onClick = { /* acción */ }, onLongClick = { /* acción larga */ }) |
alpha() |
Ajusta la transparencia del elemento (1f = opaco, 0f = invisible). | Modifier.alpha(0.5f) |
rotate() |
Rota el contenido un número de grados determinado. | Modifier.rotate(45f) |
scale() |
Escala el tamaño del elemento en los ejes X y Y. | Modifier.scale(1.5f) |
shadow() |
Aplica una sombra al elemento. | Modifier.shadow(4.dp, RoundedCornerShape(8.dp)) |
Estas propiedades de Modifier son esenciales para personalizar y controlar la apariencia y el comportamiento de los componentes en Jetpack Compose. Al combinarlas, puedes crear interfaces de usuario ricas y adaptativas.
Recuerda que las propiedades de Modifier pueden encadenarse libremente, aplicándose en el orden en que se escriben.
Aprende a crear un layout personalizado que centre un composable hijo (en este caso, un Text) manualmente, sin usar Box o Arrangement.Center.
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}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}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.
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}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.
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}Este ejemplo muestra cómo crear una barra de progreso circular usando Canvas.
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.
Este ejemplo crea un fondo decorativo con círculos y líneas, ideal para personalizar pantallas.
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}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.
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.
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).
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.
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.
string.xmlTratará 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>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}TopBarConMenuSupó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}MainActivityAhora, 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.
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.
string.xmlComo 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>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}BottomAppBarConMenuSupó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}MainActivityAhora, 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.
Este ejemplo permite crear un AlertDialog personalizado para solicitar usuario y contraseña.
string.xmlComo 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>LoginDialogSe 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.
MainScreenSiguiendo 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}MainActivityAhora, 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}Intent para la comunicación entre componentes.ViewModel en el contexto de MVVM y Clean Architecture.ViewModel en la gestión de la navegación y el estado de los permisos.Para crear una Activity nueva en el modelo de vistas se utiliza una clase conocida como Intent, esto pueden ser de dos formas:
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_.
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.
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(...))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.
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.
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.
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:
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().
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:
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")ViewModel centraliza todo el estado de los permisos (concedido, con justificación, denegado permanentemente).collectAsState() y lanza acciones según el estado:
viewModel() es la forma recomendada en Compose para instanciar ViewModels vinculados al ciclo de vida (debes incluir la biblioteca).Activity ni pasar parámetros adicionales.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.
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}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}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}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}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.
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}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.
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" />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}Utiliza constantes o companion object para definir las claves (“nombre”, “edad”), esto te evitará posibles errores de escritura.
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.
En Jetpack Compose se recomiendoa el uso múltiples actividades solo si:
En todos los demás casos, se recomienda usar una sola actividad y Navigation Compose para gestionar pantallas.
NavHost, NavController, rutas y argumentos.ViewModel en el contexto de MVVM y Clean Architecture.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.
El uso de Navigation Compose frente al uso de múltiples actividades ofrece varias ventajas:
Menor complejidad del proyecto
Con múltiples Activity:
1val intent = Intent(this, DetailsActivity::class.java)
2startActivity(intent)Con Navigation Compose:
1navController.navigate("details")Mejor experiencia de usuario
Uso eficiente de recursos
Navegación declarativa
Ejemplo de declaración:
1NavHost(navController, startDestination = "home") {
2 composable("home") { HomeScreen() }
3 composable("profile") { ProfileScreen() }
4}Mejor gestión del estado
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.
| 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 |
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.
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}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}| 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. |
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.
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}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.
| 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). |
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.
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}navigateUp() puede no funcionar si no estás en una ruta con padre definido. Para la mayoría de casos, popBackStack() es suficiente.
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:
pantalla1.pantalla2: el stack es [pantalla1, pantalla2].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}BackHandler permite interceptar el evento del botón “atrás” y definir un comportamiento personalizado.
Limpiar el stack de navegación:
En algunos casos, puede ser necesario limpiar el stack de navegación para evitar que el usuario pueda volver a ciertas pantallas. Esto se puede hacer usando la sobrecarga de navigate() con popUpTo.
Ejemplo:
1navController.navigate("pantalla1") {
2 popUpTo(navController.graph.id) {
3 inclusive = true
4 }
5}Esta sobrecarga permite eliminar todas las pantallas anteriores a pantalla1 de la pila de navegación, por lo que pulsar atrás no volverá a ellas, sino que saldrá de la aplicación o volverá a la pantalla principal.
| 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.
Ejemplo práctico 1 Aplicación con tres pantallas
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.
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.
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")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}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}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}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.
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}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}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.
Nunca debe pasarse al ViewModel parámetros sin crear ViewModelProvider.Factory o añadir inyección de dependencias con Hilt.
ViewModelProvider.FactorySe 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}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}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") }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}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). |
Ejemplo práctico 2 Scope adecuado del ViewModel por destino con HILT
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.
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")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}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} 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} 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.
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}Se reproducirá la aplicación de ejemplo planteada en la documentación (punto 4.4.2.) utilizando inyección de dependencias con HILT.
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")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 ...>1sealed class Screen(val route: String) {
2 object HomeScreen : Screen("home")
3 object DetailScreen : Screen("detail")
4 object DetailWithParamScreen : Screen("detalle/{id}") {
5 fun createRoute(id: String) = "detalle/$id"
6 }
7}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}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(Screen.DetailWithParamScreen.route) { backStackEntry ->
10 val detalleViewModel: DetalleViewModel = hiltViewModel()
11 DetalleScreen(detalleViewModel)
12 }
13 composable(Screen.DetailScreen.route) { backStackEntry ->
14 val detalleViewModel: DetalleViewModel = hiltViewModel()
15 DetalleScreen(detalleViewModel)
16 }
17 }
18}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(Screen.DetailWithParamScreen.createRoute("123"))
9 }
10 ) { Text("Detalle item") }
11
12 Button(
13 onClick = {
14 // Navegar a la pantalla de detalle sin ID
15 navController.navigate(Screen.DetailScreen.route)
16 }
17 ) { Text("Detalle item sin id") }
18 }
19}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}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}Room, Retrofit2 y ViewModel.MVVM (Model-View-ViewModel) es un patrón de arquitectura que separa la lógica de la interfaz de usuario (UI).
Componentes principales:
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.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).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}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.Se encarga de almacenar el estado de la UI y sobrevive a cambios de configuración (rotación de pantalla por ejemplo).
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}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:
SuperHero o Editorial.El diagrama clásico que representa la Clean Architecture creado por Robert C. Martin es posible que ya lo hayas visto.
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.
La comunicación entre todos los componentes de las capas será la siguiente.
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.
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")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}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.ROOM como solución de persistencia local en Android.ROOM.ROOM con el patrón MVVM y el componente ViewModel .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:
LiveData y Coroutines: Perfecto para aplicar el patrón MVVM.ContentValues, Cursor, etc y otro código innecesario o redundante.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).
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.
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 N: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.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.
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.
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.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.
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.
En este ejemplo se utiliza AndroidViewModel para crear el ViewModel, que proporciona acceso al contexto de la aplicación, necesario para inicializar la base de datos.
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.
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.
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:
ViewModel.Snackbar.El parámetro key del método se utilizará de la siguiente manera:
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}Para consumir un método del ViewModel que devuelve un valor de forma asíncrona (Deferred), se puede utilizar LaunchedEffect de la siguiente manera:
1LaunchedEffect(superId) {
2 val superHero = viewModel.getSuperById(superId).await()
3 ...
4}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}Retrofit2 para realizar peticiones HTTPS desde una app Android.Retrofit2 con ViewModel, ROOM (para caché offline) y el patrón MVVM.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:
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.
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.
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" />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.
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}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.Cuando se hacen llamadas a una API pueden producirse errores que hay que controlar:
Se utilizará try-catch y se analizará el Response obtenido para gestionarlos.
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)
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}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}PullToRefreshBox en Jetpack ComposeEn 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.
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í).
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)@Entity: indica que esta clase será una tabla en la base de datos.@PrimaryKey: el campo id es la clave primaria (obligatorio en Room).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}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).
En ocasiones, las APIs REST requieren autenticación mediante tokens. Si la API que utilizas lo requiere para realizar algunas operaciones, como puede ser un PUT o DELETE, deberás añadir un interceptor al cliente HTTP de Retrofit para incluir el token en las cabeceras de las peticiones, utilizando la etiqueta @Headers("Content-Type: application/json").
1@POST("comments") // https://www.unaapicualquiera.com/api/comments
2@Headers("Content-Type: application/json")
3suspend fun putComment(@Header("Authorization") token: String, @Body comment: Comment): Response<Comment>
4
5// Método para añadir un comentario.
6suspend fun putComment(token: String, comment: Comment): Comment {
7 val response = api.putComment("Bearer $token", comment)
8 …
9} 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.
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}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á.
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.
MediaPlayer y ExoPlayer.PhotoPicker.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.
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.MediaPlayer no está recomendado para reproducción en streaming o formatos complejos.
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}Puedes obtener más vídeos de muestra en este repositorio.
Para capturar imágenes desde la cámara se puede utilizar ActivityResultContracts.TakePicture. Será necesario especificar en el Manifiest el permiso de cámara:
1<uses-permission android:name="android.permission.CAMERA" />
2<uses-feature android:name="android.hardware.camera" android:required="false" />El siguiente Composable muestra como capturar una imagen previa de la captura fotográfica, previa solicitud de permisos, utilizando la clase PermissionHandlerViewModel del tema 3.
1@Composable
2fun CheckPermission(padding: PaddingValues, viewModel: PermissionHandlerViewModel = viewModel()) {
3 val ctxt = LocalContext.current
4
5 // Obtiene el estado del permiso desde el ViewModel.
6 val permissionState = viewModel.uiState.collectAsState()
7 // Este callback se usa para solicitar el permiso de cámara.
8 val requestPermission =
9 rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
10 viewModel.onPermissionResult(
11 granted, ActivityCompat.shouldShowRequestPermissionRationale(
12 ctxt as Activity, Manifest.permission.CAMERA
13 )
14 )
15 }
16
17 LaunchedEffect(permissionState) {
18 when {
19 permissionState.value.granted -> {
20 Log.d("CameraPermission", "Acceso a cámara concedido")
21 }
22
23 else -> {
24 // Primer lanzamiento: solicitamos el permiso
25 requestPermission.launch(Manifest.permission.CAMERA)
26 }
27 }
28 }
29
30 when {
31 permissionState.value.granted -> {
32 CameraCapture(padding = padding) {/* onImageCaptured */
33 Log.i("MainActivity", "onImageCaptured: $it")
34 }
35 }
36 }
37} 1@Composable
2fun CameraCapture(padding: PaddingValues, onImageCaptured: (Uri) -> Unit) {
3 val ctxt = LocalContext.current
4 val bitmap = remember { mutableStateOf<Bitmap?>(null) }
5 val takePicture = rememberLauncherForActivityResult(
6 contract = ActivityResultContracts.TakePicturePreview()
7 ) { bitmap.value = it }
8
9 Column(
10 modifier = Modifier
11 .fillMaxSize()
12 .padding(padding),
13 ) {
14 if (bitmap.value != null) {
15 Image(
16 bitmap = bitmap.value!!.asImageBitmap(),
17 contentDescription = "Foto capturada",
18 modifier = Modifier.size(200.dp)
19 )
20 onImageCaptured(bitmap.value!!.let {
21 // Se guarda la imagen en el almacenamiento interno y devolvemos su URI
22 val filename = "captured_image.png"
23 val stream = ctxt.openFileOutput(filename, 0)
24 it.compress(Bitmap.CompressFormat.PNG, 100, stream)
25 stream.close()
26 Uri.fromFile(ctxt.getFileStreamPath(filename))
27 })
28 }
29
30 Button(onClick = { takePicture.launch() }) {
31 Text("Tomar foto")
32 }
33 }
34}TakePicturePreview() devuelve un Bitmap. Para guardar una imagen completa, deberás utilizar TakePicture (requiere Uri).
CameraX es una biblioteca de Android Jetpack que simplifica el uso de la cámara en dispositivos Android. Diseñada para trabajar de forma consistente en diferentes modelos y marcas, resuelve muchos problemas de compatibilidad. Recomendada para proyectos con Jetpack Compose y arquitectura MVVM y Clean Architecture.
Para utilizar CameraX y reducir el uso de permisos, se recomienda usar CameraX con ViewModel y lifecycle, evitando así el permiso de almacenamiento en Android 10 (API 29) y posteriores.
Ventajas que ofrece:
PreviewView).ViewModel y lifecycle. 1// CameraX core
2implementation("androidx.camera:camera-core:1.5.1")
3
4// CameraX Lifecycle
5implementation("androidx.camera:camera-lifecycle:1.5.1")
6
7// CameraX View (para PreviewView)
8implementation("androidx.camera:camera-view:1.5.1")
9
10// CameraX Camera2 (backend recomendado)
11implementation("androidx.camera:camera-camera2:1.5.1")
12
13// Si se utiliza análisis (opcional)
14implementation("androidx.camera:camera-extensions:1.5.1")Para poder grabar vídeo, será necesario añadir la siguiente dependencia:
1// VideoCapture
2implementation("androidx.camera:camera-video:1.5.1")Además, es necesario añadir los permisos en el archivo AndroidManifest.xml:
1<uses-permission android:name="android.permission.CAMERA" />
2<uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- Para grabar audio -->
3<uses-feature android:name="android.hardware.camera.any" /> <!-- Para usar cualquier cámara (frontal o trasera) -->
4<uses-feature android:name="android.hardware.microphone" android:required="true" /> <!-- Para grabar audio -->
5<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />En Android 10 (API 29) y posteriores, no se necesita el permitso WRITE_EXTERNAL_STORAGE si se guarda en directorios de la app (como getExternalFilesDir). Se utilizará MediaStore para guardar en galería.
Es posible que en algunos emuladores no funcione la cámara. Se recomienda probar en un dispositivo real.
El objetivo es crear una app que permita capturar fotos guardándolos en el almacenamiento interno del dispositivo, la actividad principal se encargará de inicializar CameraX y gestionar la UI, mostrando la previsualización de la cámara y un botón para capturar la imagen.
En primer lugar, en la MainActivity se crea un objeto para gestionar un ExecutorService, se usará para ejecutar tareas relacionadas con la cámara en un hilo secundario, sin bloquear la UI principal.
1class MainActivity : ComponentActivity() {
2 // Executor para tareas en segundo plano.
3 private lateinit var cameraExecutor: ExecutorService
4
5 override fun onCreate(savedInstanceState: Bundle?) {
6 super.onCreate(savedInstanceState)
7 enableEdgeToEdge()
8
9 setContent {
10 // Se inicializa el executor una sola vez.
11 cameraExecutor = Executors.newSingleThreadExecutor()
12 CameraApp()
13 }
14 }
15
16 ...
17}Observa como en el bloque setContent se inicializa cameraExecutor, creando un hilo único dedicado a ejecutar las operaciones de la cámara. Esto garantiza que las tareas se procesen de manera secuencial en ese hilo y no interfieran con el hilo principal. Luego se llama a la función CameraApp(), que es el Composable principal de la aplicación.
Todos los métodos que se describen a continuación deben estar dentro de la clase MainActivity.
Ahora se creará el método CameraApp(), composable gestiona la solicitud de permisos en tiempo de ejecución y decide si muestra la pantalla de la cámara o una pantalla solicitando permisos. Utiliza la API ActivityResult desde Compose, recordando el estado, configurado para lanzar la petición una sola vez.
1@Composable
2fun CameraApp() {
3 val context = LocalContext.current
4 var hasPermissions by remember { mutableStateOf(false) }
5
6 // Solicitar permisos.
7 val launcher = rememberLauncherForActivityResult(
8 contract = ActivityResultContracts.RequestMultiplePermissions(),
9 onResult = { permissions ->
10 hasPermissions = permissions.values.all { it }
11 }
12 )
13
14 LaunchedEffect(Unit) {
15 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
16 launcher.launch(arrayOf(Manifest.permission.CAMERA))
17 else {
18 launcher.launch(
19 arrayOf(
20 Manifest.permission.CAMERA,
21 Manifest.permission.WRITE_EXTERNAL_STORAGE // Solo necesario < Q (29)
22 )
23 )
24 }
25 }
26
27 if (hasPermissions) {
28 CameraScreen(context = context, executor = cameraExecutor)
29 } else {
30 RequestPermissionScreen {
31 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
32 launcher.launch(arrayOf(Manifest.permission.CAMERA))
33 else {
34 launcher.launch(
35 arrayOf(
36 Manifest.permission.CAMERA,
37 Manifest.permission.WRITE_EXTERNAL_STORAGE // Solo necesario < Q (29)
38 )
39 )
40 }
41 }
42 }
43}context a partir de LocalContext.current para exponer el Context de Android dentro del árbol de composición. Este contexto es esencial para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraScreen para que pueda utilizarlo al configurar la cámara y guardar imágenes.hasPermissions para comprobar si la aplicación tiene los permisos necesarios. Inicialmente es false.rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambda onResult, donde se actualiza hasPermissions si todos los permisos han sido concedidos.LaunchedEffect(Unit) se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Dependiendo de la versión de Android, se solicitan los permisos necesarios.CameraScreen si los permisos han sido concedidos, o RequestPermissionScreen si no. Este último muestra un mensaje y un botón para solicitar los permisos de nuevo.A continuación, se implementa el composable RequestPermissionScreen, que muestra un mensaje indicando que se necesitan permisos y un botón para reintentar la solicitud.
1@Composable
2private fun RequestPermissionScreen(onRetry: () -> Unit) {
3 Surface(modifier = Modifier.fillMaxSize()) {
4 Column(
5 modifier = Modifier.fillMaxSize(),
6 horizontalAlignment = Alignment.CenterHorizontally,
7 verticalArrangement = Arrangement.Center
8 ) {
9 Text(text = "Se requiere permiso de cámara", color = Color.Red)
10 Spacer(modifier = Modifier.height(16.dp))
11 Button(onClick = onRetry) {// Reintentar
12 Text("Solicitar permisos")
13 }
14 }
15 }
16}El siguiente paso es implementar el composable CameraScreen, que se encargará de mostrar la previsualización de la cámara y un botón para capturar fotos. Este composable utiliza PreviewView para mostrar la vista previa de la cámara y ImageCapture para capturar imágenes.
1@Composable
2private fun CameraScreen(context: Context, executor: ExecutorService) {
3 val imageCaptureState = remember { mutableStateOf<ImageCapture?>(null) }
4 val isCameraReady by remember { derivedStateOf { imageCaptureState.value != null } }
5
6 Column(
7 modifier = Modifier
8 .fillMaxSize()
9 .padding(48.dp)
10 ) {
11 // Vista de previsualización
12 AndroidView(
13 factory = { ctx ->
14 val previewView = PreviewView(ctx)
15 val lifecycleOwner = context as LifecycleOwner
16
17 // Iniciar la cámara después de que la vista esté lista
18 startCamera(
19 context = context,
20 previewView = previewView,
21 lifecycleOwner = lifecycleOwner,
22 onSuccess = { capture ->
23 Log.d("CameraX", "Cámara iniciada correctamente")
24 imageCaptureState.value = capture // <-- Actualizamos el estado
25 },
26 onError = { e ->
27 Toast.makeText(
28 context,
29 "Error cámara: ${e.message}",
30 Toast.LENGTH_SHORT
31 ).show()
32 }
33 )
34
35 previewView
36 },
37 modifier = Modifier.weight(1f)
38 )
39
40 // Botón para tomar foto (solo si está listo)
41 Button(
42 onClick = {
43 val capture = imageCaptureState.value
44 if (capture != null) {
45 takePhoto(capture, context, executor) {
46 // Se utiliza el main executor para mostrar el Toast.
47 ContextCompat.getMainExecutor(context).execute {
48 Toast.makeText(context, "Foto guardada", Toast.LENGTH_SHORT).show()
49 }
50 }
51 } else {
52 Toast.makeText(context, "Espere… cámara iniciándose", Toast.LENGTH_SHORT).show()
53 }
54 },
55 enabled = isCameraReady, // Deshabilitado hasta que esté listo
56 modifier = Modifier
57 .align(Alignment.CenterHorizontally)
58 .padding(16.dp)
59 ) {
60 Text(if (isCameraReady) "Hacer Foto" else "Iniciando cámara...")
61 }
62 }
63}imageCaptureState como un estado mutable que almacenará la instancia de ImageCapture una vez que la cámara esté lista. Inicialmente es null.isCameraReady indica si la cámara está lista para capturar fotos, es decir, si imageCaptureState.value no es null.AndroidView para integrar PreviewView, que muestra la previsualización de la cámara. Dentro del factory se llama a startCamera para configurar e iniciar la cámara.isCameraReady es true). Al hacer clic, se llama a takePhoto para capturar y guardar la imagen.Toast para notificar al usuario que la foto se ha guardado o si la cámara aún se está iniciando.ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.A continuación, se implementa la función startCamera, que configura e inicia la cámara utilizando CameraX. Este método no es un composable, sino una función normal que se llama desde el factory de AndroidView.
1private fun startCamera(
2 context: Context,
3 previewView: PreviewView,
4 lifecycleOwner: LifecycleOwner,
5 onSuccess: (ImageCapture) -> Unit,
6 onError: (Exception) -> Unit
7) {
8 val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
9
10 cameraProviderFuture.addListener({
11 try {
12 val cameraProvider = cameraProviderFuture.get()
13
14 val preview = Preview.Builder().build().apply {
15 setSurfaceProvider(previewView.surfaceProvider)
16 }
17
18 val imageCapture = ImageCapture.Builder()
19 .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
20 .build()
21
22 val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
23
24 // Se desvincula el provider previo antes de re-vincular
25 cameraProvider.unbindAll()
26
27 // Se vincula al ciclo de vida correcto
28 cameraProvider.bindToLifecycle(
29 lifecycleOwner,
30 cameraSelector,
31 preview,
32 imageCapture
33 )
34
35 // Se notifica que ImageCapture está listo
36 onSuccess(imageCapture)
37
38 } catch (e: Exception) {
39 onError(e)
40 }
41 }, ContextCompat.getMainExecutor(context))
42}ProcessCameraProvider, que es será la responsable de gestionar la cámara.CameraProvider esté listo, y dentro del bloque try-catch se configura la cámara.Preview para mostrar la vista previa de la cámara en PreviewView.ImageCapture para capturar las fotos, estableciendo el modo de captura para minimizar la latencia.CameraSelector.DEFAULT_BACK_CAMERA.unbindAll().Preview e ImageCapture al ciclo de vida del lifecycleOwner, asegurando que la cámara se gestione correctamente según el estado de la actividad o fragmento.onSuccess pasando la instancia de ImageCapture. Si se produjese un error, se llama a onError.El siguiente método, takePhoto, se encargará de capturar la foto y guardarla en el almacenamiento interno del dispositivo. Al igual que startCamera, no es un composable, ya que se llama desde el botón en CameraScreen y realiza operaciones que no están relacionadas con la UI directamente.
1private fun takePhoto(
2 imageCapture: ImageCapture,
3 context: Context,
4 executor: ExecutorService,
5 onSaved: () -> Unit
6) {
7 val contentValues = ContentValues().apply {
8 put(MediaStore.MediaColumns.DISPLAY_NAME, "IMG_${System.currentTimeMillis()}")
9 put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
10 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Solo para Android 10+ (Q)
11 put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/CameraX-Compose")
12 }
13 }
14
15 val outputOptions = ImageCapture.OutputFileOptions.Builder(
16 context.contentResolver,
17 MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
18 contentValues
19 ).build()
20
21 imageCapture.takePicture(
22 outputOptions,
23 executor,
24 object : ImageCapture.OnImageSavedCallback {
25 override fun onImageSaved(result: ImageCapture.OutputFileResults) {
26 onSaved()
27 }
28
29 override fun onError(exception: ImageCaptureException) {
30 exception.printStackTrace()
31 Log.e("CameraX", "Error al guardar la foto: ${exception.message}", exception)
32
33 // Se utiliza el main executor para mostrar el Toast.
34 ContextCompat.getMainExecutor(context).execute {
35 Toast.makeText(
36 context,
37 "Error al guardar: ${exception.message}",
38 Toast.LENGTH_SHORT
39 ).show()
40 }
41 }
42 }
43 )
44}ContentValues para definir los metadatos de la imagen, como el nombre del archivo, el tipo MIME y la ruta relativa (solo para Android 10 y posteriores).ImageCapture.OutputFileOptions.Builder, especificando el ContentResolver, la URI de destino y los ContentValues.takePicture de la instancia de ImageCapture, pasándole las opciones de salida, el executor para ejecutar la operación en segundo plano, y un callback para manejar el resultado.onImageSaved, se llama a onSaved() para notificar que la imagen se ha guardado correctamente.onError, se maneja cualquier error que pueda producirse durante la captura o el guardado de la imagen, imprimiendo el error en el log y mostrando un Toast en el hilo principal para informar al usuario.ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.MediaStore permite que las imágenes capturadas se guarden en la galería del dispositivo, haciendo que sean accesibles para otras aplicaciones y para el propio usuario.Por último, es importante liberar los recursos del ExecutorService cuando la actividad se destruya, para evitar fugas de memoria. Esto se hace sobrescribiendo el método onDestroy en MainActivity.
1override fun onDestroy() {
2 super.onDestroy()
3 cameraExecutor.shutdown() // Se libera el executor.
4}En primer lugar, recuerda añadir la dependencia de VideoCapture en el archivo build.gradle (ver sección 8.3.1) y los permisos necesarios en el AndroidManifest.xml.
Este ejemplo se inicia en un proyecto nuevo, desde la MainActivity se gestionará la cámara y la grabación de vídeo.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5
6 setContent {
7 MaterialTheme {
8 Scaffold(modifier = Modifier.padding(8.dp)) { innerPadding ->
9 CameraRecorderScreen(innerPadding)
10 }
11 }
12 }
13 }
14}También se añadirá el método RecordingHolder, que es un objeto singleton que se utilizará para mantener una referencia al Recording activo. Será necesario para poder detener la grabación cuando se desee.
1// Holder para el Recording activo (fuera del ViewModel para simplicidad del ejemplo)
2private object RecordingHolder {
3 var recording: Recording? = null
4}A continuación, se implementa el composable CameraRecorderScreen, que se encargará de mostrar la previsualización de la cámara y los botones para iniciar y detener la grabación de vídeo. Se detallará cada parte del código paso a paso.
1@Composable
2fun CameraRecorderScreen(innerPadding: PaddingValues) {
3 val contxt = LocalContext.current // Contexto necesario para varias llamadas.
4 val lifecycleOwner = LocalLifecycleOwner.current // Necesario para vincular el ciclo de vida a CameraX.
5
6 // Gestión de permisos, estado simple para el ejemplo.
7 var hasCamera by remember { mutableStateOf(false) }
8 var hasMic by remember { mutableStateOf(false) }
9
10 val permissionsLauncher = rememberLauncherForActivityResult(
11 ActivityResultContracts.RequestMultiplePermissions()
12 ) { result ->
13 hasCamera = result[Manifest.permission.CAMERA] == true
14 hasMic = result[Manifest.permission.RECORD_AUDIO] == true
15 }
16
17 // Se lanza la petición de permisos al inicio, solo una vez (key1 = Unit).
18 LaunchedEffect(Unit) {
19 permissionsLauncher.launch(
20 arrayOf(
21 Manifest.permission.CAMERA,
22 Manifest.permission.RECORD_AUDIO
23 )
24 )
25 }En esta primera parte del código, se definen las variables y estados necesarios:
contxt: Obtiene el contexto actual de Android utilizando LocalContext.current. Este contexto es necesario para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraX para configurar la cámara y guardar vídeos.lifecycleOwner: Obtiene el propietario del ciclo de vida actual utilizando LocalLifecycleOwner.current. Esto es crucial para vincular los casos de uso de CameraX al ciclo de vida de la actividad o fragmento, asegurando que la cámara se gestione correctamente según el estado de la UI.hasCamera y hasMic: Variables de estado que indican si la aplicación tiene los permisos necesarios para acceder a la cámara y al micrófono, respectivamente. Se inician a false.permissionsLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambda onResult (parámetro result), donde se actualizan hasCamera y hasMic según los permisos concedidos.LaunchedEffect(Unit): Se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Se solicitan los permisos necesarios para la cámara y el micrófono.27 // Se crea previewView para la cámara, se usa remember para que no se recree en recomposiciones.
28 val previewView = remember {
29 PreviewView(contxt).apply { // PreviewView es un View de Android.
30 layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
31 implementationMode = PreviewView.ImplementationMode.COMPATIBLE
32 scaleType = PreviewView.ScaleType.FILL_CENTER
33 }
34 }
35
36 // cameraProviderFuture es un proceso asíncrono, se guarda en remember para que no se reinicie.
37 val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(contxt) }
38 // videoCapture se guarda como estado para poder usarlo en los botones.
39 var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
40 // isRecording para controlar el estado de grabación y deshabilitar botones.
41 var isRecording by remember { mutableStateOf(false) }
42 // lastVideoUri para mostrar la última ruta guardada en Galería.
43 var lastVideoUri by remember { mutableStateOf<String?>(null) }
44
45 // Executor principal para callbacks de CameraX
46 val mainExecutor = remember { ContextCompat.getMainExecutor(contxt) }Seguidamente, se han definido más variables y estados necesarios para la configuración de la cámara y la grabación de vídeo:
previewView: Se crea una instancia de PreviewView, que es una vista nativa de Android utilizada para mostrar la previsualización de la cámara. Se utiliza remember para asegurarse de que esta vista no se recree en cada recomposición del composable. Se configuran sus parámetros de diseño y modo de implementación.cameraProviderFuture: Se obtiene una instancia de ProcessCameraProvider utilizando getInstance(contxt). Este proceso es asíncrono, por lo que se guarda en remember para evitar que se reinicie en cada recomposición.videoCapture: Se define como un estado mutable que almacenará la instancia de VideoCapture<Recorder> una vez que la cámara esté configurada. Inicialmente es null.isRecording: Variable de estado que indica si la grabación de vídeo está en curso. Se utiliza para controlar el estado de los botones de grabación y detener. Inicialmente será false.lastVideoUri: Variable de estado que almacenará la URI del último vídeo guardado en la galería. Se utiliza para mostrar esta información al usuario. Inicialmente también es null.mainExecutor: Se obtiene el ejecutor principal utilizando ContextCompat.getMainExecutor(contxt). Este executor se utilizará para manejar los callbacks de CameraX, asegurando que se ejecuten en el hilo principal.48 // Configurar CameraX una vez que el CameraProvider esté disponible.
49 LaunchedEffect(cameraProviderFuture) {
50 // Listener asíncrono, se lanza cuando cameraProviderFuture está listo.
51 cameraProviderFuture.addListener({
52 // CameraProvider listo, se configuran casos de uso.
53 val cameraProvider = cameraProviderFuture.get()
54
55 val previewUseCase = Preview.Builder().build()
56 .also { it.setSurfaceProvider(previewView.surfaceProvider) }
57
58 // Configuración de grabación con calidad y fallback.
59 // Se puede ajustar la lista de calidades según necesidades.
60 val qualitySelector = QualitySelector.fromOrderedList(
61 listOf(Quality.UHD, Quality.FHD, Quality.HD, Quality.SD),
62 FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
63 )
64
65 // Recorder con el selector de calidad.
66 val recorder = Recorder.Builder()
67 .setQualitySelector(qualitySelector)
68 .build()
69
70 // Caso de uso de VideoCapture
71 val videoUseCase = VideoCapture.withOutput(recorder)
72
73 try {
74 cameraProvider.unbindAll()
75 cameraProvider.bindToLifecycle(
76 lifecycleOwner,
77 CameraSelector.DEFAULT_BACK_CAMERA,
78 previewUseCase,
79 videoUseCase
80 )
81 videoCapture = videoUseCase
82 } catch (e: Exception) {
83 Log.e("CameraX", "Fallo al vincular casos de uso", e)
84 }
85 }, mainExecutor)
86 }Ahora se ha configurado CameraX una vez que el CameraProvider está disponible:
LaunchedEffect(cameraProviderFuture): Se utiliza para ejecutar el bloque de código cuando cameraProviderFuture cambia. Esto asegura que la configuración de la cámara solo se realice una vez que el CameraProvider esté listo.addListener: Se añade un listener asíncrono que se ejecutará cuando cameraProviderFuture está listo. Dentro de este bloque, se obtiene la instancia de CameraProvider.previewUseCase: Se crea un caso de uso de Preview para mostrar la previsualización de la cámara en previewView.qualitySelector: Se configura un selector de calidad que define una lista ordenada de calidades de vídeo (UHD, FHD, HD, SD) y una estrategia de fallback para seleccionar una calidad inferior o superior si la solicitada no está disponible. La estrategia de fallback asegura que siempre se seleccione una calidad válida, evitando errores si la calidad deseada no está soportada por el dispositivo.recorder: Se crea una instancia de Recorder utilizando el qualitySelector.videoUseCase: Se crea un caso de uso de VideoCapture utilizando el recorder.bindToLifecycle: Se desvinculan todos los casos de uso previamente vinculados con unbindAll(), y luego se vinculan previewUseCase y videoUseCase al ciclo de vida del lifecycleOwner, utilizando la cámara trasera.
videoCapture con la instancia de videoUseCase. Si se produce un error, se captura la excepción y se registra en el log.mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal. 88 // Pantalla UI.
89 Box(Modifier.fillMaxSize().padding(innerPadding)) {
90 // Vista previa
91 AndroidView(
92 factory = { previewView },
93 modifier = Modifier.fillMaxSize()
94 )
95
96 // Controles
97 Column(
98 modifier = Modifier
99 .fillMaxWidth()
100 .align(Alignment.BottomCenter)
101 .padding(16.dp),
102 horizontalAlignment = Alignment.CenterHorizontally
103 ) {
104 if (!hasCamera) {
105 Text("Concede permiso de cámara para empezar.")
106 Spacer(Modifier.height(8.dp))
107 }
108
109 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
110 Button(
111 enabled = !isRecording && hasCamera && videoCapture != null,
112 onClick = {
113 val vc = videoCapture ?: return@Button
114
115 // 1) Crear el registro de salida en MediaStore (Galería)
116 val name = "CameraX-${
117 SimpleDateFormat(
118 "yyyyMMdd-HHmmss",
119 Locale.US
120 ).format(System.currentTimeMillis())
121 }.mp4"
122 val contentValues = ContentValues().apply {
123 put(MediaStore.Video.Media.DISPLAY_NAME, name)
124 put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
125 // Ruta visible en Galería (Android 10+)
126 put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX Recorder")
127 }
128 val mediaStoreOptions = MediaStoreOutputOptions.Builder(
129 contxt.contentResolver,
130 MediaStore.Video.Media.EXTERNAL_CONTENT_URI
131 ).setContentValues(contentValues).build()
132
133 // 2) Preparar la grabación
134 var pending = vc.output.prepareRecording(contxt, mediaStoreOptions)
135
136 // Habilitar audio solo si el permiso está concedido
137 if (hasMic) {
138 if (ActivityCompat.checkSelfPermission(
139 contxt,
140 Manifest.permission.RECORD_AUDIO
141 ) == PackageManager.PERMISSION_GRANTED
142 ) {
143 pending = pending.withAudioEnabled()
144 }
145 }
146
147 // 3) Iniciar y escuchar eventos
148 val recording = pending.start(mainExecutor) { event ->
149 when (event) {
150 is VideoRecordEvent.Start -> isRecording = true
151
152 is VideoRecordEvent.Finalize -> {
153 isRecording = false
154 if (event.error == VideoRecordEvent.Finalize.ERROR_NONE) {
155 lastVideoUri = event.outputResults.outputUri.toString()
156 // El archivo ya queda indexado en la Galería
157 } else Log.e("CameraX", "Error al finalizar: ${event.error}")
158 }
159 }
160 }
161
162 // Guardamos el handle en estado recordable para poder parar más tarde
163 RecordingHolder.recording = recording
164 }
165 ) { Text("Grabar") }
166
167 Button(
168 enabled = isRecording,
169 onClick = {
170 // 4) Detener grabación
171 RecordingHolder.recording?.stop()
172 RecordingHolder.recording?.close()
173 RecordingHolder.recording = null
174 }
175 ) {
176 Text("Detener")
177 }
178 }
179
180 Spacer(Modifier.height(8.dp))
181 if (lastVideoUri != null) {
182 Text(
183 text = "Guardado en Galería:\n$lastVideoUri",
184 style = MaterialTheme.typography.bodySmall
185 )
186 }
187 }
188 }
189}Para terminar, se implementa la UI de la pantalla de grabación de vídeo:
Box para contener la vista previa de la cámara y los controles de grabación.AndroidView se usa para integrar previewView, que muestra la previsualización de la cámara.videoCapture no es null. Al hacer clic, se prepara la grabación creando un registro en MediaStore, configurando las opciones de salida y habilitando el audio si se tiene permiso. Luego, inicia la grabación y escucha los eventos de grabación para actualizar el estado.Recording almacenado en RecordingHolder.mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal.Photo Picker es una API que aparece en Android 13 (API 33) y permite a las aplicaciones acceder a los archivos multimedia del usuario sin necesidad de solicitar permisos de almacenamiento. Su diseño se centra en la privacidad y la simplicidad, delegando al sistema la presentación del selector y el control de los permisos temporales de acceso a los archivos.
A diferencia del acceso tradicional mediante permisos (READ_EXTERNAL_STORAGE, READ_MEDIA_IMAGES), el Photo Picker:
En Compose, el Photo Picker se integra mediante el uso del Activity Result API, que proporciona un mecanismo seguro y estructurado para iniciar actividades y recibir resultados.
Para mostrar su funcionamiento se desarrollará una aplicación que muestre el selector de imágenes del sistema para elegir una imagen de la galería y mostrarla en pantalla. Se utilizará Coil para cargar y mostrar la imagen seleccionada, por lo que deberás añadir la dependencia en el archivo build.gradle:
1implementation("io.coil-kt.coil3:coil-compose:3.2.0")
2implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")Al utilizar coil-network-okhttp, se añade soporte para cargar imágenes desde URLs y otras fuentes de red, mejorando la capacidad de la aplicación para manejar imágenes de diversas ubicaciones, añade el permiso de acceso a Internet en el AndroidManifest.xml (no es necesario para el Photo Picker, pero sí para cargar imágenes desde la web):
1<uses-permission android:name="android.permission.INTERNET" />Se creará un método para obtener la información de la imagen seleccionada.
1// Obtiene información básica (nombre, tamaño y tipo MIME) de un Uri.
2fun getImageInfo(context: Context, uri: Uri): String {
3 val contentResolver = context.contentResolver
4 val projection = arrayOf(
5 android.provider.OpenableColumns.DISPLAY_NAME,
6 android.provider.OpenableColumns.SIZE
7 )
8
9 contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
10 val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
11 val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
12
13 if (cursor.moveToFirst()) {
14 val name = cursor.getString(nameIndex)
15 val sizeBytes = cursor.getLong(sizeIndex)
16 val mime = contentResolver.getType(uri) ?: "Desconocido"
17
18 val sizeFormatted = DecimalFormat("#,##0.00").format(sizeBytes / 1024.0)
19 return "📄 $name\n📐 $sizeFormatted KB\n🗂 Tipo: $mime"
20 }
21 }
22 return "Información no disponible"
23}Este método getImageInfo recibe un Context y un Uri, y devuelve una cadena con el nombre del archivo, su tamaño en KB y el tipo MIME. Se utiliza un ContentResolver para consultar los metadatos del archivo. A continuación, se implementa el método GalleryPicker, que será el composable encargado de mostrar el botón para abrir el selector y la imagen seleccionada.
1@Composable
2fun GalleryPicker(modifier: Modifier = Modifier, onImageSelected: (Uri?) -> Unit) {
3 val contxt = LocalContext.current
4 // Se guarda la Uri de forma "saveable" (Uri es Parcelable) para sobrevivir a rotaciones, etc.
5 var selectedImageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
6 var imageInfo by rememberSaveable { mutableStateOf("") }
7
8 // Lanzador del Photo Picker (selección única)
9 val pickImageLauncher = rememberLauncherForActivityResult(
10 contract = ActivityResultContracts.PickVisualMedia(),
11 onResult = { uri: Uri? ->
12 selectedImageUri = uri
13 imageInfo = uri?.let { getImageInfo(contxt, it) } ?: ""
14 onImageSelected(uri)
15 }
16 )
17
18 Column(
19 modifier = modifier
20 .fillMaxSize()
21 .padding(16.dp)
22 .verticalScroll(rememberScrollState()),
23 horizontalAlignment = Alignment.CenterHorizontally,
24 verticalArrangement = Arrangement.Center
25 ) {
26 AsyncImage( // Vista previa con Coil si hay Uri
27 model = selectedImageUri,
28 contentDescription = "Imagen seleccionada",
29 modifier = Modifier
30 .size(320.dp)
31 .clip(RoundedCornerShape(16.dp)),
32 contentScale = ContentScale.Crop
33 )
34
35 Spacer(Modifier.height(4.dp))
36
37 // Información de la imagen
38 if (selectedImageUri != null) {
39 Spacer(Modifier.height(16.dp))
40 Text(
41 text = imageInfo,
42 style = MaterialTheme.typography.bodyMedium
43 )
44 }
45
46 Spacer(Modifier.height(32.dp))
47
48 Button(
49 onClick = {
50 // Solo imágenes (puedes cambiar a ImageAndVideo si procede)
51 pickImageLauncher.launch(
52 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
53 )
54 }
55 ) {
56 Text("Elegir de galería")
57 }
58 }
59}En este método se definen varios elementos clave:
selectedImageUri: Estado que almacena la URI de la imagen seleccionada. Se utiliza rememberSaveable para que el valor persista a través de recomposiciones y cambios de configuración (como rotaciones).imageInfo: Estado que almacena la información de la imagen seleccionada, obtenida mediante el método getImageInfo.pickImageLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que inicia el Photo Picker. El contrato utilizado es ActivityResultContracts.PickVisualMedia(), que permite seleccionar medios visuales. El resultado se maneja en el lambda onResult, donde se actualizan selectedImageUri e imageInfo, y se llama a onImageSelected para notificar al padre.AsyncImage: Componente de Coil que muestra la imagen seleccionada si selectedImageUri no es null. Se aplica una forma redondeada y se ajusta el contenido para recortar la imagen (Crop).Text que muestra la información de la imagen si hay una imagen seleccionada.ImageAndVideo si quieres permitir la selección de vídeos también).Por último, se utiliza GalleryPicker en la MainActivity para mostrar el selector de imágenes.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5
6 setContent {
7 DocumentationT8_6Theme {
8 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
9 GalleryPicker(modifier = Modifier.padding(innerPadding)) { uri: Uri? ->
10 // Aquí se puede manejar el Uri seleccionado (o null si se canceló)
11 if (uri == null) {
12 Toast.makeText(
13 this,
14 "No se ha seleccionado ninguna imagen",
15 Toast.LENGTH_LONG
16 ).show()
17 return@GalleryPicker
18 } else Toast.makeText(this, "Uri: ${uri.path}", Toast.LENGTH_LONG).show()
19 }
20 }
21 }
22 }
23 }
24}Para la grabación de audio, Android dispone de la clase MediaRecorder, que permite capturar sonido desde el micrófono y codificarlo en distintos formatos, como AAC, AMR o MP3 (dependiendo del dispositivo).
Para grabar audio, es necesario solicitar el permiso RECORD_AUDIO en el archivo AndroidManifest.xml:
Si el audio se guarda en MediaStore (colección de música), no será necesario el permiso WRITE_EXTERNAL_STORAGE en Android 10 o superior.
1<uses-permission android:name="android.permission.RECORD_AUDIO" />En Android 13 (API 33) se introducen permisos granulares (READ_MEDIA_AUDIO, READ_MEDIA_IMAGES, READ_MEDIA_VIDEO), pero no son necesarios para insertar archivos propios en MediaStore.
La clase MediaRecorder permite configurar y controlar la grabación de audio. A continuación, se describen los pasos básicos para utilizar esta clase:
setAudioSource(MediaRecorder.AudioSource.MIC)MPEG_4AACsetOutputFile), en este caso, un descriptor de archivo obtenido de MediaStore.prepare() y luego start().stop() y liberar recursos con release().A continuación, se muestra una implementación completa y funcional de una grabadora de audio en Jetpack Compose. En el ejemplo podrás ver cómo solicitar permisos, iniciar y detener la grabación, y mostrar un cronómetro en tiempo real.
En primer lugar se creará el método encargado de formatear el tiempo en segundos a un formato mm:ss.
1private fun formatTime(seconds: Long): String {
2 val m = seconds / 60
3 val s = seconds % 60
4 return "%02d:%02d".format(m, s)
5}Como puedes ver, el método formatTime toma un valor en segundos y lo convierte en una cadena con el formato mm:ss, donde mm representa los minutos y ss los segundos, ambos con dos dígitos. A continuación, se implementa el método createMediaStoreOutput, que se encargará de crear un archivo de salida en MediaStore y devolver su URI y descriptor de archivo.
1private fun createMediaStoreOutput(context: Context): Pair<Uri, ParcelFileDescriptor> {
2 val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
3 val name = "REC_$timestamp.m4a"
4
5 val values = ContentValues().apply {
6 put(MediaStore.Audio.Media.DISPLAY_NAME, name)
7 put(MediaStore.Audio.Media.MIME_TYPE, "audio/mp4")
8 put(MediaStore.Audio.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MUSIC}/PMDM")
9 }
10
11 val resolver: ContentResolver = context.contentResolver
12 val uri = resolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values)
13 ?: throw IllegalStateException("No se pudo crear el archivo en MediaStore")
14
15 val pfd = resolver.openFileDescriptor(uri, "w")
16 ?: throw IllegalStateException("No se pudo abrir el descriptor de archivo")
17
18 return uri to pfd
19}Si se entra en detalle, el método createMediaStoreOutput realiza las siguientes acciones:
REC_yyyyMMdd_HHmmss.m4a.ContentValues para definir los metadatos del archivo, incluyendo el nombre, el tipo MIME y la ruta relativa dentro del directorio de música.ContentResolver para insertar un nuevo registro en MediaStore, obteniendo la URI del archivo creado.ParcelFileDescriptor en modo escritura ("w") para el archivo recién creado.MediaRecorder.Ya por último, se implementa el composable AudioRecorderScreen, que es la interfaz de usuario para la grabadora de audio.
1@Composable
2fun AudioRecorderScreen(modifier: Modifier = Modifier) {
3 val contxt = LocalContext.current
4 var isRecording by remember { mutableStateOf(false) }
5 var elapsedTime by remember { mutableStateOf(0L) }
6 var recorder by remember { mutableStateOf<MediaRecorder?>(null) }
7 var outputUri by remember { mutableStateOf<Uri?>(null) }
8
9 // Lanzador para solicitar permisos
10 val requestPermission = rememberLauncherForActivityResult(
11 contract = ActivityResultContracts.RequestPermission()
12 ) { granted ->
13 if (!granted)
14 Toast.makeText(contxt, "Permiso denegado", Toast.LENGTH_SHORT).show()
15 }
16
17 // Se lanza la petición de permisos al inicio, solo una vez (key1 = Unit).
18 LaunchedEffect(Unit) {
19 requestPermission.launch(Manifest.permission.RECORD_AUDIO)
20 }
21
22 // Cronómetro
23 LaunchedEffect(isRecording) {
24 if (isRecording) {
25 elapsedTime = 0
26 while (isActive && isRecording) {
27 delay(1000)
28 elapsedTime++
29 }
30 }
31 }
32
33 Column(
34 modifier = modifier
35 .fillMaxSize()
36 .padding(24.dp),
37 verticalArrangement = Arrangement.spacedBy(16.dp)
38 ) {
39 Text("Grabadora de audio", style = MaterialTheme.typography.titleLarge)
40 Text("Tiempo: ${formatTime(elapsedTime)}")
41
42 Button(
43 onClick = {
44 if (isRecording) {
45 recorder?.stop()
46 recorder?.release()
47 recorder = null
48 isRecording = false
49 Toast.makeText(contxt, "Grabación finalizada", Toast.LENGTH_SHORT).show()
50 } else {
51 try {
52 val (uri, fd) = createMediaStoreOutput(contxt)
53 outputUri = uri
54
55 recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
56 MediaRecorder(contxt)
57 } else {
58 MediaRecorder()
59 }.apply {
60 setAudioSource(MediaRecorder.AudioSource.MIC)
61 setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
62 setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
63 setAudioEncodingBitRate(128000)
64 setAudioSamplingRate(44100)
65 setOutputFile(fd.fileDescriptor)
66 prepare()
67 start()
68 }
69 isRecording = true
70 } catch (e: Exception) {
71 Toast.makeText(
72 contxt,
73 "Error: ${e.localizedMessage}",
74 Toast.LENGTH_SHORT
75 ).show()
76 }
77 }
78 }
79 ) {
80 Text(if (isRecording) "Detener" else "Grabar")
81 }
82
83 outputUri?.let {
84 Text("Archivo guardado en:\n$it", style = MaterialTheme.typography.bodySmall)
85 }
86 }
87}Si se analiza el código, el composable AudioRecorderScreen realiza las siguientes funciones:
Define varios estados:
isRecording: Indica si la grabación está en curso.elapsedTime: Almacena el tiempo transcurrido en segundos.recorder: Mantiene una referencia al objeto MediaRecorder.outputUri: Almacena la URI del archivo de audio guardado.Utiliza rememberLauncherForActivityResult para crear un lanzador que solicita el permiso RECORD_AUDIO. Si el permiso es denegado, muestra un mensaje de error.
LaunchedEffect(Unit): Solicita el permiso de grabación de audio cuando el composable se monta por primera vez.
LaunchedEffect(isRecording): Implementa un cronómetro que incrementa elapsedTime cada segundo mientras isRecording es true.
La UI se compone de una columna que contiene:
MediaRecorder, actualiza el estado y muestra un mensaje.MediaStore, configura y comienza la grabación con MediaRecorder. Si ocurre un error, muestra un mensaje de error.outputUri no es null, muestra la URI del archivo guardado.Android proporciona acceso a una variedad de sensores integrados en los dispositivos, como acelerómetros, giroscopios, sensores de luz, proximidad, y otros. Estos sensores permiten que las aplicaciones puedan recopilar datos del entorno y del movimiento del dispositivo para ofrecer experiencias más interactivas y contextuales.
Los sensores se pueden utilizar para una variedad de propósitos, como detectar la orientación del dispositivo, medir la luz ambiental para ajustar el brillo de la pantalla o detectar el movimiento del dispositivo para activar ciertas funciones. Para acceder a los sensores en Android, se utiliza el SensorManager, que proporciona métodos para registrar y desregistrar oyentes de sensores, así como para obtener información sobre los sensores disponibles en el dispositivo.
Obviamente, el uso de sensores puede afectar al consumo de batería del dispositivo, por lo que es importante gestionar adecuadamente el registro y desregistro de los oyentes de sensores para minimizar el impacto en la duración de la batería.
El siguiente composable muestra cómo utilizar el acelerómetro para medir los cambios en la aceleración del dispositivo y mostrar los valores de los ejes X, Y y Z en tiempo real.
1@Composable
2fun AccelerometerSensor() {
3 val contxt = LocalContext.current
4 val sensorManager = contxt.getSystemService(Context.SENSOR_SERVICE) as SensorManager
5 val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
6
7 var x by remember { mutableStateOf(0f) }
8 var y by remember { mutableStateOf(0f) }
9 var z by remember { mutableStateOf(0f) }
10
11 // Filtro paso-bajo simple, lowPass se usa para suavizar los valores del sensor,
12 // evitando cambios bruscos en la UI.
13 val alpha = 0.1f
14 fun lowPass(new: Float, old: Float) = old + alpha * (new - old)
15
16 // Registrar el listener del sensor cuando el Composable entre en composición.
17 // DisposableEffect se asegura de que el listener se desregistre cuando el Composable se elimine.
18 DisposableEffect(Unit) {
19 if (accelerometer == null) {
20 return@DisposableEffect onDispose {}
21 }
22
23 val listener = object : SensorEventListener {
24 override fun onSensorChanged(event: SensorEvent?) {
25 if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) {
26 x = lowPass(event.values[0], x)
27 y = lowPass(event.values[1], y)
28 z = lowPass(event.values[2], z)
29 }
30 }
31
32 override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
33 }
34
35 sensorManager.registerListener(
36 listener,
37 accelerometer,
38 SensorManager.SENSOR_DELAY_NORMAL
39 )
40
41 // Asegurar que se desregistre el listener cuando el Composable se elimine.
42 onDispose {
43 sensorManager.unregisterListener(listener)
44 }
45 }
46
47 Column {
48 Text("Acelerómetro (m/s²):")
49 Text("X: ${"%.2f".format(x)} Y: ${"%.2f".format(y)} Z: ${"%.2f".format(z)}")
50 if (accelerometer == null) Text("No disponible en este dispositivo.")
51 }
52}Las acciones principales que realiza este composable son:
SensorManager y el sensor de tipo TYPE_ACCELEROMETER.x, y y z para almacenar los valores del acelerómetro.DisposableEffect para registrar un SensorEventListener cuando el composable entra en composición, y se asegura de desregistrar el listener cuando el composable se elimina.SENSOR_DELAY_NORMAL), pero se pueden usar otros valores según la necesidad de la aplicación, SENSOR_DELAY_GAME, por ejemplo, es más rápido y da una respuesta más fluida, pero consume más batería.onSensorChanged, actualiza los valores de x, y y z utilizando el filtro de paso bajo.Si en lugar de utilizar el acelerómetro quieres usar otro sensor, por ejemplo, el giroscopio, simplemente cambia la línea:
val gyroscope = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)Y en lugar de comprobar los valores de X, Y y Z del acelerómetro, lo harás con los del giroscopio, que son las tasas de rotación alrededor de los ejes X, Y y Z, que serían pitch/roll/yaw, valores de rotación en radianes por segundo.
SharedPreferences y DataStore.El almacenamiento en Android permite conservar información más allá del ciclo de vida de la aplicación. Se distinguen distintos tipos:
Desde Android 10, el acceso a memoria externa está limitado por razones de seguridad. Android introduce el concepto de Scoped Storage, que restringe el acceso directo a directorios externos y fomenta el uso de MediaStore o Storage Access Framework (SAF).
Los archivos internos son privados de la aplicación. Se eliminan cuando esta se desinstala.
1// Guardar un archivo de texto en memoria interna
2fun saveToFile(context: Context, fileName: String, contenido: String) {
3 context.openFileOutput(fileName, Context.MODE_APPEND).use { output ->
4 output.write(contenido.toByteArray())
5 }
6}Si utilizas Context.MODE_PRIVATE, el archivo se sobrescribirá cada vez que se guarde.
1// Leer un archivo de texto desde memoria interna
2fun readFromFile(context: Context, fileName: String): String {
3 return try {
4 context.openFileInput(fileName).bufferedReader().use { it.readText() }
5 } catch (e: Exception) {
6 "Error al leer el archivo: ${e.message}"
7 }
8}Este código abre un archivo en modo lectura y devuelve su contenido como cadena, manejando posibles excepciones, como que el archivo no exista.
Puedes consultar los archivos guardados en memoria interna desde Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/files/ para ver los archivos de tu aplicación.
SharedPreferences es un sistema clave-valor para almacenar configuraciones simples.
1// Guardar preferencia
2fun savePreference(context: Context, key: String, value: String) {
3 val prefs = context.getSharedPreferences("MisPreferencias", Context.MODE_PRIVATE)
4 prefs.edit { putString(key, value) }
5}
6
7// Leer preferencia
8fun readPreference(context: Context, key: String): String? {
9 val prefs = context.getSharedPreferences("MisPreferencias", Context.MODE_PRIVATE)
10 // Leer preferencia
11 return prefs.getString(key, "Invitado")
12}En este ejemplo, se guarda y recupera una cadena asociada a una clave en las preferencias compartidas. Un posible resultado es el siguiente fichero XML:
1<map>
2 <string name="nombre">Javier</string>
3</map>Puedes consultar las preferencias guardadas en Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/shared_prefs/ para ver el archivo XML con las preferencias de tu aplicación.
DataStore es la alternativa moderna a SharedPreferences.
Basado en corrutinas y Flow → asincrónico y seguro.
Tipos:
1implementation("androidx.datastore:datastore-preferences:1.1.7") 1val Context.dataStore by preferencesDataStore("ajustes")
2val TEMA_OSCURO = booleanPreferencesKey("tema_oscuro")
3
4suspend fun saveThemeMode(context: Context, oscuro: Boolean) {
5 context.dataStore.edit { prefs ->
6 prefs[TEMA_OSCURO] = oscuro
7 }
8}
9
10fun readThemeMode(context: Context): Flow<Boolean> {
11 val temaFlow: Flow<Boolean> = context.dataStore.data.map { prefs ->
12 prefs[TEMA_OSCURO] ?: false
13 }
14 return temaFlow
15}En este ejemplo, se guarda y recupera una preferencia booleana que indica si el tema oscuro está activado. El valor predeterminado es false (tema claro).
Uso desde una Activity:
1// Guardar el modo tema en DataStore
2CoroutineScope(Dispatchers.IO).launch {
3 saveThemeMode(contxt, it)
4}
5
6...
7// Leer el modo tema desde DataStore
8LaunchedEffect(checked) {
9 readThemeMode(contxt).collect { valor ->
10 checked = valor
11 }
12}Los archivos externos (fotos, música, documentos) son accesibles fuera de la aplicación. Desde Android 10, solo es posible acceder mediante rutas específicas o el uso de SAF.
SAF permite a la aplicación abrir, guardar y seleccionar documentos mediante un explorador de archivos seguro, sin acceso directo al almacenamiento.
1val contxt = LocalContext.current
2var uriString by remember { mutableStateOf("") }
3var content by remember { mutableStateOf("") }
4
5val openDocumentLauncher = rememberLauncherForActivityResult(
6 contract = ActivityResultContracts.OpenDocument(),
7 onResult = { uri ->
8 uri?.let {
9 uriString = it.toString()
10 content = contxt.contentResolver.openInputStream(it)?.bufferedReader().use { reader ->
11 reader?.readText() ?: "Error al leer el archivo"
12 }
13 }
14 }
15)Este código crea un lanzador para seleccionar un documento. Al seleccionar un archivo, se obtiene su URI y se lee su contenido, permitiendo mostrarlo en la interfaz.
El almacenamiento puede exponer datos sensibles si no se protege adecuadamente, pero Android ofrece mecanismos de cifrado. Desde 2025 la librería Jetpack Security Crypto (que contenía EncryptedSharedPreferences) está deprecada. La recomendación de Google es apoyarse en APIs de plataforma (Android Keystore + JCA) y en DataStore como reemplazo moderno de SharedPreferences para preferencias no cifradas, si se necesita cifrado, cifra el valor antes de persistirlo.
Para aplicar el siguiente ejemplo se añadirá la dependencia utilizada en el punto 9.4., DataStore. Además, se utilizará el patrón de diseño MVVM y Repository para abstraer la lógica de acceso a datos, manteniendo el código limpio y modular.
+-------------------+
| UI (Compose) |
+-------------------+
|
v
+-------------------+
| ViewModel |
+-------------------+
|
v
+-------------------+
| Repository |
+-------------------+
|
v
+-------------------+
| CryptoManager |
+-------------------+data/security/CryptoManager: utilidades JCA (Keystore + AES-GCM).data/repository/SecurePrefsRepository: persiste en DataStore los valores cifrados.ui/SecurePrefsViewModel: orquesta lectura/escritura.ui/SecurePrefsScreen: ejemplo Compose para guardar/leer un token/API key.En primer lugar, se creará el objeto CryptoManager en el paquete data/security que gestionará la generación de claves y el cifrado/descifrado de datos.
1import android.security.keystore.KeyGenParameterSpec
2import android.security.keystore.KeyProperties
3import java.security.KeyStore
4import javax.crypto.Cipher
5import javax.crypto.KeyGenerator
6import javax.crypto.SecretKey
7import javax.crypto.spec.GCMParameterSpec
8
9object CryptoManager {
10
11 private const val ANDROID_KEYSTORE = "AndroidKeyStore"
12 private const val KEY_ALIAS = "prefs_aes_key"
13 private const val TRANSFORMATION = "AES/GCM/NoPadding"
14 private const val GCM_TAG_BITS = 128
15
16 // Obtiene o crea una clave simétrica AES en el Keystore (no exportable).
17 fun getOrCreateKey(): SecretKey {
18 val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
19 (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey?.let { return it }
20
21 val spec = KeyGenParameterSpec.Builder(
22 KEY_ALIAS,
23 KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
24 )
25 .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
26 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
27 .setUserAuthenticationRequired(false) // Poner a true si quieres exigir biometría/bloqueo.
28 .build()
29
30 return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
31 .apply { init(spec) }
32 .generateKey()
33 }
34
35 // Clase para mantener el IV junto a los datos cifrados.
36 data class Ciphertext(val iv: ByteArray, val bytes: ByteArray)
37
38 // Cifra datos con AES-GCM y IV aleatorio generado por el Cipher.
39 fun encrypt(plain: ByteArray, key: SecretKey = getOrCreateKey()): Ciphertext {
40 val cipher = Cipher.getInstance(TRANSFORMATION)
41 cipher.init(Cipher.ENCRYPT_MODE, key)
42 val iv = cipher.iv
43 val enc = cipher.doFinal(plain)
44 return Ciphertext(iv, enc)
45 }
46
47 // Descifra datos con AES-GCM usando el IV asociado.
48 fun decrypt(ct: Ciphertext, key: SecretKey = getOrCreateKey()): ByteArray {
49 val cipher = Cipher.getInstance(TRANSFORMATION)
50 val spec = GCMParameterSpec(GCM_TAG_BITS, ct.iv)
51 cipher.init(Cipher.DECRYPT_MODE, key, spec)
52 return cipher.doFinal(ct.bytes)
53 }
54}El objeto CryptoManager proporciona métodos para generar o recuperar una clave AES almacenada en el Keystore de Android, así como para cifrar y descifrar datos utilizando AES-GCM.
getOrCreateKey(): obtiene o crea una clave AES en el Keystore.Ciphertext encapsula el IV y los datos cifrados, necesarios para el descifrado.encrypt(plain: ByteArray): cifra un array de bytes y devuelve un objeto Ciphertext que contiene el IV y los datos cifrados.decrypt(ct: Ciphertext): descifra los datos utilizando el IV almacenado en el objeto Ciphertext.A continuación, se crea la clase SecurePrefsRepository en el paquete data/repository. Esta clase utilizará DataStore para almacenar los datos cifrados.
1import android.content.Context
2import androidx.datastore.preferences.core.Preferences
3import androidx.datastore.preferences.core.edit
4import androidx.datastore.preferences.core.stringPreferencesKey
5import androidx.datastore.preferences.preferencesDataStore
6import kotlinx.coroutines.flow.Flow
7import kotlinx.coroutines.flow.map
8import android.util.Base64
9import es.javiercarrasco.documentationt9_1.data.security.CryptoManager
10
11private val Context.dataStore by preferencesDataStore(name = "secure_prefs")
12
13class SecurePrefsRepository(private val context: Context) {
14
15 // Claves para el DataStore.
16 private val KEY_IV = stringPreferencesKey("token_iv_b64")
17 private val KEY_CT = stringPreferencesKey("token_ct_b64")
18
19 // Guarda un token cifrado en DataStore (iv + ciphertext, ambos en Base64).
20 suspend fun saveToken(token: String) {
21 val ct = CryptoManager.encrypt(token.encodeToByteArray())
22 val ivB64 = Base64.encodeToString(ct.iv, Base64.NO_WRAP)
23 val dataB64 = Base64.encodeToString(ct.bytes, Base64.NO_WRAP)
24
25 // Almacena ambos en DataStore.
26 context.dataStore.edit { prefs ->
27 prefs[KEY_IV] = ivB64
28 prefs[KEY_CT] = dataB64
29 }
30 }
31
32 // Flujo que expone el token descifrado o null si no existe.
33 val tokenFlow: Flow<String?> = context.dataStore.data.map { prefs: Preferences ->
34 val ivB64 = prefs[KEY_IV]
35 val ctB64 = prefs[KEY_CT]
36 if (ivB64 == null || ctB64 == null) return@map null
37
38 val iv = Base64.decode(ivB64, Base64.NO_WRAP)
39 val data = Base64.decode(ctB64, Base64.NO_WRAP)
40 CryptoManager.decrypt(CryptoManager.Ciphertext(iv, data)).decodeToString()
41 }
42
43 // Flujo que expone el token cifrado en formato "iv|ciphertext" o null si no existe.
44 val encryptedFlow: Flow<String?> = context.dataStore.data.map { prefs ->
45 val iv = prefs[KEY_IV]
46 val ct = prefs[KEY_CT]
47 if (iv != null && ct != null) "$iv|$ct" else null
48 }
49
50 // Elimina el token almacenado.
51 suspend fun clearToken() {
52 context.dataStore.edit { it.remove(KEY_IV); it.remove(KEY_CT) }
53 }
54}Esta clase SecurePrefsRepository proporciona métodos para guardar, leer y eliminar un token de autenticación de forma segura utilizando DataStore y el CryptoManager para el cifrado.
saveToken(token: String): cifra el token y lo guarda en DataStore en formato Base64.tokenFlow: un flujo que emite el token descifrado o null si no existe.encryptedFlow: un flujo que emite el token cifrado en formato "iv|ciphertext" o null si no existe.clearToken(): elimina el token almacenado.DataStore sustituye a SharedPreferences, es asíncrono y consistente (Flow + corutinas). Aquí se utiliza como contenedor del Ciphertext, DataStore no cifra por sí mismo.
Se crea la clase SecurePrefsViewModel en el paquete ui, que se encargará de la interacción entre la UI y el repositorio.
1import androidx.lifecycle.ViewModel
2import androidx.lifecycle.viewModelScope
3import es.javiercarrasco.documentationt9_1.data.repository.SecurePrefsRepository
4import kotlinx.coroutines.flow.SharingStarted
5import kotlinx.coroutines.flow.StateFlow
6import kotlinx.coroutines.flow.stateIn
7import kotlinx.coroutines.launch
8
9class SecurePrefsViewModel(private val repo: SecurePrefsRepository) : ViewModel() {
10
11 // StateFlow para observar el token almacenado de forma segura.
12 val token: StateFlow<String?> = repo.tokenFlow
13 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
14
15 // StateFlow para observar el token cifrado (iv + ciphertext).
16 val encrypted: StateFlow<String?> = repo.encryptedFlow
17 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
18
19 // Función para guardar el token de forma segura.
20 fun saveToken(value: String) = viewModelScope.launch {
21 repo.saveToken(value)
22 }
23
24 // Función para borrar el token almacenado de forma segura.
25 fun clearToken() = viewModelScope.launch {
26 repo.clearToken()
27 }
28}El SecurePrefsViewModel expone un StateFlow para observar el token almacenado y proporciona métodos para guardar y eliminar el token de forma segura.
token: un StateFlow que emite el token actual o null.encrypted: un StateFlow que emite el token cifrado en formato "iv|ciphertext" o null.saveToken(value: String): guarda el token de forma segura.clearToken(): elimina el token almacenado.Finalmente, se crea una pantalla Compose SecurePrefsScreen en el paquete ui para interactuar con el usuario.
1@Composable
2fun SecurePrefsScreen(vm: SecurePrefsViewModel) {
3 val token by vm.token.collectAsState()
4 val encrypted by vm.encrypted.collectAsState()
5
6 var input by remember { mutableStateOf("") }
7 val isValid = input.isNotBlank()
8
9 Card {
10 Column(
11 Modifier
12 .fillMaxWidth()
13 .padding(16.dp),
14 verticalArrangement = Arrangement.spacedBy(8.dp)
15 ) {
16 Text(
17 text = "Almacenamiento seguro (Keystore + AES-GCM + DataStore)",
18 style = MaterialTheme.typography.titleSmall
19 )
20 OutlinedTextField(
21 modifier = Modifier.fillMaxWidth(),
22 value = input,
23 onValueChange = { input = it },
24 label = { Text("Token / API key") },
25 singleLine = true
26 )
27 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
28 Button(
29 modifier = Modifier.fillMaxWidth(.5f),
30 enabled = isValid,
31 onClick = { vm.saveToken(input.trim()) }) { Text("Guardar seguro") }
32 OutlinedButton(
33 modifier = Modifier.fillMaxWidth(),
34 onClick = { vm.clearToken() }) { Text("Eliminar") }
35 }
36 HorizontalDivider()
37 Text(
38 "Valor actual (descifrado): ${token ?: "—"}",
39 style = MaterialTheme.typography.bodyMedium
40 )
41 Text(
42 "Valor actual (cifrado, Base64): ${encrypted ?: "—"}",
43 style = MaterialTheme.typography.bodySmall,
44 color = Color.Gray
45 )
46 }
47 }
48}Para llamar a esta pantalla desde una Activity, será necesario crear el ViewModel y el Repository, pasando el contexto:
1val repo = SecurePrefsRepository(this)
2val vm = SecurePrefsViewModel(repo)
3
4SecurePrefsScreen(vm)Aquí puedes consultar la versión anterior de la documentación utilizada en el módulo PMDM de CFGS.