26 April 2011

Using CWAC's EndlessAdapter with a custom adapter

In one of my projects, the app has the potential to display a very, and I mean very, long list. To minimize the loading time of the app, I limit the number of items initially included in the list and then add to it as the user scrolls down.

For this purpose, Mark Murphy's EndlessAdapter works wonders. I was trying to make it work with a CursorAdapter though, but due to time constraints, I was not able to continue with my experiment.

And then I found out that some of the items in my list are HTML-formatted. Huh. So I have to have a custom adapter to override getView(). I patterned my code on the demo included in the EndlessAdapter project and I was at a loss. Maybe it's because I just came from a vacation and my mind refuses to work. Hmmm.

To cut a long and arduous journey short, I was able to figure it out. Here's a sample activity that displays a list of countries from an array. To illustrate using a custom adapter, I add the list item number when setting the item text.

I will discuss the pertinent parts of the code in detail.
private void init() {
 LIST_SIZE = COUNTRIES.length;  
 for (int i=0; i<=BATCH_SIZE; i++){
  countriesSub.add(COUNTRIES[i]);   
 }
 
 setLastOffset(BATCH_SIZE);
 displayList(countriesSub);
}
In this part of the code, I get the first 10 items in the list as the initial contents of the list. Of course, our current list is small and is peanuts for ListView. This is just to illustrate my point. In my app, I initially load 2,000 items. I also make sure to remember where I am in my source list. In my case, it is the offset in the original array. This might also be a row in the DB, or the ID in an RSS feed.

The ArrayList countriesSub holds the items that are currently in my list. As the user goes through the list, this array will grow.

To display my list, I set an instance of DemoAdapter as my list adapter. DemoAdapter inherits from EndlessAdapter and in its constructor, I give it an instance of my custom ArrayAdapter.
@Override
  public View getView(int position, View convertView, ViewGroup parent) {

   ViewHolder holder;   

   if(convertView==null){
    LayoutInflater inflater = (LayoutInflater)
    EndlessCustom.this.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    convertView = inflater.inflate(android.R.layout.simple_list_item_1, null);
    holder = new ViewHolder();
    holder.word = (TextView)convertView.findViewById(android.R.id.text1);

    convertView.setTag(holder);
   } else{
    holder=(ViewHolder)convertView.getTag();
   }

   holder.word.setText(String.valueOf(position+1) + ". " + countriesSub.get(position));
   return convertView;
  }

The important part in my custom ArrayAdapter is the getView() method. In this method, I tell the adapter to not simply display the .toString() value of the item, but to add a number before it.

Notice that I use the ViewHolder pattern as illustrated in the Android Developers site.
DemoAdapter() {
 super(new CustomArrayAdapter(EndlessCustom.this, 
  android.R.layout.simple_list_item_1, android.R.id.text1, countriesSub));

 rotate=new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF,
  0.5f, Animation.RELATIVE_TO_SELF,
  0.5f);
 rotate.setDuration(600);
 rotate.setRepeatMode(Animation.RESTART);
 rotate.setRepeatCount(Animation.INFINITE);
}

So now we come to the EndlessAdapter part. In the constructor, I pass into it an instance of my custom ArrayAdapter, indicating the source of the list, the layout for each row, and the TextView ID from the layout. I also instantiated the animation that will be used while the list is loading the additional items.
@Override
protected boolean cacheInBackground() {
 tempList.clear();
 int lastOffset = getLastOffset();
 if(lastOffset < LIST_SIZE){
  int limit = lastOffset + BATCH_SIZE;
  for(int i=(lastOffset+1); (i<=limit && i<LIST_SIZE); i++){
   tempList.add(COUNTRIES[i]);
  } 
   
  setLastOffset(limit);

  if(limit<LIST_SIZE){
   return true;
  } else {
   return false;
  }
 } else  {
  return false;
 }
}
We have to override cacheInBackground() for EndlessAdapter to work. Here we do the heavy lifting like querying the server, reading from the DB, etc. Here, I load the next 10 items from the original list and put them in a temporary ArrayList. I also check whether I have loaded all the data, and if so, tell the EndlessAdapter to not show the extra row at the bottom. I do this by returning false from the method.
@Override
protected void appendCachedData() {
 @SuppressWarnings("unchecked")
 ArrayAdapter<String> arrAdapterNew = (ArrayAdapter<String>)getWrappedAdapter();

 int listLen = tempList.size();
 for(int i=0; i<listLen; i++){
  arrAdapterNew.add(tempList.get(i));
 }
}
Finally, I add the newly retrieved rows to my current list. And that's it.

The complete Java file for this activity follows:
package com.test.example;

import java.util.ArrayList;
import java.util.List;

import android.app.ListActivity;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.RotateAnimation;
import android.widget.ArrayAdapter;
import android.widget.TextView;

import com.commonsware.cwac.endless.EndlessAdapter;

public class EndlessCustom extends ListActivity {

 static int LIST_SIZE;
 private int mLastOffset = 0;
 
 static final int BATCH_SIZE = 10;
 
 ArrayList<String> countriesSub = new ArrayList<String>();
 
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.lib_activity_dictionary);
  init();
 }

 private void init() {
  LIST_SIZE = COUNTRIES.length;  
  for (int i=0; i<=BATCH_SIZE; i++){
   countriesSub.add(COUNTRIES[i]);   
  }
  setLastOffset(BATCH_SIZE);
  displayList(countriesSub);
 }

 private void setLastOffset(int i) {
  mLastOffset = i;  
 }
 
 private int getLastOffset(){
  return mLastOffset;
 }

 private void displayList(ArrayList<String> countriesSub) {  
  setListAdapter(new DemoAdapter());
 }

 private class CustomArrayAdapter extends ArrayAdapter<String>{

  public CustomArrayAdapter(Context context, int resource,
    int textViewResourceId, List<String> objects) {
   super(context, resource, textViewResourceId, objects);
  }  

  @Override
  public View getView(int position, View convertView, ViewGroup parent) {

   ViewHolder holder;   

   if(convertView==null){
    LayoutInflater inflater = (LayoutInflater)
    EndlessCustom.this.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    convertView = inflater.inflate(android.R.layout.simple_list_item_1, null);
    holder = new ViewHolder();
    holder.word = (TextView)convertView.findViewById(android.R.id.text1);

    convertView.setTag(holder);
   } else{
    holder=(ViewHolder)convertView.getTag();
   }

   holder.word.setText(String.valueOf(position+1) + ". " + countriesSub.get(position));
   return convertView;
  }

  public class ViewHolder{
   public TextView word;        
  }  
 }

 class DemoAdapter extends EndlessAdapter {
  private RotateAnimation rotate=null;
  ArrayList<String> tempList = new ArrayList<String>();
  
  DemoAdapter() {
   super(new CustomArrayAdapter(EndlessCustom.this, 
     android.R.layout.simple_list_item_1, android.R.id.text1, countriesSub));

   rotate=new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF,
     0.5f, Animation.RELATIVE_TO_SELF,
     0.5f);
   rotate.setDuration(600);
   rotate.setRepeatMode(Animation.RESTART);
   rotate.setRepeatCount(Animation.INFINITE);
  }

  @Override
  protected View getPendingView(ViewGroup parent) {
   View row=getLayoutInflater().inflate(R.layout.row, null);

   View child=row.findViewById(android.R.id.text1);
   child.setVisibility(View.GONE);
   child=row.findViewById(R.id.throbber);
   child.setVisibility(View.VISIBLE);
   child.startAnimation(rotate);

   return(row);
  }

  @Override
  protected boolean cacheInBackground() {
   tempList.clear();
   int lastOffset = getLastOffset();
   if(lastOffset < LIST_SIZE){
    int limit = lastOffset + BATCH_SIZE;
    for(int i=(lastOffset+1); (i<=limit && i<LIST_SIZE); i++){
     tempList.add(COUNTRIES[i]);
    }    
    setLastOffset(limit);
    
    if(limit<LIST_SIZE){
     return true;
    } else {
     return false;
    }
   } else  {
    return false;
   }
  }


  @Override
  protected void appendCachedData() {

   @SuppressWarnings("unchecked")
   ArrayAdapter<String> arrAdapterNew = (ArrayAdapter<String>)getWrappedAdapter();

   int listLen = tempList.size();
   for(int i=0; i<listLen; i++){
    arrAdapterNew.add(tempList.get(i));
   }
  }
 }
 
 
 static final String[] COUNTRIES = new String[] {
        "Afghanistan", "Albania", "Algeria", "American Samoa", "Andorra", 
        "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina",
        "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan",
        "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium",
        "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia",
        "Bosnia and Herzegovina", "Botswana", "Bouvet Island", "Brazil", "British Indian Ocean Territory",
        "British Virgin Islands", "Brunei", "Bulgaria", "Burkina Faso", "Burundi",
        "Cote d'Ivoire", "Cambodia", "Cameroon", "Canada", "Cape Verde",
        "Cayman Islands", "Central African Republic", "Chad", "Chile", "China",
        "Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros", "Congo",
        "Cook Islands", "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic",
        "Democratic Republic of the Congo", "Denmark", "Djibouti", "Dominica", "Dominican Republic",
        "East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea",
        "Estonia", "Ethiopia", "Faeroe Islands", "Falkland Islands", "Fiji", "Finland",
        "Former Yugoslav Republic of Macedonia", "France", "French Guiana", "French Polynesia",
        "French Southern Territories", "Gabon", "Georgia", "Germany", "Ghana", "Gibraltar",
        "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guinea", "Guinea-Bissau",
        "Guyana", "Haiti", "Heard Island and McDonald Islands", "Honduras", "Hong Kong", "Hungary",
        "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Jamaica",
        "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos",
        "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg",
        "Macau", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands",
        "Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia", "Moldova",
        "Monaco", "Mongolia", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia",
        "Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand",
        "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "North Korea", "Northern Marianas",
        "Norway", "Oman", "Pakistan", "Palau", "Panama", "Papua New Guinea", "Paraguay", "Peru",
        "Philippines", "Pitcairn Islands", "Poland", "Portugal", "Puerto Rico", "Qatar",
        "Reunion", "Romania", "Russia", "Rwanda", "Sqo Tome and Principe", "Saint Helena",
        "Saint Kitts and Nevis", "Saint Lucia", "Saint Pierre and Miquelon",
        "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Saudi Arabia", "Senegal",
        "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands",
        "Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "South Korea",
        "Spain", "Sri Lanka", "Sudan", "Suriname", "Svalbard and Jan Mayen", "Swaziland", "Sweden",
        "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "The Bahamas",
        "The Gambia", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey",
        "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Virgin Islands", "Uganda",
        "Ukraine", "United Arab Emirates", "United Kingdom",
        "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan",
        "Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Wallis and Futuna", "Western Sahara",
        "Yemen", "Yugoslavia", "Zambia", "Zimbabwe"
    };
}

If you know of a more efficient way to do this, please do not hesitate to let me know! :)

31 March 2011

My EditText is cut off by the on-screen keyboard!

With clients demanding left and right that my app should look like an iPhone app, I tend to be unappreciative of the way Android natively handles UI interactions and such. Notice how the screen automagically scrolls up when you click on an EditText? It turns out that in iPhone development, the developer does this manually (indicate how much the view should scroll when the on-screen keyboard appears, then scroll it back down afterwards). HA!

But even magic fails sometimes. Has this ever happened to you?

The bottommost EditText is cut off. And we don't want that!

So what do we do? Do we programmatically scroll the view up? I don't want to do that! It turns out that we can just wrap the whole view in a ScrollView and it will scroll up properly!


The only downside to this is that you might want to hide the scrollbar when the view moves up to accommodate the on-screen keyboard. And to do that, I was trying to set the android:scrollbars="none" attribute but for one reason or another the scrollbar is still being drawn. To make the scrollbar disappear, we can do it from code as such:
((ScrollView)findViewById(R.id.my_scrollview))
.setVerticalScrollBarEnabled(false);
And we're done!

18 February 2011

Using a custom font in WebView

In one of my projects, I needed to display some special characters that the Android OS by itself cannot seem to render. I figured that I would need to provide a custom font that includes the characters that I needed.

If I was using a TextView, I could use TextView#setTypeFace. But I was using a WebView and I feared that things would be more complicated than that. So how do I do this?

Here's how we can make it work.

Step 1: We would need to have our font face included in our project's /assets folder. So look for a TTF file that you can use freely, make sure the author/creator allows you to re-distribute it!

Step 2: Edit your HTML file to include some CSS stuff, just so the WebView would know what font you want to use. Here's a sample file:
<html><head><link href="YourCssFile.css" rel="stylesheet" type="text/css" /></head><body><span class="phon">This string contains special characters: əˈpåstrəfi </span></body></html>
Make sure that the href references are correct. In this case, my CSS file, HTML file and font file are in the same folder.

Step 3: Define your CSS file. In this case, our YourCssFile.css would be:
@font-face {
font-family: "My font";
src: url('MyFontFile.TTF');
}

.phon, .unicode {
display: inline;
font-family: 'My font', Verdana, sans-serif;
font-size: 14pt;
font-weight: 500;
font-style:normal;
color: black;
}
Step 4: Load the file in your WebView! You can use
WebView webView = (WebView) findViewById(R.id.myWebview);
webView.loadUrl("path/to/html/file");
or
webView.loadDataWithBaseURL("file:///android_asset/",
article, "text/html", Encoding.UTF_8.toString(), null);
If you will use the second option, your article variable would contain the HTML string. Just make sure that you escape your quotation marks with a backslash (\).
IMPORTANT NOTE: This feature seems to be broken in Android 2.0 and 2.1, as reported here.

17 February 2011

It never ends!

Been ultra super busy the past few weeks. Also learning a lot of new things. And renewing my battle with orientation change, AsyncTasks and dialog boxes.

So, I have been thinking of what to document here next. Here are my choices:
- Loading HTML pages stored in the SD card in a WebView
- Managing your SharedPreferences
- Using a ViewStub

I had a long list in my mind yesterday, but I managed to not write it down. *facepalm*

07 February 2011

*stealth ninja mode on*

Over the past couple of weeks, this blog has been getting unusually high traffic. Which means I get more than one hit per week.

So if it isn't too much to ask, can I please know how you got here, and what you were looking for. And please please let me know if I said anything wrong, or if you know of a better/easier/more optimized way to do the stuff I'm trying to talk about. Thanks! :)

02 February 2011

That damn seekbar thumb

If you have ever needed to use a SeekBar, you definitely would have noticed how hard it is to move the slider (aka thumb) when it is set to the minimum or maximum value. The slider tends to be cut in half, and fitting your finger into it to press it becomes a test of patience.

See how small the slider becomes when it reaches the far ends of the SeekBar? Crazy!

Luckily, I found a way (just today!) to move the slider just a little tiny bit to make it easier to press. Apparently, there is a method called setThumbOffset() that allows us to nudge the slider by a number of pixels.

It's pretty easy to use, aside from the fact that it accepts pixels and not dip measurements. Anyway, here's how to do it:
int pixels = convertDipToPixels(8f);
SeekBar mySeekBar = (SeekBar) findViewById(R.id.quiz_settings_seekbar);
mySeekBar.setOnSeekBarChangeListener(mySeekBarListener);
mySeekBar.setThumbOffset(pixels);
I convert dip measurements to pixels to better manage the growing number of resolutions of screen sizes present. Here's the code to do that:
private int convertDipToPixels(float dip) {
DisplayMetrics metrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metrics);
float density = metrics.density;
return (int)(dip * density);
}
Aaaaaaaand this is now how our slider looks:Applause! Confetti! Applause!

30 January 2011

Enabling/disabling menu items on the fly

In one of my applications, I want to disable some menu entries if the database is not valid or is not present at all. To do that, I make use of the onPrepareOptionsMenu() API.

Let's say the user is on the Quiz activity. When the user presses the MENU button, the Quiz item should not be there anymore; and until the application validates the presence of a database, I want the Books item to be unclickable.

So I inflate my menu as usual from the XML file:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.my_menu, menu);

removeSomeItems(menu);
return true;
}

private void removeSomeItems(Menu menu){
MenuItem books = menu.findItem(R.id.menu_books);
books.setEnabled(false); // I want this item to be available eventually

menu.removeItem(R.id.menu_quiz); // I want this item to be unavailable for this activity
}

You have to remember though, that this method is called only ONCE, the very first time the user opens the menu.

The activity does its business as usual. When I am done checking if the database is there already, I want the user to be able to use the Books item. I have a boolean field that I set when the database is validated, and so now I can enable the menu.
@Override
public boolean onPrepareOptionsMenu (Menu menu){
super.onPrepareOptionsMenu(menu);

if(mIsDbValid){
MenuItem books = menu.findItem(R.id.menu_books);
books.setEnabled(true);
}

return true;
}


You can change the contents of the menu as often as you want using onPrepareOptionsMenu() because that method is called each and every time the user presses the MENU button.

And that's it!

18 January 2011

Just wondering

You know those postscripts that Google engineers have on their posts in forums? I wonder if they have a sort of "standards body" that came up with it. They all sound like this:
Note: please don't send private questions to me, as I don't have time to provide private support. All such questions should be posted on public
forums, where I and others can see and answer them


I wonder why I wonder about these things.

10 December 2010

Quick tip: Quick Formatting of Android XML Files

One of the most useful tools in Android's Eclipse plug-in is the Layout Editor. It is easy to experiment with layouts using the drag-and-drop enabled editor without having to worry about the correct syntax or if you are using the correct attribute name.

After creating your layout this way, however, you can end up with a messy XML file. I say "messy" in the sense that elements can run on in one veeeeeeeeeeeery long line, and if you are about to edit the XML file manually, this can be a nightmare.

I used to format the XML file by hand, putting in line breaks and correcting indentation. But one edit using the graphical layout editor and it's messed up again. And then I had a light bulb moment. Eclipse allows auto-formatting of code, but what about XML files? AHA!

It turns out that auto-formatting works for XML files too! Simply select all the contents of the XML file (CTRL-A) and then press the ultra magical shortcut CTRL-I and your XML file is clean and orderly as can be! YAY!

What happened to my layout editor?

There you are, happily creating your layout files in the Eclipse plug-in's layout editor. Dragging and dropping is a breeze. But then one day, you open a layout XML file and boom! No UI! All you see is the XML tree with all the nodes and attributes. What happened?

This happened to me and I was in a panic for a few seconds. Why do things like these have to happen to me? I tried opening a layout file from another project in the same workspace, and it has the UI! What happened?

It probably has something to do with the interpreter you used to open the XML file, a voice in my head said. So I tried right-clicking on the XML file, and lo and behold, I found it. I may have accidentally clicked on some file in one of my mad-clicking moments and changed the setting.

So anyway, to bring back the Layout UI Editor, right click on an XML file > Choose Open With > Android Layout Editor.

I would say that everything is handy dandy, but apparently, the engineers at Google decided that we developers need a little less help and removed the very useful up and down arrow keys in the Outline View when editing XML layouts. Why do they hurt us like this?

I WANT MY UP/DOWN KEYS BACK!