Wednesday 21 November 2012

Pinterest styled list for Android

Pinterest is more and more popular and many apps use its "masonry" pattern. I used the same pattern in my last project and must say it wasn't as easy job as it looked at first. Here's what I'd been doing...

At first logical solution was to use vertical LinearLayouts inside a ScrollView - easy and simple.
All I had to add is code which adds elements proportionately in each LinearLayout.
<ScrollView>
<LinearLayout
   orientation="horizontal">

   <LinearLayout
     android:layout_weight="0.5"
     orientation="vertical">

   <LinearLayout
     android:layout_weight="0.5"
     orientation="vertical">

</LinearLayout>
</ScrollView>
This worked fine at the beginning but when I added more than 100 items, as expected, app started to crash. Well if you think about it it was obvious, with this approach we don't use recycling so layout constantly had 100 items filled with layouts, pictures, formatted text..

Second solution I thought of was two synchronized ListViews. They have internal caching so all I had to do is synchronize them.
<LinearLayout 
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="horizontal"
    android:paddingLeft="10dp"
    android:paddingRight="10dp">

    <ListView
        android:id="@+id/list_view_left"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_weight="1"
        android:paddingRight="5dp"
        android:scrollbars="none" >
    </ListView>

    <ListView
        android:id="@+id/list_view_right"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_weight="1"
        android:paddingLeft="5dp"
        android:scrollbars="none" >
    </ListView>

</LinearLayout> 
My first attempt was to add OnTouchListener which will pass the touch event to the opposite list and OnScrollListener which will update first opposite child.
listOne.setOnTouchListener(new OnTouchListener() {
    @Override
       public boolean onTouch(View arg0, MotionEvent arg1) {
       listTwo.dispatchTouchEvent(arg1);
       return false;
    }
});
listOne.setOnScrollListener(new OnScrollListener() {
   @Override
   public void onScrollStateChanged(AbsListView arg0, int arg1) {
   }
   @Override
   public void onScroll(AbsListView arg0, int arg1, int arg2, int arg3) {
          if (l1.getChildAt(0) != null) {
              Rect r = new Rect();
              l1.getChildVisibleRect(l1.getChildAt(0), r, null);
              l2.setSelectionFromTop(l1.getFirstVisiblePosition(), r.top);
          }
    }
});  
Well this seemed as good solution. It was working with single list, scrolling was smooth so adding the same code to the opposite list should work fine but unfortunately it didn't. They ware both synced on first visible child so when one would disappear the other would automatically be positioned to match the new one. This made scrolling feel quite unnatural so other solution had to be found.

It occurred to me that I can just calculate distances from top of both elements, subtract one from another and add offset of the current element. Hmmm, well let's be more precise it took me a while get to this calculation but if you draw an example yourself, you'll see its just logical.
Source can be found on Github  
This solution works fine. I have few bugs to fix, simplify the code - make it more simple to use and that should be it. 
Still it bothers me if this can be implemented by using BaseAdapter and AdapterView. I'll  have an option to use adapters caching and what remains is child positioning. Well, I'll give it a try as soon as I find spare time...

20 comments:

  1. Hey,

    I am using a your solution . I have the following question: Do you managed to do this using ResourceCursorAdapter. I'm using the solution with rightListView & leftListView, and I keep getting ArrayIndexOutOfBound exception OnTouchListener.onTouch when calling the other list-view event (sometimes it happens on the leftListView and sometimes on the right one...):

    ReplyDelete
  2. You can use any adapter you prefer but you have to watch for synchronization in the onScroll method. I think the problem is that leftViewsHeights and rightViewsHeights(their sizes) haven't been initialized correctly.

    ReplyDelete
  3. Hi,

    Thanks for your reply.
    I think you are right some how, but I'm still fighting with this.
    Let me describe you in a nutshell what I'm doing, I don't have a bitmap objects on the adapter's getItem function (bindView in ResourceCursorAdapter), I have a photos URLs and I have a lazy load using ImageLoader.
    I also change your int[] leftViewsHeights into SparseIntArray since I have a "Load More" button in the end of my grid (2 lists) in order to load more list items (e.g. more photos).
    Here is my OnScroll:

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem,
    int visibleItemCount, int totalItemCount) {


    if (view.getChildAt(0) != null) {
    if (view.equals(listViewLeft)) {
    leftViewsHeights.put(view.getFirstVisiblePosition(), view
    .getChildAt(0).getHeight());

    int h = 0;
    for (int i = 0; i < listViewRight.getFirstVisiblePosition(); i++) {
    h += rightViewsHeights.get(i);
    }

    int hi = 0;
    for (int i = 0; i < listViewLeft.getFirstVisiblePosition(); i++) {
    hi += leftViewsHeights.get(i);
    }

    int top = h - hi + view.getChildAt(0).getTop();
    listViewRight.setSelectionFromTop(
    listViewRight.getFirstVisiblePosition(), top);
    } else if (view.equals(listViewRight)) {
    rightViewsHeights.put(view.getFirstVisiblePosition(), view
    .getChildAt(0).getHeight());

    int h = 0;
    for (int i = 0; i < listViewLeft.getFirstVisiblePosition(); i++) {
    h += leftViewsHeights.get(i);
    }

    int hi = 0;
    for (int i = 0; i < listViewRight.getFirstVisiblePosition(); i++) {
    hi += rightViewsHeights.get(i);
    }

    int top = h - hi + view.getChildAt(0).getTop();
    listViewLeft.setSelectionFromTop(
    listViewLeft.getFirstVisiblePosition(), top);
    }

    }

    }
    };



    Hope you can help me,
    Thanks...

    ReplyDelete
  4. Hmmm, first of all you should see what's the value of the index when you get ArrayIndexOutOfBound and compare it to the size of (left/right)ViewsHeights and there should be the solution of your problem. I had similar problems when refreshing list data, didn't always update the array size. If you are adding items with load more, check are the sizes of the arrays increased.

    ReplyDelete
    Replies
    1. Hey,

      I'm not sure its about the (left/right)ViewsHeights indices. First I rollback the ViewsHeights list to be int[]. and I initialize them to 100 (leftViewsHeights = new int[100], should be enough room).
      I have something like 5 photos in every list, and I still getting the same exception.
      Here is stack trace from the exception:

      03-15 12:49:48.716: E/AndroidRuntime(317): FATAL EXCEPTION: main
      03-15 12:49:48.716: E/AndroidRuntime(317): java.lang.ArrayIndexOutOfBoundsException
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.MotionEvent.getY(MotionEvent.java:792)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.widget.AbsListView.onTouchEvent(AbsListView.java:2040)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.widget.ListView.onTouchEvent(ListView.java:3315)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.View.dispatchTouchEvent(View.java:3766)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:897)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)
      03-15 12:49:48.716: E/AndroidRuntime(317): at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:936)


      as you can see it happen on the call to onTouchEvent of the list wasn't touched. e.g. if I touches left list so the exception is thrown on the call to onTouchEvent of the right list and vice versa..

      Thanks.

      Delete
  5. Hey, the reason is because they're synced. If you move the left the right should do the same and vice versa. I think that solution of your problem lies in finding which index exceeded the limit. When you figure this out, you'll probably also figure out what's wrong. If you still have problems you can send light version of your app and I can take look. I have planned to turn this into a project/plugin but cant find time :(

    ReplyDelete
  6. Hey,

    I am facing a problem that the last item of list view is not showing completely (It seem that both listviews stops scrolling downward when either of the listview items are complete). Can you please describe a solution in which the shorter listview add extra space.

    ReplyDelete
  7. I need to make four rows instead of two. I was able to get it but the scrolling did not worked for touching the first 2 rows where as scrolling the 3 and 4th row the scroll works.

    ReplyDelete
  8. As the code is not able to publish in one comment so dividing in to two
    1st part:

    I am providing the code for which I have made the changes.

    public class ItemsActivity extends Activity {
    private ListView listViewLeft;
    private ListView listViewRight;
    private ListView listViewNewLeft;
    private ListView listViewNewRight;

    private ItemsAdapter leftAdapter;
    private ItemsAdapter rightAdapter;
    private ItemsAdapter NewleftAdapter;
    //private ItemsAdapter NewrightAdapter;

    int[] leftViewsHeights;
    int[] rightViewsHeights;
    int[] NewleftViewsHeights;
    int[] NewrightViewsHeights;

    @Override
    public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.items_list);

    listViewLeft = (ListView) findViewById(R.id.list_view_left);
    listViewRight = (ListView) findViewById(R.id.list_view_right);
    listViewNewLeft = (ListView) findViewById(R.id.list_view_Newleft);
    listViewNewRight = (ListView) findViewById(R.id.list_view_Newright);

    loadItems();

    listViewLeft.setOnTouchListener(touchListener);
    listViewRight.setOnTouchListener(touchListener);
    listViewNewLeft.setOnTouchListener(touchListener);
    listViewNewRight.setOnTouchListener(touchListener);
    listViewLeft.setOnScrollListener(scrollListener);
    listViewRight.setOnScrollListener(scrollListener);
    listViewNewLeft.setOnScrollListener(scrollListener);
    listViewNewRight.setOnScrollListener(scrollListener);
    }
    OnTouchListener touchListener = new OnTouchListener() { boolean dispatched = false;
    @Override
    public boolean onTouch(View v, MotionEvent event) {
    if (v.equals(listViewLeft) && !dispatched) {
    dispatched = true;
    listViewRight.dispatchTouchEvent(event);
    }else if (v.equals(listViewRight) && !dispatched) {
    dispatched = true;
    listViewNewLeft.dispatchTouchEvent(event);
    }else if (v.equals(listViewNewLeft) && !dispatched) {
    dispatched = true;
    listViewNewRight.dispatchTouchEvent(event);
    } else if (v.equals(listViewNewRight) && !dispatched) {
    dispatched = true;
    listViewLeft.dispatchTouchEvent(event);
    }
    dispatched = false;
    return false;
    }
    };

    ReplyDelete
  9. 2nd part

    OnScrollListener scrollListener = new OnScrollListener() {
    @Override
    public void onScrollStateChanged(AbsListView v, int scrollState) {
    }
    @Override
    public void onScroll(AbsListView view, int firstVisibleItem,
    int visibleItemCount, int totalItemCount) {

    if (view.getChildAt(0) != null) {
    if (view.equals(listViewLeft) ){
    leftViewsHeights[view.getFirstVisiblePosition()] = view.getChildAt(0).getHeight();

    int h = 0;
    for (int i = 0; i < listViewRight.getFirstVisiblePosition(); i++) {
    h += rightViewsHeights[i];
    }
    int hi = 0;
    for (int i = 0; i < listViewLeft.getFirstVisiblePosition(); i++) {
    hi += leftViewsHeights[i];
    }
    int top = h - hi + view.getChildAt(0).getTop(); listViewRight.setSelectionFromTop(listViewRight.getFirstVisiblePosition(), top);
    } else if (view.equals(listViewRight)) {
    rightViewsHeights[view.getFirstVisiblePosition()] = view.getChildAt(0).getHeight();

    int h = 0;
    for (int i = 0; i < listViewNewLeft.getFirstVisiblePosition(); i++) {
    h += NewleftViewsHeights[i];
    }
    int hi = 0;
    for (int i = 0; i < listViewRight.getFirstVisiblePosition(); i++) {
    hi += rightViewsHeights[i];
    }

    int top = h - hi + view.getChildAt(0).getTop();
    listViewNewLeft.setSelectionFromTop(listViewNewLeft.getFirstVisiblePosition(), top);
    } else if (view.equals(listViewNewLeft) ){
    NewleftViewsHeights[view.getFirstVisiblePosition()] = view.getChildAt(0).getHeight();

    int h = 0;
    for (int i = 0; i < listViewNewRight.getFirstVisiblePosition(); i++) {
    h += NewrightViewsHeights[i];
    }
    int hi = 0;
    for (int i = 0; i < listViewNewLeft.getFirstVisiblePosition(); i++) {
    hi += NewleftViewsHeights[i];
    }

    int top = h - hi + view.getChildAt(0).getTop();
    listViewNewRight.setSelectionFromTop(listViewNewRight.getFirstVisiblePosition(), top);
    } else if (view.equals(listViewNewRight)) {
    NewrightViewsHeights[view.getFirstVisiblePosition()] = view.getChildAt(0).getHeight();

    int h = 0;
    for (int i = 0; i < listViewLeft.getFirstVisiblePosition(); i++) {
    h += leftViewsHeights[i];
    }
    int hi = 0;
    for (int i = 0; i < listViewNewRight.getFirstVisiblePosition(); i++) {
    hi += NewrightViewsHeights[i];
    }

    int top = h - hi + view.getChildAt(0).getTop();
    listViewLeft.setSelectionFromTop(listViewLeft.getFirstVisiblePosition(), top);
    }
    }
    }
    };
    private void loadItems(){
    Integer[] leftItems = new Integer[]{R.drawable.ic_1, R.drawable.ic_2, R.drawable.ic_3, R.drawable.ic_4, R.drawable.ic_5};
    Integer[] rightItems = new Integer[]{R.drawable.ic_6, R.drawable.ic_7, R.drawable.ic_8, R.drawable.ic_9, R.drawable.ic_10};
    Integer[] NewleftItems = new Integer[]{R.drawable.ic_1, R.drawable.ic_2, R.drawable.ic_3, R.drawable.ic_4, R.drawable.ic_5};
    Integer[] NewrightItems = new Integer[]{R.drawable.ic_6, R.drawable.ic_7, R.drawable.ic_8, R.drawable.ic_9, R.drawable.ic_10};

    leftAdapter = new ItemsAdapter(this, R.layout.item, leftItems);
    rightAdapter = new ItemsAdapter(this, R.layout.item, rightItems);
    NewleftAdapter = new ItemsAdapter(this, R.layout.item, NewleftItems);
    NewrightAdapter = new ItemsAdapter(this, R.layout.item, NewrightItems);

    listViewLeft.setAdapter(leftAdapter);
    listViewRight.setAdapter(rightAdapter);
    listViewNewLeft.setAdapter(NewleftAdapter);
    listViewNewRight.setAdapter(NewrightAdapter);
    leftViewsHeights = new int[leftItems.length];
    rightViewsHeights = new int[rightItems.length];
    NewleftViewsHeights = new int[NewleftItems.length];
    NewrightViewsHeights = new int[NewrightItems.length];
    }
    }

    ReplyDelete
  10. Hi, I think my example is too simple to work with four columns.
    The problematic part is onScroll since you would have to do calculations for four columns which is a bit too much. I'm working on better plugin which will inherit directly from Base and have customiziable elements. I would also suggest https://github.com/maurycyw/StaggeredGridView

    ReplyDelete
  11. This comment has been removed by the author.

    ReplyDelete
  12. Hello Sir, I'm facing a problem while using your project, I have replicated 5 images of left list views 200 times and same for right listview,(Integer[] leftItems,Integer rightItems) but when I scroll then left list view is scrolled completely (199 positions) but right list view is being scrolled around (187 position). As there is differences in height of images. When either of the list view gets filled then scrolling stops even there are more items are remaining to be filled in other list view. Also if I continuously toggle between scrolling (i.e. sometimes from left listview sometimes from right listview) then there 199 items in left list but items in right list vary (may be jaust 160 )..Please let me know how this problem can be solved...

    ReplyDelete
    Replies
    1. Hi,

      I think there is a bug with onScroll function. It calculates the distance from the top and then scrolls the opposite list by the same value. So if you have a shorter list it will prevent the other one scrolling to the end. I think that solution to this problem would be making both lists the same size and adding some 'padding' to the shorter list so it doesn't prevent the full scroll.

      Delete
  13. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. Hi Hiroyuki,

      this is just an example, not yet a library so you can use it as you please. If you would also like to contribute, I would be grateful.

      Vladimir

      Delete
  14. Hello sir,

    Can you have any idea about how to create continuous scrolling in this demo app like "expedia application".

    ReplyDelete
  15. Hi sir,

    First i like to thank you. This is great i think.
    So i found that the lists stop scrolling when one of the list is reach it end.
    Do you have solution for it?

    ReplyDelete
  16. Hello,

    Thank you for this post. It has been very helpful.

    I am looking to add some additional elements to the current view. For example like pinterest, I would like to add a textview under the image and give the image and textview a boarder. I attempted to reference a style, and also tried to add a textview in the item.xml without any success. If I understand correctly, I need to make changes in the ItemsAdappter file. Please explain how I can go about making changes as such. Thank in advance.

    ReplyDelete