Synchronizing ScrollView

Dave Smith
Dave Smith

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.

Inline Header View

 

Floating Header View

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 the ScrollView completely has to do with scrollbars. If all these views are not children of the ScrollView, 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.