Toolbar Delight. In this article we explain how and why… | by Juhani Lehtimäki

In this article we explain how and why we did our Social Steps app custom toolbar from implementation point of view.

Adding delightful details to your user interface is a great way to push your app above competition (assuming, of course, that all the important functionality exists and is well designed).

Toolbar is a playground on Android. We decided to fully utilise it in playful but meaningful animations and state changes.

The design for this feature, like for the rest of the Social Steps app, was done by Pierluigi Rufo. Pier has promissed to write about the design side in much more detail soon. Stay tuned!

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.

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>

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.

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)

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.

LinearGradient(0f, 0f, scale * width, scale * height, calculateColour1(), calculateColour2(), Shader.TileMode.CLAMP)

The so called Android fragmentation is causing problems only to non-Android developers (because they do not know better). We, as Android devs, know how to handle multiple screen sizes. Together with capable designers we do not have to lock in devices in one orientation or prevent installation to tablets (or Chromebooks).

When thinking about animations, gradients, etc always keep scalability in mind from the beginning. Adding scalability later is difficult.

It’s simple. Instead of using fixed assets for gradients, draw them in code. Instead of thinking how wide your screen is, use the values provided by the Android OS. Use percentages as much as possible and whenever absolute values are needed, remember to use DiPs.

That’s it really. As you see, in our case the app is not locked into one orientation, nor do we block tablets from installing it.

On a phone both orientations.
On a Chromebook. Might not look perfect, but it still works!

Often, building complex looking designs are all about finding an easy way to cheat but still making things look exactly like the design and splitting the problem in manageable chunks. In this case using an oval instead of trying to cut a hole to the toolbar saves a lot of sweat and time.

Simplifying the time scale and scrolling scale to simple 0–1 ranges allowed me to concentrate on implementation in this limited problem space without worrying about external factors. In fact in the app day and night are not equal length as they do not have to be. Passing time does not match to the time scale variable 1-to-1. We can later very easily connect real sun position to the existing code without any problems.

PS. I’m new to Kotlin. Big parts of the example code was converted from Java code and manually tweaked after. It is unlikely that the examples follow the best Kotlin programming style but the idea should be conveyed anyways.

PPS. Social Steps is a free (not even ads :-O ) app on Google Play Store: https://play.google.com/store/apps/details?id=com.socialstepsapp.android Give it a try and see the an custom toolbar yourself. It’s also available on iOS!


Juhani Lehtimäki

Source link