Android:ListView and Custom Adapter

From Juneday education
Jump to: navigation, search

This is the follow up part to ListView and Android Network (Volley code outside the Activity using Observer). To get as much as possible out of this chapter we suggest you check out (or repeat):

The entire code in this chapter can be dowloaded from: MemberImages

The data

This time we're going to display a JSON with members (don't we ever get tired of the Member class?) looking like this:

[
  {
    "name": "Håkan Nyström",
    "email": "hakan@nystrom.com",
    "avatar": "https://avatars3.githubusercontent.com/u/20043293?s=460&v=4"
  },
  {
    "name": "Sofia Nyström",
    "email": "sofia@nystrom.com",
    "avatar": "https://avatars2.githubusercontent.com/u/1850150?s=460&v=4"
  }
]

Note that there's a field for an avatar image.

We can assume that there are no members with the same name and email address. The names above have been generated by gen_person.sh which you can find and read about here: generate-names.

Rough sketch of the layout

The way we choose to display this is using a ListView as we did in the previous lecture on ListViews. But instead of displaying a plain String as the content of each view in the list we're going to use views as this:

 +-----------------------------+
 | Name                        |
 |                     Image   |
 | Email                       |
 +-----------------------------+

Getting started with the code

Domain class

In the previous examples on ListView we used a Member. We're going to use a similar, the only difference is the added field for the avatar url.

Member.java:

package se.juneday.memberimages.domain;


public class Member {

    private String name;
    private String email;
    private String avatarUrl;

    /**
     * Constructs a new Member 
     * @param name name of the member
     * @param email email for the member
     * @param avatarUrl url to an avatar, null if no avatar
     * 
     * Throws a NullPointerException if any name of email is null
     */
   public Member(String name, String email, String avatarUrl) {
        if ( name == null ){
            throw new NullPointerException("name cannot be null");
        }
        if ( email == null ){
            throw new NullPointerException("email cannot be null");
        }
        this.name = name;
        this.email = email;
        this.avatarUrl = avatarUrl;
    }

    /**
     * Returns an objects name
     * @return the name of the object
     */
    public String name() {
        return name;
    }

    /**
     * Returns an objects avatar url
     * @return the avatar url of the object
     */
    public String avatarUrl() {
        return avatarUrl;
    }

    /**
     * Returns an objects email address
     * @return the email address of the object
     */
    public String email(){
        return email;
    }

    /**
     * Returns a String representation of the Member
     * @return a string representing the Member
     */
    @Override
    public String toString() {
        return name + (email!=null?"<" + email +">":"");
    }

}

Writing your own Layout of the Member information

The adapter is responsible for creating the views in the list and as you might have guessed Android has no clue about our Member class. In case you wonder about how Android could know about our Members in the previous lecture our answer is: Android did not know about the Member class but it sure knows about the method toString. In this case we don't have a simple layout with one TextView so we need to write an Adapter class that can create View from a layout we write our selves.

Layout of the elements in the list

Let's start with writing a layout for each of the elements in the array.

member_row.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="10dp"
    android:id="@+id/member_row">

    <TextView
        android:id="@+id/name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:textAppearance="?android:attr/textAppearanceSmall"
        android:textColor="@android:color/black" />

    <TextView
        android:id="@+id/email"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/name"
        android:layout_marginTop="5dp"
        android:textColor="@android:color/black" />

    <ImageView
        android:id="@+id/avatar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignParentRight="true"
        android:layout_centerVertical="true"
        android:src="@android:drawable/ic_dialog_info" />

</RelativeLayout>

Write an adapter for the layout

MemberAdapter.java:

package se.juneday.memberimages;

import android.content.Context;
import android.graphics.Bitmap;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;

import java.util.List;

import se.juneday.memberimages.domain.Member;

public class MemberAdapter extends ArrayAdapter<Member> {

    // String tag for logging
    private static final String LOG_TAG = MemberAdapter.class.getCanonicalName();

    // Our model or data, a list of members
    private List<Member> members;

    // Context, needed to find views etc
    private Context context;

    // Used to cache View
    // Check out: http://www.vogella.com/tutorials/AndroidListView/article.html
    private static class ViewHolder {
        TextView nameView;
        TextView emailView;
        ImageView avatarView;
    }

    /**
     * Creates a MemberAdapter
     * @param members members to create Views from
     * @param context used to find views etc
     */
    public MemberAdapter(List<Member> members, Context context) {
        super(context, R.layout.member_row, members);
        this.members = members;
        this.context = context;
    }


    private int lastPosition = -1;

    /**
     * Get the View (for a Member)
     * @param position the position/index in the list of members
     * @param convertView old view to reuse
     * @param parent The parent view this View will be attached to
     * @return a View representing the Member at index (position) .. to put in the ListView
     * https://developer.android.com/reference/android/widget/Adapter.html#getView(int,%20android.view.View,%20android.view.ViewGroup)
     */
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {

        // Get member at position
        Member member= getItem(position);

        ViewHolder viewHolder;

        // Reused view or not
        if (convertView == null) {

            viewHolder = new ViewHolder();
            convertView = LayoutInflater.from(getContext()).inflate(R.layout.member_row, parent, false);
            viewHolder.nameView = (TextView) convertView.findViewById(R.id.name);
            viewHolder.emailView = (TextView) convertView.findViewById(R.id.email);
            viewHolder.avatarView = (ImageView) convertView.findViewById(R.id.avatar);
            convertView.setTag(viewHolder);
        } else {
            viewHolder = (ViewHolder) convertView.getTag();
        }

        lastPosition = position;

        // Set ViewHolder variables
        viewHolder.nameView.setText(member.name());
        viewHolder.emailView.setText(member.email());
        Bitmap bitmap = Utils.avatarBitmap(context, member);
        if (bitmap != null) {
            Log.d(LOG_TAG, "  using existing file for " + member.name());
            viewHolder.avatarView.setImageBitmap(bitmap);
        } else {
            Log.d(LOG_TAG, "  using default resource for bitmap");
            viewHolder.avatarView.setImageResource(R.drawable.ic_launcher_background);
        }

        return convertView;
    }
}

Getting the JSON data using Volley

This time the JSON data is not complete since we need to download an avatar for each Member. So we need to find a way to solve this. Our suggestion is something like the following pseudo code:

Download Members:

 download JSON data
 parse JSON data and for each element:
 * create a Member object and add it to a list (of Members)
 * if avatar exists: 
     display it
   else:
     download the url (as specified in the Member element) using Volley
 inform listeners about new List of Members

Download Url:

 download data from URL
 store data in a file
 inform listeners about new avatar

Interface for notifying listener

  public interface MemberChangeListener {
    // Invoked when VolleyMember has a new List of Member
    void onMemberChangeList(List<Member> members);

    // Invoked when VolleyMember has a new avatar (as a bitmap)
    void onAvatarChange(Member member, Bitmap m);
  }

We can put this in the same file as we put the other Volley code.

Volley code

VolleyMember.java

package se.juneday.memberimages;

import android.content.Context;
import android.graphics.Bitmap;
import android.util.Log;
import android.widget.ImageView;

import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.ImageRequest;
import com.android.volley.toolbox.JsonArrayRequest;
import com.android.volley.toolbox.Volley;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import se.juneday.memberimages.domain.Member;

public class VolleyMember {

  // String tag for logging
  private static final String LOG_TAG = VolleyMember.class.getName();

  // This is a singleton class, so this is THE one and only instance
  private static VolleyMember volleyMember;

  // Context, needed to find views etc
  private Context context;

  /**
   * Method to get hold of the only instance
   * @param context - used to create (if needed) the only instance
   * @return the one and only instance
   */
  public static synchronized VolleyMember getInstance(Context context) {
    if (volleyMember == null) {
      volleyMember = new VolleyMember(context);
    }
    Log.d(LOG_TAG, "getInstance()");
    return volleyMember;
  }

  // Private constructor to prevent intantiation
  private VolleyMember(Context context) {
    listeners = new ArrayList<>();
    this.context = context;
  }

  // parses a JSON array and returns a list of Members
  private List<Member> jsonToMembers(JSONArray array) {
    Log.d(LOG_TAG, "jsonToMembers: " + array);

    // Create an empty arraylist
    List<Member> memberList = new ArrayList<>();

    // Loop through the elements in the array
    for (int i = 0; i < array.length(); i++) {
      Log.d(LOG_TAG, " parse JSON i: " + i);
      try {
        // Extract name, email and avatarUrl
        JSONObject row = array.getJSONObject(i);
        String name = row.getString("name");
        String email = row.getString("email");
        String avatarUrl = row.getString("avatar");
        Log.d(LOG_TAG, name +  " " + email  + "   : " + avatarUrl);
        //Create a new Member and add to the list
        Member m = new Member(name, email, avatarUrl);
        memberList.add(m);
      } catch (JSONException e) {
        ; // is ok since this is debug
        Log.d(LOG_TAG, "JSON parse: " + e);
      }
    }
    return memberList;
  }


  /**
   * Get members from server. Inform listeners when finished.
  * The code below is "slightly" (nudge nudge) based on:
  *   https://developer.android.com/training/volley/request.html
   */
  public void getMembers() {
    Log.d(LOG_TAG, "getMembers()");
    RequestQueue queue = Volley.newRequestQueue(context);

    JsonArrayRequest jsonArrayRequest = new JsonArrayRequest(
        Request.Method.GET,
        Settings.url,
        null,
        new Response.Listener<JSONArray>() {

          @Override
          public void onResponse(JSONArray array) {
            // Convert JSON array to List of Members
            List<Member> members = jsonToMembers(array);

            // For each listeners inform about new List of members
            for (MemberChangeListener m : listeners) {
              m.onMemberChangeList(members);
            }

            // For each member, manage avatar
            for (Member m : members) {
              // Check if avatar file exists (already downloaded and stored)
              if (!Utils.avatarExists(context, m)) {
                // avatar does not exist, invoke volley code to download it
                Log.d(LOG_TAG, "  download avatar for " + m.name());
                VolleyMember.getInstance(context).fetchAvatar(m);
              } else {
                // already exists, skip download
                Log.d(LOG_TAG, "  avatar already exists for " + m.name());
              }
            }

          }
        }, new Response.ErrorListener() {

      @Override
      public void onErrorResponse(VolleyError error) {
        Log.d(LOG_TAG, " cause: " + error.getCause().getMessage());
      }
    });

    // Add the request to the RequestQueue.
    queue.add(jsonArrayRequest);
  }

  /**
   * Downloads avatar for a Member
   * @param member - member  to download avatar for
   */
  public void fetchAvatar(final Member member) {
    Log.d(LOG_TAG, "getAvatars()");
    RequestQueue queue = Volley.newRequestQueue(context);
    Log.d(LOG_TAG, "  url: " + member.avatarUrl());
    String url = member.avatarUrl();

    // if no url for member, simply do nada
    if ( (url == null) || url.equals ("null") ) {
//      url = "https://avatars0.githubusercontent.com/u/19474334?s=400&u=1ade95c4770d096ec33107c05b51c99cfdd6ab01&v=4";
      return;
    }
    Log.d(LOG_TAG, "download url: " + url);

    ImageRequest imageRequest = new ImageRequest(url,
            new Response.Listener<Bitmap>() {
              @Override
              public void onResponse(Bitmap bitmap) {
                Log.d(LOG_TAG, "onResponse ok: " + bitmap.toString());
                for (MemberChangeListener m : listeners) {
                  m.onAvatarChange(member, bitmap);
                }
              }
            }, 0, 0, ImageView.ScaleType.CENTER,
            Bitmap.Config.RGB_565,
            new Response.ErrorListener() {
              public void onErrorResponse(VolleyError error) {
                Log.d(LOG_TAG, "onResponse fail");
              }
            });

    // Add the request to the RequestQueue.
    queue.add(imageRequest);
  }

  /******************************************
   MemberChangeListener
   ******************************************/

  // internal list of listeners
  private List<MemberChangeListener> listeners;

  public interface MemberChangeListener {
    // Invoked when VolleyMember has a new List of Member
    void onMemberChangeList(List<Member> members);

    // Invoked when VolleyMember has a new avatar (as a bitmap)
    void onAvatarChange(Member member, Bitmap m);
  }

  // Listener should call this method to get notified on change (se above methods)
  public void addMemberChangeListener(MemberChangeListener l) {
    listeners.add(l);
  }

}

Helper class for managing avatar files on disk

We need some help managing the Member avatars on file. Let's write methods that:

  • creates a file given a Member and a Bitmap ... and a Context to get the app's directory
  • checks if a file already is present (exists) given Member and a Context
  • creates a Bitmap from disk given Member and a Context

Let's write a class called Utils:

package se.juneday.memberimages;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import se.juneday.memberimages.domain.Member;

public class Utils {

    // String tag for logging
    private static final String LOG_TAG = Utils.class.getCanonicalName();

    // String constants for files and dirs
    private static final String FILE_SEP = "/" ;
    private static final String FIELD_SEP = "_" ;
    private static final Object AVATAR_DIR = "logos";
    private static final Object AVATAR_SUFFIX = ".png";

    // no objects needed, so keep the constructor private
    private Utils() {};

    /**
     * Creates a file for a Member given a bitmap
     * @param context used to find the app's directory
     * @param member
     * @param bitmap
     * @return
     * @throws IOException
     */
    public static File createImageFile(Context context, Member member, Bitmap bitmap) throws IOException {
        // Directory
        String dirName = fileDir(context);
        File dir = new File(dirName);
        if(!dir.exists()) {
            dir.mkdirs();
        }

        // Fileame
        String fileName = completeFileName(context, member);

        // Write to file
        FileOutputStream outputStream;
        try {
            outputStream = new FileOutputStream (fileName);
            bitmap.compress(Bitmap.CompressFormat.PNG, 85, outputStream);
            outputStream.flush();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }

        Log.d(LOG_TAG, "Created file: " + fileName);

        return new File(fileName);
    }

    // returns the directory name for an app using the Context
    private static String fileDir(Context c) {
        return c.getFilesDir().getAbsoluteFile()+
            FILE_SEP + AVATAR_DIR + FILE_SEP;
    }

    // returns the file name from a Member
    private static String fileName(Member member) {
        return (member.name() + FIELD_SEP + member.email()).replace(" ", FIELD_SEP) +
                AVATAR_SUFFIX;
    }

    // returns the complete file name from a Member and Context
    private static String completeFileName(Context c, Member member) {
        return fileDir(c) + FILE_SEP + fileName(member);
    }

    // returns the File from a Member and Context
    private static File completeFile(Context c, Member member) {
        return new File(completeFileName(c, member));
    }

    /**
     * Checks if an avatar file for a member exists
     * @param c context used to get the app's directory
     * @param member - member which file to check for presence
     * @return true if the file exists, false otherwise
     */
    public static boolean avatarExists(Context c, Member member) {
        return completeFile(c, member).exists();
    }

    /**
     * Reads the content of the Members file and creates a bitmap from that.
     * @param c context used to get the app's directory
     * @param member member which file to read from
     * @return bitmap or null if something failed
     */
    public static Bitmap avatarBitmap(Context c, Member member) {
        if (! avatarExists(c, member)) {
            return null;
        }
        return BitmapFactory.decodeFile(completeFileName(c,member));
    }

}

An Activity with the ListView

The Activity should:

  • create a ListView with the newly written Adapter, MemberAdapter
  • Download JSON using the VolleyMember class
  • add listeners to the MemberChangeListener interface
  • act on the two events:
    • new List of Members downloaded (onMemberChangeList
    • new avatar present (onAvatarChange)

MemberActivity.java:

package se.juneday.memberimages;

import android.graphics.Bitmap;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ImageView;
import android.widget.ListView;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import se.juneday.memberimages.domain.Member;


public class MemberActivity extends AppCompatActivity {

  // String tag for logging
  private static final String LOG_TAG = MemberActivity.class.getSimpleName();

  // Adapter to create Member Views for us
  private MemberAdapter adapter;

  // ListView to display the Views
  private ListView listview;

  // Our model or data, a list of members
  private List<Member> members;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_volley);

    // Create an empty list to display until we get some data
    members = new ArrayList<>();
    resetListView();

    Log.d(LOG_TAG, " onCreate()");
  }

  private void updateImageView(Member member, Bitmap bitmap) {
    // if invalid in data, return
    if ((member == null) || (bitmap == null)) {
      return;
    }

    // Find index in the list for the member
    int index = members.indexOf(member);
    Log.d(LOG_TAG, "  index of: " + index + "  of " + members.size() + " members");

    // Get the coresponding view
    View child = listview.getChildAt(index);
    if (child == null) {
      // view null, return
      Log.d(LOG_TAG, " child null");
      return;
    }

    // Get the avatar view (in the specific Member view)
    ImageView iv = child.findViewById(R.id.avatar);
    if (iv == null) {
      // view null, return
      return;
    }

    // Set the bitmap in the imageview
    iv.setImageBitmap(bitmap);
  }

  // Method to refresh the listview
  private void resetListView() {
    Log.d(LOG_TAG, " resetListView() : " + members);
    listview = (ListView) findViewById(R.id.volley_list);
    adapter = new MemberAdapter(members, this);
    listview.setAdapter(adapter);
  }

  @Override
  public void onStart() {
    super.onStart();

    // register to listen to member updates in VolleyMember
    VolleyMember.getInstance(this).addMemberChangeListener(new VolleyMember.MemberChangeListener() {

      @Override
      public void onMemberChangeList(List<Member> members) {
        // reset listview with new members, update member (instance variable)
        MemberActivity.this.members = members;
        resetListView();

        // Show toast to inform new data is displayed
        ActivityHelper.showToast(MemberActivity.this, "Members updated");
      }


      @Override
      public void onAvatarChange(Member member, Bitmap bitmap) {
        Log.d(LOG_TAG, "avatar Change on: " + bitmap);

        // From the bitmap we should create a file
        try {
          // Create a file from the bitmap
          File f = Utils.createImageFile(MemberActivity.this, member, bitmap);
          Log.d(LOG_TAG, " created file: " + f);
        } catch (IOException e) {
          // Since we failed creating the file, we don't need to remove any
          Log.d(LOG_TAG, " failed created file: " + e);
          e.printStackTrace();
          return;
        }

        // if we failed creating a file e should not display the bitmap
        //    (and give the use the impression all is fine)
        // No need to check since we have already returned if the creation failed
        updateImageView(member, bitmap);
      }

    });
    // finished adding listener

    // Inform user we're trying to get a fresh list of Members
    ActivityHelper.showToast(this, "Updating members");
    // .. and then actually do try to get a fresh list of Members
    VolleyMember.getInstance(this).getMembers();

    // for the fun of (and to show you it is possible), register a listener
    // if the user clicks on any of the member views in the listview
    listview.setOnItemClickListener(new AdapterView.OnItemClickListener() {
      @Override
      public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
        Member member = (Member) members.get((int) l);
        Log.d(LOG_TAG, "Member " + member.name() + " clicked  (" + i + "|" + l + ")");
      }
    });

  }

}

Other files

Settings.java:

package se.juneday.memberimages;

public class Settings {

  public static final String url = "https://raw.githubusercontent.com/progund/android-examples/master/common-data/membersi.json";

}

ActivityHelper.java:

package se.juneday.memberimages;

import android.content.Context;
import android.util.Log;
import android.widget.Toast;

public class ActivityHelper {

  private static final String LOG_TAG = ActivityHelper.class.getSimpleName();

  public static void showToast(Context context, String msg) {
    Log.d(LOG_TAG, " showToast: " + msg);
    int duration = Toast.LENGTH_SHORT;
    Toast toast = Toast.makeText(context, msg, duration);
    toast.show();
  }

}

activity_volley.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context="se.juneday.memberimages.MemberActivity">

  <ListView
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:id="@+id/volley_list">
  </ListView>


</android.support.constraint.ConstraintLayout>