Recently, Kirill Grouchnikov made a post on his blog providing details on how the Android Market app uses a synchronized scrolling technique to take a certain view from inside the ScrollView content and let it float to the top of the screen when it would have otherwise been scrolled off-screen. This post is meant to elaborate on his details with a splash of sample code.
Doing this hinges primarily on a simple custom subclass of ScrollView
that tracks
the onScrollChanged()
method callback to keep all views in sync. We could use this
class to expose these scroll changes as a public interface, but in this example we
attach the views of interest and do all the tracking inside the custom class:
package com.examples.synchronizedscrolling;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ScrollView;
public class SynchronizedScrollView extends ScrollView {
private View mAnchorView;
private View mSyncView;
public SynchronizedScrollView(Context context) {
super(context);
}
public SynchronizedScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SynchronizedScrollView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
/**
* Attach the appropriate child view to monitor during scrolling
* as the anchoring space for the floating view. This view MUST
* be an existing child.
*
* @param v View to manage as the anchoring space
*/
public void setAnchorView(View v) {
mAnchorView = v;
syncViews();
}
/**
* Attach the appropriate child view to managed during scrolling
* as the floating view. This view MUST be an existing child.
*
* @param v View to manage as the floating view
*/
public void setSynchronizedView(View v) {
mSyncView = v;
syncViews();
}
//Position the views together
private void syncViews() {
if(mAnchorView == null || mSyncView == null) {
return;
}
//Distance between the anchor view and the header view
int distance = mAnchorView.getTop() - mSyncView.getTop();
mSyncView.offsetTopAndBottom(distance);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
//Calling this here attaches the views together if they were added
// before layout finished
syncViews();
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if(mAnchorView == null || mSyncView == null) {
return;
}
//Distance between the anchor view and the scroll position
int matchDistance = mAnchorView.getTop() - getScrollY();
//Distance between scroll position and sync view
int offset = getScrollY() - mSyncView.getTop();
//Check if anchor is scrolled off screen
if(matchDistance < 0) {
mSyncView.offsetTopAndBottom(offset);
} else {
syncViews();
}
}
}
This example keeps track of two views, which must be laid out as children of the
ScrollView
(we’ll see an example layout in a minute). We’ll call these views the
anchor view and the synchronized view. The synchronized view is the view that will
float to the top of the screen when scrolled. The anchor view is a view that lives
inside the list of content and represents the space and location where the synchronized
view should be if it weren’t floating.
The two workhorse methods in this example are syncViews()
and onScrollChanged()
.
The job of syncViews()
is to align the position of the floating view with its
anchor. We use this to align the views when everything is initialized, as well as
when scrolling occurs and the anchor view is fully visible.
The onScrollChanged()
callback is overridden to track the user’s scrolling and
slide the view accordingly. We calculate two important distance values with each
call. The first is the distance between the anchor view and the current top visible
scroll position. We use this value to determine if the anchor view is visible or
has been scrolled off-screen. If this value is negative, then the anchor view is
at least partially scrolled off-screen, and we need to set the position of the
synchronized view as an offset.
The second calculated value is the distance between the current visible scroll
position and the current position of the synchronized view. This is the value we
apply as the offset to the synchronized view using offsetTopAndBottom()
.
As previously mentioned, when the anchor distance is zero or greater, the view is
fully visible and the floating view should be aligned with the anchor’s position.
The Layout
The key to making this custom scrolling work is that the two views we are tracking must be laid out as children. Here is the layout used in the example:
main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<com.examples.synchronizedscrolling.SynchronizedScrollView
android:id="@+id/scroll"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<FrameLayout
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<View
android:layout_width="fill_parent"
android:layout_height="65dip"
android:background="#A00"/>
<View
android:id="@+id/anchor"
android:layout_width="fill_parent"
android:layout_height="65dip"
android:background="#AAA"/>
<View
android:layout_width="fill_parent"
android:layout_height="225dip"
android:background="#0A0"/>
<View
android:layout_width="fill_parent"
android:layout_height="225dip"
android:background="#00A"/>
<View
android:layout_width="fill_parent"
android:layout_height="225dip"
android:background="#A00"/>
<View
android:layout_width="fill_parent"
android:layout_height="225dip"
android:background="#0A0"/>
</LinearLayout>
<TextView
android:id="@+id/header"
android:layout_width="fill_parent"
android:layout_height="65dip"
android:gravity="center"
android:text="Heading View"
android:background="#A555" />
</FrameLayout>
</com.examples.synchronizedscrolling.SynchronizedScrollView>
</FrameLayout>
Since ScrollView
can only have one child, we use a FrameLayout
to contain everything.
The traditionally scrollable list of items (here, represented as Views in assorted
colors) are laid out in a vertical LinearLayout
. The view to be synchronized is
laid out as a sibling of this list, but still contained in the FrameLayout
.
The reason we do this instead of letting the
LinearLayout
be the sole child and let the floating view live outside theScrollView
completely has to do with scrollbars. If all these views are not children of theScrollView
, then they will be drawn over the top of the scrollbars when they are visible. This, as Kirill mentions in this post, is ugly.
Tie it Together
Finally, let's take a look at the sample Activity that brings these pieces together to make the lovely screenshots we saw at the beginning of this article:
package com.examples.synchronizedscrolling;
import android.app.Activity;
import android.os.Bundle;
public class ScrollingActivity extends Activity {
private SynchronizedScrollView mScrollView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mScrollView = (SynchronizedScrollView)findViewById(R.id.scroll);
mScrollView.setAnchorView(findViewById(R.id.anchor));
mScrollView.setSynchronizedView(findViewById(R.id.header));
}
}
You can see there isn’t much to do here, since we do the heavy lifting inside
our SynchronizedScrollView
. We simply need to attach the to views from our
layout that represent the anchor and the floating view.