Flavien Laurent

Personal blog on programming | mainly Android, Java

Making Your ActionBar Not Boring

Back in May 2013, everyone has discovered the beautiful action bar effect in the Play Music app (explained by Cyril Mottier , Pushing the ActionBar to the Next Level). This effect is really a great way to make the ActionBar a reactive part of the UI.

Yesterday, I discovered the new Newsstand app which introduced a new way to push the ActionBar to the next level. If you open the edition screen, you’ll notice the ActionBar is transparent (without icon) and overlaps a large images set (overlaped by the big magazine icon) describing some news. Once you start scrolling down, the big magazine icon is translated and scaled to match the ActionBar icon.

Here it’s a short demo of the trick

The main advantage is to make the ActionBar real part of the content.

In this article, I’m going to explain how to make the whole edition screen (the ActionBar effect and the Ken Burns animation). I will deep dive into the details of implementing the ActionBar effect. As a bonus, I will explain how to create a view animated with the Ken Burns effect.

You can download & install the sample application.

Download Sample Application

The ActionBar trick

Styles

The first step is to setup styles to:

styles.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<resources>
    <style name="TransparentTheme" parent="@android:style/Theme.Holo.Light">
        <item name="android:windowBackground">@null</item>
        <item name="android:actionBarStyle">@style/ActionBarStyle.Transparent</item>
        <item name="android:windowActionBarOverlay">true</item>
    </style>

    <style name="ActionBarStyle.Transparent" parent="@android:Widget.ActionBar">
        <item name="android:background">@null</item>
        <item name="android:displayOptions">homeAsUp|showHome|showTitle</item>
        <item name="android:titleTextStyle">@style/ActionBarStyle.Transparent.TitleTextStyle</item>
    </style>

    <style name="ActionBarStyle.Transparent.TitleTextStyle" parent="@android:style/TextAppearance.Holo.Widget.ActionBar.Title">
        <item name="android:textColor">@android:color/white</item>
    </style>

</resources>

Layout structure

The layout structure is really important. The main layout is a FrameLayout composed by a ListView and another FrameLayout (ie header). The header contains one ImageView (ie header_picture) which is the picture and one ImageView (ie header_logo) which is the icon to be translated & scaled into the ActionBar.

main.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
26
27
28
29
30
31
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ListView
        android:id="@+id/listview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/white" />

    <FrameLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="@dimen/header_height">

        <ImageView
            android:id="@+id/header_picture"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:src="@drawable/picture0" />

        <ImageView
            android:id="@+id/header_logo"
            android:layout_width="@dimen/header_logo_size"
            android:layout_height="@dimen/header_logo_size"
            android:layout_gravity="center"
            android:src="@drawable/ic_header_logo" />

    </FrameLayout>

</FrameLayout>

The trick here is to add an fake header view in the ListView. This fake header must have the same height as the real header. You can use a layout xml file to describe the fake header

fake_header.xml
1
2
3
4
5
6
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/header_height"
    android:orientation="vertical">

</LinearLayout>

Then, you just have to inflate & add it by code

MainActivity.java
1
2
mFakeHeader = getLayoutInflater().inflate(R.layout.fake_header, mListView, false);
mListView.addHeaderView(mFakeHeader);

Get the scroll position

I’m not going to explain this in details because there is a bunch of StackOverflow, posts etc about it.

MainActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public int getScrollY() {
    View c = mListView.getChildAt(0);
    if (c == null) {
        return 0;
    }

    int firstVisiblePosition = mListView.getFirstVisiblePosition();
    int top = c.getTop();

    int headerHeight = 0;
    if (firstVisiblePosition >= 1) {
        headerHeight = mPlaceHolderView.getHeight();
    }

    return -top + firstVisiblePosition * c.getHeight() + headerHeight;
}

Notice that you have to take care of the ListView header height in the calculation if the first visible position is >= 1.

Translating the header

On ListView scroll, you have to tranlate the header in order to follow the ListView fake header. Notice that the translation must be bounded to the ActionBar height.

MainActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            int scrollY = getScrollY();
            //sticky actionbar
            mHeader.setTranslationY(Math.max(-scrollY, mMinHeaderTranslation));
        }
    });

Fading the title

You need to retrieve the title View to apply a fade effect. This view can be retrieve using the Resources.getIdentifier method.

MainActivity.java
1
2
3
4
private TextView getActionBarTitleView() {
    int id = Resources.getSystem().getIdentifier("action_bar_title", "id", "android");
    return (TextView) findViewById(id);
}

Then, just initialize alpha value on this View

MainActivity.java
1
getActionBarTitleView().setAlpha(0f);

On ListView scroll, you have to fade the title depending on the header translation ratio

MainActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            float ratio = clamp(mHeader.getTranslationY() / mMinHeaderTranslation, 0.0f, 1.0f);
            //actionbar title alpha
            getActionBarTitleView().setAlpha(clamp(5.0F * ratio - 4.0F, 0.0F, 1.0F));
        }
    });

Alpha values: f(x) = 5x-4

The clamp method is basic Math method (see Wikipedia)

Clamp.java
1
2
3
public static float clamp(float value, float max, float min) {
    return Math.max(Math.min(value, min), max);
}

A better solution by Cyril Mottier

First, we don’t need to get the ActionBar title view reference anymore. We are going to use a SpannableString with a custom ForegroundColorSpan. We need a custom ForegroundColorSpan to be able to set the color alpha.

AlphaForegroundColorSpan.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
public class AlphaForegroundColorSpan extends ForegroundColorSpan {

    private float mAlpha;

    public AlphaForegroundColorSpan(int color) {
        super(color);
        }
        […]

        @Override
        public void updateDrawState(TextPaint ds) {
                ds.setColor(getAlphaColor());
        }

    public void setAlpha(float alpha) {
        mAlpha = alpha;
    }

    public float getAlpha() {
        return mAlpha;
    }

    private int getAlphaColor() {
        int foregroundColor = getForegroundColor();
        return Color.argb((int) (mAlpha * 255), Color.red(foregroundColor), Color.green(foregroundColor), Color.blue(foregroundColor));
    }
}

On ListView scroll, we set the alpha value on the AlphaForegroundColorSpan object and finally call ActionBar#setTitle to refresh the title view. Notice that you must keep the same AlphaForegroundColorSpan and SpannableString instances for performance (avoiding GC).

MainActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            float ratio = clamp(mHeader.getTranslationY() / mMinHeaderTranslation, 0.0f, 1.0f);
            //actionbar title alpha
            setTitleAlpha(clamp(5.0F * ratio - 4.0F, 0.0F, 1.0F));
        }
    });

private void setTitleAlpha(float alpha) {
        mAlphaForegroundColorSpan.setAlpha(alpha);
        mSpannableString.setSpan(mAlphaForegroundColorSpan, 0, mSpannableString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        getActionBar().setTitle(mSpannableString);
    }

Thanks to Cyril Mottier for pointing this out. It’s a very elegant solution!

Moving & scaling the icon

You need to retrieve the icon View to make a translation. This view can be retrieve using the classical findById method.

MainActivity.java
1
2
3
private ImageView getActionBarIconView() {
    return (ImageView) findViewById(android.R.id.home);
}

Then, set a transparent icon on the ActionBar

MainActivity.java
1
2
ActionBar actionBar = getActionBar();
actionBar.setIcon(R.drawable.ic_transparent);

On ListView scroll, you have to move & scale depending on the header translation ratio. The main principle is to make a “diff” between the transparent (i.e. invisible) ActionBar icon view and the header logo view. This “diff” is calculated using the Rect on screen of the 2 views and results in scaleX, scaleY, translationX and translationY values. Finally, those values are used to translate and scale the header logo view until it matches the transparent ActionBar icon view.

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
mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            float ratio = clamp(mHeader.getTranslationY() / mMinHeaderTranslation, 0.0f, 1.0f);
            //move & scale

            interpolation = mAccelerateDecelerateInterpolator.getInterpolation(ratio);

            View actionBarIconView = getActionBarIconView();

            getOnScreenRect(mRect1, mHeaderLogo);
            getOnScreenRect(mRect2, actionBarIconView);

            float scaleX = 1.0F + interpolation  (mRect2.width() / mRect1.width() - 1.0F);
            float scaleY = 1.0F + interpolation  (mRect2.height() / mRect1.height() - 1.0F);
            float translationX = 0.5F  (interpolation  (mRect2.left + mRect2.right - mRect1.left - mRect1.right));
            float translationY = 0.5F  (interpolation  (mRect2.top + mRect2.bottom - mRect1.top - mRect1.bottom));

            mHeaderLogo.setTranslationX(translationX);
            mHeaderLogo.setTranslationY(translationY - mHeader.getTranslationY());
            mHeaderLogo.setScaleX(scaleX);
            mHeaderLogo.setScaleY(scaleY);
        }
    });

Notice that you can use an AccelerateDecelerateInterpolator on the ratio to make this animation smoother

BONUS: Ken Burns animation

Currently working on this part. The code is already available KenBurnsView.java

Conclusion

As it’s said here, it’s always (with a few different details) the same trick called synchronized scrolling. The true genius of this effect is to have thought about it!

Comments