Android aplicación completa parte VI: Personalizando presentación de la lista de resultados

En esta parte, vamos a crear una view personalizada para tener asi una mejor presentación visual de los datos.

La vista utilizada hasta ahora para presentar los resultados de la búsqueda es bastante rudimentaria y plana. Si recordamos , los datos fueron pasados a una ListActivity la cual, una vez renderizado, parecía a algo como esto:

Vamos ahora a añadirle un poco de vistosidad al UI del activity. El primer paso es reemplazar el ArrayAdapter que tenemos e implementar un custom adapter. Nuestro adapter extenderá la clase arrayAdapter y sobreescribirá el método getView para así proporcionar una custom list View.

Recuerda que la clase de modelo Movie contiene información variada en relación a la película, entre las que están las siguientes:

  • Calificación: Movie rating
  • Fecha de estreno: Release date
  • Certification
  • Idioma: Language
  • URL de la imagen pequeña: Thumbnail image URL

Vamos a crear un custom layout que incluirá los datos estos para cada película de la lista de resultados. Cada fila de la lista debe quedar a algo parecido a esto:

La nueva vista de los resultados

El fichero XML que describe el layout de cada fila lo llamamos “movie_data_row.xml” y contiene lo siguiente:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
							android:layout_width="fill_parent"
							android:layout_height="?android:attr/listPreferredItemHeight"
							android:padding="6dip">

	<ImageView 	android:id="@+id/movie_thumb_icon"
							android:layout_width="wrap_content"
							android:layout_height="fill_parent"
							android:layout_marginRight="6dip"/>

	<LinearLayout	android:orientation="vertical"
								android:layout_width="0dip"
								android:layout_weight="1"
								android:layout_height="fill_parent">

		<TextView
				android:id="@+id/name_text_view"
				android:layout_width="fill_parent"
				android:layout_height="0dip"
				android:layout_weight="1"
				android:singleLine="true"
				android:ellipsize="marquee"
				android:textStyle="bold"
		/>

		<TextView
				android:id="@+id/rating_text_view"
				android:layout_width="fill_parent"
				android:layout_height="0dip"
				android:layout_weight="1"
				android:singleLine="true"
				android:ellipsize="marquee"
		/>

		<TextView
				android:id="@+id/released_text_view"
				android:layout_width="fill_parent"
				android:layout_height="0dip"
				android:layout_weight="1"
				android:singleLine="true"
				android:ellipsize="marquee"
		/>

		<TextView
				android:id="@+id/certification_text_view"
				android:layout_width="fill_parent"
				android:layout_height="0dip"
				android:layout_weight="1"
				android:singleLine="true"
				android:ellipsize="marquee"
		/>

		<TextView
			android:id="@+id/language_text_view"
			android:layout_width="fill_parent"
			android:layout_height="0dip"
			android:layout_weight="1"
			android:singleLine="true"
			android:ellipsize="marquee"
		/>

		<TextView
			android:id="@+id/adult_text_view"
			android:layout_width="fill_parent"
			android:layout_height="0dip"
			android:layout_weight="1"
			android:singleLine="true"
			android:ellipsize="marquee"
		/>

	</LinearLayout>

</LinearLayout>

Utilizamos un LinearLayout para el layout base y dentro incluimos un ImageView (que tiene la imagen thumb) y otro LinearLayout anidado que es el contenedor para varios TextView. Cada elemento se le asigna un ID único así que puede ser referenciado desde nuestro adaptador.

Un nuevo ArrayAdapter personalizado

Observese que un ArrayAdapter no puede usar el método setContentView que es típicamente utilizado por una Activity para declarar el layout que será utilizado. La forma de recuperar un layout XML durante el tiempo de ejecución es utilizando el LayoutInflater service. Esta clase se utiliza para instanciar ficheros XML de layout en sus objetos View correspondientes. Más específicamente, el método inflate se utiliza para inflar una nueva jeraquia de views desde el recurso xml especificado. Después de que hemos tomado la referencia de la view subyacente, podemos usarlo de la forma normal y modificar sus widgets internos, es decir, proporcionar el texto para los TextViews y cargar la imagen en el ImageView. Aquí está el código:

package com.javacodegeeks.android.apps.moviesearchapp.ui;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import com.javacodegeeks.android.apps.moviesearchapp.R;
import com.javacodegeeks.android.apps.moviesearchapp.io.FlushedInputStream;
import com.javacodegeeks.android.apps.moviesearchapp.model.Movie;
import com.javacodegeeks.android.apps.moviesearchapp.services.HttpRetriever;

public class MoviesAdapter extends ArrayAdapter<Movie> {
	private HttpRetriever httpRetriever = new HttpRetriever();
	private ArrayList<Movie> movieDataItems;
	private Activity context;

	public MoviesAdapter(Activity context, int textViewResourceId, ArrayList<Movie> movieDataItems) {
		super(context, textViewResourceId, movieDataItems);
		this.context = context;
		this.movieDataItems = movieDataItems;
	}

@Override
public View getView(int position, View convertView, ViewGroup parent) {
	View view = convertView;
	if (view == null) {
		LayoutInflater vi = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
		view = vi.inflate(R.layout.movie_data_row, null);
	}

	Movie movie = movieDataItems.get(position);
	if (movie != null) {
		// name
		TextView nameTextView = (TextView) view.findViewById(R.id.name_text_view);
		nameTextView.setText(movie.name);
		// rating
		TextView ratingTextView = (TextView) view.findViewById(R.id.rating_text_view);
		ratingTextView.setText("Rating: " + movie.rating);
		// released
		TextView releasedTextView = (TextView) view.findViewById(R.id.released_text_view);
		releasedTextView.setText("Release Date: " + movie.released);
		// certification
		TextView certificationTextView = (TextView) view.findViewById(R.id.certification_text_view);
		certificationTextView.setText("Certification: " + movie.certification);
		// language
		TextView languageTextView = (TextView) view.findViewById(R.id.language_text_view);
		languageTextView.setText("Language: " + movie.language);
		// thumb image
		ImageView imageView = (ImageView) view.findViewById(R.id.movie_thumb_icon);
		String url = movie.retrieveThumbnail();
		if (url!=null) {
			Bitmap bitmap = fetchBitmapFromCache(url);
			if (bitmap==null) {
			new BitmapDownloaderTask(imageView).execute(url);
			} else {
			imageView.setImageBitmap(bitmap);
			}
		}	else {
			imageView.setImageBitmap(null);
		}
	}
	return view;
}

private LinkedHashMap<String, Bitmap> bitmapCache = new LinkedHashMap<String, Bitmap>();
private void addBitmapToCache(String url, Bitmap bitmap) {
	if (bitmap != null) {
		synchronized (bitmapCache) {
			bitmapCache.put(url, bitmap);
		}
	}
}

private Bitmap fetchBitmapFromCache(String url) {
	synchronized (bitmapCache) {
		final Bitmap bitmap = bitmapCache.get(url);
		if (bitmap != null) {
			// Bitmap found in cache
			// Move element to first position, so that it is removed last
			bitmapCache.remove(url);
			bitmapCache.put(url, bitmap);
			return bitmap;
		}
	}
	return null;
}

private class BitmapDownloaderTask extends AsyncTask<String, Void, Bitmap> {
		private String url;
		private final WeakReference<ImageView> imageViewReference;

		public BitmapDownloaderTask(ImageView imageView) {
			imageViewReference = new WeakReference<ImageView>(imageView);
		}

		@Override
		protected Bitmap doInBackground(String... params) {
			url = params[0];
			InputStream is = httpRetriever.retrieveStream(url);
			if (is==null) {
				return null;
			}
			return BitmapFactory.decodeStream(new FlushedInputStream(is));
		}

		@Override
		protected void onPostExecute(Bitmap bitmap) {
			if (isCancelled()) {
				bitmap = null;
			}
			addBitmapToCache(url, bitmap);
			if (imageViewReference != null) {
				ImageView imageView = imageViewReference.get();
				if (imageView != null) {
					imageView.setImageBitmap(bitmap);
				}
			}
		}
	}
}

Dentro nuestro método getView, primero “inflamos” o configuramos el fichero XML layout y recuperamos la referencia de la View descrita. Entonces, tomamos la referencia de cada una de las views widgets utilizando el método findViewById. Para cada TextView proporcionamos el texto relevante, mientras que para cada ImageView proporcionamos un Bitmap que contiene la imagen Thumbnail.

Se utiliza un mecanismo básico de cacheado en este punto para eliminar la re-descarga de la misma imagen una y otra vez. No olvides que el método getView va a ser llamado múltiples veces cuando el usuario esté usando el interface, así que no queremos realizar peticiones HTTP para la misma imagen. Por esta razón, nos creamos un map conteniendo asociaciones URL-bitmap.Si la imagen no es encontrada en el cache, un tarea background es lanzada para recuperar la imagen (y la almacena en la cache para las próximas llamadas). La tarea background se llama “BitmapDownloaderTask” y extiende la clase AsyncTask.

También nótese que dentro de cada tarea, la instancia ImageView se referencia a través de un WeakReference.Esto se hace por razones de rendimiento y más específicamente para permitir al Garbage collector de la máquina virtual que limpie cualquier ImageView que pueda pertenecer a una activity ya acabada. En otras palabras, no queremos un activity que tenga referencias fuertes de sus ImageViews asi que estas puedan ser fácilmente limpiadas. Consultese

http://android-developers.blogspot.com/2010/07/multithreading-for-performance.html

para más información.

Modificamos el ListActivity

Además del list activity, hay algunos cambios que deben ser hechos, vayamos con ellos. El cambio más importante es que el ArrayAdapter original es reemplazado por el que hemos creado personalizado. Rellenamos el adapter con los contenidos de los objetos resultados de búsqueda y entonces invocamos el método notifyDataSetChanged para notificar a la View enganchada que los datos subyacentes han sido cambiados y debería refrescarse. Este es el código de la nueva implementación:

package com.javacodegeeks.android.apps.moviesearchapp;

import java.util.ArrayList;
import android.app.ListActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.ListView;
import android.widget.Toast;
import com.javacodegeeks.android.apps.moviesearchapp.model.Movie;
import com.javacodegeeks.android.apps.moviesearchapp.ui.MoviesAdapter;

public class MoviesListActivity extends ListActivity {
	private static final String IMDB_BASE_URL = "http://m.imdb.com/title/";
	private ArrayList<Movie> moviesList = new ArrayList<Movie>();
	private MoviesAdapter moviesAdapter;

	@SuppressWarnings("unchecked")
	@Override
	public void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
	setContentView(R.layout.movies_layout);
	moviesAdapter = new MoviesAdapter(this, R.layout.movie_data_row, moviesList);
	moviesList = (ArrayList<Movie>) getIntent().getSerializableExtra("movies");
	setListAdapter(moviesAdapter);
	if (moviesList!=null && !moviesList.isEmpty()) {
	moviesAdapter.notifyDataSetChanged();
	moviesAdapter.clear();
	for (int i = 0; i < moviesList.size(); i++) {
	moviesAdapter.add(moviesList.get(i));
	}
	}
	moviesAdapter.notifyDataSetChanged();
	}

	@Override
	protected void onListItemClick(ListView l, View v, int position, long id) {
		super.onListItemClick(l, v, position, id);
		Movie movie = moviesAdapter.getItem(position);
		String imdbId = movie.imdbId;
		if (imdbId==null || imdbId.length()==0) {
		longToast(getString(R.string.no_imdb_id_found));
		return;
		}
		String imdbUrl = IMDB_BASE_URL + movie.imdbId;
		Intent imdbIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(imdbUrl));
		startActivity(imdbIntent);
	}

	public void longToast(CharSequence message) {
		Toast.makeText(this, message, Toast.LENGTH_LONG).show();
	}

}

Probemos ahora la aplicación. Cuando se inicia el “MoviesListActivity”, primero verás las películas del resultado y su correspondiente información. Lentamente , las imágenes de los thumbs irán apareciendo según vayan descargándose. No olvides que estas operaciones tienen lugar en el background. Date cuenta, que algunas movies, especialmente las antiguas no tienen thumbnail.

That’s it! You can download here the Eclipse project created so far.