Bottom Sheet like in a Google Task app

Alex Fialko
7 min readOct 27, 2019

--

Since i have started using Tasks app, animation of bottom sheet attracts me all the time. It looks like:

Disclaimer: We already have a great article about bottom sheet of Google’s task app on Medium, but it’s not about the animation part. So i decided to reproduce same design interaction. Here you can find my steps of achieving this and a result that can be used in your apps. You also should already be familiar with custom views and bottom sheet to understand the material below.

First of all, we need to make a transparent status bar and a simple bottom sheet. Let’s set correct activity’s windows flags and system ui visibility flags in activity’s onCreate:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
//make fullscreen, so we can draw behind status bar
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE

//make status bar color transparent
window.statusBarColor = Color.TRANSPARENT
var flags = window.decorView.systemUiVisibility
// make dark status bar icons
flags =
flags xor View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//make navigation bar color white
window.navigationBarColor = Color.WHITE
// make dark navigation bar icons
flags =
flags xor View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
window.decorView.systemUiVisibility = flags
}

Let’s make a bottom sheet:

<androidx.coordinatorlayout.widget.CoordinatorLayout 
android:id="@+id/bottom_sheet_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white">

//...
<LinearLayout
android:id="@+id/bottom_sheet_drawer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:fitsSystemWindows="false"
android:orientation="vertical"
app:behavior_hideable="true"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
//...your content
<LinearLayout/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Our drawer should be with fitsSystemWindows=”false”, because we want to handle drawing content under status bar by ourself.

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//don't forget about windows flags described above
setContentView(R.layout.activity_main_test)
}

It will look like:

We are still far from expected result.

There are 4 animation steps:

  1. Background shadow animation
  2. Dynamic corner’s radius
  3. HandleView Animation (view on the top of bottom sheet)
  4. Bottom sheet content transition upon expanding under the status bar

Let’s take a more detailed look about each step.

Background shadow animation

For shadow animation we just need to listen bottom sheet behavior and change alpha of view which is on the same level with root.

Add to existing CoordinatorLayout a simple View:

<View
android:id="@+id/touch_outside"
android:background="#80000000"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

And in Activity:

class MainActivity : AppCompatActivity() {    var behavior: BottomSheetBehavior<LinearLayout>
var drawer: LinearLayout
var coordinator: CoordinatorLayout
var shadow: View

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//don't forget about windows flags described above
setContentView(R.layout.activity_main_test)
coordinator = findViewById(R.id.bottom_sheet_coordinator)
drawer = coordinator.findViewById(R.id.bottom_sheet_drawer) as LinearLayout
behavior = BottomSheetBehavior.from(drawer)
shadow = coordinator.findViewById(R.id.touch_outside) behavior?.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(view: View, slideOffset: Float) {
//offset value [-1; 1]
var offset = slideOffset
//offset value [0; 2]
offset++
if (offset <= 1) {
shadow.alpha = offset
} else {
shadow.alpha = 1f
}
}

override fun onStateChanged(view: View, state: Int) {}
})
}

As you can see, we just listen behavior and change alpha of view

Dynamic corner’s radius

The idea is to draw a GradientDrawable as background of ViewGroup with behavior. So our ViewGroup will be something like:

class BottomDrawer: FrameLayout {

val defaultBackgroundDrawable = GradientDrawable()
val cornerRadiusDrawable = GradientDrawable()
//default corner array
val cornerArray: FloatArray =
floatArrayOf(
cornerRadius, cornerRadius, cornerRadius, cornerRadius, 0.0f, 0.0f, 0.0f, 0.0f
)

val cornerRadius: Float = 35dp.toFloat()// default corner radius
var defaultCorner = false

constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
: super(context, attrs, defStyleAttr) {

//we will draw on our ViewGroup
setWillNotDraw(false)

val color = Color.WHITE
cornerRadiusDrawable.setColor(color)
//set default color and corner radius (as 35dp)
defaultBackgroundDrawable.setColor(color)
defaultBackgroundDrawable.cornerRadii = cornerArray
onSlide(0f)
}

override fun onDraw(canvas: Canvas) {
if (!defaultCorner) {
//draw a dynamic corners on canvas of ViewGroup
cornerRadiusDrawable.draw(canvas)
}
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
cornerRadiusDrawable.bounds = rect
defaultBackgroundDrawable.bounds = rect

}
//we can call this from our BottomSheetBehaviorCallback
fun onSlide(value: Float) {
//if it less than 70% just draw default corner radius
if (value <= offsetTrigger) {
if (!defaultCorner) {
//default corners than expanded less than 70%
ViewCompat.setBackground(this, defaultBackgroundDrawable)
defaultCorner = true
invalidate()
}
return
}
//starts from here, we will draw dynamic corners
if (defaultCorner) {
ViewCompat.setBackground(this, null)
defaultCorner = false
}
/
/calculate offset (it's pretty simple, calculate a percent of expanding starting from 70%)
val offset = ((value - offsetTrigger) * (1f / (1f - offsetTrigger)))
val invert = 1.0f - offset
//calculate corner radius
val currentCornerRadius = cornerRadius * invert
val fArr = cornerArray
fArr[3] = currentCornerRadius
fArr[2] = currentCornerRadius
fArr[1] = currentCornerRadius
fArr[0] = currentCornerRadius
cornerRadiusDrawable.cornerRadii = fArr
invalidate()
}


companion object {
const val offsetTrigger: Float = 0.70f //start of expanding
}
}

So in this code, we just starting drawing a dynamic GradientDrawable with corner radius from 70 percent of expanding of bottom sheet.

So now, we can use this ViewGroup as container instead of our LinearLayout. Root will look like:

<androidx.coordinatorlayout.widget.CoordinatorLayout 
android:id="@+id/bottom_sheet_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white">

//...
<com.github.heyalex.bottomdrawer.BottomDrawer
android:id="@+id/bottom_sheet_drawer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:fitsSystemWindows="false"
app:behavior_hideable="true"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
//...your content
<com.github.heyalex.bottomdrawer.BottomDrawer/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Now call onSlide on drawer from BottomSheetCallback:

override fun onSlide(view: View, slideOffset: Float) {
//offset value [-1; 1]
var offset = slideOffset
//offset value [0; 2]
offset++
if (offset <= 1) {
shadow.alpha = offset
} else {
shadow.alpha = 1f
}
drawer.onSlide(offset / 2f) // divide offset to get a of [0;1]
}

HandleView

Now we need to make a view on the top of bottom sheet, which will collapsing during expanding bottom sheet.

We are already providing expanding percentage ratios for dynamic corners, the same value we need to pass to our HandleView, that will be drawn based on this values.

It’s pretty simple, a round rect, that can draw depends on opening of bottom sheet.

class HandleView : View {

var rect = RectF()
var tempRect: RectF = RectF()

var paint = Paint()
var thickness = 10dp.toFloat()

constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {

paint.apply {
color = Color.Black
strokeWidth = thickness
flags = Paint.ANTI_ALIAS_FLAG
}
}

override fun onDraw(canvas: Canvas) {
canvas.drawRoundRect(tempRect, thickness, thickness, paint)
}


override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
rect.set(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())
}

fun updateProgress(@FloatRange(from = 0.0, to = 1.0) value: Float) {
val offset = (width.toFloat() * value) / 2
tempRect.set(0 + offset, 0f, width - offset, height.toFloat())
invalidate()

}

Add this view into BottomDrawer:

<androidx.coordinatorlayout.widget.CoordinatorLayout 
android:id="@+id/bottom_sheet_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/darker_gray">

//...
<com.github.heyalex.bottomdrawer.BottomDrawer
android:id="@+id/bottom_sheet_drawer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:fitsSystemWindows="false"
android:orientation="vertical"
app:behavior_hideable="true"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<com.github.heyalex.handle.HandleView
android:layout_marginTop="7dp"
android:id="@+id/handle_view"
android:layout_gravity="top|center_horizontal"
android:layout_width="32dp"
android:layout_height="6dp"/>
<LinearLayout
android:layout_marginTop="12dp"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
//...your content <LinearLayout/>
<com.github.heyalex.bottomdrawer.BottomDrawer/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

So now with triggering updateProgress, we will have same animation of HandleView like in Google Tasks app.

override fun onSlide(view: View, slideOffset: Float) {
//offset value [-1; 1]
var offset = slideOffset
//offset value [0; 2]
offset++
if (offset <= 1) {
shadow.alpha = offset
} else {
shadow.alpha = 1f
}
// divide offset to get a [0;1]
val slide = offset / 2f
drawer.onSlide(slide)
//same calculation in BottomDrawer on dynamic corner
if (slide >= 0.7) {
val translation = ((slide - BottomDrawer.offsetTrigger) * (1f / (1f - BottomDrawer.offsetTrigger)))
handleView.updateProgress(translation)
} else {
handleView.updateProgress(0f)
}
}

Bottom sheet content transition upon expanding under the status bar

Last animation step is a little bit tricky. You can see that the BottomSheet is moving under the status bar while it’s content is moving down simultaneously. We can update a translation of content on status bar height.

First of all, we need to get a status bar height through WindowInsetsListener:

class MainActivity : AppCompatActivity() {

private var statusBarHeight: Int = 0
//...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main_test)
coordinator = findViewById(R.id.bottom_sheet_coordinator)
coordinator.setOnApplyWindowInsetsListener { v, insets ->
statusBarHeight = insets.systemWindowInsetTop

insets
}
//...
}
}

And in our onSlide:

override fun onSlide(view: View, slideOffset: Float) {
//offset value [-1; 1]
var offset = slideOffset
//offset value [0; 2]
offset++
if (offset <= 1) {
shadow.alpha = offset
} else {
shadow.alpha = 1f
}
// divide offset to get a [0;1]
val slide = offset / 2f
drawer.onSlide(slide)
//same calculation in BottomDrawer on dynamic corner
if(slide >= 0.7) {
val translation = ((slide - BottomDrawer.offsetTrigger) * (1f / (1f - BottomDrawer.offsetTrigger)))
handleView.updateProgress(translation)
//change translationY and padding of container
container.translationY = translation * statusBarHeight

container.setPadding(0, 0, 0, translation * statusBarHeight)
} else {
handleView.updateTranslation(0f)
container.translationY = 0f
container.setPadding(0, 0, 0, 0)
}
}

But after you change translationY, views, which is on the bottom of layout, will be hidden. So we need to set a bottom padding of container, that will push content higher on value of translation. Final result:

As you can see, through all these steps you can get same animations, but a lot of stuff is needed to be done. You can do all of it by yourself, but if you are lazy enough — you can use this, where I made everything in DialogFragment and wrapped into library. By this you can make your own HandleView, or use 2 types defined by library, set custom translation and corner radius, change color of bottom drawer etc...

--

--