Android ListView - stop scrolling at 'whole' row position

10,023

Solution 1

You can get the visible dimensions of a child using the getChildVisibleRect method. When you have that, and you get the total height of the child, you can scroll to the appropriate child.

In the example below I check whether at least half of the child is visible:

View child = lv.getChildAt (0);    // first visible child
Rect r = new Rect (0, 0, child.getWidth(), child.getHeight());     // set this initially, as required by the docs
double height = child.getHeight () * 1.0;

lv.getChildVisibleRect (child, r, null);

if (Math.abs (r.height ()) < height / 2.0) {
    // show next child
}

else {
    // show this child
}

Solution 2

Here's my final code inspired by Shade's answer.

I forgot to add "if(Math.abs(r.height())!=height)" at first. Then it just scrolls twice after it scroll to correct position because it's always greater than height/2 of childView. Hope it helps.

listView.setOnScrollListener(new AbsListView.OnScrollListener(){

            @Override
            public void onScrollStateChanged(AbsListView view,int scrollState) {
                if (scrollState == SCROLL_STATE_IDLE){
                    View child = listView.getChildAt (0);    // first visible child
                    Rect r = new Rect (0, 0, child.getWidth(), child.getHeight());     // set this initially, as required by the docs
                    double height = child.getHeight () * 1.0;
                    listView.getChildVisibleRect (child, r, null);
                    if(Math.abs(r.height())!=height){//only smooth scroll when not scroll to correct position
                        if (Math.abs (r.height ()) < height / 2.0) {
                            listView.smoothScrollToPosition(listView.getLastVisiblePosition());
                        }
                        else if(Math.abs (r.height ()) > height / 2.0){
                            listView.smoothScrollToPosition(listView.getFirstVisiblePosition());
                        }
                        else{
                            listView.smoothScrollToPosition(listView.getFirstVisiblePosition());
                        }

                    }
                }
            }

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

            }});

Solution 3

Follow these 3 steps, then you can get exactly what you want!!!!

1.Initialize the two variable for scrolling up and down:

int scrollingUp=0,scrollingDown=0;

2.Then increment the value of the variable based on scrolling:

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

        if(mLastFirstVisibleItem<firstVisibleItem)
                {
                    scrollingDown=1;
                }
                if(mLastFirstVisibleItem>firstVisibleItem)
                {
                    scrollingUp=1;
                }
                mLastFirstVisibleItem=firstVisibleItem;
    } 

3.Then do the changes in the onScrollStateChanged():

@Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            switch (scrollState) {
            case SCROLL_STATE_IDLE:

                if(scrollingUp==1)
                {
                    mainListView.post(new Runnable() {
                        public void run() {
                            View child = mainListView.getChildAt (0);    // first visible child
                            Rect r = new Rect (0, 0, child.getWidth(), child.getHeight());     // set this initially, as required by the docs
                            double height = child.getHeight () * 1.0;
                            mainListView.getChildVisibleRect (child, r, null);
                            int dpDistance=Math.abs (r.height());
                            double minusDistance=dpDistance-height;
                            if (Math.abs (r.height()) < height/2)
                            {
                                mainListView.smoothScrollBy(dpDistance, 1500);
                            }       
                            else
                            {
                                mainListView.smoothScrollBy((int)minusDistance, 1500);
                            }
                            scrollingUp=0;

                        }
                    });
                }
                if(scrollingDown==1)
                {
                    mainListView.post(new Runnable() {
                        public void run() {

                            View child = mainListView.getChildAt (0);    // first visible child
                            Rect r = new Rect (0, 0, child.getWidth(), child.getHeight());     // set this initially, as required by the docs
                            double height = child.getHeight () * 1.0;
                            mainListView.getChildVisibleRect (child, r, null);
                            int dpDistance=Math.abs (r.height());
                            double minusDistance=dpDistance-height;
                            if (Math.abs (r.height()) < height/2)
                            {
                                mainListView.smoothScrollBy(dpDistance, 1500);
                            }       
                            else
                            {
                                mainListView.smoothScrollBy((int)minusDistance, 1500);
                            }
                            scrollingDown=0;
                        } 
                    });
                }
            break;
            case SCROLL_STATE_TOUCH_SCROLL:

                break;
                }
            }

Solution 4

You probably solved this problem but I think that this solution should work

if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
    View firstChild = lv.getChildAt(0);
    int pos = lv.getFirstVisiblePosition();

    //if first visible item is higher than the half of its height
    if (-firstChild.getTop() > firstChild.getHeight()/2) {
        pos++;
    }

    lv.setSelection(pos);
}

getTop() for first item view always return nonpositive value so I don't use Math.abs(firstChild.getTop()) but just -firstChild.getTop(). Even if this value will be >0 then this condition is still working.

If you want to make this smoother then you can try to use lv.smoothScrollToPosition(pos) and enclose all above piece of code in

if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
    post(new Runnable() {
        @Override
        public void run() {
            //put above code here
            //and change lv.setSelection(pos) to lv.smoothScrollToPosition(pos)
        }
    });
}
Share:
10,023
Nick Thissen
Author by

Nick Thissen

Updated on July 19, 2022

Comments

  • Nick Thissen
    Nick Thissen almost 2 years

    Sorry for the confusing title, I cannot express the problem very concisely...

    I have an Android app with a ListView that uses a circular / "infinite" adapter, which basically means I can scroll it up or down as much as I want and the items will wrap around when it reaches the top or bottom, making it seem to the user as if he is spinning an infinitely long list of (~100) repeating items.

    The point of this setup is to let the user select a random item, simply by spinning / flinging the listview and waiting to see where it stops. I decreased the friction of the Listview so it flings a bit faster and longer and this seems to work really nice. Finally I placed a partially transparent image on top of the ListView to block out the top and bottom items (with a transition from transparent to black), making it seem as if the user is "selecting" the item in the middle, as if they were on a rotating "wheel" that they control by flinging.

    There is one obvious problem: after flinging the ListView does not stop at a particular item, but it can stop hovering between two items (where the first visible item is then only partially shown). I want to avoid this because in that case it is not obvious which item has been "randomly selected".

    Long story short: after the ListView has finished scrolling after flinging, I want it to stop on a "whole" row, instead of on a partially visible row.

    Right now I implemented this behavior by checking when the scrolling has stopped, and then selecting the first visible item, as such:

        lv = this.getListView();
        
        lv.setFriction(0.005f);
        lv.setOnScrollListener(new OnScrollListener() {
            public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {}
    
            public void onScrollStateChanged(AbsListView view, int scrollState) {
                if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) 
                {
                    if (isAutoScrolling) return;
                    
                    isAutoScrolling = true;
                    int pos = lv.getFirstVisiblePosition();
                    lv.setSelection(pos);
                    isAutoScrolling = false;
                }
            }
        });
    

    This works reasonably well, apart from one glaringly obvious problem... The first visible item might only be visible for a pixel or two. In that case, I want the ListView to jump "up" for those two pixels so that the second visible item is selected. Instead, of course, the first visible item is selected which means the ListView jumps "down" almost an entire row (minus those two pixels).

    In short, instead of jumping to the first visible item, I want it to jump to the item that is visible the most. If the first visible item is less than half visible, I want it to jump to the second visible item.

    Here's an illustration that hopefully conveys my point. The left most ListView (of each pair) shows the state after flinging has stopped (where it comes to a halt), and the right ListView shows how it looks after it made the "jump" by selecting the first visible item. On the left I show the current (wrong) situation: Item B is only barely visible, but it is still the first visible item so the listView jumps to select that item - which is not logical because it has to scroll almost an entire item height to get there. It would be much more logical to scroll to Item C (which is depicted on the right) because that is "closer".

    Image
    (source: nickthissen.nl)

    How can I achieve this behavior? The only way I can think of is to somehow measure how much of the first visible item is visible. If that is more than 50%, then I jump to that position. If it is less than 50%, I jump to that position + 1. However I have no clue how to measure that...

    Any idea's?

  • Nick Thissen
    Nick Thissen about 11 years
    The whole point is to get the position of the row to scroll to... When I have that I can use smoothScroll or just setSelection to make it go there. I tried smoothScroll and it acted weird on some devices (after scrolling it started 'walking' up, selecting the next item basically once per second). So I prefer the jump actually by setPosition, at least that works. As I said: I now need to figure out WHICH item to jump to.
  • Nick Thissen
    Nick Thissen about 11 years
    Thanks, that seems like I was looking for. I keep getting a null reference somewhere internally though (I already tried replacing your 'null' with 'new Point(0, 0)' but still happens). I'll see if I can debug it (debugging doesn't work properly in my app because of some reason.....)
  • Nick Thissen
    Nick Thissen about 11 years
    I just tried your second suggestion but it doesn't seem to work. It now always scrolls to 'first + 1'. I think you are assuming that there always fit an integer amount of items in the height of the ListView (so that at the start there is no partial item visible)? I think I can get away with forcing that, I'll try that next.
  • Lonely Developer
    Lonely Developer about 11 years
    Updated my answer with a possible solution. Please come back with results, I have yet to test this.
  • Nick Thissen
    Nick Thissen about 11 years
    The problem was that 'child' was null. It seems getChildAt isn't returning anything. I first thought it was caused by my use of a CircularArrayAdapter (as in this answer). The position returned by getFirstVisiblePosition is something close to halve the max value of Integer (1073741756). I thought perhaps I should 'unwrap' that value back to the real index of the item which was 116 (1073741756 % itemcount = 116) but using that 'virtual' index it also returns null... What am I doing wrong?
  • Geobits
    Geobits about 11 years
    Yeah, that's why I said it was probably less robust. It really depends on how the listview is laid out. Incidentally, I upvoted Shade's answer, since I think that would probably work better for you, assuming you can fix your null issue.
  • Shade
    Shade about 11 years
    This is totally unrelated to the current question. I haven't really used the adapter you mention and can't help you further. You should ask another question.
  • Geobits
    Geobits about 11 years
    His use of the circular adapter is mentioned in the first sentence of the question.
  • Shade
    Shade about 11 years
    Oops. My bad. From a vague memory and from this answer - stackoverflow.com/a/6767006/361230, I would say that you should try to use getChildAt (0). Will edit above.
  • Nick Thissen
    Nick Thissen about 11 years
    Strange, but getChildAt(0) seems to work. However, the rectangle I get from getVisibleChildRect always has a height of 0...
  • sandrstar
    sandrstar almost 11 years
    Actually, rectangle should be the following: Rect r = new Rect (0, 0, child.getWidth(), child.getHeight());, refer to stackoverflow.com/a/17544173/657487 answer
  • Shade
    Shade almost 11 years
    @sandrstar, you're right. I verified the claim in the source code of the ViewParent and indeed, an offset is applied to r's left/right/rop/bottom, which means that they have to be set initially. Will update my answer.
  • Lorenzo Barbagli
    Lorenzo Barbagli about 9 years
    Your solution seems to work well but not always, I mean, sometimes it doesn't compute the smooth scrolling sometimes yes.. however it's the best solution I've found!
  • Artjom B.
    Artjom B. over 8 years
    Can you edit your answer to include a short description?