======= Programación de móviles con Android ======= {{ android-logo.jpg?200 }} ====== ¿Qué es Android ====== Actualmente cuando se habla de //Android// se hace tanto para referirse al Sistema Operativo que viene instalado en nuestros móviles como para hablar del framework que se utilizar para crear las aplicaciones. Conviene por tanto distinguir claramente entre //Android// que será el Sistema Operativo y //Android SDK// que es el framework ó SDK (Software Development Kit) utilizado para desarrollar las aplicaciones que funciona sobre dicho S.O. Si miramos más al detalle, //Android// realmente es un Sistema Operativo //Linux// al que se le ha añadido una capa extra de software (con una JVM, Java Virtual Machine) que incluye desde aplicaciones (como la aplicación de Contactos, aplicación Teléfono, . . .), motores de Bases de Datos (//SQLite//) y otros motores para navegación web, renderizado 3D (//OpenGL//) y demás software. Más adelante se puede ver un esquema que muestra la estructura completa del Sistema Operativo y se puede observar con más detalle todo lo que incluye. Por otro lado, //Android SDK// es un framework de desarrollo centrado en la creación de aplicaciones para dispositivos móviles. También cuenta con todas las herramientas necesarias para ensamblar la aplicación e incluso para probarla con un emulador integrado. Decimos que es un (Software Development Kit) puesto que es la forma genérica de llamar a un conjunto de librerías y herramientas destinados a construir un determinado tipo de aplicaciones. Puesto que los frameworks o SDKs no incluyen el IDE con el que el programador debe escribir el código, en los últimos años, Google desarrolló //Android Studio//,un IDE completo para su uso con el framework. === Android Studio === **Android Studio** es el IDE //oficial// para desarrollar aplicaciones para Android. En los inicios del framework, la única manera de desarrollar aplicaciones fue utilizar un plugin que se instalaba con el IDE [[http://www.eclipse.org|Eclipse]] pero hace unos años Google comenzó el desarrollo de //Android Studio// utilizando como base el IDE IntelliJ IDEA. Actualmente //Android Studio// es un IDE muy maduro ya que se encuentra en su versión 2.X y cuenta con todas las herramientas necesarias (Editor de código, multitud de asistentes, emulador, . . .). Para aprender a manejar esta herramienta, que será con la que desarrollemos aplicaciones durante el curso, puedes encontrar el {{apuntes:paas_-_ee1_-_rev_2.0.pdf|Manual de Android Studio}} de [[http://www.sgoliver.net|sgoliver]] en la sección [[extra:referencias]] {{ youtube>Rdq48idTA9w }} \\ {{ youtube>f4_qqeHlF-4 }} \\ ====== Estructura de Android ====== ===== Estructura del Sistema Operativo ===== {{ android-stack_2x.png?550 |Estructura Android}} ===== Ciclo de vida de una aplicación ====== {{ activity_lifecycle.png |Ciclo de vida Android}} El [[https://developer.android.com/guide/topics/ui/multi-window.html?hl=ES|Modo multiventana]] permite visualizar varias Activities al mismo tiempo pero no modifica este ciclo de vida. Se pueden ver más detalles sobre esto en el enlace ===== Estructura de una aplicación =====
{{ android_project.png }} Estructura de un proyecto
* **libs** Carpeta con librerías externas añadidas de forma //manual// por el programador (conviene añadirlas utilizando el fichero de configuración de //gradle//) * **src** Carpeta que contiene el código //Java// de la aplicación. Se debe organizar (y es muy importante) utilizando las mismas reglas que cualquier aplicación //Java// en cuanto a la estructura y nombrado de paquetes * **res** Dentro de esta carpeta se almacena todo aquello que forma parte del proyecto que no sea código //Java// (imágenes, layouts, textos, . . .) * **res->drawable-xxxx** En esta carpetas se almacenan las imágenes e iconos que se vayan a utilizar en la aplicación. Se deben organizar por resolución puesto que es //Android// quién decide que resolución procede en función de las características del móvil que ejecuta nuestra aplicación * **res->layout** Contiene los diseños de las pantallas de nuestra aplicación. Hay que tener en cuenta que el framework nos va a obligar a programar separando lógica (código) de presentación (pantallas) * **res->menu** Contiene los ficheros XML que definen los menús que aparezcan en las diferentes pantallas de nuestra aplicación * **res->values** Contiene los ficheros XML con los textos de la aplicación en el idioma por defecto * **res->values-en** Contiene los ficheros XML con los textos de la aplicación en otro idioma (en este caso inglés->**en**glish) * **AndroidManifest.xml** Es un fichero XML que permite configurar ciertos aspectos importantes de la aplicación * **build.gradle** Es el fichero de configuración de //gradle// para nuestro proyecto de aplicación //Android// ==== Código de la aplicación ==== El código de la aplicación debe ser empaquetado siguiendo la misma jerarquía y nombrado de paquetes que indican las [[http://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html|convenciones de código de Java]] para ello. Además, conviene seguir una serie de reglas que recomienda //Android// en su [[http://source.android.com/source/code-style.html|Guía de estilo para contribuidores]] Es especialmente importante seguir las reglas para el nombrado de paquetes y que además está coincida con la que se especifica al inicio del proyecto y que quedará registrada en el fichero de manifiesto ''AndroidManifest.xml'' (que veremos más adelante). En caso contrario es muy posible que el proyecto no se pueda ensamblar y, por tanto, no funcionará. ==== Recursos de imagen (drawable) ==== ==== Recursos de layout (layouts) ==== Aqui se almacenan los ficheros que contienen los layouts (diseños) de la aplicación. Lo más habitual será encontrar un layout por cada ''Activity'' que se haya creado, pero al igual que necesitaremos crear clases Java que no estén asociadas con ninguna pantalla, también podemos encontrar layouts que no estén vinculados con ninguna Activity porque sean parte de otros layouts más complejos. Por ejemplo, en una Activity podremos diseñar un layout que sea una lista de elementos, que a su vez serán otro layout. De esa forma podremos diseñar el aspecto de cada uno de los elementos y luego listar tantos elementos como haya en una lista, utilizando el mismo layout para cada uno de ellos. ==== Recursos de idioma (values) ==== Aquí se almacenan los ficheros que contienen todos los textos de la aplicación. El objetivo es separar totalmente el código de la aplicación del texto con la finalidad de simplificar la internacionalización de la misma. De esa manera, cuando se quiera traducir la aplicación a un nuevo idioma bastará con crear el fichero correspondiente con los textos en dicho idioma y no será necesario realizar ningún cambio en el código. Por defecto, en la carpeta ''values'' nos encontraremos con los ficheros en el idioma por defecto. En nuestro caso sería el español. === Fichero de textos === El fichero principal que encontramos en esta carpeta ''values'' es ''strings.xml'', que contiene todos los textos de la aplicación acompañados de una cadena que funciona de identificador, para poder hacer referencia a cada cadena desde el código. Agenda Settings Nuevo Contacto Preferencias Acerca de Agendroid v2 Nuevo Contacto Preferencias Nombre Apellidos Teléfono Móvil Fax Favorito Aceptar Cerrar Imagen No hay contactos Acerca de Contacto Añadido ¿Está seguro? Agendroid v2\nAgenda para Android\n(c) 2013 Santiago Faci Llamar a casa Llamar al móvil Eliminar Así, en el caso de que queramos traducir esta aplicación al inglés, sólo tenemos que hacer una copia de este fichero, moverlo a una carpeta ''values-en'' y traducir los textos, dejando intacta la cadena identificadora de cada uno de ellos. De esa forma, cuando hagamos referencia a una cadena, será Android quién decida en que idioma escribirla dependiendo del idioma que el usuario tenga configurado en su dispositivo móvil. Contacts Settings New Contact Preferences About Agendroid v2 New Contact Preferences Name Last name Phone Mobile Fax Favorite Accept Close About Contact Added Are you sure? Agendroid v2\nAndroid Contacts App\n(c) 2013 Santiago Faci Image No data Call Home Call Mobile Delete Así, si queremos desde el código, mostrar un mensaje con alguno de estos textos en una ''TextView'', podemos hacerlo de la siguiente forma: . . . ViewText vtMensaje; . . . vtMensaje.setText(getResources().getString(R.string.nuevo_contacto_message)); . . . También podremos, donde se admita un ''int'' indicando el identificador de la cadena (por ejemplo para mostrar mensajes emergentes con ''Toast''), usar las cadenas de la siguiente forma: Toas.makeText(this, R.string.nuevo_contacto_message, Toast.LENGTH_SHORT).show(); === Arrays de textos === También se pueden crear //arrays// de datos que luego pueden ser usados desde el código de la aplicación. El siguiente lo almacenaríamos en la carpeta ''values'' puesto que esta en español. Para traducirlo procederíamos de la misma forma que hemos hecho para traducir los textos de la aplicación Nombre Apellidos Fichero traducido y almacenado en ''values-en'' Name Lastname ==== Fichero de manifiesto AndroidManifest.xml ==== A continuación se muestra como ejemplo un fichero ''AndroidManifest.xml'' de una aplicación de ejemplo. A continuación se explicarán cada una de las partes que lo forman * ''package'' Con este parámetro indicamos el paquete base de nuestra aplicación * '''' Pueden aparecer tantas líneas como permisos sea necesario activar en la aplicación. Los permisos permiten que la aplicación haga uso de determinadas características del móvil de las que debe ser notificado el usuario a la hora de instalarla, tales como el uso de Internet, acceso al GPS, acceso a la tarjeta de memoria y otras * ''...'' Cada una de estas etiquetas indica la existencia de una Activity. Se añaden de forma automática cuando se crea la Activity desde el IDE * '' . . . android.intent.action.MAIN . . . '' Estas 4 líneas se colocan dentro de las etiquetas de la //Activity// que queremos marcar como //Activity// principal que será la primera que se lance cuando se ejecute la aplicación * '''' En este caso utilizamos esta etiqueta para indicar información adicional como el //API KEY// de Google Maps para utilizar los mapas en la aplicación ====== Componentes Android ====== ===== Layouts ===== Los layouts definen la forma en que se distribuyen todos los componentes en la interfaz de la aplicación. Hay que tener en cuenta que todos los componentes (//Views//) como pueden ser botones, cajas de texto, . . . se distribuyen según una jerarquía y la ubicación que les vendrá dada por el //Layout// que los contenga. Todo layout puede ser considerado también como una //View//, por lo que también pueden ser añadidos como parte de otro //Layout//. Los principales layouts actualmente son: === ConstraintLayout === Es el layout por defecto cuando se crea una nueva //Activity//:
{{ constraintlayout.png}} ConstraintLayout
=== LinearLayout === Layout sencillo para alinear verticalmente/horizontalmente los componentes
{{ linearLayout.png}} ConstraintLayout
=== FrameLayout === Permite deliminar una zona donde se pueden colocar componentes superpuestos
{{ FrameLayout.png}} ConstraintLayout
===== TextView =====
{{ textview.jpg }} Etiqueta de texto
===== EditText =====
{{ edittext.jpg }} Caja de texto
===== Button =====
{{ button.jpg }} Button
=== RadioButton ===
{{ radiobutton.png }} RadioButton
=== CheckBox ===
{{ checkbox.png }} Checkbox
===== Spinner ===== Un ''Spinner'' es lo que se conoce como lista desplegable. Físicamente se muestra siempre compactada ocupando una sola línea pero al pinchar en ella se despliegan en forma de lista todos los elementos que contiene.
{{ spinner.png }} Spinner
En cuanto al funcionamiento interno, es muy similar a un ''ListView'' por lo que conviene estudiar el siguiente apartado si se quiere trabajar con un ''Spinner'', tanto para listar contenido simple como para crear un ''Adapter'' propio y configurar un //Layout// para cada elemento de la lista. ===== ListView (y CustomAdapter) [deprecated] ===== Ver el apartado sobre [[https://multimedia.codeandcoke.com/apuntes:android#recyclerview|RecyclerView]]. El uso de ListView se puede considerar como desaconsejado en favor de este nuevo componente. Es un componente que permite crear una lista de elementos más o menos compleja, muy similar a ''Spinner'' pero en este caso, la lista se muestra expandida pudiendo ocupar incluso toda la pantalla del dispositivo. Para el caso más sencillo de ''ListView'' tendremos que disponer de una serie de datos simples para mostrar, por ejemplo una lista de cadenas de texto, números o similar . . . List lista = new ArrayList<>(); lista.add("Primer elemento"); lista.add("Segundo elemento"); . . . ListView lvLista = (ListView) findViewById(R.id.lvLista); ArrayAdapter adapter = new ArrayAdapter<>( this, android.R.layout.simple_list_item_1, lista); lvLista.setAdapter(adapter); . . . En este caso nos quedaría una lista como la de la siguiente captura:
{{ listview1.png }} ListView simple (simple_list_item1 layout)
Si necesitamos alguna presentación más compleja, necesitaremos crearnos un layout personalizado y además implementar la clase que se asociará con dicho layout para indicarle a Android como queremos rellenarlo. Para este caso podemos tener un listado de objetos más complejos (por ejemplo, de objetos de nuestra aplicación cuya clase hemos creado nosotros). Suponemos que tenemos una clase donde guardamos información sobre personas: cadenas de texto, fechas, números y una imagen public class Amigo { private String nombreApellidos; private String email; private String telefonoFijo; private String telefonoMovil; private Bitmap foto; private Date fechaNacimiento; private float deudas; // Constructores, getters y setters . . . . . . Definiremos también el ''layout'' para representar a cada persona con la siguiente estructura: {{ fila.png }} Y su representación en XML: Así, tendremos que crear un adaptador personalizado para indicar a Android como queremos representar a cada elemento (persona) en la lista (teniendo en cuenta que se pintará la misma imagen para cada uno de ellos): public class AmigoAdapter extends BaseAdapter { private Context context; private ArrayList listaAmigos; private LayoutInflater inflater; public AmigoAdapter(Activity context, ArrayList listaAmigos) { this.context = context; this.listaAmigos = listaAmigos; inflater = LayoutInflater.from(context); } static class ViewHolder { ImageView foto; TextView nombreApellidos; TextView movil; TextView fijo; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder = null; // Si la View es null se crea de nuevo if (convertView == null) { convertView = inflater.inflate(R.layout.fila, null); holder = new ViewHolder(); holder.foto = (ImageView) convertView.findViewById(R.id.ivFoto); holder.nombreApellidos = (TextView) convertView.findViewById(R.id.tvNombreApellidos); holder.fijo = (TextView) convertView.findViewById(R.id.tvTelefonoFijo); holder.movil = (TextView) convertView.findViewById(R.id.tvTelefonoMovil); convertView.setTag(holder); } /* * En caso de que la View no sea null se reutilizará con los * nuevos valores */ else { holder = (ViewHolder) convertView.getTag(); } Amigo amigo = listaAmigos.get(position); holder.foto.setImageBitmap(amigo.getFoto()); holder.nombreApellidos.setText(amigo.getNombreApellidos()); holder.fijo.setText(amigo.getTelefonoFijo()); holder.movil.setText(amigo.getTelefonoMovil()); return convertView; } @Override public int getCount() { return listaAmigos.size(); } @Override public Object getItem(int posicion) { return listaAmigos.get(posicion); } @Override public long getItemId(int posicion) { return posicion; } } Y por último tendremos que asociar la lista donde están los datos (Amigos) con la ''ListView'' utilizando para ello el adapter que hemos creado . . . ArrayList listaAmigos = new ArrayList<>(); . . . AmigoAdapter adaptador = new AmigoAdapter(this, listaAmigos); ListView lvLista = (ListView) findViewById(R.id.lvLista); lvLista.setAdapter(adaptador); . . . Si queremos que la vista del ''ListView'' se actualice a medida que haya cambios en la lista (en el ''ArrayList'') tendremos que actualizar el //adaptador// . . . // Actualiza la vista del ListView adaptador.notifyDataSetChanged(); . . . Quedaría algo parecido a lo que muestra la siguiente captura donde se puede ver que, para cada elemento de la lista, se muestra un layout más o menos complejo con una foto y tres datos (nombre y apellidos, teléfono fijo y teléfono móvil) tal y como se había diseñado en el layout de cada fila anteriormente:
{{ listview2.png }} ListView con layout personalizado (Custom Layout)
{{ listview3.png }} ListView con layour personalizado II (Custom Layout)
* [[https://www.youtube.com/watch?v=9a_18EogMKk|Trabajar con ListView I]] (Videotutorial) * [[https://www.youtube.com/watch?v=Z9KkQWeCxz0|Trabajar con ListView II]] (Videotutorial) * [[https://www.youtube.com/watch?v=M52SNrSJVVg|Trabajar con ListView III]] (Videotutorial) ===== RecyclerView ===== ''RecyclerView'' es la ''View'' que viene a sustituir a ''ListView''. En el siguiente ejemplo organizaremos una lista de super héroes en la que cada elemento contará con un botón para realizar una acción sobre la lista (en este caso se eliminado de la misma). Primero, definimos la clase Java que modela el objeto con el que vamos a trabajar. En el caso de que fuéramos a listar simples ''String'', no sería necesario puesto que podríamos trabajar sobre una ''List'' directamente. public class Superhero { private String name; private String surname; private String superHeroeName; public Superhero(String name, String surname, String superHeroeName) { this.name = name; this.surname = surname; this.superHeroeName = superHeroeName; } public String getName() { return name; } public String getSurname() { return surname; } public String getSuperHeroeName() { return superHeroeName; } public String getFullName() { return name + " " + surname; } } La siguiente clase es el //Customer Adapter// que define cómo hay que renderizar cada elemento de la lista. En este caso, para cada super héroe, queremos mostrar 2 líneas con su nombre y apellidos, y una tercera con el botón para eliminar dicho elemento de la lista. Además, la lista permite marcar como seleccionado (modificando su color de fondo) el elemento seleccionado, sobre el que luego podremos actuar desde la ''Activity'' (como veremos más adelante). public class SuperheroAdapter extends RecyclerView.Adapter { private List dataList; private int selectedPosition; public SuperheroAdapter(List dataList) { this.dataList = dataList; selectedPosition = -1; } @Override public SuperheroHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.character_item, parent, false); return new SuperheroHolder(view); } @Override public void onBindViewHolder(SuperheroHolder holder, int position) { holder.tvFullName.setText(dataList.get(position).getFullName()); holder.tvSuperheroName.setText(dataList.get(position).getSuperHeroeName()); } @Override public int getItemCount() { return dataList.size(); } public class SuperheroHolder extends RecyclerView.ViewHolder { public TextView tvFullName; public TextView tvSuperheroName; public Button button; public View parentView; public SuperheroHolder(View view) { super(view); parentView = view; tvFullName = view.findViewById(R.id.tvFullName); tvSuperheroName = view.findViewById(R.id.tvSuperheroName); button = view.findViewById(R.id.button); // Click on superhero (select/unselect) view.setOnClickListener(view1 -> selectSuperhero(parentView, view1, getAdapterPosition())); // Click on button (remove superhero from the list) button.setOnClickListener(view12 -> deleteSuperhero(getAdapterPosition())); } } private void selectSuperhero(View parentView, View view, int position) { // Select / Unselect if (getSelectedPosition() != position) { // Only single selection is allowed if (selectedPosition != -1) return; parentView.setBackgroundColor(view.getContext().getResources().getColor( android.R.color.holo_blue_light)); selectedPosition = position; // FIXME eliminar estos Toasts Toast.makeText(view.getContext(), "Superhero selected. Position = " + position, Toast.LENGTH_SHORT).show(); } else { parentView.setBackgroundColor(view.getContext().getResources().getColor( android.R.color.white)); selectedPosition = -1; // FIXME eliminar estos Toasts Toast.makeText(view.getContext(), "Superhero deselected. Position = " + position, Toast.LENGTH_SHORT).show(); } } private void deleteSuperhero(int position) { dataList.remove(position); notifyItemRemoved(position); } public int getSelectedPosition() { return selectedPosition; } public Superhero getSelectedSuperhero() { if (getSelectedPosition() == -1) return null; return dataList.get(getSelectedPosition()); } } Por último, en la ''MainActivity'' de nuestra aplicación, definimos la ''RecyclerView'' y la poblamos con una serie de elementos directamente en el código a modo de prueba. Además hemos programado el botón de la parte de abajo de la Activity para que muestre el nombre y apellidos del elemento seleccionado en la ''RecylerView''. public class MainActivity extends AppCompatActivity implements View.OnClickListener { private List superheroList; private SuperheroAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); populateSuperheroList(); RecyclerView recyclerView = findViewById(R.id.recylerView); recyclerView.setHasFixedSize(true); LinearLayoutManager layoutManager = new LinearLayoutManager(this); recyclerView.setLayoutManager(layoutManager); adapter = new SuperheroAdapter(superheroList); recyclerView.setAdapter(adapter); Button continueButton = findViewById(R.id.continueButton); continueButton.setOnClickListener(this); } private void populateSuperheroList() { superheroList = new ArrayList<>(); superheroList.add(new Superhero("Clark", "Kent", "Superman")); superheroList.add(new Superhero("Peter", "Parker", "Spiderman")); superheroList.add(new Superhero("Bruce", "Wayne", "Batman")); } @Override public void onClick(View view) { Superhero selectedCharacter = adapter.getSelectedSuperhero(); if (selectedCharacter == null) Toast.makeText(this, R.string.a_superhero_must_be_selected, Toast.LENGTH_LONG).show() Toast.makeText(this, selectedCharacter.getFullName(), Toast.LENGTH_SHORT).show(); } }
{{ recyclerview_sample.png }} RecyclerView con layout personalizado
===== ActionBar ===== La ''ActionBar'' es la barra superior que encontramos en todas las ''Activities'' de Android. Permite mostrar un texto indicando normalmente la ''Activity'' o aplicación que estamos mostrando y una serie de iconos con las acciones que podemos realizar en dicha ''Activity''. Estas acciones suelen cambiar dependiendo de donde nos encontramos dentro de una misma app. Este elemento es la sustitución (a partir de Android 4) del antiguo botón de opciones del que disponían los móviles Android hace un tiempo y que mostraba en la parte superior las opciones a realizar cuando el usuario estaba en una ''Activity'' determinada. Entonces se carecía de ''ActionBar''. Es por tanto importante tener en cuenta que, si ejecutamos una aplicación en un móvil anterior a Android 4, todas las opciones que incorporemos a esta ''ActionBar'' se mostrarán como menú de opciones cuando pulsemos en dicho botón. Lo más habitual será encontrar dos o tres iconos para realizar las tareas más habituales y un icono que mostrará un menú con el resto de opciones posibles. Así, para implementar una ''ActionBar'' como la que se muestra en la imagen anterior tenemos que llevar a cabo los siguientes pasos: * Diseñar el menú como un fichero XML en ''res->menu'' * Indicar en el código de la ''Activity'' donde queramos mostrarlo, que éste se tiene que //inflar//, sobreescribiendo para ello el método ''onCreateOptionsMenu'' * Sobreescribir el método ''onOptionsItemSelected'' para indicar qué hacer para cada una de las opciones de la ''ActionBar''
{{actionbar1.png }} {{ actionbar2.png}} ActionBar
Definimos el menú como un fichero XML con las opciones que queremos Para cada elemento de la ''ActionBar'' podemos indicar, al menos, lo siguiente: * ''android:icon'' El icono que queremos que se muestre (de la carpeta ''res->drawable'') * ''android:showAsAction'' Con el valor ''always'' indicamos que queremos que se muestre en cualquier caso (se puede modificar) * ''android:title'' El texto que queremos que aparezca si no está disponible el icono asociado * El último elemento de la ''ActionBar'' es a su vez un menú desplegable con el resto de opciones que no caben directamente en la ''ActionBar'' Hay que indicar en el método ''onCreateOptionsMenu'' de la ''Activity'' donde queremos la ''ActionBar'' que ésta se debe //inflar// . . . public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.main, menu); return true; } . . . A continuación, tendremos que implementar qué queremos hacer para cada una de las opciones del menú de la ''ActionBar'' . . . @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_nuevo_contacto: // hacer algo return true; case R.id.menu_preferencias: // hacer algo return true; case R.id.menu_acerca_de: // hacer algo return true; default: return super.onOptionsItemSelected(item); } } . . . Además, también podemos, en tiempo de ejecución, realizar algunas acciones sobre la ''ActionBar'' . . . ActionBar actionBar = getActionBar(); // Muestra la ActionBar actionBar.show(); // Oculta la ActionBar actionBar.hide(); // Cambiar el texto actionBar.setSubtitle("Mi ActionBar"); actionBar.setTitle("Mi App"); . . . {{ youtube>3CPCI4boc8o }} ===== Menú contextual ===== En Android los menús contextuales aparecen cuando el usuario realiza una pulsación prolongada en alguna parte de la pantalla. Entonces se activa (si así se ha dispuesto) un menú asociado a dicho elemento que muestra opciones para realizar sobre dicho elemento. Para llevar a cabo la implementación de un menú contextual en Android hay que llevar a cabo los siguientes pasos: - Diseñar el layout de dicho menú y colocarlo en el apartado correspondiente de la carpeta de recursos (''res'') - Indicar que el elemento de la GUI tiene asociado un menú contextual (en este caso un ''ListView'') - Sobreescribir el método heredado ''onCreateContextMenu'' para forzar que dicho menú aparezca. Si tenemos varios menús contextuales tendremos que //inflar// un menú diferente para cada ''View'' - Sobrescribir el método ''onContextItemSelected'' donde indicaremos qué hacer para cada elemento del menú contextual (según los //ids// que se hayan indicado en el menú))
{{ contextmenu.png }} Context Menu
Diseñamos el menú en XML en ''res->menu'' Registramos que la ''ListView'' tiene asociado un menú contextual . . . ListView lvLista = (ListView) findViewById(R.id.lvLista); // Registra el menú contextual a la lista de elementos registerForContextMenu(lvLista); . . . Sobreescribimos los métodos para indicar qué menú hay que //inflar// y qué hacer para cada una de las opciones de dicho menú . . . . . . @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); getMenuInflater().inflate(R.menu.menu_context_listado, menu); } @Override public boolean onContextItemSelected(MenuItem item) { AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); final int itemSeleccionado = info.position; switch (item.getItemId()) { case R.id.action_fijo: // hacer algo return true; case R.id.action_movil: // hacer algo return true; case R.id.action_editar: // hacer algo return true; case R.id.action_eliminar: // hacer algo return true; case R.id.action_email: // hacer algo return true; case R.id.action_detalles: // hacer algo return true; default: return super.onContextItemSelected(item); } } . . . . . . {{ youtube>1JOU7qi3sA0 }} ===== Diálogos ===== Los diálogos son ventanas emergentes que aparecen cuando el usuario debe seleccionar una acción antes de seguir con la ejecución de la aplicación. Se tratan siempre de ventana modales por lo que bloquean el flujo de ejecución de la aplicación hasta que el usuario selecciona qué hacer. A continuación se muestra una imagen de un diálogo clásico de respuesta Si/No y el código correspondiente para tratar las dos posibles opciones que el usuario puede seleccionar
{{ dialog.png }} Dialog (Yes/No)
. . . AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.lb_esta_seguro) .setPositiveButton(R.string.lb_si, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { // Qué hacer si el usuario pulsa "Si" }}) .setNegativeButton(R.string.lb_no, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { // Qué hacer si el usuario pulsa "No" // En este caso se cierra directamente el diálogo y no se hace nada más dialog.dismiss(); }}); builder.create().show(); . . . ===== Mensajes emergentes ===== Es posible mostrar pequeños mensajes emergentes de corta duración con la clase ''Toast'' ((https://developer.android.com/reference/android/widget/Toast.html)) El funcionamiento básico de estos mensajes se muestra a continuación, pero se pueden configurar con más detalle en [[https://developer.android.com/guide/topics/ui/notifiers/toasts.html|la guía de Android sobre Toasts]]
{{ toast.png }} Toast por defecto | Toast personalizado
. . . Toast.makeText(this, R.string.cadena_de_texto, Toast.LENGHT_LONG).show(); . . . Toast.makeText(this, R.string.cadena_de_texto, Toast.LENGHT_SHORT).show(); . . . ===== Notificaciones ===== Las notificaciones son mensaje emergentes que aparecen en la pantalla de notificaciones del móvil. Sirve para mostrar información y también como forma rápida de acceso a la aplicación que emite dicha notificación, pudiendo acceder al contenido directamente.
{{ notification.png?400 }} Notificaciones
Lo primero que tendremos que hacer será definir un canal para nuestras notificaciones private final String CHANNEL_ID = "mychannel_id"; . . . private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { CharSequence name = "Nombre del canal"; String description = "Descripcion del canal"; int importance = NotificationManager.IMPORTANCE_DEFAULT; NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); channel.setImportance(NotificationManager.IMPORTANCE_HIGH); NotificationManager notificationManager = getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } Y, a continuación, definir una notificación que quedará vinculada a dicho canal: . . . NotificationCompat.Builder nBuilder = new NotificationCompat.Builder(MainActivity.this, CHANNEL_ID) .setContentTitle("Titulo de la notificación") .setContentText("Esto es el texto de la notificación") .setSmallIcon(R.drawable.default_marker) Y lanzarla cuando sea necesario: NotificationManager nManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nManager.notify(0, nBuilder.build()); . . . === Asociar una Activity a una notificación === Se puede asociar una Activity a una notificación, de forma que ésta sea lanzada cuando se pulse en dicha notificación: // Definimos el Intent que permitirá lanzar la Activity Intent intent = new Intent(this, OtraActivity.class); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); . . . // Vinculamos el Intent con la notificación NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) . . . .setContentIntent(pendingIntent) ... === Añadir acciones a una Notificación === También podemos añadir diferentes acciones a una misma Notificación: // Podemos definir Intents si lo que queremos es lanzar diferentes Activities. En este caso añadiremos 2 acciones diferentes a una misma notificación: . . . // Y gestionar las diferentes acciones asociada a la notificación NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) . . . .addAction(R.drawable.ic_hacer_algo, "HACER ALGO", hacerAlgoIntent); .addAction(R.drawable.ic_otra_cosa, "OTRA COSA", otraCosaIntent); . . .
{{ action-notification.png }} Acciones en una Notificación
=== Notificar el progreso en una notificación === También podemos notificar el progreso de una tarea usando una notificación: NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) . . . .setContentText("En progreso") .setProgress(100, 20, false) // máximo, progreso, ¿indeterminado? . . .
{{ progress-notification.png }} Acciones en una Notificación
Si lo que queremos es ir notificando el progresivo avance de una tarea, habría que meter la actualización del mismo (''builder.setProgress()'' y la propia notificación de este mensaje (''notificationManager.notify()'') en un hilo que fuera ejecutando la tarea asociada en segundo plano. De esa manera se iría mostrando una nueva notificación con el avance actualizado de forma que parecería la notificación original actualizando la barra de progreso. ====== Comunicación entre Activities ====== En algunas ocasiones necesitaremos pasar información entre dos //Activities//, por ejemplo, cuando en un ''ListView'' pinchemos sobre algún elemento que queramos ver en el mapa. Este mapa estará en otra ''Activity'' a la que tendremos que pasar la localización (y quizás otra información) del elemento que queremos mostrar. Esa otra ''Activity'' tendrá que recuperar dicha información para pintarla en el mapa. Asi, para algo parecido al ejemplo anterior, en la ''Activity'' origen, justo cuando creemos el ''Intent'' que permite invocarla, tendremos que incluir además los datos que necesitemos que la otra ''Activity'' reciba: . . . // Crea el Intent que lanzará la nueva Activity Intent intentMapa = new Intent(this, ActivityMapa.class); // Añadimos la información que llegará a la Activity destino // Si sólo queremos lanzarla podemos omitir estas líneas intentMapa.putExtra("nombre", nombreLugar); intentMapa.putExtra("latitud", latitud); intentMapa.putExtra("longitud", longitud); // Lanza la nueva Activity startActivity(intentMapa); . . . Así, si hemos enviado información desde otra ''Activity'', en el destino la recogeremos también a través del objeto ''Intent'' dentro del método del evento ''onCreate'', que es cuando se crea ésta: public class MapaActivity . . . public void onCreate(. . .) . . . Intent intent = getIntent(); String nombreLugar = intent.getStringExtra("nombre"); // El segundo parámetro permite indicar qué valor se asignará por defecto float latitud = intent.getFloatExtra("latitud", -1); float longitud = intent.getFloatExtra("longitud", -1); /* Ahora ya tenemos toda la información que necesitamos para hacer lo que sea * con ella */ . . .
{{ intent_activities.png }} Comunicar dos activities
{{ youtube>ASZZ_td4qKE }} \\ ====== Gestión de permisos ====== En Android, para que las aplicaciones que instalamos en nuestro dispositivo, puedan acceder a determinadas funciones o bien a información sensible, debe estar preparada para gestionar los permisos que le permitirán realizar dicha acciones. Los permisos que puede o no requerir una aplicación son muy diversos. Se puede consultar la referencia completa en el siguiente enlace. [[ https://developer.android.com/reference/android/Manifest.permission.html#READ_EXTERNAL_STORAGE | Listado de permisos ]] Para ver cómo funciona, vamos a suponer e implementar un caso concreto. Más adelante, en algunos apartados se pueden ver otros casos donde también es necesaria la concesión de determinados permisos. Por ejemplo, si quisieramos que nuestra aplicación pudiera hacer fotografías, tendríamos que gestionar el permiso correspondiente (CAMERA) además de implementar la llamada al Intent correspondiente y gestionar la correspondiente foto más adelante. A continuación se muestra cómo hacerlo paso a paso: Primero añadimos el permiso al fichero ''AndroidManifest.xml'' . . . . . . A continuación, en algún momento antes de lanzar la acción que hará la foto, tendremos que controlar que el permiso se haya concedido a la aplicación. En caso de que no sea asi no se podrá hacer y habría que comprobarlo más adelante, cada vez que el usuario lo intente. . . . private final int TAKE_PICTURE = 1; . . . if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{ Manifest.permission.CAMERA}, 1); } Y, donde proceda, podemos invocar al ''Intent'' que lanza la cámara del dispositivo para luego recuperar la imagen tomada para colocarla o hacer lo que queramos con ella (en este caso se coloca en un ImageView de la propia Activity). Sería conveniente comprobar que la aplicación tiene el permiso correspondiente para hacer la fotografía de nuevo para poder mostrarle un aviso al usuario en caso contrario (en forma de Toast o Notificación, por ejemplo). . . . // Lanza la cámara para hacer una foto. A la vuelta invoca el método onActivityResult con TAKE_PICTURE como // requestCode Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE); } . . . . . . @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == RESULT_OK) { // Coloca la foto en un ImageView que debería tener en el layout de la Activity Bundle extras = data.getExtras(); Bitmap imageBitmap = (Bitmap) extras.get("data"); ImageView imageView = findViewById(R.id.imageView); imageView.setImageBitmap(imageBitmap); } } ====== Gestión de preferencias ====== Para empezar tendremos que añadir una dependencia al fichero ''build.gradle'': . . . implementation 'androidx.preference:preference:1.2.0' . . . Para la gestión de preferencias se debe diseñar un //layout// específico para crear la pantalla desde donde el usuario podrá configurar los diferentes aspectos que se preparen. Esta Activity será la que contenga el fragmento que será quien realmente tenga definidas las opciones que queramos que aparezcan: Este layout quedará relacionado con la Activity correspondiente donde se cargará como pantalla de Preferencias: public class PreferencesActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_preferences); } } En ''xml->preference_screen.xml'' definiremos las preferencias que queremos gestionar en la Activity anterior: En cuando al código Java, tendremos que definir el fragmento que se encargará de cargar la pantalla de preferencias según el layout y las preferencias que hemos definido anteriormente. Hay que tener en cuenta que este fragmento quedará relacionado con el layout ''preference_scree.xml'' que acabamos de definir. public class PreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { setPreferencesFromResource(R.xml.preference_screen, rootKey); } } Hasta ahí hemos conseguido preparar la ''Activity'' de preferencias desde donde el usuario podrá configurar la aplicación. Todos los cambios que éste realice en esa ''Activity'' se almacenarán de forma automática (no hay que programar nada) como preferencias de la aplicación. Nos corresponde a nosotros programar el qué hacer en función de las preferencias que haya escogido o configurado el usuario. Así, para acceder al estado de las mismas utilizaremos la clase ''SharedPreferences'' que nos permitirá acceder a cada una de las preferencias por su nombre (''android:key'') estableciendo en cada caso un valor por defecto en caso de que el usuario no hubiera establecido ningún valor para la misma . . . SharedPreferences myPreferences = PreferenceManager.getDefaultSharedPreferences(this); boolean notifications = myPreferences.getBoolean("notifications", false); . . .
{{ preferences.png?400 }} Activity de preferencias
Hay que tener en cuenta que, en el caso de que se usen ''array'' de datos, éstos deben figurar también como ficheros //XML// dentro de la carpeta ''xml'' Nombre Apellidos ====== Imágenes ====== En esta sección veremos una serie de artículos sobre cómo trabajar con imágenes en Android: hacer una foto, seleccionar una existente de la galería, almacenarlas en base de datos, . . . ===== Hacer una foto ===== El primer paso será indicar que la aplicación podrá solicitar los permisos para hacer uso de la cámara del dispositivo: Además, tendremos que solicitar expresamente los permisos al inicio de la Activity (nada nos asegura que el permiso esté ya asignado puesto que en las versiones modernas de Android el usuario puede cancelarlo en cualquier momento). Podemos hacerlo en el método ''onCreate''. public class SomeActivity extends AppCompatActivity { . . . private final int PICK_PICTURE = 1; private Uri pictureUri; . . . protected void onCreate(Bundle savedInstanceState) { . . . if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_DENIED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, 1); } . . . } . . . } . . . Justo en el momento en que el usuario pincha en un botón o un ''ImageView'' para que salte la cámara y hacer la foto, tendremos que comprobar si los permisos, efectivamente, han siado asignados. Es entonces cuando lanzaremos el método ''launchCamera'' que lanzará la cámara. . . . if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { launchCamera(); } . . . El proceso tiene 2 pasos: - ''launchCamera'': que lanza la cámara y permite que realicemos una foto - El objeto ''startCamera'' que es una ''ActivityResultLauncher'' y que será invocado cuando la cámara haya realizado la foto para seleccionarla y que podamos visualizarla, por ejemplo, en un ''ImageView'' para verla a modo de vista previa. A partir de aqui, si lo que queremos hacer es almacenarla en base de datos, podemos hacerlo siguiendo los pasos que se indican en [[https://multimedia.codeandcoke.com/apuntes:android#almacenar_una_imagen_en_base_de_datos|Almacenar una imagen en base de datos]] ActivityResultLauncher startCamera = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), new ActivityResultCallback() { @Override public void onActivityResult(ActivityResult result) { if (result.getResultCode() == RESULT_OK) { // Podemos mostrar la miniatura en un ImageView imageView.setImageURI(pictureUri); // Podemos tener la imagen como Bitmap si queremos guardarla en base de datos, por ejemplo Bitmap bitmapImage = ((BitmapDrawable) imageView.getDrawable()).getBitmap(); } } }); private void launchCamera() { ContentValues values = new ContentValues(); values.put(MediaStore.Images.Media.TITLE, "Nueva imagen"); values.put(MediaStore.Images.Media.DESCRIPTION, "Cámara"); pictureUri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, pictureUri); startCamera.launch(cameraIntent); } ===== Seleccionar una imagen de la galería del móvil ===== En el siguiente ejemplo suponemos que tenemos un ''ImageView'' con el que, haciendlo click sobre él, queremos seleccionar una imagen de nuestra galería para terminar visualizándola sobre si mismo. Podríamos utilizarlo, por ejemplo, para el caso en que queramos registrar información en un formulario y uno de los datos sea una imagen. public class RegisterActivity extends AppCompatActivity { . . . private ImageView imageView; public void onCreate(.....) { . . . imageView = findViewById(R.id.myImageView); . . . } ActivityResultLauncher galleryActivityResultLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == Activity.RESULT_OK) { Uri image_uri = result.getData().getData(); imageView.setImageURI(image_uri); } } ); public void selectImage(View view) { Intent galleryIntent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); galleryActivityResultLauncher.launch(galleryIntent); } . . . } Más adelante, cuando queramos guardar la información en la base de datos, podemos obtener el objeto imagen como ''Bitmap'' (es el tipo de dato que podemos usar en Android para almacenar una imagen) de la siguiente forma: Bitmap bitmapImage = ((BitmapDrawable) imageView.getDrawable()).getBitmap(); Y ese objeto //bitmapImage// ya puede ser almacenado tal cual en una base de datos utilizando la [[https://multimedia.codeandcoke.com/apuntes:android#acceso_a_bases_de_datos_con_room|librería Room]]. ===== Almacenar una imagen en base de datos ===== Una forma sencilla de "almacenar" una imagen en la base de datos del móvil (usando la librería //Room//) consiste, precisamente, en evitar almacenarla y guardar simplemente la ruta a la misma, puesto que estará almacenada en el móvil de alguna forma. El campo que almacenará la imagen será, por tanto, un ''String'' public class Book { . . . @ColumnInfo private String image; . . . } En el momento en que cogemos la Uri de la foto para luego ver su vista previa en un ImageView, podemos también convertir ese valor a un String y asignarlo al campo que hemos definido en el momento de dar de alta, en este caso, un libro: . . . if (result.getResultCode() == Activity.RESULT_OK) { Uri uri = result.getData().getData(); bookImage.setImageURI(uri); imageUri = uri.toString(); } . . . Asi, al crear el objeto justo antes de pasarlo al Dao de //Room//, podemos asignarle el valor: book.setImage(imageUri); Y a la hora de cargarlo (por ejemplo en el //Adapter// del ''RecyclerView'' donde visualicemos todos los datos) solamente tendremos que asignarle al ''ImageView'' la Uri que crearemos a partir del ''String'' que hemos almacenado: . . . if (book.getImage() != null) { holder.ivImage.setImageURI(Uri.parse(book.getImage())); } . . . ====== Acceso a Bases de Datos con SQLite [deprecated] ====== El acceso a Bases de Datos está integrado con el API de //Android// por lo que resulta muy sencillo. Además, como ya se ha visto en la estructura de este framework, se incluye con el mismo el motor de almacenamiento //SQLite//, que será el que se emplee para almacenar información dentro del dispositivo móvil. Si se quiere almacenar información fuera del teléfono (que tendrá que ser accedida vía Servicio Web, por ejemplo) se podrán utilizar otros motores puesto que son otros equipos quienes tendrán que gestionar el acceso a los datos. SQLite es un motor extremadamente ligero, muy apto para entornos donde el rendimiento y las prestaciones son limitadas: * Apenas una unos pocos MBytes * Ideal para pequeñas Bases de Datos (hasta 1GByte aprox.) * Perfecto para dispositivos pequeños donde las prestaciones son limitadas * No necesita de instalación. En el caso de Android incluso viene ya de serie con el framework por lo que podremos acceder a las Bases de Datos directamente desde la propia API * Se puede utilizar ''SQL'' para comunicarse con él o bien la API si no vamos a hacer algo muy complicado === Acceso a Bases de Datos con SQLiteHelper (deprecated) === En //Android//, para acceder a una Base de Datos, debemos crearnos una clase que herede de la clase auxiliar ''SQLiteOpenHelper'' (incluida en el framework) de forma que tendremos que implementar allí todos los métodos que permitan acceder a los datos para insertar, consultar, eliminar o cualquier otra operación. Además, en esta clase de deben incluir las sentencias que permitan crear o actualizar la Base de Datos de forma que el framework pueda realizar estas operaciones cuando sea necesario. A continuación se muestra la estructura más básica que deberá tener una clase que acceda a una Base de Datos, que en este caso sólo contiene una tabla llamada //eventos// Primero, suponemos una clase de ''Constantes'' (creada, por ejemplo, dentro de un paquete ''util'' en el proyecto). Luego podremos usarlas (importándolas estáticamente) para referirnos a los nombres de tablas o campos de la Base de Datos que hayamos creado. Para el campo ''id'' de la tabla usaremos la constante ''_ID'' que ya viene definida con Android en la clase ''android.provider.BaseColumns''. public class Constantes { public static final String BASE_DATOS = "mibasededatos.db"; public static final String TABLA_EVENTOS = "eventos"; public static final String NOMBRE = "nombre"; public static final String DESCRIPCION = "descripcion"; public static final String DIRECCION = "direccion"; public static final String PRECIO = "precio"; public static final String FECHA = "fecha"; public static final String AFORO = "aforo"; public static final String IMAGEN = "imagen"; . . . } Ahora comenzamos con la clase que se encargará de gestionar la Base de Datos: package com.sfaci.aplicacion.db; import static android.provider.BaseColumns._ID; import static com.sfaci.aplicacion.util.Constantes.BASE_DATOS; . . . . . . public class Database extends SQLiteOpenHelper { private static final int VERSION = 1; public Database(Context contexto) { super(contexto, BASE_DATOS, null, VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + TABLA_EVENTOS + "(" + _ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + NOMBRE + " TEXT, " + DESCRIPCION + " TEXT, " + DIRECCION + " TEXT, " + PRECIO + " REAL, " + FECHA + " TEXT, " + AFORO + " INT, " + IMAGEN " BLOB)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLA_EVENTOS); onCreate(db); } * Las constantes ''BASE_DATOS'' y ''VERSION'' nos indican nombre y version en la que se encuentra la Base de Datos * El método ''onCreate'' se ejecutará automáticamente cuando sea necesario crear la Base de Datos por primera vez * El método ''onUpgrade'' se ejecutará cuando sea necesario actualizar la Base de Datos, por ejemplo, cuando la aplicación se actualice y haya que modificar la estructura de la misma A partir de esta estructura, tendremos que añadir, libremente, tantos métodos como formas de acceso a la Base de Datos queramos tener. Para los casos siguientes supondremos que tenemos una clase ''Evento'' con la siguiente estructura: public class Evento { private long id; private String nombre; private String descripcion; private String direccion; private float precio; private Date fecha; private int aforo; private Bitmap imagen; } === Registrar datos === public void nuevoEvento(Evento evento) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); values.put(NOMBRE, evento.getNombre()); values.put(DESCRIPCION, evento.getDescripcion()); values.put(DIRECCION, evento.getDireccion()); values.put(PRECIO, evento.getPrecio()); values.put(FECHA, Util.formatearFecha(evento.getFecha())); values.put(AFORO, evento.getAforo()); values.put(IMAGEN, Util.getBytes(evento.getImagen())); db.insertOrThrow(TABLA_EVENTOS, null, values); db.close(); // Tambien se pueden lanzar sentencia SQL directamente // String[] argumentos = new String[]{arg1, arg2, arg3}; //db.execSQL("INSERT INTO . . . . ? ? ?", argumentos); } Para registrar datos es posible utilizando la clase ''ContentValues'' asignando valores a los diferentes campos de la tabla o bien componer la sentencia ''INSERT INTO'' correspondiente directamente en //SQL// (parte comentada) === Borrar datos === public void eliminarEvento(Evento evento) { SQLiteDatabase db = getWritableDatabase(); String[] argumentos = new String[]{String.valueOf(evento.getId())}; db.delete(TABLA_EVENTOS, "id = ?", argumentos); db.close(); // Tambien se pueden lanzar sentencia SQL // String[] argumentos = new String[]{arg1, arg2, arg3}; //db.execSQL("DELETE FROM . . . . ? ? ?", argumentos); } De forma similar, para eliminar datos es posible utilizando la clase ''ContentValues'' asignando valores a los diferentes campos de la tabla y más adelante indicar mediante el campo clave ''id'' (u otro) cuál es el evento que se debe eliminar o bien componer la sentencia ''DELETE'' correspondiente directamente en //SQL// (parte comentada) === Modificar datos === public void modificarEvento(Evento evento) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); values.put(NOMBRE, evento.getNombre()); values.put(DESCRIPCION, evento.getDescripcion()); values.put(DIRECCION, evento.getDireccion()); values.put(PRECIO, evento.getPrecio()); values.put(FECHA, Util.formatearFecha(evento.getFecha())); values.put(AFORO, evento.getAforo()); values.put(IMAGEN, Util.getBytes(evento.getImagen())); String[] argumentos = new String[]{String.valueOf(evento.getId())}; db.update(TABLA_EVENTOS, values, "id = ?", argumentos); db.close(); // Tambien se pueden lanzar sentencia SQL // String[] argumentos = new String[]{arg1, arg2, arg3}; //db.execSQL("UPDATE " + TABLA_EVENTOS + " SET . . . ? ? ?", argumentos); } De forma similar, para modificar datos es posible utilizando la clase ''ContentValues'' asignando valores a los diferentes campos de la tabla y más adelante indicar mediante el campo clave ''id'' (u otro) cuál es el evento que se debe modificar o bien componer la sentencia ''UPDATE'' correspondiente directamente en //SQL// (parte comentada). === Consultar datos === Para la parte de consulta de datos, tendremos que tener en cuenta que en esta clase no podremos listar los datos puesto que no estamos en ninguna Activity que son las encargadas de presentar la información en pantalla. Así, lo que haremos será extraer los datos de la Base de Datos y devolverlos, de forma que sea la clase que invoque a este método la encargada de listarlos de la forma que convenga en un ''ListView'', ''Spinner'' o cualquier otra ''View''. public ArrayList getEventos() { final String[] SELECT = {_ID, NOMBRE, DESCRIPCION, DIRECCION, PRECIO, FECHA, AFORO, IMAGEN}; final String ORDER_BY = "fecha"; SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query(TABLA_EVENTOS, SELECT, null, null, null, null, ORDER_BY); ArrayList listaEventos = new ArrayList(); Evento evento = null; while (cursor.moveToNext()) { evento = new Evento(); evento.setId(cursor.getLong(0)); evento.setNombre(cursor.getString(1)); evento.setDescripcion(cursor.getString(2)); evento.setDireccion(cursor.getString(3)); evento.setPrecio(cursor.getFloat(4)); try { evento.setFecha(Util.parsearFecha(cursor.getString(5))); } catch (ParseException pe) { // Si no se puede leer la fecha se coloca la de hoy por defecto evento.setFecha(new Date(System.currentTimeMillis())); } evento.setAforo(cursor.getInt(6)); evento.setImagen(Util.getBitmap(cursor.getBlob(7))); listaEventos.add(evento); } cursor.close(); db.close(); return listaEventos; // Tambien se pueden lanzar sentencia SQL // String[] argumentos = new String[]{arg1, arg2, arg3}; //db.rawQuery("SELECT " + NOMBRE + ", " + DESCRIPCION + " . . . WHERE . . . ? ? ?", argumentos); } === Almacenar imágenes en Base de Datos (con SQLite) [deprecated] === Merece especial atención este caso, puesto que almacenar imágenes en una Base de Datos SQLite no es una tarea trivial. Lo primero será buscar la manera de que el usuario pueda asignar una imagen de su dispositivo móvil a algún objeto de la aplicación. Para eso, podremos disponer de un botón o similar para lanzar la galería/cámara del móvil y que de esa manera dicho usuario pueda incorporar las imágenes a la aplicación. El siguiente fragmento de código permite cargar la galería del dispositivo de forma que el usuario pueda seleccionar una imagen (o hacer una foto con la cámara). Dicha imagen se asignará a algún elemento de la pantalla para luego poder trabajar con ella para incorporarla a la Base de Datos al dar de alta (por ejemplo) la información. Este código no hará saltar la galería, sino que debe ser lanzado según se indica más adelante. En este caso, podríamos utilizar una vista ''ImageView'' ((https://developer.android.com/reference/android/widget/ImageView.html)) para mostrar la imagen seleccionada por el usuario. Además, sobre esa vista podemos asignar un ''ClickListener'' para lanzar la galería/cámara cuando el usuario pulse sobre la foto para cambiarla. Usaremos la librería [[https://square.github.io/picasso/|Picasso]] que permite cargar imágenes directamente sobre cualquier ImageView de nuestra Activity. Hay que tener en cuenta que tendremos que añadir la siguiente línea al ''build.gradle'' para importarla en nuestro proyecto: ''implementation 'com.squareup.picasso:picasso:2.8'''
{{ imageview1.png?300 }} Formulario para dar de alta y elegir imagen
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super(requestCode, resultCode, data); if ((requestCode == RESULTADO_CARGA_IMAGEN) && (resultCode == RESULT_OK) && (data != null)) { Picasso.get().load(data.getData()).noPlaceholder().centerCrop().fit() .into((ImageView) findViewById(R.id.imageView)); } } . . . Ahora, tendremos que asociar el siguiente código a algún botón o elemento con el que usuario tenga que interactuar para hacer saltar la galería según el fragmento de código anterior. . . . private int RESULTADO_CARGA_IMAGEN = 1; . . . // Lanza la galería/cámara de fotos del dispositivo Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); startActivityForResult(intent, RESULTADO_CARGA_IMAGEN); . . . Y en el Manifest de nuestra aplicación, permiso para acceder al almacenamiento externo del dispositivo . . .
{{ imageview2.png?300 }} Galería/Cámara
Suponemos que la clase cuyos objetos queremos almacenar en la Base de Datos, tiene declarado un atributo para almacenar la imagen con la clase ''Bitmap'' ((https://developer.android.com/reference/android/graphics/Bitmap.html)) public class Evento { . . . private Bitmap imagen; . . . } Así, para obtener la imagen del ''ImageView'' (es donde la hemos dejado al seleccionarla de la galería en los pasos anteriores) y pasarla a ''Bitmap'', tendremos que hacerlo como a continuación: Evento evento = new Evento(); . . . evento.setImagen(((BitmapDrawable) ivImagen.getDrawable()).getBitmap()); Database db = new Database(this); db.nuevoEvento(evento); . . . Y una vez en la clase donde gestionamos la Base de Datos, tendremos que pasar ese objeto ''Bitmap'' a un //array// de //bytes// para que pueda ser almacenada en la columna que le corresponda (columna que habrá sido definida como de tipo ''BLOB'' ((https://es.wikipedia.org/wiki/Binary_large_object)) en la sentencia ''CREATE TABLE'' correspondiente) public class DataBase extends SQLiteOpenHelper { public void nuevoEvento(Evento evento) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); . . . values.put(IMAGEN_EVENTO, Util.getBytes(evento.getImagen())); . . . } } En el caso de que queramos leerla, tendremos que convertir ese //array// de //bytes// de nuevo a un objeto ''Bitmap'' public class DataBase extends SQLiteOpenHelper { . . . public ArrayList obtenerEventos() { SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query(TABLA_EVENTOS, SELECT_CURSOR, null, null, null, null, ORDER_BY); ArrayList listaEventos = new ArrayList(); Evento evento = null; while (cursor.moveToNext()) { evento = new Evento(); . . . evento.setImagen(Util.getBitmap(cursor.getBlob(7))); listaEventos.add(evento); } db.close(); return listaEventos; } } . . . Y para terminar se muestran los dos métodos implementados para la conversión de una imagen ''Bitmap'' a //array// de //bytes// y al contrario, que podemos tener en una clase ''Util'' para su uso donde convenga public class Util { . . . /** * Convierte un Bitmap en un array de bytes * @param bitmap * @return */ public static byte[] getBytes(Bitmap bitmap) { ByteArrayOutputStream bos = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.PNG, 0, bos); return bos.toByteArray(); } /** * Convierte un array de bytes en un objeto Bitmap * @param bytes * @return */ public static Bitmap getBitmap(byte[] bytes) { return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } . . . } ====== Acceso a Bases de Datos con Room ====== === Room persistence library === * Librería incluida con Android que abstrae todavía más al usuario de conocer los detalles de la base de datos * Según se define la clase Java es posible añadir anotaciones para indicar cómo debe almacenar dicho objeto cuando se quiera llevar a base de datos
{{ room.png }} Ejemplo anotaciones Room
En primer lugar, necesitamos añadir las dependencias necesarias al fichero ''build.gradle'': . . . implementation "androidx.room:room-runtime:2.4.3" annotationProcessor "androidx.room:room-compiler:2.4.3" . . . Asi, para una clase ''Product'', quedaría como sigue: @Entity public class Product { @PrimaryKey(autoGenerate = true) private int id; @ColumnInfo private String name; @ColumnInfo private String category; @ColumnInfo private int quantity; @ColumnInfo private float price; @ColumnInfo private boolean important; @ColumnInfo(typeAffinity = ColumnInfo.BLOB) private byte[] image; . . . . . . A continuación definiremos el //DAO// con todas las operaciones que queramos que estén disponibles (Habría que hacer un //DAO// para cada clase del modelo de datos: @Dao public interface ProductDao { @Query("SELECT * FROM product") List getAll(); @Query("SELECT * FROM product WHERE name = :name") List findByName(String name); @Insert void insert(Product product); @Update void update(Product product); @Delete void delete(Product product); @Query("DELETE FROM product WHERE name = :name") void deleteByName(String name); } Y, por último, la clase que nos dará acceso a la Base de datos allá donde la queramos utilizar para realizar alguna operación sobre ella: @Database(entities = {Product.class}, version = 2) public abstract class AppDatabase extends RoomDatabase { public abstract ProductDao productDao(); } === Registrar/Modificar información === // Se recogen los datos para construir el nuevo Producto Product product = . . . . . . final AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, DATABASE_NAME) .allowMainThreadQueries().build(); try { db.productDao().insert(product); . . . } catch (SQLiteConstraintException sce) { Snackbar.make(aView, "Error al registrar el producto", BaseTransientBottomBar.LENGTH_LONG).show(); } === Consultas === Podríamos necesitar listar todos los productos de la base de datos: AppDatabase db = AppDatabase db = Room.databaseBuilder(context, AppDatabase.class, "products") .allowMainThreadQueries() .fallbackToDestructiveMigration().build(); List myProducts = db.productDao().getAll(); O bien hacer alguna de las consultas que hemos definido en el DAO: // Recogemos el nombre del producto por el que queremos filtrar String productName = . . . AppDatabase db = AppDatabase db = Room.databaseBuilder(context, AppDatabase.class, "products") .allowMainThreadQueries() .fallbackToDestructiveMigration().build(); Product product = db.productDao().findByName(productName); === Eliminar información === // Nos hacemos con el producto que queremos eliminar Book selectedBook = . . . . . . final AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, DATABASE_NAME) .allowMainThreadQueries().build(); try { db.productDao().delete(product); . . . } catch (SQLiteConstraintException sce) { Snackbar.make(aView, "Error al eliminar el producto", BaseTransientBottomBar.LENGTH_LONG).show(); } ====== Consumo de APIs ====== Uno de los mayores atractivos de los dispositivos móviles es el acceso a Internet para recuperar y tratar información de cualquier tipo. Prácticamente todas las aplicaciones permiten comunicar los dispositivos entre sí o con Internet. Para ello, siempre tendremos que contar con una aplicación servidor que permita la comunicación entre los diferentes dispositivos. Dicho servidor, normalmente, hará disponible la información mediante diferentes mecanismos como //APIs// con mensajes que normalmente estarán escritos en formato //JSON//, que es un formato de intercambio de información que en los últimos años ha ganado mucha popularidad para la comunicación asíncrona de información, desplazando en cierta manera al formato //XML// que se venía usando ampliamente hasta entonces.
{{ json-rest.png }} Petición/Respuesta JSON-REST
===== Formato JSON ===== { "firstName": "John", "lastName": "Smith", "isAlive": true, "age": 25, "address": { "streetAddress": "21 2nd Street", "city": "New York", "state": "NY", "postalCode": "10021-3100" }, "phoneNumbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" }, { "type": "mobile", "number": "123 456-7890" } ], "children": [], "spouse": null } ===== Consumo de APIs con Retrofit ===== Para hacer este ejemplo, de consumo de APIs usando Retrofit, vamos a suponer: * Tenemos una API de productos funcionando en local con las siguientes operaciones: * POST /products * GET /product/{productId} * DELETE /product/{productId} * GET /products * Tenemos la clase Java definida con sus atributos, getters y setters * Estamos usando una arquitectura MVP (Model-View-Presenter) * Solamente vamos a montar el ejemplo que corresponde con el registro de un nuevo Producto Para empezar, añadimos las dependencias que necesitamos al fichero ''build.gradle'': implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.4.0' Y también tendremos que crear las clases e interfaces que nos permiten "configurar" Retrofit: Primero la clase que nos permitirá crear una instancia de nuestra API en este proyecto, "en el lado Android" para comunicarnos con ella (''BASE_URL'' será una constante que tendrá como valor la dirección base de nuestra API, normalmente ''http://10.0.2.2:8080'' si es una prueba depurando por USB): public class ProductApi { public static ProductApiInterface buildInstance() { Retrofit retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build(); return retrofit.create(ProductApiInterface.class); } } A continuación la interface con las operaciones de la API que queremos que Retrofit implemente para nosotros: public interface ProductApiInterface { Call addProduct(@Body Product product); . . . . . . } A continuación la interface que define el contrato para nuestra Arquitectura MVP. Hay que tener en cuenta que para este ejemplo solamente hemos dejado el código que corresponde a la operación de registrar un producto: public interface NewProductContract { interface Model { interface OnAddProductListener { void onAddProductSuccess(Product newProduct); void onAddProductError(String message); } void addProduct(Product product, OnAddProductListener listener); . . . } interface View { void addProduct(android.view.View view); . . . } interface Presenter { void addProduct(String name, String category, String quantity, String price, boolean important, byte[] productImage); . . . } } El modelo es la capa que contiene las operaciones donde nuestra aplicación Android se comunica con la API del lado servidor. En función de como se ejecuten esas operaciones, invocaremos a un método u otro del listener, que acabará ejecutando esos métodos en el Presenter: public class NewProductModel implements NewProductContract.Model { private Context context; public NewProductModel(Context context) { this.context = context; } @Override public void addProduct(Product product, OnAddProductListener listener) { ProductApiInterface api = ProductApi.buildInstance(); Call callProducts = api.addProduct(product); callProducts.enqueue(new Callback() { @Override public void onResponse(Call call, Response response) { Product product = response.body(); listener.onAddProductSuccess(product); } @Override public void onFailure(Call call, Throwable t) { listener.onAddProductError("Se ha producido un error al conectar con el servidor"); t.printStackTrace(); } }); } . . . . . . El Presenter, como capa que hacer de "mensajero" entre el Model y la View, valida la información antes de invocar al Model para que se comunique con la API en el lado servidor. Dependiendo de como vaya esa comunicación, el Model acabará invocando a sus métodos ''onAddProductSuccess'' si todo van bien o ''onAddProductError'' si se produce algún fallo durante la comunicación. Será entonces cuando el Presenter se comunique con la View para comunicar al usuario qué ha pasado: public class NewProductPresenter implements NewProductContract.Presenter, NewProductContract.Model.OnAddProductListener, NewProductContract.Model.OnModifyProductListener { private NewProductModel model; private NewProductView view; public NewProductPresenter(NewProductView view) { this.view = view; model = new NewProductModel(view.getApplicationContext()); } @Override public void addProduct(String name, String category, String quantity, String price, boolean important, byte[] productImage) { if (!validData(name, category, quantity, price, important, productImage)) view.showMessage("Error al validar la información"); Product product = new Product(name, category, Integer.parseInt(quantity), Float.parseFloat(price), important, productImage); model.addProduct(product, this); } @Override public void onAddProductSuccess(Product product) { view.showMessage("ok"); } @Override public void onAddProductError(String message) { view.showMessage("error"); } . . . . . . Para este caso, en esta capa, el usuario ve el formulario con el que podrá registrar un nuevo producto. También se implementan aqui los métodos a los que el Presenter tiene que invocar cuando todo vaya bien o mal al intentar registrar un producto: public class NewProductView extends AppCompatActivity implements NewProductContract.View { private int SELECT_PICTURE_RESULT = 1; private NewProductPresenter presenter; private Action action; private Product product; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_new_product); // Tenemos una enumeración definida para saber si tenemos que Registrar o Modificar un producto // Eso nos permite reutilizar el formulario que usamos para la recogida de datos action = Action.valueOf(getIntent().getStringExtra("ACTION")); if (action == PUT) { product = getIntent().getParcelableExtra("product"); fillProductDetails(); // TODO No está hecho para este ejemplo } presenter = new NewProductPresenter(this); } public void addProduct(View view) { EditText etName = findViewById(R.id.product_name); EditText etCategory = findViewById(R.id.product_category); EditText etQuantity = findViewById(R.id.product_quantity); EditText etPrice = findViewById(R.id.product_price); CheckBox checkImportant = findViewById(R.id.important_product); ImageView productImageView = findViewById(R.id.product_image); String name = etName.getText().toString(); String category = etCategory.getText().toString(); String quantity = etQuantity.getText().toString(); String price = etPrice.getText().toString(); boolean important = checkImportant.isChecked(); byte[] productImage = ImageUtils.fromImageViewToByteArray(productImageView); if (action == POST) presenter.addProduct(name, category, quantity, price, important, productImage); else presenter.modifyProduct(product.getId(), name, category, quantity, price, important, productImage); etName.setText(""); etCategory.setText(""); etQuantity.setText(""); etPrice.setText(""); checkImportant.setChecked(false); etName.requestFocus(); } @Override public void showMessage(String message) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } public void selectPicture(View view) { Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); startActivityForResult(intent, SELECT_PICTURE_RESULT); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if ((requestCode == SELECT_PICTURE_RESULT) && (resultCode == RESULT_OK) && (data != null)) { Picasso.get().load(data.getData()) .noPlaceholder() .centerCrop() .fit() .into((ImageView) findViewById(R.id.product_image)); } } . . . . . . } ===== Tareas asíncronas: AsyncTask [deprecated] ===== Las tareas asíncronas (''AsynTask'') permiten ejecutar código en segundo plano de forma que mientras se ejecute dicha tarea no se bloquea la GUI para el usuario y éste puede seguir interactuando con ella o simplemente visualizar el avance o progreso de la misma. El funcionamiento de ''AsyncTask'' es el mismo que el de la clase ''SwingWorker'' que ya traía el API de Java private class TareaDescargaDatos extends AsyncTask { /* * En este método se debe escribir el código de la tarea que se desea * realizar en segundo plano. * Hay que tener en cuenta que Android no nos permitirá acceder a * ningún componente de la GUI desde este método */ @Override protected Void doInBackground(String... params) { return null; } /* * Este método se ejecuta cuando se cancela la tarea * Permite interactuar con la GUI */ @Override protected void onCancelled() { super.onCancelled(); } /* * Este método se ejecuta a medida que avanza la tarea * Permite, por ejemplo, actualizar parte de la GUI para * que el usuario pueda ver el avance de la misma */ @Override protected void onProgressUpdate(Void... progreso) { super.onProgressUpdate(progreso); } /* * Este método se ejecuta automáticamente cuando la tarea * termina (cuando termina el método ''doInBackground'') * Permite interactuar con la GUI con lo que podemos comunicar * al usuario la finalización de la tarea o mensajes de error * si proceden */ @Override protected void onPostExecute(Void resultado) { super.onPostExecute(resultado); } } ===== Acceder a contenido en la red [deprecated] ===== En el caso concreto de que queramos acceder a contenido en la red es muy probable que éste nos venga preparado en formato //JSON// como hemos visto anteriormente. Además, hay que tener en cuenta que //Android// nos obliga a implementar en un //AsyncTask// cualquier fragmento de código que establezca alguna conexión con Internet (de esa manera se realiza en segundo plano y no bloquea el interfaz de usuario). Así, una vez visto la estructura de un fichero //JSON// y la de una tarea asíncrona, nos queda ver como unir ambos conceptos para preparar una //Activity// capaz de conectarse a Internet, acceder a un fichero //JSON// y //parsearlo// para extraer la información que nos interesa y poderla mostrar al usuario de la forma más cómoda posible. Como ejemplo, vamos a ver la implementación necesaria para crear una //Activity// que se descargue la información sobre los Monumentos de Zaragoza del [[https://www.zaragoza.es/ciudad/risp/buscar_Risp?&content_type=Excel|Catálogo de Datos]] de la página web del Ayuntamiento. Para ello, en la clase Constantes se ha creado una constante que contiene el valor de la URL que nos devuelve los datos en formato //JSON// public class Constantes { . . . public final static String URL = "http://www.zaragoza.es/georref/json/hilo/verconsulta_Piezas?georss_tag_1=-&georss_materiales=-&georss_tematica=-&georss_barrio=-&georss_epoca=-"; . . . } En el diseño de la //Activity// hemos preparado una ''ListView'' donde se listará el contenido una vez parseado desde la //AsyncTask// (explicado más adelante)). En este caso se omite la implementación del //Adapter// tal y como corresponda en función de la información que se quiera mostrar y cómo se quiera hacer. Contamos con que la clase ''MonumentoAdapter'' contiene dicha implementación @Override protected void onCreate(Bundle savedInstanceState) { listaMonumentos = new ArrayList(); adapter = new MonumentoAdapter(this, R.layout.monumento_item, listaMonumentos); ListView lvMonumentos = (ListView) findViewById(R.id.lvMonumentos); lvMonumentos.setAdapter(adapter); } Así, la tarea asíncrona será la encargada de, con la URL que recibirá como parámetro en el momento de su ejecución (más abajo), cargar el //JSON// como una cadena de texto para luego parsearlo y poblar la ''ListView'' a medida que progrese su ejecución private class TareaDescarga extends AsyncTask { private boolean error = false; private ProgressDialog dialog; /** * Método que ejecuta la tarea en segundo plano * @param params * @return */ @Override protected Void doInBackground(String... params) { String url = params[0]; InputStream is = null; String resultado = null; JSONObject json = null; JSONArray jsonArray = null; try { // Conecta con la URL y obtenemos el fichero con los datos URL url = new URL(Constantes.URL); HttpURLConnection conexion = (HttpURLConnection) url.openConnection(); // Lee el fichero de datos y genera una cadena de texto como resultado BufferedReader br = new BufferedReader(new InputStreamReader(conexion.getInputStream())); StringBuilder sb = new StringBuilder(); String linea = null; while ((linea = br.readLine()) != null) sb.append(linea + "\n"); conexion.disconnect(); br.close(); resultado = sb.toString(); json = new JSONObject(resultado); jsonArray = json.getJSONArray("features"); String titulo = null; String link = null; String coordenadas = null; Monumento monumento = null; for (int i = 0; i < jsonArray.length(); i++) { titulo = jsonArray.getJSONObject(i).getJSONObject("properties").getString("title"); link = jsonArray.getJSONObject(i).getJSONObject("properties").getString("link"); coordenadas = jsonArray.getJSONObject(i).getJSONObject("geometry").getString("coordinates"); coordenadas = coordenadas.substring(1, coordenadas.length() - 1); String latlong[] = coordenadas.split(","); monumento = new Monumento(); monumento.setTitulo(titulo); monumento.setLink(link); monumento.setLatitud(Float.parseFloat(latlong[0])); monumento.setLongitud(Float.parseFloat(latlong[1])); listaMonumentos.add(monumento); } } catch (IOException ioe) { ioe.printStackTrace(); error = true; } catch (JSONException jse) { jse.printStackTrace(); error = true; } return null; } /** * Método que se ejecuta si la tarea es cancelada antes de terminar */ @Override protected void onCancelled() { super.onCancelled(); adapter.clear(); listaMonumentos = new ArrayList(); } /** * Método que se ejecuta durante el progreso de la tarea * @param progreso */ @Override protected void onProgressUpdate(Void... progreso) { super.onProgressUpdate(progreso); adapter.notifyDataSetChanged(); } /** * Método ejecutado automáticamente justo antes de lanzar la tarea en segundo plano */ @Override protected void onPreExecute() { super.onPreExecute(); dialog = new ProgressDialog(ListadoMonumentos.this); dialog.setTitle(R.string.mensaje_cargando); dialog.show(); } /** * Método ejecutado automáticamente justo después de terminar la parte en segundo plano * Es la parte donde podemos interactuar con el UI para notificar lo sucedido al usuario * @param resultado */ @Override protected void onPostExecute(Void resultado) { super.onPostExecute(resultado); if (error) { Toast.makeText(getApplicationContext(), getResources().getString(R.string.mensaje_error), Toast.LENGTH_SHORT).show(); return; } if (dialog != null) dialog.dismiss(); adapter.notifyDataSetChanged(); } } Y finalmente, se lanza la tarea. En este caso la lanzamos desde el método ''onResume'' de forma que se ejecutará cada vez que la //Activity// vuelva del segundo plano (para asi actualizarla) y también cuando la //Activity// se cargue por primera vez (ver ciclo de vida de una Activity)). private void cargarListaMonumentos() { TareaDescarga tarea = new TareaDescarga(); tarea.execute(Constants.URL); } @Override protected void onResume() { super.onResume(); cargarListaMonumentos(); } Sólo quedará conceder en el manifiesto permiso a la aplicación para hacer uso de Internet y configurar la aplicación para permitir la conexión con el protocolo HTTP para permitir el acceso a texto claro. . . . . . . . . . . . . ====== Google Maps ====== Antes de poder trabajar con la API de Google Maps en cualquier proyecto de Android, tendremos que configurar nuestra cuenta en ''Google Cloud Console'', registrar nuestro proyecto alli y conseguir una API Key que nos permita hacer uso de la API. Para ello tendremos que seguir los pasos que se describen en la [[https://developers.google.com/maps/documentation/android-sdk/cloud-setup|guia que Android ha preparado para empezar con el uso de Google Maps]]. Una vez tengamos el proyecto registrado en la Google Cloud Console y hayamos solicitado la API Key asociada a la API de Google Maps, guardaremos ésta en nuestro proyecto en res->values->google_maps_api.xml de la siguiente forma: API_KEY_HERE Y tenemos que asegurarnos de que indicamos la ubicación de esta API Key en el manifest de nuestro proyecto en Android Studio (podemos colocarlo justo donde termina la definición de todas las Activities): . . . . . . ===== Utilizar el mapa de Google Maps =====
{{ googlemaps.png?300 }} Mapa de Google Maps
Para trabajar con los mapas de //Google Maps// lo primero que necesitamos es definir el layout de la Activity donde mostraremos el mapa. Tendremos que definir, al menos, el fragmento donde se incrustará el mapa tal y como se puede ver a continuación: Más adelante, en la ''Activity'' que esté asociada con el layout que acabamos de crear, tendremos que cargar el ''fragment'' del mapa. También podemos obtener la referencia al componente del mapa para más adelante trabajar con él, aunque no es necesario hacerlo si sólo se quiere mostrar el mapa (aunque no es habitual que sólo queramos hacer eso). Asi, en el método ''onMapReady'' disponemos ya del objeto googleMap listo para trabajar con él, tras haberse cargado completamente tras llamar al método ''getMapAsync'' al que hemos invocado en el método ''onCreate''. public class MapsActivity extends FragmentActivity implements OnMapReadyCallback { private GoogleMap map; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_mapa); SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager() .findFragmentById(R.id.map_fragment); mapFragment.getMapAsync(this); } @Override public void onMapReady(GoogleMap googleMap) { map = googleMap; map.getUiSettings().setZoomControlsEnabled(true); map.getUiSettings().setMapToolbarEnabled(false); } } ===== Seleccionar ubicaciones utilizando un mapa Mapbox ===== Una vez que ya tenemos lo necesario para visualizar el mapa siguiendo los pasos del punto anterior, vamos a ver cómo empezar a interactuar con él. Como primer ejemplo veremos cómo seleccionar una ubicación en el mapa haciendo click en ella y colocando un "marker" en la posición para representar el lugar escogido. A partir de ahi, podremos obtener las coordenadas de dicha ubicación para almacenarlas o hacer con ella lo que queramos (que serán los valores de latitud y longitud del punto seleccionado). Para ello, necesitaremos los componentes ''PointAnnotationManager'' para gestionar los markers que coloquemos en el mapa y ''GesturesPlugin'' que nos permite interactuar con el mapa a través de eventos como hacer click sobre él. Además de esto, necesitaremos lo mínimo que ya sabemos hacer para representar el mapa. * El método ''OnStyleLoaded'' se encarga de todo lo que queramos hacer en cuanto el mapa esté activo. Nos viene de implementar ''Style.OnStyleLoaded''. Hay que tener en cuenta que el mapa se carga en segundo plano y es la forma que tenemos de "esperar" a que lo haga para poder empezar a dibujar o hacer algo sobre él (podríamos, por ejemplo, pintar unas ubicaciones que previamente he traído o cargado en la Activity). * El método ''initializeMapView'' inicializa el Mapa para que empiece a cargarse en pantalla (cuando acabe de hacerlo es cuando llamará al método que hemos comentado anteriormente ''OnStyleLoaded'') * El método ''initializeGesturesPlugin'' inicializa el componente que se encarga de actuar frente a las interacciones del usuario sobre el mapa. En nuestro caso añadiremos un listener para estar atentos a los clicks que el usuario haga sobre el mapa * El método ''initializePointAnnotationManager'' nos permite initializar el componente que se encarga de añadir o eliminar markers en el mapa * El método ''addMarker'' es quién se encarga de colocar un marker en la ubicación que se pasa como parámetro junto con el texto que se indique * El método ''onMapClick'' atiende al evento //click// sobre el mapa (a través de la interface ''OnMapClickListener'' que hemos implementado al definir la clase) para poder programar alli qué queremos hacer cuando el usuario haga click en el mapa. En nuestro caso, borramos todos los markers anterior (por si habíamos pinchado ya), almacenamos la posición para luego poder hacer lo que queramos con ella y visualizar un marker justo en esa misma localización. public class MapActivity extends AppCompatActivity implements Style.OnStyleLoaded, OnMapClickListener { private MapView mapView; private PointAnnotationManager pointAnnotationManager; private GesturesPlugin gesturesPlugin; private Point currentPoint; @Override protected void onCreate(Bundle savedInstanceState) { . . . initializeMapView(); initializePointAnnotationManager(); initializeGesturesPlugin(); . . . } @Override public void onStyleLoaded(@NonNull Style style) { } private void initializeMapView() { mapView = findViewById(R.id.mapView); mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS, this); } private void initializePointAnnotationManager() { AnnotationPlugin annotationPlugin = AnnotationPluginImplKt.getAnnotations(mapView); AnnotationConfig annotationConfig = new AnnotationConfig(); pointAnnotationManager = PointAnnotationManagerKt.createPointAnnotationManager(annotationPlugin, annotationConfig); } private void initializeGesturesPlugin() { gesturesPlugin = GesturesUtils.getGestures(mapView); gesturesPlugin.addOnMapClickListener(this); } private void addMarker(double latitude, double longitude, String title) { PointAnnotationOptions pointAnnotationOptions = new PointAnnotationOptions() .withPoint(Point.fromLngLat(longitude, latitude)) .withIconImage(BitmapFactory.decodeResource(getResources(), R.mipmap.red_marker)) .withTextField(title); pointAnnotationManager.create(pointAnnotationOptions); } @Override public boolean onMapClick(@NonNull Point point) { pointAnnotationManager.deleteAll(); currentPoint = point; addMarker(point.latitude(), point.longitude(), getString(R.string.here)); return false; } } ===== Marcar ubicaciones en el mapa de Google Maps ===== Para los ejemplos de estos apuntes se han utilizado datos geolocalizados del [[http://www.zaragoza.es/ciudad/risp/buscar_Risp|catálogo de datos abiertos del Ayuntamiento de Zaragoza]]. En este catálogo las coordenadas vienen en formato [[https://es.wikipedia.org/wiki/Sistema_de_coordenadas_universal_transversal_de_Mercator|UTM]] y éstas tienen que transformarse al sistema que se usa en Google Maps. Por eso, a través de la librería [[http://www.jstott.com/jcoord/|jcoord]] se transforman de UTM al sistema utilizado en Google Maps con el siguiente método que podemos implementar en nuestra clase ''Utils'' junto al resto de métodos de utilidad que tengamos en nuestro proyecto. public class Util { /** * Transforma las coordenadas del sistema UTM que * el ayuntamiento utiliza * al sistema LatLng que es con lo que trabaja Google Maps * Hay que tener en cuenta que hace falta la librería jcoord * que viene con el proyecto * @param este * @param oeste * @param zonaLat * @param zonaLong * @return */ public static LatLng DeUMTSaLatLng(double latitud, double longitud) { UTMRef utm = new UTMRef(latitud, longitud, 'N', 30); return utm.toLatLng(); } } Así, si queremos marcar ubicaciones en un mapa //Google Maps// podemos enviarle a la ''Activity'' donde se pinta el mapa las coordenadas de una ubicación a través del ''Intent'' ([[http://multimedia.codeandcoke.com/apuntes:android#comunicacion_entre_activities|Comunicación entre Activities]]) y, una vez cargadas, utilizando la propia //API// podemos añadir una marca (''addMarker(MarkerOptions)'') en el mapa. public class Mapa extends Activity { private double latitud; private double longitud; private String nombre; . . . @Override public void onCreate(Bundle savedInstanceState) { . . . // Recoge los datos enviados por la Activity que la invoca Intent i = getIntent(); latitud = i.getFloatExtra("latitud", 0); longitud = i.getFloatExtra("longitud", 0); nombre = i.getStringExtra("nombre"); // Transforma las coordenadas al sistema LatLng y las almacena uk.me.jstott.jcoord.LatLng ubicacion = Util.DeUMTSaLatLng(latitud, longitud); latitud = ubicacion.getLat(); longitud = ubicacion.getLng(); . . . } @Override public void onResume() { super.onResume(); ubicarRestaurante(); } /** * Marca el restaurante elegido en el mapa */ private void ubicarRestaurante() { // Obtiene una vista de cámara CameraUpdate camara = CameraUpdateFactory.newLatLng(new LatLng(latitud, longitud)); // Coloca la vista del mapa sobre la posición del restaurante // y activa el zoom para verlo de cerca mapa.moveCamera(camara); mapa.animateCamera(CameraUpdateFactory.zoomTo(17.0f)); // Añade una marca en la posición del restaurante con el nombre de éste mapa.addMarker(new MarkerOptions() .position(new LatLng(latitud, longitud)) .title(nombre)); } } {{ youtube>L5JpDSpxhfE }} \\ {{ youtube>xHYRgHyWWUw }} ====== Mapbox ======
{{ mapbox.png }} Mapa de la librería Mapbox
Mapbox es [[http://wiki.openstreetmap.org/wiki/Android|uno de tantos SDKs]] que permite trabajar con los mapas de [[http://www.openstreetmap.org|OpenStreetMap]]. Se puede utilizar como alternativa al servicio de mapas de Google Maps. El primer paso es seguir la [[https://docs.mapbox.com/android/maps/guides/install/|Guía de Instalación]] de su web, que nos permitirá configurar gradle y preparar un pequeño proyecto de ejemplo para visualizar el primer mapa. En la gúia, una vez realizados los pasos para registrarnos, crear los tokens y configurar gradle, se nos explica cómo crear un layout y su correspondiente Activity para visualizar un mapa por primera vez. También hay bastantes [[https://docs.mapbox.com/android/maps/examples/|ejemplos disponibles en su web]]. ===== Configurar el proyecto para comenzar a usar Mapbox ===== * Una vez que nos hemos registrado en https://www.mapbox.com, tendremos que acceder a nuestra cuenta para crear un token. Será suficiente con dejar seleccionar los //Public scopes// y añadir el //Secret scope// de //DOWNLOADS:READ//. Ese será el token que usaremos en nuestro proyecto
{{ create-token-mapbox.png }} Crear un nuevo token Mapbox
{{ token-mapbox.png }} Configurar el token Mapbox
* El siguiente paso será crear un fichero llamado ''gradle.properties'' en la carpeta ''.gradle'' que habrá en nuestra carpeta de usuario. Añadiremos la siguiente línea, cuyo valor es el token obtenido en el paso anterior: MAPBOX_DOWNLOADS_TOKEN=sk.eyJ1Ijoic2ZhY2kiLCkjahsdjkhJHJHMGFzZTJpazFqZGd6ZGVxYiJ9.jBfUMuKJHjhjhjh3nMg * En el fichero ''settings.gradle.tks'' o ''settings.gradle'' de nuestro proyecto tendremos que asegurarnos de tener la sección ''dependencyResolutionManagement'' con esta configuración (de nuevo usando nuestro token donde corresponda): . . . dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url = uri("https://api.mapbox.com/downloads/v2/releases/maven") authentication { create("basic") } credentials { // Do not change the username below. // This should always be `mapbox` (not your username). username = "mapbox" // Use the secret token you stored in gradle.properties as the password password = "sk.eyJ1Ijoic2ZhY2kiLCkjahsdjkhJHJHMGFzZTJpazFqZGd6ZGVxYiJ9.jBfUMuKJHjhjhjh3nMg" } } } } . . . * En el fichero ''AndroidManifest.xml'' añadiremos los permisos para que nuestra aplicación pueda disponer de los servicios de localización del dispositivo: . . . * También tendremos que crear un fichero ''developer-config.xml'' en la carpeta ''res/values'' del proyecto donde definiremos el valor del token público (disponible en nuestro perfil de Mapbox): sk.eyJ1Ijoic2ZhY2kiLjUHjhjJiklcmdHMGFzZTJpazFqZGd6ZGVxYiJ9.jBfUMuKJHjhjhjh3nMg Y ahora es el momento de comenzar ya a trabajar con nuestra aplicación, preparando el layout y el código para que nuestro mapa funcione. En el layout de la Activity donde queramos que aparezca el mapa, tendremos que añadir el código XML que permite insertar el componente donde se dibujará el mapa. En este caso, hemos dejado predefinidas las coordenadas de la ciudad de Zaragoza y un nivel de zoom de 12. En cualquier caso son parámetros que luego desde el código pueden ser modificados e incluso el usuario haciendo uso de la pantalla podrá modificar a su gusto. En cuanto al ćodigo, será necesario acceder al objeto ''MapView'' del layout para poder ya cargar el mapa y que éste aparezca en pantalla. public class MapsActivity extends AppCompatActivity { private MapView mapView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_maps); mapView = findViewById(R.id.mapView); mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS); } }
{{ map-view.png }} Mapa Mapbox cargando en aplicación Android
===== Seleccionar ubicaciones utilizando un mapa Mapbox ===== Una vez que ya tenemos lo necesario para visualizar el mapa siguiendo los pasos del punto anterior, vamos a ver cómo empezar a interactuar con él. Como primer ejemplo veremos cómo seleccionar una ubicación en el mapa haciendo click en ella y colocando un "marker" en la posición para representar el lugar escogido. A partir de ahi, podremos obtener las coordenadas de dicha ubicación para almacenarlas o hacer con ella lo que queramos (que serán los valores de latitud y longitud del punto seleccionado). Para ello, necesitaremos los componentes ''PointAnnotationManager'' para gestionar los markers que coloquemos en el mapa y ''GesturesPlugin'' que nos permite interactuar con el mapa a través de eventos como hacer click sobre él. Además de esto, necesitaremos lo mínimo que ya sabemos hacer para representar el mapa. * El método ''OnStyleLoaded'' se encarga de todo lo que queramos hacer en cuanto el mapa esté activo. Nos viene de implementar ''Style.OnStyleLoaded''. Hay que tener en cuenta que el mapa se carga en segundo plano y es la forma que tenemos de "esperar" a que lo haga para poder empezar a dibujar o hacer algo sobre él (podríamos, por ejemplo, pintar unas ubicaciones que previamente he traído o cargado en la Activity). * El método ''initializeMapView'' inicializa el Mapa para que empiece a cargarse en pantalla (cuando acabe de hacerlo es cuando llamará al método que hemos comentado anteriormente ''OnStyleLoaded'') * El método ''initializeGesturesPlugin'' inicializa el componente que se encarga de actuar frente a las interacciones del usuario sobre el mapa. En nuestro caso añadiremos un listener para estar atentos a los clicks que el usuario haga sobre el mapa * El método ''initializePointAnnotationManager'' nos permite initializar el componente que se encarga de añadir o eliminar markers en el mapa * El método ''addMarker'' es quién se encarga de colocar un marker en la ubicación que se pasa como parámetro junto con el texto que se indique * El método ''onMapClick'' atiende al evento //click// sobre el mapa (a través de la interface ''OnMapClickListener'' que hemos implementado al definir la clase) para poder programar alli qué queremos hacer cuando el usuario haga click en el mapa. En nuestro caso, borramos todos los markers anterior (por si habíamos pinchado ya), almacenamos la posición para luego poder hacer lo que queramos con ella y visualizar un marker justo en esa misma localización. public class MapActivity extends AppCompatActivity implements Style.OnStyleLoaded, OnMapClickListener { private MapView mapView; private PointAnnotationManager pointAnnotationManager; private GesturesPlugin gesturesPlugin; private Point currentPoint; @Override protected void onCreate(Bundle savedInstanceState) { . . . initializeMapView(); initializePointAnnotationManager(); initializeGesturesPlugin(); . . . } @Override public void onStyleLoaded(@NonNull Style style) { } private void initializeMapView() { mapView = findViewById(R.id.mapView); mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS, this); } private void initializePointAnnotationManager() { AnnotationPlugin annotationPlugin = AnnotationPluginImplKt.getAnnotations(mapView); AnnotationConfig annotationConfig = new AnnotationConfig(); pointAnnotationManager = PointAnnotationManagerKt.createPointAnnotationManager(annotationPlugin, annotationConfig); } private void initializeGesturesPlugin() { gesturesPlugin = GesturesUtils.getGestures(mapView); gesturesPlugin.addOnMapClickListener(this); } private void addMarker(double latitude, double longitude, String title) { PointAnnotationOptions pointAnnotationOptions = new PointAnnotationOptions() .withPoint(Point.fromLngLat(longitude, latitude)) .withIconImage(BitmapFactory.decodeResource(getResources(), R.mipmap.red_marker)) .withTextField(title); pointAnnotationManager.create(pointAnnotationOptions); } @Override public boolean onMapClick(@NonNull Point point) { pointAnnotationManager.deleteAll(); currentPoint = point; addMarker(point.latitude(), point.longitude(), getString(R.string.here)); return false; } }
{{ map-view-marker.png }} Marker añadido al mapa tras hacer click
===== Visualizar ubicaciones en un mapa Mapbox ===== Para visualizar una ubicación en el mapa necesitamos: * latitud y longitud del punto que queremos visualizar * Un texto o title que queramos asignar (opcional) * La imagen que queremos colocar como "marker" (en nuestro caso se llama red_marker y la hemos obtenido siguiendo el tutorial de Mapbox) En Mapbox lo que se solía conocer como ''marker'' ahora se llama Annotation y para colocar uno en un mapa de Mapbox hay que seguir el código que se ejecuta en el método ''addMarker(latitude, longitude, title)''. Hay que tener en cuenta que el código tendrá que se ejecuta tras cargarse el mapa. Es por eso que lo hacemos invocándolo desde el método ''onStyleLoaded'', que a su vez será ejecutado una vez termine de cargarse el mapa. Hay que tener en cuenta que, antes de poder añadir markers, tendremos que inicializar el ''PointAnnotationManager'' ejecutando el método ''initializePointAnnotationManager()'' . . . public class MapsActivity extends AppCompatActivity implements Style.OnStyleLoaded { . . . private PointAnnotationManager pointAnnotationManager; . . . protected void onCreate(Bundle savedInstanceState) { . . . mapView = findViewById(R.id.mapView); mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS, this); initializePointAnnotationManager(); } private void initializePointAnnotationManager() { AnnotationPlugin annotationPlugin = AnnotationPluginImplKt.getAnnotations(mapView); AnnotationConfig annotationConfig = new AnnotationConfig(); pointAnnotationManager = PointAnnotationManagerKt.createPointAnnotationManager(annotationPlugin, annotationConfig); } @Override public void onStyleLoaded(@NonNull Style style) { addMarker(40.1, -0.8, "Zaragoza"); } private void addMarker(double latitude, double longitude, String title) { PointAnnotationOptions pointAnnotationOptions = new PointAnnotationOptions() .withPoint(Point.fromLngLat(longitude, latitude)) .withIconImage(BitmapFactory.decodeResource(getResources(), R.mipmap.red_marker)) .withTextField(title); pointAnnotationManager.create(pointAnnotationOptions); } . . . });
{{ marker.png }} Marker en un mapa Mapbox
Y si además queremos posicionar directamente la cámara del mapa en esa posición y, opcionalmente, acercarla y modificar otros parámetros, podemos hacerlo como sigue, añadiendo el código dentro del método ''onStyleLoaded'' justo después de invocar a ''addMarker'', tal y como hemos hecho para añadir el //marker// En este caso metemos el código en un método para que podamos usarlo siempre que queramos posicionar sobre un punto determinado con una latitud y longitud dadas. También se podrían parametrizar otros valores como el nivel de zoom para poder personalizarlo en cada llamada. . . . private void setCameraPosition(double latitude, double longitude) { CameraOptions cameraPosition = new CameraOptions.Builder() .center(Point.fromLngLat(longitude, latitude)) .pitch(45.0) .zoom(15.5) .bearing(-17.6) .build(); mapView.getMapboxMap().setCamera(cameraPosition); } . . . ===== Ubicaciones y GPS con Mapbox ===== En el siguiente ejemplo se muestra como, utilizando el GPS, podemos acceder a la ubicación actual del usuario. En este caso se calcula su ubicación y se moviliza la cámara del mapa para posicionarnos en ella. Hay que tener en cuenta que puesto en los ejemplos anteriores ya hemos añadido los permisos necesarios para trabajar con el GPS no lo tendremos que hacer ahora. En caso de que no se haya hecho habrá que añadir los permisos en el ''AndroidManifest.xml'' . . . . . . Lo primero será crear un botón flotante (''FloatingActionButton'') en el layout del mapa de forma que el usuario pueda pulsarlo cuando quiera conocer y acceder a su posición en el mapa . . . . . . A continuación, en la ''Activity'' del mapa tendremos que añadir el código necesario para hacer funcionar el GPS y acceder a la posición del usuario public class MapActivity extends AppCompatActivity implements LocationEngineCallback { private MapView mapaView; . . . @Override protected void onCreate(Bundle savedInstanceState) { . . . LocationEngine locationEngine = LocationEngineProvider.getBestLocationEngine(this); if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 1); } locationEngine.getLastLocation(this); } @Override public void onSuccess(LocationEngineResult result) { Location currentLocation = result.getLastLocation(); Point point = Point.fromLngLat(currentLocation.getLongitude(), currentLocation.getLatitude()); setCameraPosition(point); addMarker(point, "Estoy aqui"); } @Override public void onFailure(@NonNull Exception exception) { } private setCameraPosition(Point point) { CameraOptions cameraPosition = new CameraOptions.Builder() .center(point) .pitch(0.0) .zoom(13.5) .bearing(-17.6) .build(); mapView.getMapboxMap().setCamera(cameraPosition); } }
{{ gps.png?400 }} Ubicación del usuario en un mapa Mapbox
===== Cálculo de rutas con Mapbox =====
{{ rutas.png?400 }} Ruta entre dos puntos en un mapa Mapbox
Utilizando los servicios de Android de //Mapbox// se pueden calcular las rutas y distancias entre dos puntos dados sobre el mapa, para posteriormente pintarla. Para ello, lo primero que tenemos que hacer es añadir la correspondiente librería en el ''build.gradle'' de nuestra //app//, tal y como ya hicimos con la API de Android antes de empezar a trabajar con la librería //Mapbox//. En este caso se trata de acceder a la API específica para el cálculo de rutas. En total, tendríamos que añadir las siguientes librerías //Mapbox//: . . . dependencies { . . . implementation 'com.mapbox.maps:android:10.9.1' implementation 'com.mapbox.navigation:core:2.11.0' implementation 'com.mapbox.navigation:android:2.11.0' } } . . . Y a continuación, dos métodos, ''calculateRoute(Point origin, Point destination)'' para hacer el cálculo de la ruta entre dos puntos dados, utilizando como punto de partida dos objetos ''Point'' que identifican la longitud y la latitud de los mismos. Con este método prepararemos la ruta que queremos calcular e invocaremos a ''directions.enqueeCall(this)'' para calcular, en segundo plano, la ruta entre los puntos datos. Hemos implementado la interface ''Callback public class MapsActivity extends AppCompatActivity implements Callback { . . . // Calcula la ruta entre dos puntos dados private void calculateRoute(Point origin, Point destination) { RouteOptions routeOptions = RouteOptions.builder() .baseUrl(Constants.BASE_API_URL) .user(Constants.MAPBOX_USER) .profile(DirectionsCriteria.PROFILE_WALKING) .steps(true) .coordinatesList(List.of(origin, destination)) .build(); MapboxDirections directions = MapboxDirections.builder() .routeOptions(routeOptions) .accessToken(getString(R.string.mapbox_access_token)) .build(); directions.enqueueCall(this); } // Este método será invocado cuando la ruta esté lista. En él, pintamos la ruta en el mapa @Override public void onResponse(Call call, Response response) { DirectionsRoute mainRoute = response.body().routes().get(0); Log.d("ROUTELEGS", String.valueOf(mainRoute.legs().size())); for (RouteLeg routeLeg: mainRoute.legs()) { Log.d("LEGS", String.valueOf(routeLeg.steps().size())); for (LegStep legStep : routeLeg.steps()) { Log.d("STEP", legStep.name() + " " + legStep.speedLimitSign() + " " + legStep); } } mapView.getMapboxMap().getStyle(style -> { LineString routeLine = LineString.fromPolyline(mainRoute.geometry(), PRECISION_6); GeoJsonSource routeSource = new GeoJsonSource.Builder("trace-source") .geometry(routeLine) .build(); LineLayer routeLayer = new LineLayer("trace-layer", "trace-source") .lineWidth(7.f) .lineColor(Color.BLUE) .lineOpacity(1f); SourceUtils.addSource(style, routeSource); LayerUtils.addLayer(style, routeLayer); }); } @Override public void onFailure(Call call, Throwable t) { Log.e("CalculateRoute", "Fallo al invocar a la API de Mapbox", t); } . . .
====== Firebase ====== * Plataforma de desarrollo en la nube * Nosotros nos centraremos en ver a Firebase como una plataforma de almacenamiento en la nube * Base de Datos en tiempo real * Autenticación === Configuración de Firebase con Android === * ¿Dónde empezar? -> https://firebase.google.com/docs/database/android/start * Acceder a la consola de Firebase y crear una base de datos (Database): https://console.firebase.google.com/ * Añadir la App a Firebase * Configuración gradle * Crear una instancia de Firebase y almacenar información (código Java) === Introducción a Firestore === * Base de datos NoSQL * Los datos se guardan en colecciones (collections) * Cada fila/dato/objeto se corresponde con un documento (document) * Cada atributo/campo es un campo (field) del documento
{{ firestore.png }} Firestore
===== Registro y configuración de la base de datos ===== . . . implementation 'com.google.firebase:firebase-database:20.2.2' implementation 'com.google.firebase:firebase-firestore:24.6.1' . . . ===== Registrar/Modificar información ===== Book book = new Book(); book.setId(UUID.randomUUID().toString()); book.setTitle("El Quijote"); book.setPageCount(400); FirebaseFirestore db = FirebaseFirestore.getInstance(); db.collection("books").document(book.getId()).set(book);
{{ books.png }} base de datos Books
===== Consultar información ===== FirebaseFirestore db = FirebaseFirestore.getInstance(); db.collection("books").get() .addOnCompleteListener(new OnCompleteListener() { @Override public void onComplete(@NonNull Task task) { if (task.isSuccessful()) { for (QueryDocumentSnapshot document : task.getResult()) { Book book = document.toObject(Book.class); // TODO Añadir el objeto a una lista, por ejemplo } } }); Se pueden añadir condiciones, antes de invocar al método get(). Por ejemplo: db.collection("books") .whereGreaterThan("pageCount", 100) .get() Y ordenar o limitar el número de documentos con ''.limit()'' y ''.orderBy()'' ===== Eliminar información ===== FirebaseFirestore db = FirebaseFirestore.getInstance(); db.collection("books").document("id") .delete() .addOnSuccessListener(new OnSuccessListener() { @Override public void onSuccess(Void aVoid) { // TODO Documento eliminado correctamente } }) .addOnFailureListener(new OnFailureListener() { @Override public void onFailure(@NonNull Exception e) { // TODO Error eliminando documento } }); ======= Arquitectura MVP =======
{{ mvp.png?400 }} Arquitectura Model-View-Presenter
---- ====== Ejercicios ====== {{ ejercicio.png?75}} - Realiza una aplicación que sirva para que el usuario tenga una lista actualizada de tareas pendientes de ser realizadas. De cada tarea sólo se almacenará un texto que la describa. La tarea se añadirá automáticamente a una lista de tareas pendientes que el usuario visualizará en la misma ''Activity''. Desde esa lista, mediante un menú contextual, el usuario podrá eliminar o bien marcarla como "Hecha", en cuyo caso irá a parar a otra lista de tareas realizadas. El usuario también dispondrá de dos botones que permitirán visualizar las tareas pendientes o las ya realizadas en el ''ListView'' {{ recordatorios.png?250 }}\\ \\ - Añade soporte para español e inglés a la aplicación anterior\\ \\ - Realiza una aplicación que sirva como lista de la compra. El usuario podrá introducir tanto el nombre del producto como la cantidad y su precio aproximado. Hay que tener en cuenta que el único dato obligatorio será el nombre del producto, pudiéndose dejar en blanco los otros dos. La aplicación mostrará siempre el número de productos de la lista y el precio total de la misma (contabilizando solamente aquellos productos para los que se ha indicado su precio). {{ listadelacompra.png?250 }}\\ \\ - Realiza una aplicación que sirva como lista de tareas (MisTareas2) pero en este caso la aplicación constará de 3 Activities: registro de tareas, listado de tareas y detalles de tareas. La aplicación seguirá el diseño que se indica en las siguientes capturas de pantalla: {{alta.png?200}} {{listatareas.png?200}} {{vertarea.png?200}}\\ \\ - Realiza una aplicación con 3 opciones (tal y como se muestra en el diseño adjunto). El usuario podrá crear su propio horario introduciendo para cada asignatura los días y horas en las que se imparte, podrá consultar el horario completo para un día dado y también tendrá acceso rápido a consultar qué asignatura se está impartiendo ahora mismo (según hora y fecha del móvil de dicho usuario) {{ mihorario.png?400 }}\\ - Realiza la siguiente aplicación para mantener un listado de eventos con soporte para español e inglés {{ eventos.png?400 }} - Realiza una aplicación que liste todas las farmacias de Zaragoza utilizando para ello los [[http://www.zaragoza.es/georref/json/hilo/farmacias_Equipamiento|datos abiertos que proporciona el Ayuntamiento de Zaragoza]]. En la lista se mostrará un icono (el mismo para todas), el nombre y el teléfono. Cuando el usuario seleccione una de las farmacias la aplicación cargará otra ''Activity'' donde se mostrará un mapa marcando la ubicación de dicha farmacia ---- ====== Proyectos de ejemplo ====== [[https://github.com/codeandcoke/android-samples|Android samples]] Repositorio con varios ejemplos de código actualizados: * **BiziStations**: Full project with SwipeRefresh, Maps, Directions API, RecyclerView, API consumption and more * **BottomNavigation**: How to create an Activity with some Fragments and a Bootom Navigation bar (loading some data to a ListView) * **Drawer**: Drawer sample * **FileSystem**: How to access to filesystem * **Fragments**: How to design an Activity using two Fragments * **Fragments2**: Fragments sample project * **GPS**: GPS sample project * **Mapbox**: Mapbox library sample project (load map, add markers and set camera position) * **Maps**: Maps sample project: Google Maps, Markers and Directions API * **MasterDetail** (Fragments): Master-Detail sample project using Fragments * **Notifications**: How to show notifications * **Permissions**: How to ask for Permissions sample project * **Preferences**: How to create a Preference Activity * **RecyclerView**: How to use a RecyclerView * **Room database**: CRUD sample using Room library * **themealdb**: Consume an API using Retrofit ---- (c) 2016-2023 Santiago Faci