Cyril Mottier

“It’s the little details that are vital. Little things make big things happen.” – John Wooden

Back to Top: Android vs. iOS

I generally don’t talk a lot about iOS on this blog. I’ll be honest with you, it is not because I consider iOS as an evil platform. As an extremely curious person, I just hate that iOS is a closed-source platform. I would really love to look at the implementation of some parts of the system or framework sometimes. However, Apple’s iOS remains an incredibly awesome mobile platform to develop for and to use. I assure you the APIs are gorgeous. From the UI point of view, iOS also has tons of exciting features, one of which is the “tap status bar to scroll to top”.

The purpose of this article is to give you a clear explanation about the control offered by Android over scroll containers. I have intentionally used iOS to do the comparison because the philosophy behind scroll containers in iOS is relatively different than Android.

Once upon a time, there was iOS

Just like every mobile OS, iOS runs on devices with a rather limited display surface. In order to display as much content as possible, applications can use scroll containers such as UIScrollView (ScrollView-equivalent), UITableView (ListView-equivalent), UIWebView (WebView-equivalent), etc. These containers let you scroll the content using gestures now considered elementary: the swipe gestures. When looking at your content, you may want to be brought back to the top of that content. While this is not something obvious on Android, the feature is available via a consistent and nice gesture on iOS: you simply have to tap the status bar. When doing so, the system will basically look for a UIScrollView in your app’s view hierarchy and scroll it back to the top if allowed to (i.e. if the UIScrollView has the scrollToTop property set to YES and the delegate allows it)).

Some might criticize the lack of discoverability of this feature and I totally agree. This is clearly something that is not natural to the user. It will generally be discovered by mistake. Once spotted, this is a power-feature reserved to power-users. However, always keep in mind that it is important to satisfy these guys : they generally push feedbacks and review your application way more rapidly than “regular” users.

So what about Android? I guess you are not aware of such a gesture on your devices. The reason is pretty obvious: Android doesn’t offer such a system-wide/global gesture! I don’t know the exact reason of this “lack”. Google considering it as not required? Apple having patented the gesture? The only thing I’m sure of is implementing scroll-to-top is almost impossible on Android because scroll containers in the SDK are a complete mess.

The Android scroll issue

This is not a mystery to anyone, globally speaking, I do love Android. However, I’m also pragmatic enough to notice some parts of the platform are not well designed or badly implemented. The scrollable containers APIs belong in this category. You don’t need to be an API designer to notice they are extremely confusing regarding scroll-related capabilities.

By default, the framework provides basic support for Views that wish to internally scroll their content and draw scrollbars. For instance, you can scrollTo(int, int)). While this works perfectly with ScrollView it doesn’t with ListView nor WebView nor my beloved MapView. Another example of this confusion is the ViewTreeObserver.OnScrollListener that works perfectly on all kinds of scrollable content but doesn’t provide you with the container that scrolled. Once again, Google Maps Android API v2 MapView is an exception and won’t fire the callback when being scrolled or zoomed. Finally, there are some inconsistencies. For instance, AbsListView.OnScrollListener lets you listen to AbsListView scrolls but there is no View.OnScrollListener counterpart. If you want to listen to scrolls at the View level, you’ll need to override the onScrollChanged(int, int, int, int) method.

Put simply, Android offers several scroll containers, but no consistent way to formalize scrolling and notify the developer. Even if you can determine if a View is a scroll container by using View.setScrollContainer(boolean)1, there is absolutely no way to develop a unified algorithm that would scroll your container to its top with a single call to View.scrollTo(0, 0).

On the other side, iOS simplified the problem by making sure all scrollable containers are unified via a UIScrollView - the base class containing the “scrolling” and “scroll-to-top” implementation. The framework offers a bunch of scrollable containers: UITextView, UITableView, UIWebView, MKMapView, etc. that all inherit or encapsulate a UIScrollView. By factorizing the scrolling behavior, iOS ensure that the scrolling physics (velocity, friction, bouncing, etc.) are consistent throughout iOS apps and guarantee all scrollable content can be scrolled back to the top.

So, is Android a crappy framework? Well I don’t think so. The API mess is probably difficult to apprehend - especially for new developers - but this is also what makes Android’s ListView so powerful compared to iOS UITableView when displaying items with variable heights for instance. UITableView relying on UIScrollView, it has to know all of the list’s items. On the other hand, Android’s ListView only requires the height of the visible items.

To sum up, iOS' UIScrollView-based API simplifies development and enforces UIs consistency. On the other hand Android’s messy API requires more attention from the developer, but can kick iOS' ass in some special cases.

It appears that not being able to implement a global scroll-to-top gesture is not really a problem. Indeed, most issues can be solved at the application level using components that are generally way more specific to the data displayed by your app. It obviously requires more work than relying on the default system’s behavior and doesn’t provide a consistent and coherent gesture throughout the platform. But, why would I need a scroll-to-top gesture in the Contacts iOS app if it also offers an index on the right?

Back to top on Android: the ethereal problem

Ultimately, the scroll content issue Android suffers from at the API level has no impact on the UI. If you are complaining about the feature missing, you should probably notify the developer his/her app needs some enhancements. The framework includes out-of-the-box workarounds and components that prevent the user from flinging for eternity trying to reach the top of your scrollable container:

  • Avoid long scrollable content at all cost: The best way to avoid scrolling pains is to avoid large scroll containers. In general, avoid long ListViews at all costs. Failing to do so will drown the important information in the middle of an almost un-findable/searchable list.

  • Enable fly-wheel: Since API 11, Android offers a fly-wheel mode in Scroller and OverScroller (the base components used to implement scrolling behaviors). When activated, successive fling motions will keep on increasing scroll speed. As a result, the user can rapidly increase the speed of the scroll containers to go back to an edge. Prior API 11, the velocity was generally topped by ViewConfiguration.getScaledMaximumFlingVelocity().

  • Enable fast scroll whenever possible: AbsListView can be scrolled extremely rapidly with a call to setFastScrollEnabled(true). Used in addition to SectionIndexer this makes navigation though an ordered list of grouped items extremely pleasant and powerful. While fast scroll can be used with all kind of data, it is generally only appropriate with ordered and grouped data. The Contacts app for instance uses it brilliantly.

Contrary to iOS, you generally don’t need to implement a tap-on-something-to-scroll-to-top behavior on Android. However, there is one case where the previously described techniques don’t fit: the timeline. Most of the time, a timeline is a vertically scrolling area displaying events sorted by creation date. The closer you are to the top, the more recent the data are.

The best - or should I say the worst - example of this is Google+. Google+ for Android displays a timeline with all of the posts from your circles' members. Reading posts is usually done from top to bottom which basically means from the most recent to the oldest ones. Sometimes you want to scroll way back to the top to see if there is a new post. That sounds easy, right? Well good luck with that :s. Here are the two options I found:

  • Start flinging like crazy back to top. Unfortunately, it looks like they completely disabled the fly-wheel mode which makes scrolling a pain in the ass.

  • Exit the timeline and reopens it I don’t think I need to describe this technique. You’ll all have understood it is purely non-logical and hence not user-friendly.

In this rare case I think, the tap-on-something-to-scroll-to-top is the correct option.

Tweaking the Quick Return pattern

Android not letting us listen to taps on the status bar, the only option is to use a clickable area in your application: a tab, a regular TextView, etc. A few months ago, Roman Nurik and Nick Butcher described and formalized a pattern they called “Quick Return”. I highly suggest you take a look at Roman’s G+ post or at Juhani Lehtimäki’s blog article to learn more about this emerging UI pattern.

While this pattern is great to make some important controls of your UI reappear, it doesn’t exactly fit the scroll-to-top gesture. Indeed, using the Quick Return pattern in this case would involve having a button appearing once the user starts scrolling up. This could be really annoying or frustrating.

In order to fix the issue, I’ve decided to tweak the pattern. Because users usually scroll up rapidly when going back to top, I thought it was only necessary to display the button when the velocity is higher than a given threshold. The rest of the article will focus on implementing such a widget but you can download an APK of the project (API 12 min) here:

Note: The code given below is a proof of concept. I have never used it in production and I already know it may behave weirdly (crash ?) when the underlying Adapter’s data is modified. Please make sure to understand what you are doing when using/modifying the snippet of code below.

Scrolling to the top

Going back to the top in a ListView is rather complicated. Here are some of the methods you can use:

  • setSelection(int): This method works like a charm by selecting the given position. As a result setSelection(0)can bring us back to the top. Unfortunately it has two mains disadvantages: the transition is not animated at all which is visually jarring and modifying the selected position in the middle of a fling animation doesn’t stop the animation.

  • smoothScrollToPosition(int): Available since API 8, this methods sounds like a good match. Unfortunately, I have never made it work in my projects. I’ve found a lot of complains about it on the web and stopped using it.

  • smoothScrollToPositionFromTop(int, int): Available since API 11, this method is a low-level counterpart of the previous method. The only different is it seems to work. Put simply, Android does not offer per-pixel scrolling in ListView prior API 11.

As you may have noticed, scrolling a ListView to its top in an animated way is rather difficult. Fortunately, some people in the Android team already did the job of creating an extension of ListView: the AutoScrollListView. Available in the Contacts app, the AutoScrollListView can be asked to scroll (smoothly or otherwise) to a position.

Measuring the velocity of a ListView

ListView doesn’t provide a method to get its current velocity. As a consequence, the only thing we can do is computing it. Measuring the velocity of a ListView is rather difficult. Indeed, measuring a velocity is usually done using the simple formula: v = Δd/Δt. Getting Δt is pretty elementary but that’s not the case for Δd on Android.

Contrary to iOS’s UITableView, ListView doesn’t give you a “current scroll Y”. The “measure items on demand” strategy used by ListView makes it hard to scroll at the pixel level and to measure its physical property (such as the velocity). However, even if you can’t determine the exact velocity of a ListView, you can approximate the value using an approximation of the travelled distance. Here is the approach I created:

  • At each scroll step n, keep the values of the View top dn and the position pn of the underlying data in the Adapter of the ListView’s child at index 0

  • If the item’s at position pn+1 is still visible then Δd is equal to the difference between the new top and the previous top: dn+1 - dn.

  • If the position is not visible anymore, then we can approximate the distance by computing the average height of the visible items in the ListView and multiply this value by the difference between the current position and the old position.

The schema shows a list being scrolled up (i.e. the user is swiping from top to bottom). As explained previously, Δd = d2 - d1.

While the technique works great and scrolling up, you may easily fall into a case where d2 is not measurable because the view at index 0 in the previous measurement has been recycled. The trick consists of using the exact same technique twice: once for the child at index 0 (mostly used when scrolling up) and also for the child at index getChildCount() - 1 (mostly used when scrolling down).

Finally, if you are scrolling up or down extremely rapidly you may have none of the children on screen from one step to another. In this case we will use the “position is not visible anymore” approximation. This case can also occur if your application freezes the UI thread.

The code is provided below and consists on extending AutoScrollListView to approximate the velocity of the ListView and notifying an optional client:

VelocityListView.java
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
package com.cyrilmottier.android.scrolltotop.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.view.animation.AnimationUtils;
import android.widget.AbsListView;
import android.widget.ListView;

/**
 * An extension of the framework's {@link ListView} that can determine an
 * approximate value of its current velocity on the Y-axis.
 * 
 * @author Cyril Mottier
 */
public class VelocityListView extends AutoScrollListView {

    /**
     * A callback to be notified the velocity has changed.
     * 
     * @author Cyril Mottier
     */
    public interface OnVelocityListViewListener {
        void onVelocityChanged(int velocity);
    }

    private static final long INVALID_TIME = -1;

    /**
     * This value is really necessary to avoid weird velocity values. Indeed, in
     * fly-wheel mode, onScroll is called twice per-frame which results in
     * having a delta divided by a value close to zero. onScroll is usually
     * being called 60 times per seconds (i.e. every 16ms) so 10ms is a good
     * threshold.
     */
    private static final long MINIMUM_TIME_DELTA = 10L;

    private final ForwardingOnScrollListener mForwardingOnScrollListener = new ForwardingOnScrollListener();

    private OnVelocityListViewListener mOnVelocityListViewListener;

    private long mTime = INVALID_TIME;
    private int mVelocity;

    private int mFirstVisiblePosition;
    private int mFirstVisibleViewTop;
    private int mLastVisiblePosition;
    private int mLastVisibleViewTop;

    public VelocityListView(Context context) {
        super(context);
        init();
    }

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

    public VelocityListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        super.setOnScrollListener(mForwardingOnScrollListener);
        mForwardingOnScrollListener.selfListener = mOnScrollListener;
    }

    @Override
    public void setOnScrollListener(OnScrollListener l) {
        mForwardingOnScrollListener.clientListener = l;
    }

    public void setOnVelocityListener(OnVelocityListViewListener l) {
        mOnVelocityListViewListener = l;
    }

    /**
     * Return an approximative value of the ListView's current velocity on the
     * Y-axis. A negative value indicates the ListView is currently being
     * scrolled towards the bottom (i.e items are moving from bottom to top)
     * while a positive value indicates it is currently being scrolled towards
     * the top (i.e. items are moving from top to bottom).
     * 
     * @return An approximative value of the ListView's velocity on the Y-axis
     */
    public int getVelocity() {
        return mVelocity;
    }

    private void setVelocity(int velocity) {
        if (mVelocity != velocity) {
            mVelocity = velocity;
            if (mOnVelocityListViewListener != null) {
                mOnVelocityListViewListener.onVelocityChanged(velocity);
            }
        }
    }

    /**
     * @author Cyril Mottier
     */
    private static class ForwardingOnScrollListener implements OnScrollListener {

        private OnScrollListener selfListener;
        private OnScrollListener clientListener;

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            if (selfListener != null) {
                selfListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
            if (clientListener != null) {
                clientListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
        }

        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            if (selfListener != null) {
                selfListener.onScrollStateChanged(view, scrollState);
            }
            if (clientListener != null) {
                clientListener.onScrollStateChanged(view, scrollState);
            }
        }
    }

    private OnScrollListener mOnScrollListener = new OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            switch (scrollState) {
                case SCROLL_STATE_IDLE:
                    mTime = INVALID_TIME;
                    setVelocity(0);
                    break;

                default:
                    break;
            }
        }

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

            final long now = AnimationUtils.currentAnimationTimeMillis();
            final int lastVisiblePosition = firstVisiblePosition + visibleItemCount - 1;

            if (mTime != INVALID_TIME) {

                final long delta = now - mTime;
                if (now - mTime > MINIMUM_TIME_DELTA) {
                    int distance = 0;
                    //@formatter:off
                    if (mFirstVisiblePosition >= firstVisiblePosition
                            && mFirstVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mFirstVisiblePosition - firstVisiblePosition).getTop() - mFirstVisibleViewTop;

                    } else if (mLastVisiblePosition >= firstVisiblePosition
                            && mLastVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mLastVisiblePosition - firstVisiblePosition).getTop() - mLastVisibleViewTop;
                    //@formatter:on
                    } else {
                        // We're in a case were the item we were previously
                        // referencing has moved out of the visible window.
                        // Let's compute an approximative distance
                        int heightSum = 0;
                        for (int i = 0; i < visibleItemCount; i++) {
                            heightSum += getChildAt(i).getHeight();
                        }

                        distance = heightSum / visibleItemCount * (mFirstVisiblePosition - firstVisiblePosition);
                    }

                    setVelocity((int) (1000d * distance / delta));
                }
            }

            mFirstVisiblePosition = firstVisiblePosition;
            mFirstVisibleViewTop = getChildAt(0).getTop();
            mLastVisiblePosition = lastVisiblePosition;
            mLastVisibleViewTop = getChildAt(visibleItemCount - 1).getTop();

            mTime = now;
        }
    };

}

The final code

Now we can be notified of a change in the velocity of our ListView, so we can animate in a scroll-to-top button only when going beyond a certain threshold. First of all, let’s create the layout of our Activity:

main_activity.xml
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
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <com.cyrilmottier.android.scrolltotop.widget.VelocityListView
        android:id="@android:id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <Button
        android:id="@+id/btn_scroll_to_top"
        android:layout_width="match_parent"
        android:layout_height="32dp"
        android:layout_gravity="top"
        android:background="@drawable/list_selector"
        android:gravity="center"
        android:text="@string/tap_to_scroll_to_top"
        android:textColor="@android:color/white"
        android:textSize="12sp"
        android:textStyle="bold"
        android:translationY="-32dp" />

</merge>

The Activity’s code is now crystal clear:

MainActivity.java
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package com.cyrilmottier.android.scrolltotop;

import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.TextView;

import com.cyrilmottier.android.scrolltotop.widget.VelocityListView;
import com.cyrilmottier.android.scrolltotop.widget.VelocityListView.OnVelocityListViewListener;

public class MainActivity extends Activity {

    private static final int VELOCITY_ABSOLUTE_THRESHOLD = 5500;

    private static final int BIT_VISIBILITY = 0x01;
    private static final int BIT_ANIMATION = 0x02;

    private static final int SCROLL_TO_TOP_HIDDEN = 0;
    private static final int SCROLL_TO_TOP_HIDING = BIT_ANIMATION;
    private static final int SCROLL_TO_TOP_SHOWN = BIT_VISIBILITY;
    private static final int SCROLL_TO_TOP_SHOWING = BIT_ANIMATION | BIT_VISIBILITY;

    private VelocityListView mListView;
    private Button mScrollToTopButton;

    private ViewPropertyAnimator mAnimator;

    private int mVelocityAbsoluteThreshold;
    private int mScrollToTopState = SCROLL_TO_TOP_HIDDEN;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mVelocityAbsoluteThreshold = (int) (VELOCITY_ABSOLUTE_THRESHOLD * getResources().getDisplayMetrics().density + 0.5f);

        setContentView(R.layout.main_activity);

        mScrollToTopButton = (Button) findViewById(R.id.btn_scroll_to_top);
        mScrollToTopButton.setOnClickListener(mOnClickListener);

        mAnimator = mScrollToTopButton.animate();

        mListView = (VelocityListView) findViewById(android.R.id.list);
        mListView.setAdapter(new CheesesAdapter());
        mListView.setOnVelocityListener(mOnVelocityListener);
    }

    private OnClickListener mOnClickListener = new OnClickListener() {
        @Override
        public void onClick(View v) {
            mListView.requestPositionToScreen(0, true);
        }
    };

    private OnVelocityListViewListener mOnVelocityListener = new OnVelocityListViewListener() {
        @Override
        public void onVelocityChanged(int velocity) {
            if (velocity > 0) {
                if (Math.abs(velocity) > mVelocityAbsoluteThreshold) {
                    if ((mScrollToTopState && BIT_VISIBILITY) == 0) {
                        mAnimator.translationY(0).setListener(mOnShownListener);
                        mScrollToTopState = SCROLL_TO_TOP_SHOWING;
                    }
                }
            } else {
                if ((mScrollToTopState && BIT_VISIBILITY) == BIT_VISIBILITY) {
                    mAnimator.translationY(-mScrollToTopButton.getHeight()).setListener(mOnHiddenListener);
                    mScrollToTopState = SCROLL_TO_TOP_HIDING;
                }
            }
        }
    };

    private final AnimatorListener mOnHiddenListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_HIDDEN;
        };
    };

    private final AnimatorListener mOnShownListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_SHOWN;
        };
    };

    public class CheesesAdapter extends BaseAdapter {

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

        @Override
        public String getItem(int position) {
            return CHEESES[position];
        }

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

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

            if (convertView == null) {
                convertView = getLayoutInflater().inflate(R.layout.text_item, parent, false);
            }

            ((TextView) convertView).setText(getItem(position));

            return convertView;
        }
    }

    public static final String CHEESES[] = {
            "Abbaye de Belloc", "Abbaye du Mont des Cats",
            // ...
            "Zanetti Grana Padano", "Zanetti Parmigiano Reggiano"
    };

}

As described previously, the code above should be considered as a proof of concept rather than a ready-to-use widget. Because of this I have decided not to push it on GitHub but share it “as it” here. Please note the license attached to it is the Apache v2.

Conclusion

Android’s scroll containers are probably more difficult to understand than their iOS counterparts, but they also offer a larger set of features. While scrolling to the top is extremely easy to implement on iOS, it requires more work from developers on Android. However, always keep in mind that implementing an iOS-like scroll-to-top gesture is not necessary 95% of the time. The other 5% can freely tweak or reuse the code I shared here.

Thanks to @franklinharper and @moystard for reading drafts of this


  1. This flag is currently used by Android to determine whether the window can resize or must pan when a soft IME is open.