Android Material Components: Exploring MaterialShapeDrawable

le 18/07/2019 par Pierre Degand
Tags: Software Engineering

Material Components is the (not so) new library made by the Material team to replace the old support design library. It provides components to apply Material Design in your application with ease. Among these components, you can find the famous FloatingActionButton, the CardView or the BottomSheet. But there are also some less known, nonetheless powerful, components. And one of them is the MaterialShapeDrawable.

Note: This article was written using the version 1.1.0-alpha07> of the Material Components. MaterialShapeDrawable existed in the 1.0.0 stable but the API changed quite a lot in the version 1.1.0-alpha01.

What is MaterialShapeDrawable ?

According to the documentation:

Base drawable class for Material Shapes that handles shadows, elevation, scale and color for a generated path.

And by Shape, they are referring to the Material Guidelines shapes: Rectangular shapes with curved or angled edges and/or corners...Material Shapes

In the build.gradle of your app, add the new dependency (or update it if you already use Material Components)

implementation 'com.google.android.material:material:1.1.0-alpha07'

We will start by creating a new MaterialShapeDrawable and setting it as the background of a TextView.

<TextView
  android:id="@+id/helloTV"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:layout_gravity="center"
  android:padding="8dp"
  android:textColor="@android:color/white"
  android:text="Hello world!" />
val shapeDrawable = MaterialShapeDrawable()
helloTextView.background = shapeDrawable

basic shape

The drawable supports a couple of customizations like:

  • Fill color
shapeDrawable.fillColor = ColorStateList.valueOf(colorAccent)
  • Strokes
shapeDrawable.strokeWidth = resources.getDimension(R.dimen.stroke_width)
shapeDrawable.strokeColor = ColorStateList.valueOf(colorPrimary)

If your view has elevation, a shadow will be cast on API above 21 like any other drawable. But this drawable supports a compatibility version of shadows for devices running API lower than 21. In this case, you need to specify the elevation on the drawable itself:

shapeDrawable.elevation = resources.getDimension(R.dimen.elevation)

Customized Shape

For now, this provides nothing more that a simple ShapeDrawable couldn’t do, apart from the compatibility shadows. So what’s the point?

Introducing ShapeAppearanceModel

A ShapeAppearanceModel is a model of edges and corners used by a MaterialShapeDrawable to render itself. Every MaterialShapeDrawable has a ShapeAppearanceModel.

The default ShapeAppearanceModel has 4 rounded corners with a 0px radius and 4 flat edges. And of course, you can customize it in order to provide your own model:

val shapeAppearanceModel = ShapeAppearanceModel()
shapeDrawable.shapeAppearanceModel = shapeAppearanceModel
// or
val shapeDrawable = MaterialShapeDrawable(shapeAppearanceModel)

Corners

ShapeAppearanceModel has two out-of-the-box types of corners: rounded and cut.

Shape Rounded Corners8dp rounded corners

Shape Cut Corners8dp cut corners

You can change all 4 corners at once:

val cornerSize = resources.getDimensionPixelSize(R.dimen.corner_size)
shapeAppearanceModel.setAllCorners(
 CornerFamily.CUT, //or CornerFamily.ROUNDED
 cornerSize
)

Or you can change each corner individually:

shapeAppearanceModel.setTopLeftCorner(CornerFamily.CUT, cornerSize)
shapeAppearanceModel.setBottomRightCorner(CornerFamily.ROUNDED, cornerSize)

Shape with different cornersdifferent corners with strokes

You can also create your own corners. ShapeAppearanceModel uses a CornerTreatment object for each corner. The previously shown ways of customizing the corners were just shortcuts to build instances of CutCornerTreatment and RoundedCornerTreatment.

You can also write your own implementation of CornerTreatment if cut or rounded corners are not enough for you. For example, if you want an inside cut corner, you could use this implementation of CornerTreatment:

class InnerCutCornerTreatment(cornerSize: Float): CornerTreatment(cornerSize) {
  override fun getCornerPath(angle: Float, interpolation: Float, shapePath: ShapePath) {
    val radius = cornerSize * interpolation
    shapePath.reset(0f, radius, 180f, 180 - angle)
    shapePath.lineTo(radius, radius)
    shapePath.lineTo(radius, 0f)
  }
}

The interpolation parameter is used for animations. Its value ranges from 0 to 1 and it's used to compute the current radius of the corner. More example are shown later in this article.

And apply it to your ShapeAppearanceModel:

shapeAppearanceModel.setAllCorners(InnerCutCornerTreatment(cornerSize))

Custom CornerTreatment

Edges

Edges are handled the same way as corners, by giving to the model instances of EdgeTreatment objects. There is only one built-in edge treatment: the TriangleEdgeTreatment. It draws a triangle facing in or out of the shape in the middle of an edge.

shapeAppearanceModel.topEdge = TriangleEdgeTreatment(edgeSize, false)
shapeAppearanceModel.bottomEdge = TriangleEdgeTreatment(edgeSize, true)

TriangleEdgeTreatment

This can be useful to add nice looking tooltips into your app.

Like for corners, you can build custom edge treatments. You can find a very good example in the code of the BottomAppBar of the material components.

BottomAppBarThe BottomAppBar is using a MaterialShapeDrawable and the cradle for the FAB is a custom EdgeTreatment.

Animations

MaterialShapeDrawable supports animation of its treatments, whether it’s edge or corner, via the interpolation parameter. This attribute is ranging from 0 to 1. At 0, the treatments are not rendered at all (this results in square corners and flat edges). At 1, they are fully rendered.

When building custom treatments, be aware of the interpolation parameter of getCornerPath method as this is the current interpolation of the drawable and you should take it into account when building the ShapePath of your treatment.

seekBar.progress = seekBar.max
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
  override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
    shapeDrawable.interpolation = progress.toFloat() / seekBar.max.toFloat()
  }
  override fun onStartTrackingTouch(seekBar: SeekBar) {}
  override fun onStopTrackingTouch(seekBar: SeekBar) {}
})

animateButton.setOnClickListener {
  val seekbarRatio = seekBar.progress.toFloat() / seekBar.max.toFloat()
  ObjectAnimator.ofFloat(shapeDrawable, "interpolation", seekbarRatio, 1f).apply {
    duration = 1000
    addUpdateListener {
      seekBar.progress = ((it.animatedValue as Float) * seekBar.max).toInt()
    }
  }.start()
}

In this example, the interpolation property is either animated with an ObjectAnimator, or modified by the tracking of a SeekBar.

Shape Animation

XML usage with Material Theming

The shapeAppearance attribute

If you dug a bit in the source code of MaterialShapeDrawable, you probably noticed that this component supports XML inflation, but in a special way.

You can leverage it by using Material components widgets that are using MaterialShapeDrawable internally:

  • BottomSheet
  • Chip
  • MaterialButton (and subclasses like ExtandedFloatingActionButton)
  • FloatingActionButton
  • MaterialCardView
  • TextInputLayout

On all theses widgets, you can use the

app:shapeAppearance

attribute and pass it a reference to a style containing the attributes of your custom appearance.

<com.google.android.material.button.MaterialButton
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:text="MaterialButton"
   app:shapeAppearance="@style/CustomShapeAppearance" />

<style name="CustomShapeAppearance">
  <!-- Attributes for the custom appearance-->
</style>

Here is a list of all supported attributes to customize the appearance :

  • cornerFamily (values: rounded or cut)

  • cornerFamilyTopLeft

  • cornerFamilyTopRight

  • cornerFamilyBottomRight

  • cornerFamilyBottomLeft

  • cornerSize (dimension)

  • cornerSizeTopLeft

  • cornerSizeTopRight

  • cornerSizeBottomRight

  • cornerSizeBottomLeft

If you want to implements the shape of the Shrine Material Studies, you can simply write:

<style name="CustomShapeAppearance">
  <item name="cornerFamily">cut</item>
  <item name="cornerSize">4dp</item>
</style>

You can change the shape of any supported widgets without writing code.

But it can be tedious to remember to always specify the shapeAppearance if you want all your widgets to have the same shape.

Fortunately, with the new Material Themes, you can control the default shape of all the Material widgets.

There are 3 new theme attributes to control the default shapes:

  • shapeAppearanceSmallComponent

  • used by:

  • Chip

  • MaterialButton (and ExtendedFloatingActionButton)

  • FloatingActionButton

  • TextInputLayout

  • the items of NavigationView

  • shapeAppearanceMediumComponent

  • used by:

  • MaterialCardView

  • shapeAppearanceLargeComponent

  • used by:

  • BottomSheet

By settings those attributes in your theme to a custom ShapeAppearance Style, every widgets impacted will be automatically properly shaped.

How does it work internally? The default style of these widgets all include a shapeAppearance attribute referencing one of the 3 theme attributes. You can see an example for MaterialButton in the source code.

If you want to learn more about how all the theming, styling and default attributes work, you can read Android Styles & Themes for developers.

Here is how you would customise all the shapes of you app using Material Theming:

<resources>

  <!-- Application theme. -->
  <style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
    <!-- Other Theme Attributes. -->

    <item name="shapeAppearanceSmallComponent">@style/CustomSmallShapeAppearance</item>
    <item name="shapeAppearanceMediumComponent">@style/CustomMediumShapeAppearance</item>
    <item name="shapeAppearanceLargeComponent">@style/CustomLargeShapeAppearance</item>
  </style>

  <style name="CustomSmallShapeAppearance">
    <item name="cornerFamily">cut</item>
    <item name="cornerSize">4dp</item>
  </style>
  <style name="CustomMediumShapeAppearance">
    <item name="cornerFamily">rounded</item>
    <item name="cornerSize">16dp</item>
  </style>
</resources>

  <com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="24dp"
    android:clipChildren="false"
    app:cardBackgroundColor="#FFFFFF"
    app:cardElevation="8dp"
    app:contentPadding="16dp">

    <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="center_horizontal"
      android:orientation="vertical">

      <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="MaterialCardView" />

      <com.google.android.material.button.MaterialButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:text="MaterialButton" />

      <com.google.android.material.textfield.TextInputLayout
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="TextInputLayout">

        <com.google.android.material.textfield.TextInputEditText
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="TextInputEditText" />
      </com.google.android.material.textfield.TextInputLayout>

      <com.google.android.material.chip.ChipGroup
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp">

        <com.google.android.material.chip.Chip
          style="@style/Widget.MaterialComponents.Chip.Action"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:text="Chip1" />

        <com.google.android.material.chip.Chip
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:text="Chip2" />
      </com.google.android.material.chip.ChipGroup>
    </LinearLayout>
  </com.google.android.material.card.MaterialCardView>

  <com.google.android.material.floatingactionbutton.FloatingActionButton
    style="@style/Widget.MaterialComponents.FloatingActionButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="end"
    android:layout_marginTop="16dp"
    app:srcCompat="@drawable/ic_android_black_24dp" />
</LinearLayout>

All Material Shapes

We can observe that some widgets like the Chip or the FloatingActionButton override the cornerSize of the theme. For the FAB, the cornerSize will be computed to always be 50% of the size of the fab.

Wrap-up

MaterialShapeDrawable is a very powerful tool used a lot across the Material Components library. You have learned how to use it directly in code to build very specific shapes and you also learned how to use it in your layout files and to customise your app theme to globally change all the shapes of your application.

Material Components are open-source and you can find the code on Github.

If you want to go further with Material Theming (colors, typography etc…), I highly recommend Styles, Themes, Material Theming, Oh My! by Anita Singh.

I hope you will have fun experiencing with it in your own apps!