Adel Nizamutdinov

I wanted to share this

Android Adapters

This post is about AdapterView adapters, please don’t confuse it with ViewPager ones I have 3 simple rules for writing adapters:

  1. Use immutable collections
  2. Replace ViewHolders with Views
  3. Stop writing adapters

1. Use immutable collections

1
java.lang.IllegalStateException: The content of the adapter has changed but ListView did not receive a notification.

Ever got an Exception like that? It’s easy to track down in a development stage, but when you get that in production – you’ll have to go through all of your codebase, looking for a desired List<> mutation. The easiest way to prevent those kinds of crashes, is to use immutable collections: arrays or Collections.unmodifiableList. Here’s the quick gist of how your adapter can look:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ImmutableAdapter<T> extends BaseAdapter {
    final LayoutInflater inflater;
    final int            layout;
    List<T> items;

    public ImmutableAdapter(Context context, int layout, List<T> items) {
        this.inflater = LayoutInflater.from(context);
        this.layout = layout;
        this.items = Collections.unmodifiableList(items);
    }

    public void setItems(List<T> items) {
        this.items = Collections.unmodifiableList(items);
        notifyDataSetChanged();
    }
}

2. Replace ViewHolders with Views

Let’s say we have a download manager, and for each list item we have a following layout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="match_parent">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:id="@+id/textView"/>
    <ProgressBar
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/progressBar"/>
</LinearLayout>

And a ViewHolder:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ViewHolder {
    @InjectView(R.id.textView)    TextView    name;
    @InjectView(R.id.progressBar) ProgressBar progressBar;

    public ViewHolder(View convertView) {
        ButterKnife.inject(this, convertView);
    }

    public void draw(Download download) {
        name.setText(download.name);
        progressBar.setProgress(download.progress);
    }
}

Now suppose we want to update those progressbars in realtime, we need to subscribe ViewHolders to some event source. We also need to unsubscribe them to prevent an Activity leak. Our adapter is now a bit too complex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class DownloadsAdapter extends BaseAdapter {
    final Set<ViewHolder> holders = new HashSet<>();
    final LayoutInflater inflater;
    final int            layout;
    List<Download> items;

    public DownloadsAdapter(Context context, int layout, List<Download> items) {
        this.inflater = LayoutInflater.from(context);
        this.layout = layout;
        this.items = Collections.unmodifiableList(items);
    }

    public void setItems(List<Download> items) {
        this.items = Collections.unmodifiableList(items);
        notifyDataSetChanged();
    }

    @Override public int getCount() { return items.size(); }

    @Override public Download getItem(int position) {
        return items.get(position);
    }

    @Override public long getItemId(int position) { return position; }

    public static class ViewHolder {
        @InjectView(R.id.textView)    TextView    name;
        @InjectView(R.id.progressBar) ProgressBar progressBar;
        Subscription sub = Subscriptions.empty();

        public ViewHolder(View convertView) {
            ButterKnife.inject(this, convertView);
        }

        public void draw(Download download) {
            name.setText(download.name);
            progressBar.setProgress(download.progress);

            sub.unsubscribe();
            sub = download.progressEvents.subscribe(progressBar::setProgress);
        }

        public void release() {
            sub.unsubscribe();
        }
    }

    @Override public View getView(int position,
                                  View convertView,
                                  ViewGroup parent) {
        if (convertView == null) {
            convertView = inflater.inflate(layout, parent, false);
            ViewHolder holder = new ViewHolder(convertView);
            holders.add(holder);
            convertView.setTag(holder);
        }
        ViewHolder holder = (ViewHolder) convertView.getTag();
        holder.draw(getItem(position));
        return convertView;
    }

    public void release() {
        for (ViewHolder holder : holders) {
            holder.release();
        }
    }
}

And we also have to make sure that we release() our adapter in our Fragment/Activity

Use Views, Luke

Why?

  • They have lifecycle callbacks (for free)
  • You can reuse them outside of your adapters
  • Adapters should just pass data to the views, views should manage the drawing.

How?

First, define your View:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DownloadView extends LinearLayout {
    @InjectView(R.id.textView)    TextView    name;
    @InjectView(R.id.progressBar) ProgressBar progressBar;

    Subscription sub = Subscriptions.empty();

    public DownloadView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override protected void onFinishInflate() {
        super.onFinishInflate();
        if (!isInEditMode()) {
            ButterKnife.inject(this);
        }
    }

    public void draw(Download download) {
        name.setText(download.name);
        progressBar.setProgress(download.progress);

        sub.unsubscribe();
        sub = download.progressEvents.subscribe(progressBar::setProgress);
    }

    @Override protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        sub.unsubscribe();
    }
}

Then modify your layout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<com.example.DownloadView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:id="@+id/textView"/>

    <ProgressBar
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/progressBar"/>
</com.example.DownloadView>

The Adapter now looks much cleaner, thanks to the free lifecycle callbacks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class DownloadsAdapter extends BaseAdapter {
    final LayoutInflater inflater;
    final int            layout;
    List<Download> items;

    public DownloadsAdapter(Context context, int layout, List<Download> items) {
        this.inflater = LayoutInflater.from(context);
        this.layout = layout;
        this.items = Collections.unmodifiableList(items);
    }

    public void setItems(List<Download> items) {
        this.items = Collections.unmodifiableList(items);
        notifyDataSetChanged();
    }

    @Override public int getCount() { return items.size(); }

    @Override public Download getItem(int position) {
        return items.get(position);
    }

    @Override public long getItemId(int position) { return position; }

    @Override public View getView(int position,
                                  View convertView,
                                  ViewGroup parent) {
        DownloadView downloadView = (DownloadView) (convertView == null
                ? inflater.inflate(layout, parent, false)
                : convertView);
        downloadView.draw(getItem(position));
        return convertView;
    }
}

3. Stop writing adapters

Take a closer look at the previous adapter. The only things it need is a collection of items and a layout int, backed by a View with an appropriate draw method. Let’s abstract over both collections and views. Define a view interface like this:

1
2
3
public interface ViewHolder<T> {
    void draw(int position, T t);
}

And a source interface like this:

1
2
3
4
public interface Source<T> {
    T get(int position);
    int getCount();
}

Now we came to the point where we can write a fully generic adapter for our apps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Adapter<T> extends BaseAdapter {
    private final LayoutInflater inflater;
    private final int            layout;
    private       Source<T>      source;

    public Adapter(Context context,
                   Source<T> source,
                   int layout) {
        this.inflater = LayoutInflater.from(context);
        this.source = source;
        this.layout = layout;
    }

    @Override public int getCount() { return source.getCount(); }

    @Override public T getItem(int position) { return source.get(position); }

    @Override public long getItemId(int position) { return position; }

    @Override public View getView(int position,
                                  View convertView,
                                  ViewGroup parent) {
        ViewHolder<T> viewHolder = (ViewHolder<T>) (convertView == null
                ? inflater.inflate(layout, parent, false)
                : convertView);
        viewHolder.draw(position, getItem(position));
        return (View) viewHolder;
    }

    public void setSource(Source<T> items) {
        this.source = items;
        notifyDataSetChanged();
    }
}

When it comes to the views, just implement the view interface:

1
public class AudioListItemView extends LinearLayout implements ViewHolder<Audio>

And create a bunch of Sources:

1
2
3
4
5
6
7
8
9
public class ListSource<T> implements Source<T> {
    final List<T> list;

    public ListSource(List<T> list) {this.list = list;}

    @Override public T get(int position) { return list.get(position); }

    @Override public int getCount() { return list.size(); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MergeSource<T> implements Source<T> {
    private final Source<T>[] sources;

    public MergeSource(Source<T>... sources) { this.sources = sources; }

    @Override public T get(int position) {
        for (Source<T> source : sources) {
            int size = source.getCount();

            if (position < size) {
                return source.get(position);
            }

            position -= size;
        }
        throw new AssertionError("impossible");
    }

    @Override public int getCount() {
        int total = 0;
        for (Source<T> source : sources) {
            total += source.getCount();
        }
        return total;
    }
}

You can create a Source for any collection of items, even Cursors! Just remember to make your sources immutable, and generate a new one for a ListView update.

Libraries that appear in the code snippets:

Homework:

Implement a generic adapter for a multiple view types, that should be easy enough.

Comments