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 status
If the firewall is active, you will have to open some ports. If it is inactive, you won’t have any problem with the FTP server (but there might be a problem with the security of your server). If you need to open ports in your firewall for the FTP server: 20, 21 for FTP, and 990 to enable TLS.
$ sudo ufw allow 20,21,990/tcp
Additionally, we will need to open some ports for passive mode, for example, from 40000 to 50000.
$ sudo ufw allow 40000:50000/tcp
Now, we are going to create a specific user for FTP and configure its home directory to be secure, with no chance to exit to the main directories of the system.
First, we add a user for FTP connections.
$ sudo adduser maricheloftp
You will be asked for a password and some other information. We only need to enter the password; the other information can remain empty.
Vsftpd jails local users in their home directory, and it may not be writable by the user when using shell connections instead of FTP. To avoid this and continue to secure our FTP connections, we are going to create a specific directory for FTP connections where the user, through FTP, will be unable to exit this directory.
$ sudo mkdir /home/maricheloftp/ftp
$ sudo chown nobody:nogroup /home/maricheloftp/ftp
$ sudo chmod a-w /home/maricheloftp/ftp
If we verify the permissions:
$ sudo ls -la /home/maricheloftp/ftp
dr-xr-xr-x 3 nobody nogroup 4096 Feb 21 17:24 .
drwxr-xr-x 3 maricheloftp maricheloftp 4096 Feb 21 18:48 ..
Now, we are going to create a directory for uploading files.
$ sudo mkdir /home/maricheloftp/ftp/files
$ sudo chown maricheloftp:maricheloftp /home/maricheloftp/ftp/files
$ sudo ls -la /home/maricheloftp/ftp
dr-xr-xr-x 3 nobody nogroup 4096 Feb 21 17:24 .
drwxr-xr-x 3 maricheloftp maricheloftp 4096 Feb 21 18:48 ..
drwxr-xr-x 2 maricheloftp maricheloftp 4096 Feb 21 18:57 files
And here, we can create a file to test our server.
$ echo "test file" | sudo tee /home/maricheloftp/ftp/files/test.txt
$ sudo nano /etc/vsftpd.conf
Here, you have to change or uncomment the following lines:
anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YES
And add these ones (to enable passive FTP ports):
pasv_min_port=40000
pasv_max_port=50000
and these ones:
user_sub_token=$USER
local_root=/home/$USER/ftp
These last two lines will ensure that local users, when connecting through FTP, will access directly to the ftp directory and not to their home directory.
To allow FTP access only to some users, we add these lines:
userlist_enable=YES
userlist_file=/etc/vsftpd.userlist
userlist_deny=NO
We create the file vsftpd.userlist with the names of the users that will be allowed to use FTP.
$ echo "maricheloftp" | sudo tee -a /etc/vsftpd.userlist
Restart the service.
$ sudo service vsftpd restart
To check if the service is running okay, we can see its status with:
$ sudo service vsftpd status
Enter an editor with the information; to exit, press q.
You can test the FTP access with the ftp command available in Linux and Windows. For Mac, you can use Finder and the option Go and Connect to a Server.
$ ftp maricheloftp@ftp.marich
elo.es
$ ftp ftp.marichelo.es
You will be asked for the password, and you can list with ls and change directory with cd. If you try to connect without inserting a user or using a valid user but not the one included in the vsftpd.userlist file, the connection will be refused.
Now, you can check your Java sources to connect to an FTP server.
FTP does not encrypt data transactions, including user credentials, to avoid this hole in security we are going to enable TLS/SSL to provide encryption. First, we have to create the SSL certificates in our server to use them with vsftpd. Here we use openssl to create a certificate valid for 1 year and both the private key and the certificate will be located in the same file. This is only one instruction, only one line.
$ sudo openssl req -x509 -nodes -days 365 -newkey
rsa:2048 -keyout /etc/ssl/private/vsftpd.pem -out
/etc/ssl/private/vsftpd.pem
You will be asked about your address information that will be incorporated in your certificate request.
Once you have created the certificates, we will open the file vsftpd.conf again. We will comment these lines:
#rsa_cert_file=/etc/ssl/certs/ssl-cer-snakeoil.pem
#rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
And we will add these ones:
rsa_cert_file=/etc/ssl/private/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.pem
Now, we are going to force the use of SSL always in our FTP server. Change ssl_enable to YES:
ssl_enable=YES
Next, add the following lines to explicitly deny anonymous conections over SSL and require SSL for both data transfer and logins:
allow_anon_ssl=NO
force_local_data_ssl=YES
force_local_logins_ssl=YES
With the following lines we will configure our server to use TLS:
ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NO
And finally, we will add these 2 options to don’t require reuse of ssl because it can break many FTP clients and to use key lenghts equal or greater than 128 bits:
require_ssl_reuse=NO
ssl_ciphers=HIGH
Now we will close the configuration file and restart the service. Now, if we try to to connect with the command ftp we will have a message saying that non-anonymous sessions must use encryption.
To try it now we will need a FTP client that supports TLS as FileZilla or our Java program using the class FTPSClient.
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
FileChooser
dialog to select a file that will be uploaded to the FTP server (current directory). Dowload/Enter: Only active when something in the list is selected. Two situations can happen
- If the selected item is a directory, the application will change to that directory listing its files.
- If it’s a normal file, the application will open a
DirectoryChooser
dialog to select a local directory and download the file there. Go up: Will change to the parent directory and list its contents. Delete: Only active when something in the list is selected. Will delete the file.There will be a label where you’ll show every action’s result, whether it’s a success or a failure. When it’s needed, refresh the list’s contents.
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
,Address
andGetPersonResponse
classes, apart from the main application) to connect to a server and retrieve a person information.You can use the Node server provided to you in this session’s resources, and access the localhost/services/example URL to get the JSON data back. You can also edit the code of this server to change the URI or the port, if you want to.
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
GetSumService
class to access the sum service and retrieve the sum of the two digits sent as parameters.As in previous exercise, you can use the Node services provided to you in this session. In this case, you should access localhost/services/sum with the two parameters needed (for instance, localhost/services/sum/5/2 should return 7 as a result). You can also change the URI or port in the Node project, if you want to.
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
, etc.1val i: Int = 42 // Constante inmutable
2var mutableNum = 10 // Variable mutable
3val d: Double = i.toDouble()
4val c: Char = 'c'
5val iFromChar = c.code
val
define constantes, var
permite cambios.1val bitwiseOr = FLAG1 or FLAG2
2val bitwiseAnd = FLAG1 and FLAG2
1val s = "Ejemplo"
2val c = s[3] // Accede a 'm'
3val s2 = "Example"
4for (c2 in s2) print(c2) // Recorre cada carácter
En Java, el código generalmente es defensivo, por lo que se debe comprobar en todo momento que no se produzca un null
y prevenir NullPointerException
. Kotlin, en cambio, es null safety, lo que significa que se puede definir si un objeto puede o no ser null
utilizando para ello el operador ?. Fíjate en los siguientes ejemplos.
1fun main(args: Array<String>) {
2 // No compilaría, Artista no puede ser nulo.
3 var notNullArtista: Artista = null
4
5 // Artista puede ser nulo.
6 val artista: Artista? = null
7
8 // No compilará, artista podría ser nulo.
9 artista.toString()
10
11 // Mostrará por pantalla artista si es distinto de nulo.
12 artista?.toString()
13
14 // No necesitaríamos utilizar el operador ? si previamente
15 // comprobamos si es nulo.
16 if (artista != null) {
17 artista.toString()
18 }
19 // Esta operación la utilizaremos si estamos completamente seguros
20 // que no será nulo. En caso contrario se producirá una excepción.
21 artista!!.toString()
22
23 // También podríamos utilizar el operador Elvis (?:) para dar
24 // una alternativa en caso que el objeto sea nulo.
25 val nombre = artista?.nombre ?: "vacío"
26}
!!
) 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
- else
Forma básica:
1val a = 5
2val b = 10
3if (a > b) {
4 println("a es mayor que b")
5} else {
6 println("a no es mayor que b")
7}
Como expresión, devolviendo un valor:
1val max = if (a > b) a else b
2println("El máximo es $max")
when
La sentencia when
podría decirse que es el equivalente de switch
en Java, aunque con algunas diferencias:
when
no necesita la sentencia break
, switch
sí.when
se puede utilizar para comprobar datos de un rango (1..6
), switch
no.when
es más flexible que switch
.when
permite la verificación de tipos, switch
no.when
permite diferentes tipos de verificación de tipos de datos, switch
no.switch
tiene limitaciones y solo admite tipos primitivos, enum
y string
, when
no.Forma básica:
1val x = 3
2when (x) {
3 1 -> println("Uno")
4 2 -> println("Dos")
5 3 -> println("Tres")
6 else -> println("Otro número")
7}
Con múltiples condiciones y rangos:
1when (x) {
2 1, 2 -> println("Uno o Dos")
3 in 3..5 -> println("Entre 3 y 5")
4 else -> println("Otro")
5}
Como expresión (devolviendo un valor):
1val mensaje = when (x) {
2 1 -> "Uno"
3 2 -> "Dos"
4 else -> "Otro"
5}
6println(mensaje)
for
Recorrer una lista:
1val lista = listOf("A", "B", "C")
2for (item in lista) {
3 println(item)
4}
Recorrer un rango:
1for (i in 1..5) {
2 println(i)
3}
Con índices:
1for ((index, value) in lista.withIndex()) {
2 println("Elemento $index: $value")
3}
while
1var contador = 0
2while (contador < 3) {
3 println("Contador: $contador")
4 contador++
5}
do-while
1var numero = 0
2do {
3 println("Número: $numero")
4 numero++
5} while (numero < 3)
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.init
También se dispone en Kotlin del bloque init
, es un inicializador que se ejecutará de manera automática al crearse una instancia de la clase, inmediatamente después del constructor primario. Es especialmente útil para realizar operaciones de inicialización complejas, validaciones o cálculos adicionales con los parámetros del constructor. Se puede declarar más de un bloque init
, y se ejecutarán en el orden en que aparecen en el código. Aunque las propiedades pueden inicializarse directamente, init
permite incluir lógica adicional.
1class Persona(nombre: String, apellido: String) {
2 var nombreCompleto: String
3
4 init {
5 // Se ejecutará tras el constructor primario
6 nombreCompleto = "$nombre $apellido"
7 println("Inicializando Persona: $nombreCompleto")
8 }
9}
10
11fun main() {
12 val persona = Persona("Patricia", "Aracil")
13 println(persona.nombreCompleto)
14}
Salida esperada:
1Inicializando Persona: Patricia Aracil
2Patricia Aracil
Por defecto, las clases en Kotlin son final
, es decir, no se pueden heredar. Para permitir la herencia, se debe marcar explícitamente con open
.
1open class Persona(val nombre: String) {
2 fun presentarse() = println("Hola, soy $nombre")
3}
4
5class Estudiante(nombre: String, val curso: String) : Persona(nombre) {
6 fun mostrarCurso() = println("Curso: $curso")
7}
8
9fun main() {
10 val estudiante = Estudiante("Carlos", "2º DAM")
11 estudiante.presentarse() // Hereda de Persona
12 estudiante.mostrarCurso()
13}
open class
permite que otra clase herede de Persona
.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.
filter
Filtra elementos que cumplan una condición específica.
1val numeros = listOf(1, 2, 3, 4, 5)
2val pares = numeros.filter { it % 2 == 0 }
3println(pares) // [2, 4]
Descripción: Selecciona elementos para los que la condición es verdadera.
map
Transforma cada elemento.
1val nombres = listOf("Ana", "Luis", "Eva")
2val mayusculas = nombres.map { it.uppercase() }
3println(mayusculas) // [ANA, LUIS, EVA]
Descripción: Aplica una transformación a cada elemento.
sortedBy
y sortedDescending
Ordena elementos por un criterio.
1val personas = listOf(Persona("Ana", "Pérez"), Persona("Luis", "Gómez"))
2val ordenadas = personas.sortedBy { it.nombre }
Descripción: Ordena ascendente o descendente por una clave.
groupBy
Agrupa elementos por una clave.
1val animales = listOf("gato", "perro", "gallina", "caballo")
2val agrupados = animales.groupBy { it.first() }
Descripción: Agrupa por el resultado de una función clave.
any
y all
Comprueba condiciones.
1val edades = listOf(18, 20, 25)
2println(edades.any { it >= 21 }) // true
3println(edades.all { it >= 18 }) // true
Descripción: any
verifica si alguno cumple, all
si todos cumplen.
count
Cuenta elementos que cumplen una condición.
1println(edades.count { it >= 21 }) // 1
distinct
y distinctBy
Elimina duplicados.
1val duplicados = listOf(1, 2, 2, 3, 3, 3)
2println(duplicados.distinct()) // [1, 2, 3]
take
y drop
Selecciona o descarta elementos.
1val lista = listOf(1, 2, 3, 4, 5)
2println(lista.take(3)) // [1, 2, 3]
3println(lista.drop(2)) // [3, 4, 5]
zip
y unzip
Combina listas.
1val nombres = listOf("Ana", "Luis")
2val edades = listOf(25, 30)
3val combinados = nombres.zip(edades)
4println(combinados) // [(Ana, 25), (Luis, 30)]
flatten
Aplana listas de listas, es decir, convierte una lista de listas (colección anidada) en una única lista.
1val listas = listOf(listOf(1, 2), listOf(3, 4))
2println(listas.flatten()) // [1, 2, 3, 4]
reduce
y fold
Acumulan elementos.
1val suma = numeros.reduce { acc, num -> acc + num }
2val sumaConInicial = numeros.fold(10) { acc, num -> acc + num }
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:
@Composable
en 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 contador = remember { mutableStateOf(0) }
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.
Consejo, evitar operaciones complejas en métodos
@Composable
, los métodos@Composable
deben ser rápidos y sin efectos secundarios. Las operaciones intensivas deben realizarse fuera de estas funciones y sus resultados deben pasarse como parámetros.
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() {
3 var texto by remember { mutableStateOf("") }
4 val esTextoLargo by remember {
5 derivedStateOf { texto.length > 10 }
6 }
7
8 Column(modifier = Modifier.padding(16.dp)) {
9 TextField(
10 value = texto,
11 onValueChange = { texto = it },
12 label = { Text("Ingrese texto") }
13 )
14 if (esTextoLargo) {
15 Text("El texto es largo")
16 }
17 }
18}
En este código de ejemplo, la variable esTextoLargo
se actualiza únicamente cuando la longitud de texto
cambie y supere la longitud establecida, evitando las recomposiciones innecesarias.
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: RESUMED
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.
Modifier.layout
permite crear Composables con disposición personalizada, es decir, que no dependen de los layouts predeterminados como Column
, Row
o Box
, sino que definen sus propias medidas y ubicación del contenido.
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}
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)
Modifier.width(IntrinsicSize.Max)
Modifier.height(IntrinsicSize.Min)
Modifier.height(IntrinsicSize.Max)
El siguiente ejemplo asegura que ambos textos tengan la altura del más alto, gracias a IntrinsicSize.Min
.
1@Composable
2fun DemoIntrinsicSize() {
3 Row(
4 modifier = Modifier.height(IntrinsicSize.Min)
5 ) {
6 Text(
7 text = "Texto alto",
8 modifier = Modifier
9 .background(Color.Red)
10 .padding(8.dp)
11 )
12 HorizontalDivider(
13 color = Color.Black,
14 modifier = Modifier
15 .fillMaxHeight()
16 .width(1.dp)
17 )
18 Text(
19 text = "Texto más corto",
20 modifier = Modifier
21 .background(Color.Green)
22 .padding(8.dp)
23 )
24 }
25}
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}
23```~
24
25Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye `Modifier.size(200.dp)` por `Modifier.fillMaxSize()`,
26
27Visualización del ejemplo:
28
29```kotlin { lineNos="inline" title="Kotlin" }
30@Preview(showBackground = true)
31@Composable
32fun VistaPreviaCanvas() {
33 EjemploCanvasSencillo()
34}
Como puedes observar, el uso de Canvas
es 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
.
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.ScrollView
El desplazamiento de contenido en Compose puede aplicarse directamente sobre Column
y Row
con los modificadores verticalScroll
y horizontalScroll
, que equivalen al clásico ScrollView
en XML.
Un scroll de este tipo, no es lazy, como se verá más adelante, carga todo el contenido.
1Column(
2 modifier = Modifier
3 .fillMaxSize()
4 .verticalScroll(rememberScrollState())
5 .padding(innerPadding)
6) {
7 ...
8}
9```~
10
11```kotlin { lineNos="inline" title="Kotlin" }
12Row(
13 modifier = Modifier
14 .fillMaxWidth()
15 .horizontalScroll(rememberScrollState())
16 .padding(16.dp)
17) {
18 ...
19}
El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.
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 |
items
Por ejemplo, se crea un companion que se pasará en la llamada al método desde onCreate()
que contrendrá la lista de datos.
1class MainActivity : ComponentActivity() {
2 companion object {
3 val itemsList = List(100) { "Item #$it" }
4 }
5 ...
6}
El Composable que permite crear una lista básica utilizando items
puede ser como se muestra:
1@Composable
2fun ExampleLazyColum(itemsList: List<String>) {
3 LazyColumn(
4 modifier = Modifier.fillMaxSize(),
5 verticalArrangement = Arrangement.spacedBy(8.dp) // Espacio entre los elementos.
6 ) {
7 items(itemsList) { item ->
8 Text(text = item)
9 }
10 }
11}
Se suele utilizar para listas de elementos de tipo simple o complejo, y puede usarse con o sin key para mejorar el rendimiento.
itemsIndexed
Añade la siguiente lista al companion:
1val dias = listOf("Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo")
El Composable utilizando itemsIndexed
podría ser como se muestra:
1fun ExampleLazyColum2(dias: List<String>) {
2 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
3 itemsIndexed(dias) { index, dia ->
4 Text("[$index] Día: $dia")
5 }
6 }
7}
Esta versión permite acceder al índice y al valor en cada iteración. Perfecto para numeraciones, saltos condicionales o detección del último elemento.
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.
CascadeDropdownMenu
Estos son un tipo de menú que actualmente no se encuentran en el core de Compose, pero pueden añadirse utilizando la librería cascade-compose
.
1implementation("me.saket.cascade:cascade-compose:2.3.0")
Este tipo de menú puede ser una buena opción para añadirlo a la TopAppBar
.
1@Composable
2fun CascadeMenu() {
3 var expanded by remember { mutableStateOf(false) }
4 val context = LocalContext.current
5
6 Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
7 IconButton(onClick = { expanded = !expanded }) {
8 Icon(Icons.Default.MoreVert, contentDescription = "Menú cascada")
9 }
10
11 CascadeDropdownMenu(
12 expanded = expanded,
13 onDismissRequest = { expanded = false }
14 ) {
15 // Item principal con submenú
16 DropdownMenuItem(
17 text = { Text("Opciones Avanzadas ▸") },
18 children = {
19 DropdownMenuItem(
20 text = { Text("Sub‑opción 1") },
21 onClick = {
22 expanded = false
23 Toast.makeText(context, "Sub‑opción 1", Toast.LENGTH_SHORT).show()
24 }
25 )
26 DropdownMenuItem(
27 text = { Text("Sub‑opción 2") },
28 onClick = {
29 expanded = false
30 Toast.makeText(context, "Sub‑opción 2", Toast.LENGTH_SHORT).show()
31 }
32 )
33 }
34 )
35 // Otro item principal
36 DropdownMenuItem(
37 text = { Text("Acerca de") },
38 onClick = {
39 expanded = false
40 Toast.makeText(context, "Acerca de", Toast.LENGTH_SHORT).show()
41 }
42 )
43 }
44 }
45}
Las ventajas de utilizar esta librería, principalmente, es la simplificación a la hora de crear menús jerárquicos. Además, añade animaciones en la expansión y contracción de los menús y es compatible con Material Design y Compose.
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
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.xml
Tratará de evitarse lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_8</string>
3
4 <string name="txt_welcome">Selecciona una opción del menú</string>
5
6 <string name="txt_option_title">Más opciones</string>
7
8 <string name="txt_option_share">Compartir</string>
9 <string name="txt_option_save">Guardar</string>
10 <string name="txt_option_logout">Cerrar sesión</string>
11
12 <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13 <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14 <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>
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}
TopBarConMenu
Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt
.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun TopBarConMenu(
4 onOpcionSeleccionada: (OpcionMenu) -> Unit
5) {
6 var expanded by remember { mutableStateOf(false) }
7 val context = LocalContext.current
8
9 TopAppBar(
10 title = { Text("TopAppBar con Menú") },
11 colors = topAppBarColors(
12 containerColor = MaterialTheme.colorScheme.primaryContainer,
13 titleContentColor = MaterialTheme.colorScheme.primary,
14 ),
15 actions = {
16 IconButton(onClick = { expanded = true }) {
17 Icon(Icons.Default.MoreVert, contentDescription = context.getString(R.string.txt_option_title))
18 }
19 DropdownMenu(
20 expanded = expanded,
21 onDismissRequest = { expanded = false }
22 ) {
23 DropdownMenuItem(
24 text = { Text(context.getString(R.string.txt_option_share)) },
25 onClick = {
26 expanded = false
27 onOpcionSeleccionada(OpcionMenu.Compartir)
28 }
29 )
30 DropdownMenuItem(
31 text = { Text(context.getString(R.string.txt_option_save)) },
32 onClick = {
33 expanded = false
34 onOpcionSeleccionada(OpcionMenu.Guardar)
35 }
36 )
37 DropdownMenuItem(
38 text = { Text(context.getString(R.string.txt_option_logout)) },
39 onClick = {
40 expanded = false
41 onOpcionSeleccionada(OpcionMenu.Logout)
42 }
43 )
44 }
45 }
46 )
47}
MainActivity
Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5
6 setContent {
7 ExampleT2_8Theme {
8 PantallaPrincipal()
9 }
10 }
11 }
12}
13
14@Preview(showBackground = true)
15@Composable
16fun PantallaPrincipal() {
17 val context = LocalContext.current
18 var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
19
20 Scaffold(
21 topBar = {
22 TopBarConMenu { opcion ->
23 mensaje = when (opcion) {
24 is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
25 is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
26 is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
27 }
28 }
29 },
30 modifier = Modifier.fillMaxSize()
31 ) { innerPadding ->
32 Box(
33 modifier = Modifier
34 .padding(innerPadding)
35 .fillMaxSize(),
36 contentAlignment = Alignment.Center
37 ) {
38 Text(
39 text = mensaje,
40 fontSize = 18.sp
41 )
42 }
43 }
44}
El uso de este esquema permite el tipado seguro, evitando así errores de escritura en las cadenas, es escalable, está integrado con when
lo que permite una evaluación exhaustiva y permite la reutilización, ya que la acción se realizará en el when
, y no en el método encargado de montar el menú.
Puede ser más óptimo, por ejemplo, añadiendo propiedades como label
o icon
dentro de la sealed class.
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.xml
Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_8</string>
3
4 <string name="txt_welcome">Selecciona una opción del menú</string>
5
6 <string name="txt_option_title">Más opciones</string>
7
8 <string name="txt_option_share">Compartir</string>
9 <string name="txt_option_save">Guardar</string>
10 <string name="txt_option_logout">Cerrar sesión</string>
11
12 <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13 <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14 <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>
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}
BottomAppBarConMenu
Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt
. Esta versión está mejorada con respecto al ejemplo anterior, se crea un bucle para mostrar las opciones que se vayan añadiendo en la sealed class
.
1@Composable
2fun BottomAppBarConMenu(
3 onOpcionSeleccionada: (OpcionMenu) -> Unit
4) {
5 var expanded by remember { mutableStateOf(false) }
6
7 BottomAppBar(
8 actions = {
9 IconButton(onClick = { expanded = !expanded }) {
10 Icon(Icons.Default.MoreVert, contentDescription = "Menú inferior")
11 }
12
13 DropdownMenu(
14 expanded = expanded,
15 onDismissRequest = { expanded = false }
16 ) {
17 OpcionMenu.todas.forEach { opcion ->
18 DropdownMenuItem(
19 text = { Text(opcion.label) },
20 leadingIcon = { Icon(opcion.icon, contentDescription = null) },
21 onClick = {
22 expanded = false
23 onOpcionSeleccionada(opcion)
24 }
25 )
26 }
27 }
28 }
29 )
30}
MainActivity
Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 ExampleT2_9Theme {
7 PantallaPrincipal()
8 }
9 }
10 }
11}
12
13@Preview(showBackground = true)
14@Composable
15fun PantallaPrincipal() {
16 val context = LocalContext.current
17 var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
18
19 Scaffold(
20 bottomBar = {
21 BottomAppBarConMenu { opcion ->
22 mensaje = when (opcion) {
23 is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
24 is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
25 is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
26 }
27 }
28 },
29 modifier = Modifier.fillMaxSize()
30 ) { innerPadding ->
31 Box(
32 modifier = Modifier
33 .fillMaxSize()
34 .padding(innerPadding),
35 contentAlignment = Alignment.Center
36 ) {
37 Text(mensaje)
38 }
39 }
40}
Como puedes ver, BottomAppBar
permite el uso de actions
igual que la TopAppBar
. También es posible implementar este menú utilizando la sección floatingActionButton
y utilizando FloatingActionButton
, y se puede combinar ambas barras (topBar
y bottomBar
) en el mismo Scaffold
.
Este ejemplo permite crear un AlertDialog
personalizado para solicitar usuario y contraseña.
string.xml
Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_10</string>
3
4 <string name="txt_title">Login</string>
5 <string name="txt_user">Usuario</string>
6 <string name="txt_password">Contraseña</string>
7
8 <string name="txt_login_error">Credenciales incorrectas</string>
9 <string name="txt_login_ok">Credenciales correctas</string>
10</resources>
LoginDialog
Se creará el siguiente compose para mostrar el cuadro de diálogo personalizado, en esta ocasión se utiliza AlertDialog
ya que la personalización es mínima y simplifica el código, pero para los cuadros de diálogo personalizados se recomienda el uso de Dialog
.
1@Composable
2fun LoginDialog(onLogin: (String, String) -> Unit = { _, _ -> }) {
3 val ctxt = LocalContext.current
4 val openDialog = remember { mutableStateOf(false) }
5 var user by remember { mutableStateOf("") }
6 var pass by remember { mutableStateOf("") }
7
8 Box(
9 modifier = Modifier.fillMaxSize(),
10 contentAlignment = Alignment.Center
11 ) {
12 Button(onClick = { openDialog.value = true }) {
13 Text(text = ctxt.getString(R.string.txt_title))
14 }
15
16 if (openDialog.value) {
17 AlertDialog(
18 onDismissRequest = { openDialog.value = true }, // Se mantiene el diálogo abierto.
19 title = { Text(text = ctxt.getString(R.string.txt_title)) },
20 text = {
21 Column {
22 OutlinedTextField(
23 value = user,
24 onValueChange = { user = it },
25 singleLine = true,
26 label = { Text(ctxt.getString(R.string.txt_user)) }
27 )
28 OutlinedTextField(
29 value = pass,
30 onValueChange = { pass = it },
31 singleLine = true,
32 label = { Text(ctxt.getString(R.string.txt_password)) },
33 visualTransformation = PasswordVisualTransformation()
34 )
35 }
36 },
37 confirmButton = {
38 TextButton(onClick = {
39 if (user.isNotBlank() && pass.isNotBlank()) {
40 onLogin(user, pass)
41 openDialog.value = false
42 // Limpiar los campos después del inicio de sesión.
43 user = ""
44 pass = ""
45 }
46 }) {
47 Text(ctxt.getString(android.R.string.ok))
48 }
49 },
50 dismissButton = {
51 TextButton(onClick = {
52 openDialog.value = false
53 user = ""
54 pass = ""
55 }) {
56 Text(ctxt.getString(android.R.string.cancel))
57 }
58 }
59 )
60 }
61 }
62}
Observa el uso de visualTransformation
en el OutlinedTextField
, este permite la ocultación del password.
MainScreen
Siguiendo con la reutilización y actualización del estado, se creará el siguiente composable para mostrar el botón de login o el mensaje de login correcto.
1@Composable
2fun MainScreen() {
3 var isLoggedIn by remember { mutableStateOf(false) }
4 val ctxt = LocalContext.current
5
6 if (!isLoggedIn) {
7 // Se muestra el diálogo de inicio de sesión
8 LoginDialog(
9 onLogin = { user, pass ->
10 // Aquí se maneja la lógica de inicio de sesión
11 // Por ejemplo, verificar las credenciales
12 if (user == "admin" && pass == "1234") {
13 isLoggedIn = true // Simulación de inicio de sesión exitoso
14 println("Inicio de sesión correcto. Usuario: $user, Contraseña: $pass")
15 } else {
16 // Se muestra un mensaje de error o manejar el fallo de inicio de sesión
17 Toast.makeText(
18 ctxt,
19 ctxt.getString(R.string.txt_login_error),
20 Toast.LENGTH_SHORT
21 ).show()
22 }
23 }
24 )
25 } else {
26 // Contenido principal de la aplicación
27 Text(text = ctxt.getString(R.string.txt_login_ok))
28 }
29}
MainActivity
Ahora, la actividad principal tendrá el siguiente aspecto.
1class MainActivity : ComponentActivity() {
2 @OptIn(ExperimentalMaterial3Api::class)
3 override fun onCreate(savedInstanceState: Bundle?) {
4 super.onCreate(savedInstanceState)
5 enableEdgeToEdge()
6 setContent {
7 ExampleT2_10Theme {
8 Scaffold(
9 topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) }
10 ) { innerPadding ->
11 Box(
12 modifier = Modifier
13 .fillMaxSize()
14 .padding(innerPadding),
15 contentAlignment = Alignment.Center
16 ) {
17 MainScreen()
18 }
19 }
20 }
21 }
22 }
23}
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}
Ejemplo práctico 11 Recuperar la imagen capturada desde la cámara de fotos
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}
!> Importante: Deberás asegúrate de convertir el valor si fuese necesario a otro tipo, como Int
.
NavController
Es el encargado de gestionar la navegación entre destinos.
Se obtiene con rememberNavController()
y se pasa al NavHost
.
Se utiliza para navegar entre pantallas, por ejemplo: navController.navigate("details")
.
Navegación desde un botón:
1Button(onClick = { navController.navigate("aboutit") }) {
2 Text("Acerca de...")
3}
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.
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 12 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.Factory
Se crea la clase DetalleViewModel
que extenderá de ViewModel()
y se añade el factory.
1class DetalleViewModel(id: String) : ViewModel() {
2 val itemId = id
3 val contenido = mutableStateOf("Contenido del ítem $itemId")
4}
5
6class DetalleViewModelFactory(private val id: String) : ViewModelProvider.Factory {
7 override fun <T : ViewModel> create(modelClass: Class<T>): T {
8 @Suppress("UNCHECKED_CAST")
9 return DetalleViewModel(id) as T
10 }
11}
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 13 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}
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("detalle/{id}") { backStackEntry ->
10 val detalleViewModel: DetalleViewModel = hiltViewModel()
11 DetalleScreen(detalleViewModel)
12 }
13 }
14}
Representa la pantalla inicial.
1@Composable
2fun HomeScreen(navController: NavController) {
3 Column(modifier = Modifier.padding(16.dp)) {
4 Text("Home Screen")
5 Button(
6 onClick = {
7 // Navegar a la pantalla de detalle con un ID ficticio
8 navController.navigate("detalle/321")
9 }
10 ) { Text("Detalle item") }
11 }
12}
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 1:1. Observa que las clases que representan una relación no llevan la etiqueta @Entity
.
1import androidx.room.Embedded
2import androidx.room.Relation
3
4// SuperWithEditorial.kt
5data class SuperWithEditorial(
6 @Embedded val supers: SuperHero,
7 @Relation(
8 parentColumn = "idEditorial",
9 entityColumn = "idEd"
10 )
11 val editorial: Editorial
12)
@Embedded
se utiliza para incluir los campos de la entidad SuperHero en el objeto SuperWithEditorial.@Relation
se utiliza para definir la relación entre SuperHero y Editorial, especificando las columnas que se utilizan para enlazarlas.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.
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}
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 loading
y 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).
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
.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.
Aquí puedes consultar la versión anterior de la documentación utilizada en el módulo PMDM de CFGS.