Animation / AppBar / AppBarLayout / Material Design

AppBar – Part 2

The AppBar is an evolution of the ActionBar which provides us with a rich toolbox for defining and customising the behaviour. All apps will have different requirements for their AppBars and each app may have different AppBar implementations in different Activities. In this series, rather than look at the AppBar implementations themselves, we’re going to explore how can integrate and animate different elements of the AppBar to provide smooth UI and UX which implement material design style animations and transitions.

The next thing we’re going to turn our attention to is changing the feature image in out collapsing Toolbar when the user swipes between pages in the ViewPager. The first thing we need to do is update the PagerAdapter to enable us to look up an appropriate image resource ID for each page:

public class SectionsPagerAdapter extends FragmentPagerAdapter {
    private static final Section[] SECTIONS = {
            new Section("SECTION 1", R.drawable.beach_huts),
            new Section("SECTION 2", R.drawable.hoveton),
            new Section("SECTION 3", R.drawable.needles)
    };

    public SectionsPagerAdapter(FragmentManager fm) {
        super(fm);
    }

    @Override
    public Fragment getItem(int position) {
        return SimpleFragment.newInstance(position + 1);
    }

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

    @Override
    public CharSequence getPageTitle(int position) {
        return getSection(position).getTitle();
    }

    @DrawableRes
    public int getImageId(int position) {
        return getSection(position).getDrawableId();
    }

    private Section getSection(int position) {
        return SECTIONS[position];
    }

    private static class Section {
        private final String title;
        private final @DrawableRes int drawableId;

        private Section(String title, int drawableId) {
            this.title = title;
            this.drawableId = drawableId;
        }

        public String getTitle() {
            return title;
        }

        public int getDrawableId() {
            return drawableId;
        }
    }
}

I won’t give a detailed explanation of this because it is a pretty basic PagerAdapter implementation – we’re just adding a method named getImageId which will return a resource ID of the image for that position. We could have got away with using a Pair<String, Integer>Section class, but we’re going to be adding further to this, so I’ve opted to use a class instead.

The next thing we need to do is change our layout to add a second image so that we can transition between the image for the outgoing page and the incoming page. This second image will only ever be visible during the actual transitions:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.AppBarLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/appbar"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:fitsSystemWindows="true"
  android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

  <android.support.design.widget.CollapsingToolbarLayout
    android:layout_width="match_parent"
    android:layout_height="400dp"
    android:fitsSystemWindows="true"
    app:contentScrim="?attr/colorPrimary"
    app:expandedTitleMarginEnd="@dimen/activity_horizontal_margin"
    app:expandedTitleMarginStart="@dimen/activity_horizontal_margin"
    app:layout_scrollFlags="scroll|exitUntilCollapsed">

    <FrameLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="center_horizontal"
      android:clipChildren="false"
      android:fitsSystemWindows="true"
      app:layout_collapseMode="parallax">

      <ImageView
        android:id="@+id/toolbar_image_outgoing"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:adjustViewBounds="true"
        android:contentDescription="@null"
        android:fitsSystemWindows="true"
        android:scaleType="centerCrop"
        android:visibility="gone" />

      <ImageView
        android:id="@+id/toolbar_image"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:adjustViewBounds="true"
        android:contentDescription="@null"
        android:fitsSystemWindows="true"
        android:scaleType="centerCrop"
        android:src="@drawable/beach_huts" />
    </FrameLayout>

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      app:layout_collapseMode="pin"
      app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

  </android.support.design.widget.CollapsingToolbarLayout>

  <android.support.design.widget.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:layout_gravity="bottom"
    app:layout_scrollFlags="scroll" />

</android.support.design.widget.AppBarLayout>

Next we need to add a ViewPager.OnPageChangeListener which will track the movement between pages, and trigger and manage the appropriate transitions:

public class PageChangeListener implements ViewPager.OnPageChangeListener {

    private final ImageAnimator imageAnimator;

    private int currentPosition = 0;
    private int finalPosition = 0;

    private boolean isScrolling = false;

    public static PageChangeListener newInstance(SectionsPagerAdapter pagerAdapter, ImageView imageView, ImageView outgoing) {
        ImageAnimator imageAnimator = new ImageAnimator(pagerAdapter, imageView, outgoing);
        return new PageChangeListener(imageAnimator);
    }

    PageChangeListener(ImageAnimator imageAnimator) {
        this.imageAnimator = imageAnimator;
    }

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        if(isFinishedScrolling(position, positionOffset)) {
            finishScroll(position);
        }
        if (isStartingScrollToPrevious(position, positionOffset)) {
            startScroll(position);
        } else if (isStartingScrollToNext(position, positionOffset)) {
            startScroll(position + 1);
        }
        if (isScrollingToNext(position, positionOffset)) {
            imageAnimator.forward(position, positionOffset);
        } else if (isScrollingToPrevious(position, positionOffset)) {
            imageAnimator.backwards(position, positionOffset);
        }
    }

    public boolean isFinishedScrolling(int position, float positionOffset) {
        return isScrolling && (positionOffset == 0f && position == finalPosition) || !imageAnimator.isWithin(position);
    }

    private boolean isScrollingToNext(int position, float positionOffset) {
        return isScrolling && position >= currentPosition && positionOffset != 0f;
    }

    private boolean isScrollingToPrevious(int position, float positionOffset) {
        return isScrolling && position != currentPosition && positionOffset != 0f;
    }

    private boolean isStartingScrollToNext(int position, float positionOffset) {
        return !isScrolling && position == currentPosition && positionOffset != 0f;
    }

    private boolean isStartingScrollToPrevious(int position, float positionOffset) {
        return !isScrolling && position != currentPosition && positionOffset != 0f;
    }

    private void startScroll(int position) {
        isScrolling = true;
        finalPosition = position;
        imageAnimator.start(currentPosition, position);
    }

    private void finishScroll(int position) {
        if (isScrolling) {
            currentPosition = position;
            isScrolling = false;
            imageAnimator.end(position);
        }
    }

    @Override
    public void onPageScrollStateChanged(int state) {
        //NO-OP
    }

    @Override
    public void onPageSelected(int position) {
        if (!isScrolling) {
            isScrolling = true;
            finalPosition = position;
            imageAnimator.start(currentPosition, position);
        }
    }
}

The main workhorse here is the onPageScrolled() method which will be called whenever the ViewPager scroll position changes in response to a drag or swipe by the user. It essentially manages a state machine whose state changes depending on whether a scroll is in progress or not. The first three conditionals check for changes in state, and the final two apply an appropriate movement depending on the direction of scrolling.

There are a number of methods which simply encapsulate some boolean logic which return things like whether the scroll has finished, or the scroll direction. These are simply to improve readability of the code – the actual boolean logic itself requires some study and understanding, so by replacing this with a meaningful method name instead of the raw logic it make it easier to understand the flow through onPageScrolled().

Similarly there are two methods for starting and stopping the scroll which set the state appropriately.

Finally there are a couple more interface methods – onPageScrollStateChanged() which we’re not interested in because we are detecting state changes ourself. The reason for this is handling some edge cases where the user begins dragging in one direction, and then changes direction without lifting.

The final method is onPageSelected() which gets called if the user taps on one of the tabs rather than dragging or swiping the ViewPager. In this case we actually want to smoothly transition from the image for the start position directly to the image in the end position. If we were to omit this, we might see a strobing effect when we pass though many different positions because we would load each of the images in the positions between the start and end position.

An alternative approach for managing transition is to use ViewPager.PageTransformer instead of ViewPager.OnPageChangeListener. The reason I have opted for this approach is that PageTransformer is well suited for handling transitions between the different pages within the ViewPager itself, but OnPageChangeListener gives us a little finer-grained control when it comes to applying the transition to object external to the ViewPager.

So PageChangeListener is responsible for managing the scroll state, but does not actually perform the transitions. This separation of concerns is useful because we can reuse this logic an just apply different ImageAnimator implementations – and it’s the ImageAnimator which actually manages the transition itself. So let’s take a look at this:

class ImageAnimator {
    private final SectionsPagerAdapter pagerAdapter;
    private final ImageView outgoing;
    private final ImageView imageView;

    private int actualStart;
    private int start;
    private int end;
    private float positionFactor;

    public ImageAnimator(SectionsPagerAdapter pagerAdapter, ImageView imageView, ImageView outgoing) {
        this.pagerAdapter = pagerAdapter;
        this.imageView = imageView;
        this.outgoing = outgoing;
    }

    public void start(int startPosition, int finalPosition) {
        actualStart = startPosition;
        start = Math.min(startPosition, finalPosition);
        end = Math.max(startPosition, finalPosition);
        @DrawableRes int incomingId = pagerAdapter.getImageId(finalPosition);
        positionFactor = 1f / (end - start);
        outgoing.setImageDrawable(imageView.getDrawable());
        outgoing.setVisibility(View.VISIBLE);
        outgoing.setAlpha(1f);
        imageView.setImageResource(incomingId);
    }

    public void end(int finalPosition) {
        @DrawableRes int incomingId = pagerAdapter.getImageId(finalPosition);
        imageView.setTranslationX(0f);
        if (finalPosition == actualStart) {
            imageView.setImageDrawable(outgoing.getDrawable());
            outgoing.setAlpha(1f);
        } else {
            imageView.setImageResource(incomingId);
            outgoing.setVisibility(View.GONE);
            imageView.setAlpha(1f);
        }
    }

    public void forward(int position, float positionOffset) {
        float offset = getOffset(position, positionOffset);
        imageView.setAlpha(offset);
    }

    public void backwards(int position, float positionOffset) {
        float offset = getOffset(position, positionOffset);
        imageView.setAlpha(1 - offset);
    }

    private float getOffset(int position, float positionOffset) {
        int positions = position - start;
        return Math.abs(positions * positionFactor + positionOffset * positionFactor);
    }

    public boolean isWithin(int position) {
        return position >= start && position < end;
    }
}

This will perform an alpha fade between two images.The outgoing image is drawn behind the incoming image because of the positioning within the layout, and we simply change the alpha value of the incoming image depending on the position within the scroll. start() and end() are called to intialise and clean things up at the start and end of the scroll. Then forwards() and backwards() are called to either fade in or out depending on the direction of scroll. There is a small piece of complexity because of the case we discussed previously where we may be traversing a number of separate positions when the user tabs on a tap rather than swiping, but the getOffset() method maps the position & positionOffset values to a simple float with a range of 0.0-1.0 where 0.0 is the start position, 1.0 is the end position and intermediate values are the mid points during the transition.

All that remains is to actually hook up PageChangeListener:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    final ImageView toolbarImage = (ImageView) findViewById(R.id.toolbar_image);
    final ImageView outgoingImage = (ImageView) findViewById(R.id.toolbar_image_outgoing);

    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    SectionsPagerAdapter sectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());

    ViewPager viewPager = (ViewPager) findViewById(R.id.container);
    viewPager.setAdapter(sectionsPagerAdapter);
    viewPager.addOnPageChangeListener(PageChangeListener.newInstance(sectionsPagerAdapter, toolbarImage, outgoingImage));

    TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs);
    tabLayout.setupWithViewPager(viewPager);

    DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
    ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
            this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
    );
    drawer.setDrawerListener(toggle);
    toggle.syncState();

    NavigationView navigationView = (NavigationView) findViewById(R.id.nav_view);
    navigationView.setNavigationItemSelectedListener(this);
}

If we run this we can see that we get a nice alpha fade between the images when we drag and the transition follows the movement of the drag, and we also get a smooth transition when tapping the tabs:

That’s nice – but we can do better. In the next article we’ll look at adding some motion to the transition.

The source code for this article is available here.

© 2015, Mark Allison. All rights reserved.

Copyright © 2015 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

1 Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.