Implementation

Android’s UI framework is extremely powerful and flexible. If you take the time to learn what you can do with it you’ll be adding a very powerful tool to your toolbox. Personally, I believe native Android UI being the most powerful prototyping tool currently available. Nearly everything your designer comes up with you can implement in matter of hours (or at least create an approximation of the intended feature).

This flexibility extends to proper, scalable, implementations of production-ready features. In our Social Steps app the toolbar was the obvious place where to push the brand and user delight aspects of the app.

To maintain scalability, scrolling containers are very commonplace in Android screens. So much so that Google introduces special components for developers to be able to add interesting and useful behaviour to the Android toolbar: AppBarLayout and CollapsingToolbarLayout.

With the two above components and a small custom view it’s possible to work magic on your toolbar design.

Tracking scrolling events

AppBarLayout.OnOffsetChangedListener

This is the tool you can use to get a handle to events when user scrolls your main view (collapses your toolbar).

This code is in my main Activity but it works as well in a Fragment if your toolbar is defined in one.

appbarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {

internal var scrollRange = -1



override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {

//Initialize the size of the scroll

if (scrollRange == -1) {

scrollRange = appBarLayout.totalScrollRange

}





val scale = 1 + verticalOffset / scrollRange.toFloat()



toolbarArcBackground.setScale(scale)



if (scale <= 0) {

appbarLayout.elevation = toolbarElevation

} else {

appbarLayout.elevation = 0f

}



}

})

This code has a very simple responsibility -> calculate the current scale of the scrolling container in terms of percentage. This code has no idea what it is used for but it simply calculates it and passes the value to my custom view (see below).

Ah, also. This code takes care of the toolbar elevation when the whole toolbar is collapsed.

The layout simple (this probably could be optimised a bit). My custom ToolbarArcBackground is what does most of the heavy lifting here. Rest are either standard Android.. Other than the NonClickableToolbar which is needed here to make the collapsing toolbar work. It doesn’t do anything else.

<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"

xmlns:app="http://schemas.android.com/apk/res-auto"

xmlns:tools="http://schemas.android.com/tools"

android:id="@+id/rootLayout"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:background="@color/content_background">





<android.support.design.widget.CoordinatorLayout

android:layout_width="match_parent"

android:layout_height="match_parent">





<android.support.design.widget.AppBarLayout

android:id="@+id/appbarLayout"

android:layout_width="match_parent"

android:layout_height="wrap_content"

app:elevation="0dp">





<android.support.design.widget.CollapsingToolbarLayout

android:id="@+id/collapsing_toolbar"

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:fitsSystemWindows="true"

app:layout_scrollFlags="scroll|exitUntilCollapsed">



<FrameLayout

android:id="@+id/collapsing_content"

android:layout_width="match_parent"

android:layout_height="160dp"

app:layout_collapseMode="pin">



<com.socialstepsapp.socialsteps.widget.

ToolbarArcBackground

android:id="@+id/toolbarArcBackground"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:background=

"@color/content_background" />





</FrameLayout>





<com.socialstepsapp.socialsteps.widget.

NonClickableToolbar

android:layout_width="match_parent"

android:layout_height="?attr/actionBarSize"

android:layout_marginTop="24dp" />





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





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



<android.support.v4.widget.NestedScrollView

android:id="@+id/scroll_view"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:fillViewport="true"

app:layout_behavior=

"@string/appbar_scrolling_view_behavior"> <!-- Here's some views of the app logic -->

</android.support.v4.widget.NestedScrollView>

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





<android.support.v7.widget.Toolbar

android:id="@+id/toolbar"

android:layout_width="match_parent"

android:layout_height="?attr/actionBarSize"

android:layout_marginTop="24dp"

android:background="#00000000"

android:elevation="0dp"> <!-- Here's couple of irrelevant views ->







</android.support.v7.widget.Toolbar>

</FrameLayout>

Implementing the arc

ToolbarArcBackground Custom View is where the magic happens. It’s a fairly simple subclass of the Android View. As we already have a component delivering us the scale (see above) only thing we need to do is to figure out how to draw what we want.

I experimented few different approaches to get the arc done well. My first approach was to use a Path to cut out the bottom part of my canvas. Unfortunately, it didn’t seem to be possible to make the path use anti-aliasing and the edge become jagged.

As with many things with UI details the best way often is the simplest.. i.e. cheating.

I took advantage of the fact that the main screen background was constant colour. The simple answer is to draw a white ellipse on top of everything else done in the toolbar content. :-)

An ellipse arcs off too sharp at the pointy ends so to avoid this I actually draw the ellipse slightly outside the view bounds on left and right.

The custom view’s setScale method simply stores the current value and invalidates the content.

fun setScale(scale: Float) {

this.scale = if (scale < 0) {

0f

} else {

scale

}



invalidate()

}

OnDraw then simply paints a suitable ellipse on bottom

override fun onDraw(canvas: Canvas) {

super.onDraw(canvas) // draw some other stuff here first canvas.drawOval(

(-extendOverBoundary).toFloat(), height - arcSize * scale,

(width + extendOverBoundary).toFloat(),

height + arcSize * scale,

ovalPaint)

}

As the scale approaches 0 when user scrolls at the transition point where the toolbar edge becomes straight the ellipse completely disappears.

Implementing clouds with scrolling

Clouds are simple bitmaps with a fixed starting location (although in the future I wouldn’t be surprised them to move based on current wind conditions ;). To get them to move out of the toolbar when collapsed I’ve simply precalculated a position I want them to be when the toolbar is collapsed and rest is simple multiplication using the scale.

canvas.drawBitmap(cloud1Bitmap, cloud1X + cloud1OffsetX * (1 - scale), cloud1Y + cloud1OffsetY * (1 - scale), bitmapPaint)

Implementing time-of-day change

In this first version time-of-day is simply based on time (matching real sun position would require user’s location and that’s not a permission we want to ask for yet).

And of course, while in the attached videos the time-of -day is animated, in the released version it will be nearly static.

To get things working reliably I added another scale to the toolbar view component, time scale. This is simply a number between 0 and 1 telling the view how far from left to right the sun or moon has travelled. isNight is a self explanatory. It defines which colour palette to use and which heavenly body to use.

fun setTimeScale(isNight: Boolean, timeScale: Float) {

this.timeScale = timeScale.coerceIn(0f, 1f)



this.isNight = isNight

invalidate()

}

The toolbar colour is adapted from few predefined colour points and interpolated using ArgbEvaluator.evaluate() and drawn as a background using gradient shader paint.

To improve the colour effect we added an interpolator value to the time scale value before colour calculations are done. This adds the dusk and dawn only to the early and late hours emulating real lighting more accurately.

private fun calculateColour2(): Int {

return colourEvaluator.evaluate(scale,

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_noon), calculateColour2Base())

as Int



}







private fun calculateColour2Base(): Int {



val interpolatedScale = interpolate(timeScale)



return if (isNight) {

when (interpolatedScale) {

in 0.0f..0.5f ->

colourEvaluator.evaluate(interpolatedScale * 2,

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_evening),

ContextCompat.getColor(context,

R.color.toolbar_ gradient_2_midnight)) as Int

else -> colourEvaluator.evaluate((interpolatedScale -

0.5f) * 2, ContextCompat.getColor(context,

R.color.toolbar_gradient_2_midnight),

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_morning)) as Int

}

} else {

when (interpolatedScale) {

in 0.0f..0.5f ->

colourEvaluator.evaluate(interpolatedScale * 2,

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_morning),

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_noon)) as Int

in 0.5f..0.75f ->

colourEvaluator.evaluate((interpolatedScale - 0.5f)

* 4, ContextCompat.getColor(context,

R.color.toolbar_gradient_2_noon),

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_noon_evening)) as Int

else -> colourEvaluator.evaluate((interpolatedScale -

0.75f) * 4, ContextCompat.getColor(context,

R.color.toolbar_gradient_2_noon_evening),

ContextCompat.getColor(context,

R.color.toolbar_gradient_2_evening)) as Int

}

}

}

For the evening colour we added one more manual point (0.75f) as the interpolated colour between noon and evening looked bad.

To make sure the toolbar always returns to the brand colour when collapsed second colour of the gradient also interpolates towards the brand colour based on the scroll scale.