Creating a morphing, material-style header for your Android app
A sample app for this post is available here: https://gitlab.com/noengblog/Collapsable_Header_Android_Sample.git
One of the telltale features of material design to me are the collapsable headers as seen in Telegram or Viadi or WhatsApp.
I find myself playing around with the headers quite often, slowly expanding and collapsing them and just studying how they work. Well this odd fascination of mine came in handy when I had to design a collapsable header for one of Noser’s clients. I analyzed the solutions of Viadi and Telegram and tried to think of the best way to solve it. In this post I will demonstrate my solution to the collapsable header problem along with some explanations as to why I chose to implement it in the way that I did. It goes without saying that this solution is by no means the silver bullet to the problem and that there may be other solutions out there that may suit your needs more. However, I do feel that my way offers quite a bit of flexibility and also seems fairly straightforward.
The above screenshots show the two states of the search mask in the sample application. The first screenshot shows the expanded search mask whereas the second screenshot shows the collapsed search mask. The idea is that as the user scrolls down, the expanded header is slowly collapsed to a smaller version. This transition happens as the user is scrolling. The header should morph seamlessly from the expanded state to the collapsed state and vice versa.
The concept
In order to create a nice morphing effect, you attach an OnScrollListener to the RecyclerView and perform the morph as the user scrolls. The following diagram shows the search mask in its expanded state.
As the user scrolls down the RecyclerView, the expanded header begins to fade away while the collapsed header begins to fade in. At the same time, the background color that the collapsed header will have, begins to fade in over the entire search mask.
Finally when the user has completely collapsed the header, only the collapsed version along with the background color are visible.
The Activity
The following code snippet shows the onCreate method of our activity which contains the search mask.
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mSearchButton = (Button) findViewById(R.id.searchButton); mRecyclerView = (RecyclerView) findViewById(R.id.recyclerView); mSearchMask = (RelativeLayout) findViewById(R.id.searchMask); mSearchMaskExpanded = (LinearLayout) findViewById(R.id.searchMaskExpanded); mSearchMaskCollapsedBackground = findViewById(R.id.searchMaskCollapsedBackground); mSearchMaskCollapsed = (LinearLayout) findViewById(R.id.searchMaskCollapsed); mCollapsedArtistText = (TextView) findViewById(R.id.collapsedArtistText); mCollapsedAlbumText = (TextView) findViewById(R.id.collapsedAlbumText); mOverViewModels = new ArrayList<>(); mAdapter = new MusicPlayerAdapter(this, mOverViewModels, new MusicPlayerAdapter.Listener() { @Override public void onItemClicked(int position) { } }); initializeRecyclerView(); mSearchButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { performSearch(); } }); mSearchMaskCollapsed.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { snapToExpandedSearchMask((int) mSearchMaskScrollOffset); } }); findViewById(R.id.contentView).getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { mExpandedSearchMaskHeight = mSearchMaskExpanded.getHeight(); mInitialExpandedSearchMaskY = (int) mSearchMask.getY(); } }); }
At first we initialise our view elements. Notice that we are initialising a searchMask, searchMaskExpanded, searchMaskCollapsedBackground, and searchMaskCollapsed view. The following diagram elaborates on what those views represent.
In the above diagram, searchMask is the part surrounded by the dashed line. searchMaskExpanded is the orange part that’s being pushed out of the screen. searchMaskCollapsedBackground is not visible in this diagram but it is as big as the searchMask. searchMaskCollapsed is the blue box in the diagram.
After view initialisation, we set up some basic listeners. The important listeners are the ones attached to the mSearchMaskCollapsed and the GlobalLayoutListener. The listener attached to the mSearchMaskCollapsed simply expands the search mask if the user taps on the collapsed header. The GlobalLayoutListener is called when layout is complete (and when the layout changes) and stores the height of the expanded search mask as well as the initial y position of the expanded search mask. The reason why these are set in the GlobalLayoutListener is because the height as well as the position of the views are not known until layout has completed.
Within the onCreate method we call the initializeRecyclerView() method. This method looks as follows:
private void initializeRecyclerView() { mRecyclerView.setAdapter(mAdapter); final LinearLayoutManager layoutManager = new LinearLayoutManager(this); layoutManager.setOrientation(LinearLayoutManager.VERTICAL); mRecyclerView.setLayoutManager(layoutManager); mRecyclerView.setOnScrollListener(new MyScrollListener()); }
Important in this method is the attaching of our custom ScrollListener. That scroll listener performs all of the magic of the morphing. It looks as follows:
private class MyScrollListener extends RecyclerView.OnScrollListener { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); mSearchMaskScrollOffset += dy; expandOrCollapseSearchMask(); } @Override public void onScrollStateChanged(RecyclerView recyclerView, int newState{ switch (newState) { case RecyclerView.SCROLL_STATE_IDLE: //Snap into place int expandedMaskBottom = (int) (mInitialExpandedSearchMaskY + mSearchMask.getHeight()); int collapsedMaskBottom = (int) (mInitialExpandedSearchMaskY + mSearchMaskCollapsed.getHeight()); int currentMaskBottom = (int) (mSearchMask.getY() + mSearchMask.getHeight()); int distanceToExpandedState = Math.abs(currentMaskBottom - expandedMaskBottom); int distanceToCollapsedState = Math.abs(currentMaskBottom - collapsedMaskBottom); Log.e("", "Distance To Exp: " + distanceToExpandedState + " DistToColl: " + distanceToCollapsedState); if (distanceToExpandedState <= distanceToCollapsedState) { //Snap to expanded state Log.e("", "Snap to expanded state: " + distanceToExpandedState); snapToExpandedSearchMask(distanceToExpandedState); } else { //Snap to collapsed state Log.e("", "Snap to collapsed state: " + distanceToCollapsedState); snapToCollapsedSearchMask(distanceToCollapsedState); } break; } } }
Our scroll listener implements two methods of the RecyclerView.OnScrollListener class: the onScrolled() and the onScrollStateChanged() methods. The onScrolled() method gets called each time the scroll position of the RecyclerView changes. This gets called many, many times while you scroll. The onScrollStateChanged method gets called when the scrolling state changes, as the name implies.
In our onScrolled()method we store our scrollOffset. This tracks our current, vertical offset caused by the scroll. We need this to perform the morphing. It then calls the expandOrCollapseSearchMask() method. The onScrollStateChanged() method is used to create a nice snapping effect. If I fling my RecyclerView so that my header is partially expanded by the time the scrolling has come to a stop, I want the header to snap to its collapsed or expanded state, depending on what state it is closer to at the end of the fling. The onScrollStateChanged() method does exactly this. It checks when the scrolling has come to a stop and checks to see if the search mask is closer to being fully collapsed or to being fully expanded. Depending on which it is closer to, it either fully collapses or expands the search mask.
I know I said that our custom ScrollListener is responsible for all of the magic of the morphing. This is true but only with the help of the expandOrCollapseSearchMask() method. This method is where all of the number crunching takes place to create our morph. Let’s look at that method.
private void expandOrCollapseSearchMask() { int differenceCollapsedAndExpandedMasks = (int) (mExpandedSearchMaskHeight - mSearchMaskCollapsed.getHeight()); float ratio = mSearchMaskScrollOffset / differenceCollapsedAndExpandedMasks; float higherRatio = ratio * 4; ratio = 1 - ratio; higherRatio = 1 - higherRatio; float scrollOffset = mSearchMaskScrollOffset <= differenceCollapsedAndExpandedMasks ? mSearchMaskScrollOffset : differenceCollapsedAndExpandedMasks; mSearchMask.setTranslationY(-scrollOffset); mSearchButton.setTranslationY(-scrollOffset); mSearchButton.setAlpha(ratio); mSearchMaskExpanded.setAlpha(higherRatio); mSearchMaskCollapsedBackground.setAlpha(1 - ratio); mSearchMaskCollapsed.setAlpha(1 - (ratio)); if (mSearchMaskExpanded.getAlpha() == 0) { mSearchMaskExpanded.setVisibility(View.INVISIBLE); } else { mSearchMaskExpanded.setVisibility(View.VISIBLE); } if (mSearchMaskCollapsed.getAlpha() == 0) { mSearchMaskCollapsed.setVisibility(View.INVISIBLE); } else { mSearchMaskCollapsed.setVisibility(View.VISIBLE); } }
This method calculates the scroll offset and the ratio of the amount the user has scrolled to the amount the user needs to scroll to expand or collapse the header. The scroll offset and the ratio are then used to calculate the y translations and the alpha levels of the components of the search mask to create the morphing effect. In other words, the y translations as well as the alpha levels of the components of the search mask are proportional to the distance the user has scrolled. For the elements that need to be faded in, the inverse of the ratio is applied. The higherRatio is used to have some elements fade in or out faster than others.
The RecyclerView
The RecyclerView is used to display the search results in the sample app. A scroll listener which performs the expanding/collapsing of the header is attached to the RecyclerView. This is the part where things get a bit tricky. The RecyclerView contains items for displaying the search results but it also contains one placeholder item that mirrors the search mask. The reason why this was done is so that the RecyclerView could be made to fit the whole screen while still having the search results appear below the search mask. Furthermore, it prevents the search results from disappearing behind search mask until the mask has collapsed. Consider the following two diagrams.
The above diagram shows what would happen if the RecyclerView was positioned below the search mask. When the user scrolls, the search mask would collapse. However, as the search mask was collapsed, the RecyclerView would remain in place. This creates a gap between the RecyclerView and the search mask. At this point you might argue that you could apply the same translation that you apply to the search mask to the RecyclerView. You could indeed do this but this will create a nasty jitter effect because as your scroll listener responds to a scroll event, you translate the view in which you are scrolling. As the view gets translated, the scroll events will interfere with the new position of the view, firing additional scroll events. Therefore this solution is not ideal. The next diagram shows what would happen if we kept the RecyclerView full screen.
In this solution, as the search mask is collapsed, the search results displayed in the RecyclerView continue to appear right below the search mask. It is not necessary to translate the RecyclerView as it’s being scrolled which prevents the jitter effect. The challenge we face now is having the search results appear precisely below the search mask. The following section will describe how this is achieved.
The Adapter and the View Model
The RecyclerView uses an Adapter to populate its rows. The adapter accesses a list of objects that contain the data and then displays that data in a layout. The individual objects in the adapter are what I call view models and are of type MusicPlayerOverviewViewModel. This type contains properties for the artist name, song name, album name and duration. It also contains a type property which specifies what kind of item this represents. The type can either be a SearchResult or a SearchMaskPlaceholder. Based on this type property, the adapter displays either a row that will contain data for a single search result or a placeholder that mirrors the search mask. Consider the following code:
@Override public void onBindViewHolder(ViewHolder holder, int position) { holder.mSearchMaskPlaceholder.setVisibility(View.GONE); holder.mSearchResultItem.setVisibility(View.GONE); if (mOverviewItems.get(position).getItemType() == MusicPlayerOverviewViewModel.ItemType.SearchMaskPlaceholder) { holder.mSearchMaskPlaceholder.setVisibility(View.VISIBLE); } else { holder.mSearchResultItem.setVisibility(View.VISIBLE); setRowLabels(holder, position); } }
This method is called for each row that the RecyclerView needs to display. The adapter uses the view model to decide what kind of row to display. If the view model is of type SearchMaskPlaceholder, the adapter displays a search mask place holder item. If the item’s type is not SearchMaskPlaceholder, the adapter displays the search result item. The following xml layout is used by the adapter to display the rows of the RecyclerView:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <RelativeLayout android:id="@+id/searchMaskPlaceholder" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingBottom="@dimen/space.small" android:background="@color/snow"> <include android:layout_width="match_parent" android:layout_height="wrap_content" layout="@layout/layout_search_mask"/> </RelativeLayout> <RelativeLayout android:id="@+id/searchResultItem" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="@dimen/space.large"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_toLeftOf="@+id/durationText" android:layout_toStartOf="@id/durationText" android:orientation="vertical"> <TextView android:id="@+id/artistText" style="@style/Widget.SearchResult.TextView.Black" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="Artist" /> <TextView android:id="@+id/albumText" style="@style/Widget.SearchResult.TextView.Black" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="Album" /> <TextView android:id="@+id/songText" style="@style/Widget.SearchResult.TextView.Black" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="Song" /> </LinearLayout> <TextView android:id="@+id/durationText" style="@style/Widget.SearchResult.TextView.Gray" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_alignParentEnd="true" android:padding="@dimen/space.small" tools:text="3:09" /> </RelativeLayout> </LinearLayout>
The xml layout consists of a LinearLayout with two nested RelativeLayouts. The first RelativeLayout is the searchMaskPlaceholder. It includes the same search mask layout that’s used to display the search mask in the main activity. When the adapter receives an item of type SearchMaskPlaceholder, it makes this RelativeLayout visible. The second RelativeLayout is the searchResultItem. This layout contains all of the views necessary to display a single row within the list of search results. The adapter displays this layout for all of its items that are not of type SearchMaskPlaceholder.
Conclusion
The fancy expandable and collapsable header, while not necessary, adds just the flash and pizazz that sets your app apart from others. If you use view models to display elements in your UI, this approach will probably also not take all that much time to integrate into your existing solution. While there may be other ways to achieve this, I found this solution to be quite robust and elegant. Yes, the mirroring of the search mask within the RecyclerView might seem counterintuitive at first but it actually provides quite a bit of flexibility. Consider a header that grows in size. Using this approach, you could store meta data in the view model that tells the adapter how much the header has grown. This would ensure that no matter how large your header got, it would still collapse and expand smoothly because the mirrored header element in your RecyclerView would grow as well.
I hope you enjoyed this post and I hope this solution helps whoever is trying to implement a collapsable and expandable header.