Chapters

Hide chapters

Real-World Android by Tutorials

First Edition · Android 10 · Kotlin 1.4 · AS 4

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

13. Custom Views
Written by Subhrajyoti Sen

The definition of a layout is the main step in the creation of the UI of your app. Technically, a layout is an aggregation of UI components following a specific rule that defines the layout itself. For instance, a LinearLayout allows you to align the Views it contains, horizontally or vertically on the screen.

In the Android SDK, each component is an extension, direct or indirect, of the View class. Following the Composite pattern, each layout is also a View with the ability to aggregates other Views. Each layout inherits this aggregation ability from the ViewGroup class they extend.

Figure 13.1 — Android SDK View Hierarchy
Figure 13.1 — Android SDK View Hierarchy

As you can see in Figure 13.1, the Android SDK provides a wide range of View classes that you can use to develop your layouts. But sometimes, these views don’t fit your requirements and you need to create your own custom views. There are several good reasons to create a custom view:

  • Implementing advanced UI designs.
  • Creating reusable UI components.
  • Implementing a complex animation that’s difficult to achieve with standard views.
  • Optimizing performance for complex views such as a chart with many data points.

Creating a Custom View can be a challenging task. In this chapter, you’ll:

  • Learn about Android’s View hierarchy.
  • Extend View and create a custom button.
  • Add custom attributes to the custom view.
  • Integrate animations inside the custom view.
  • Handle state restoration for custom views.
  • Learn how to make custom views more performant.

It’s time to get started!

Creating Custom Views

You can create a custom view in different ways depending on how much you need to customize the existing Views based on your requirements. You can:

  1. Compose existing Views in a custom way using a custom layout. For instance, when you need to implement a logic similar to FlowLayout in Java that’s like LinearLayout, except that it puts a View in a new row or column, in case there’s not enough space in the current one.
  2. Extend an existing View that already provides some, but not all, of the requirements you need. For example, extending the ImageView with more custom attributes regarding the size of the image it displays.
  3. Extend View and implement the drawing logic using the Canvas API.

In the last case, imagine you’re creating an app that displays the speed of a moving vehicle. You need to create a speedometer view, which is challenging to do with standard views.

Instead, you choose to draw the entire view using your own logic. To do so, you need to understand how the Canvas coordinate system works.

Understanding the Canvas coordinate system

Android’s Canvas uses a 2D matrix. The origin is at the top-left of the screen. The x-axis values increase as they move to the right, while the y-axis values increase as they move downwards:

Figure 13.2 — Android Canvas Coordinates System
Figure 13.2 — Android Canvas Coordinates System

In Figure 13.2, you can see that an (x,y) pair represents each point, where y is the distance in pixels from the top of the screen and x is the distance from the left edge of the screen.

Implementing a Progress Button

There are cases where it’s impossible to develop a certain UI element using the standard Views. In cases like that, you need to manually draw the UI on Canvas.

In this chapter, you’ll create a button that makes an API call when the user clicks it. After the API call starts, the button transforms into a progress bar. Finally, when the API call completes, the progress bar changes into a Done icon.

To see this in action, open the final project in Android Studio, then build and run. Go to the details page for any pet and click the Adopt button. You’ll see the animation play.

Figure 13.3 — Progress Button Stages
Figure 13.3 — Progress Button Stages

Constructing a view like this is complicated using standard views. Instead, you’ll learn how to create that animated view using Canvas.

In this chapter, you’ll:

  1. Create the ProgressButton class, extending directly View.
  2. Define the custom attributes.
  3. Access the custom attribute values from ProgressButton.
  4. Initialize the Paint objects.
  5. Design the animation you want to apply.
  6. Paint your shape on Canvas.
  7. Check your job with a simple preview document.
  8. Add the animation.
  9. Draw the check icon on the ProgressButton’s final state.
  10. Enjoy your custom view.

Now, it’s time to get to work!

Extending View

For your first step, you need to create the class for your custom view. Create a new file with name ProgressButton.kt in common/presentation and add the following code to it:

class ProgressButton @JvmOverloads constructor( // 1
    context: Context, // 2
    attrs: AttributeSet? = null, // 3
    defStyleAttr: Int = 0 // 4
) : View(context, attrs, defStyleAttr) {

}

In the previous code, you:

  1. Create ProgressButton, which extends View and uses @JvmOverloads to overload the multiple constructors that all Views require. You’ll learn about constructors in detail in the next chapter. For now, keep in mind that the constructor has three parameters.
  2. Define context, which is the only parameter every View needs. It allows you to access resources.
  3. Every component has some attributes encapsulated into an object of type AttributeSet, which you receive as a second primary constructor parameter.
  4. As you’ll see in Chapter 14, “Style & Theme”, you can apply some styles to Views that are basically resources. You use this parameter to refer to them.

Right now, the class is nothing more than the View it extends. It’s time to add some custom attributes.

Creating custom attributes

When you create a custom view, you need custom attributes. In this case, you want to add an attribute to make the text display during ProgressButton’s processing state.

To see how this works, create an XML file named attrs.xml in res/values and add the following code:

<resources>
  <declare-styleable name="ProgressButton">
    <attr name="progressButton_text" format="string"/>
  </declare-styleable>
</resources>

The code above does multiple things:

  1. Declares a styleable resource specific to ProgressButton. The custom view uses this to read the attributes.
  2. Adds an attribute named progressButton_text with the format string.

The format of the attributes has two main purposes, letting you:

  1. Read values from attributes in a type-safe way.
  2. Provide value suggestions when assigning the attributes in XML.

It’s a good practice to prefix attribute names with the name of the view. This prevents name clashes if any of the built-in views have an attribute with the same name. It also helps with readability.

It’s important to say that here you just defined some resources you can access from any other View that knows they exist. There’s not strong binding between the name ProgressButton and the styleable resources. Of course, you need to access the values you set in the XML layout from the code.

Reading custom attribute values

You can see a custom parameter as a way to configure your component. Of course, you need a way to access the values from the custom view source code.

Open ProgressButton.kt and change it, like this:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {


  private var buttonText = ""

  init {
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ProgressButton) // 1
    buttonText = typedArray.getString(R.styleable.ProgressButton_progressButton_text) ?: "" // 2
    typedArray.recycle() // 3
  }

}

Here’s what’s going on in this code:

  1. By invoking obtainStyledAttributes() on the Context, you access the TypedArray that contains the array of attribute values. To do this, you pass the attrs you receive in the constructor as the first parameter and R.styleable.ProgressButton as the second parameter. Note how the name of the constants is the same as the styleable resource you created earlier.
  2. TypedArray, which you got above, contains all the custom attributes you’ve defined. To access each of those, you need to know their type. In this case, you use getString() and pass R.styleable.ProgressButton_progressButton_text as a parameter. Note how the name for this resource conforms to the template <ComponentName>_<CustomProperty>. TypedArray provides different methods like getBoolean(), getFont() and many others to access properties of different types. Note that all attribute references are prefixed with styleable.
  3. Finally, you invoke recycle() on TypedArray. This operation lets the Android environment optimize the way resources are used.

Now, you have the values for all the custom attributes for ProgressButton. You now have to use them to customize your component.

Initializing the Paint objects

As you’ll see later, you’re going to draw your custom component on the Canvas using some Paint objects. Paint is like a paintbrush. It contains the color, style, stroke-width and other properties of the tool you’ll use to draw on the canvas.

Open ProgressButton.kt and add the following code before the init block:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
  // ...

  private val textPaint = Paint().apply { // 1
    isAntiAlias = true // 2
    style = Paint.Style.FILL // 3
    color = Color.WHITE
    textSize = context.dpToPx(16f)
  }

  private val backgroundPaint = Paint().apply { // 1
    isAntiAlias = true // 2
    style = Paint.Style.FILL // 3
    color = ContextCompat.getColor(context, R.color.colorPrimary)
  }

  private val progressPaint = Paint().apply { // 1
    isAntiAlias = true // 2
    style = Paint.Style.STROKE // 3
    color = Color.WHITE
    strokeWidth = context.dpToPx(2f) // 4
  }

  private val buttonRect = RectF() // 5
  private val progressRect = RectF() // 5

  private var buttonRadius = context.dpToPx(16f)
  // ...
}

In this code, you:

  1. Initialize Paint objects to use for the text, background and progress state.
  2. Set isAntiAlias to true. Antialiasing is a technique that smooths the edges of shapes you draw on the screen. You’ll almost always want to enable it.
  3. Use style to specify whether Paint will draw only the shape outline (STROKE) or fill the shape with the current color (FILL).
  4. Set the width of the paint stroke, which you can think of as the brush size. You set the size using dpToPx, which converts values from dp to px. This is handy because developers are accustomed to providing values in dp, but Canvas only understands px.
  5. Initialize the RectF that will contain the button and the progress, respectively. RectF is a class that allows you to use Float to set the position of the left, top, right and bottom vertexes.

Now, you have all the tools you need to start drawing on the Canvas. Now, it’s time to think about animation.

Designing the animation logic

Before you start writing any code to draw your images, break down the animation logic:

Figure 13.4 — The Progress Button Animation
Figure 13.4 — The Progress Button Animation

In the image above, consider the dashed box to be the bounds of your view. Your first animation will gradually increase the offset value from 0, squishing the button until it becomes circular. The button will be circular when its width equals its height. Therefore, you’ll define the final offset as:

offset = (initial_width - height) / 2

You need to divide the value by 2 because the offset is at both ends of the button. You want half on one side of the button and half on the other.

Now that you have a plan, it’s time to start creating your button.

Painting your shape

Now, you’ll create the Adopt button by painting it in Canvas. Add the following code in ProgressButton.kt:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
  // ...
  private var offset: Float = 0f

  override fun onDraw(canvas: Canvas) { // 1
    super.onDraw(canvas)

    buttonRadius = measuredHeight / 2f // 2
    buttonRect.apply { // 3
      top = 0f
      left = 0f + offset
      right = measuredWidth.toFloat() - offset 
      bottom = measuredHeight.toFloat()
    }
    canvas.drawRoundRect(buttonRect, buttonRadius, buttonRadius, backgroundPaint) // 4

    if (offset < (measuredWidth - measuredHeight) / 2f) { // 5
      val textX = measuredWidth / 2.0f - textPaint.getTextWidth(buttonText) / 2.0f
      val textY = measuredHeight / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
      canvas.drawText(buttonText, textX, // 6
          textY,
          textPaint)
    }
  }

}

In the previous code, you:

  1. Define an override for onDraw, which is the method where the drawing happens. It has a parameter of type Canvas, where all your drawing operations will take place.
  2. Initialize buttonRadius as half the value of measuredHeight so when the button shrinks, it becomes a circle and not an oval. measuredHeight represents the height of the component as defined by the LayoutInflator, while inflating the view from XML.
  3. Set the actual edges for the buttonRect using measuredHeight and measuredWidth. Of course, measuredWidth represents the width of the component after the inflate.
  4. Draw a rectangle with rounded edges using drawRoundedRect. The first parameter is the RectF instance that defines the edges of the rectangle. The second and third parameters are the radius of the top and bottom buttons. The last parameter is a Paint instance.
  5. Draw the button text, as long as the offset is lower than the required value, which leaves enough room on the button for the words. drawText draws a string at the given x and y coordinates using the provided paint. textX and textY use standard calculations that align the text in the center of the view. Note that textX and textY represent the coordinates of the top-left corner of the drawn text.
  6. Use drawText on Canvas for the actual drawing of the buttonText using the textPaint object.

You’ve used Canvas to draw your component. But how can you check if everything is OK? Most of the time, you can use a simple XML layout document for a preview.

Previewing your shape

You’ve drawn your first shape on the canvas. To preview it, open fragment_details.xml and add the following code inside the ConstraintLayout tag:

 <com.raywenderlich.android.petsave.common.presentation.ProgressButton
  android:layout_width="match_parent"
  android:layout_height="40dp"
  android:layout_marginTop="16dp"
  android:layout_marginStart="24dp"
  android:layout_marginEnd="24dp"
  android:background="#FFFFFF"
  app:layout_constraintTop_toBottomOf="@id/good_boi_label"
  app:progressButton_text="@string/adopt"
  android:id="@+id/adopt_button" />

Build and run, then go to the details screen of any pet and scroll to see the Adopt button.

Figure 13.5 — Initial State of the Adopt Button
Figure 13.5 — Initial State of the Adopt Button

If you don’t want to build and run the app, you can use the Layout Editor Preview using what you have learned in Chapter 12, “MotionLayout & Motion Editor”. Follow these steps to see the result in Figure 13.6:

  1. Open fragment_details.xml in Preview Editor and select the Design view.
  2. Only display the Design view of the layout.
  3. Select Transition in the Motion Editor.
  4. Move the animation indicator to the end of the transition.
  5. Find the ProgressButton at the bottom of the layout.

Figure 13.6 — ProgressButton Preview in Layout Editor
Figure 13.6 — ProgressButton Preview in Layout Editor

However, the ProgressButton doesn’t animate yet. You’ll fix that next.

Adding animation

Your next step is to add the animation that changes the button from an oval to a circle when the user clicks it. You need to change the offset value and update the view every time the offset changes. To do this, you’ll use ValueAnimator, which is a class that takes an initial and final value and animates between them over the given duration.

Open ProgressButton.kt and add the following member variable declarations:

private var widthAnimator: ValueAnimator? = null
private var loading = false
private var startAngle = 0f

The code above declares a ValueAnimator instance, which you’ll use to animate the width of the button. It also declares a flag named loading and sets its initial value to false. You’ll use this flag to indicate whether the view should display the progress bar or not.

Animating the button

Next, you’re ready to begin the animation, so add the following method to ProgressButton:

fun startLoading() { // 1
  widthAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
    addUpdateListener { // 2
      offset = (measuredWidth - measuredHeight) / 2f * it.animatedValue as Float
      invalidate() // 3
    }
    addListener(object : AnimatorListenerAdapter() {
      override fun onAnimationEnd(animation: Animator?) {
        super.onAnimationEnd(animation)
        // TODO: call startProgressAnimation()
      }
    })
    duration = 200
  }
  loading = true // 4
  isClickable = false // 5  
  widthAnimator?.start()
}

In the previous code, you:

  1. Define startLoading, which animates the button to shrink in width. It uses ValueAnimator to animate between 0 and 1 over 200 milliseconds.
  2. Use addUpdateListener to add a listener that gets a callback every time ValueAnimator changes the value. When the value changes, you update the offset to a fraction of the final required value.
  3. Call invalidate, which tells Canvas that it needs to redraw the view. Canvas will respond by invoking onDraw.
  4. Set loading to true to inform onDraw that it needs to redraw the progress bar.
  5. You also set isClickable to false so the user can’t click the view while a task is in progress.

Note that you don’t perform any action inside onAnimationEnd. You’ll use this callback a bit later.

Drawing the progress bar

Now that you’ve started animating the offset value, you need to write the commands to draw the progress bar. Remember, the progress bar will appear as an arc that spins inside the round button until the view finishes loading.

To do this, add the following code to the end of ProgressButton’s onDraw:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    // ...
  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    // ...
    if (loading && offset == (measuredWidth - measuredHeight) / 2f) { // 1
      progressRect.left = measuredWidth / 2.0f - buttonRect.width() / 4 // 2
      progressRect.top = measuredHeight / 2.0f - buttonRect.width() / 4 // 2
      progressRect.right = measuredWidth / 2.0f + buttonRect.width() / 4 // 2
      progressRect.bottom = measuredHeight / 2.0f + buttonRect.width() / 4 // 2
      canvas.drawArc(progressRect, startAngle, 140f, false, progressPaint) // 3
    }
  }
  // ...
}

In the code above, you :

  1. First, check if loading is true and if the offset has reached its required final value — in other words, if the button is now a circle.
  2. If both the conditions are true, you set the coordinates of the edges of the rect for your progress bar. Take a closer look at the calculations and you’ll notice that the rect is a bit smaller than the circular shape the button transforms into. That’s because the progress bar needs to display inside the shape, not along its edges.
  3. Use drawArc to draw an arc of a given sweep angle starting from an initial angle. The curve is tangential to the edges of the rect. Given a start angle of 30 degrees and a sweep angle of 100 degrees, the canvas will draw an arc from 30 degrees to 130 (30 + 100) degrees. In this case, you start at an angle of 0 degrees and provide a sweep angle of 140 degrees.

The idea here is to gradually increase the start angle so that each time the arc is drawn, it rotates by a few degrees. If this happens fast enough, it will render the illusion of a spinning progress bar. Now you need to start the animation when you click on the ProgressButton.

Starting the animation

Open AnimalDetailsFragment.kt and add the following code at the end of displayPetDetails(), like this:

@AndroidEntryPoint
class AnimalDetailsFragment : Fragment() {
  // ...
  @SuppressLint("ClickableViewAccessibility")
  private fun displayPetDetails(animalDetails: UIAnimalDetailed, adopted: Boolean) {
     // ...
    binding.adoptButton.setOnClickListener {
      binding.adoptButton.startLoading()
    }
  }
  // ...
}

Build and run, then go to the details screen for any pet and click the Adopt button. The button will slowly shrink in width until it becomes a circle, then an arc forms a circular shape.

Figure 13.7 — The ProgressButton Animation’s Final State
Figure 13.7 — The ProgressButton Animation’s Final State

Animating the progress bar

The code to animate the value of the starting angle of the arc is similar to the one to animate the button width. Open ProgressButton.kt and add the following code:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
  // ...
  private var rotationAnimator: ValueAnimator? = null

  private fun startProgressAnimation() {
    rotationAnimator = ValueAnimator.ofFloat(0f, 360f).apply { // 1
      addUpdateListener {
        startAngle = it.animatedValue as Float // 2
        invalidate() // 2
      }
      duration = 600
      repeatCount = Animation.INFINITE // 3
      interpolator = LinearInterpolator() // 4      
      addListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator?) { // 5
          super.onAnimationEnd(animation)
          loading = false
          invalidate()
        }
      })
    }
    rotationAnimator?.start()
  }
}

In this code, you:

  1. Create an instance of ValueAnimator and use it to animate between 0 and 360.

  2. Every time the value animates, you assign the new value to startAngle and call invalidate. This causes the canvas to draw the new starting angle and renders the illusion of rotation.

  3. Assign INFINITE to repeatCount, which specifies the number of times the animation repeats. You do this because you don’t know ahead of time how long it will take the view to load, so you don’t know how long the animation needs to run.

  4. Set LinearInterpolator as the interpolator since you want to animate the values linearly. This gives the animation a smooth, rather than staggered, look.

  5. When the animation ends, you set loading to false and invoke invalidate() to update the UI.

Starting the progress bar animation

The progress bar animation needs to start when the shrinking animation stops. To do this, invoke startProgressAnimation from the onAnimationEnd callback inside startLoading, as follows:

fun startLoading() {
  //...
  widthAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
    //...
    addListener(object : AnimatorListenerAdapter() {
      override fun onAnimationEnd(animation: Animator?) {
        super.onAnimationEnd(animation)
        startProgressAnimation()
      }
    })
    // ...
  }
}  

In the above code, as soon as widthAnimator stops, the progress bar animation will start.

Build and run, then click the Adopt button. You can see the progress bar spinning as soon the button shrinks to a circle.

Drawing the check icon

When the progress bar completes, you want to display a check icon as an indication that the action has finished successfully. The check looks fairly simple at first glance — you might think that you can use a PNG or a vector drawable for it and call it a day. But why not make it a bit more interesting? Instead, you’ll use Canvas to draw the icon.

The check consists of two straight lines that are perpendicular to one other. To build this, you need to pick three points and connect them using lines. To draw a line in Canvas, use the following method:

drawLine(x1, y1, x2, y2)

x1 and y1 represent the coordinates of the starting point and x2 and y2 represent the ending point for the line. The ending point of the shorter line is the starting point of the longer line, so you only need to calculate the coordinates for three points.

Looking at the icon, you see that it’s tricky to calculate the points because both lines are at an angle. You’d need to do a lot of math to get them right, but, fortunately, there’s an easier way. Look what happens when you rotate the check by 45 degrees:

Figure 13.8 — The Rotated Check
Figure 13.8 — The Rotated Check

That’s right, you can eliminate the need for complicated calculations by simply drawing two perpendicular lines and rotating them. So the steps you need to follow are:

  1. Rotate Canvas by 45 degrees.
  2. Draw the simpler version of the tick.
  3. Rotate Canvas back to its original state.

But there’s one step you need to take before you do that.

Saving Canvas

Before you can perform that transformation, you need to call save on Canvas. save creates a restore point for Canvas. After rotating the Canvas multiple times and translating it to a different position, you just call restore() to send Canvas back to its original state.

That means you don’t need to remember the details of every transformation you made and reverse them. Furthermore, calling restore retains everything you drew between save() and restore().

To do this, add the following code to ProgressButton.kt:

  private var drawCheck = false // 1

  fun done() {
    loading = false
    drawCheck = true
    rotationAnimator?.cancel()
    invalidate()
  }

In this code, you:

  1. Declare a flag named drawCheck and initialize it to false. You’ll use this flag to indicate whether Canvas should draw the check icon.
  2. Implement the method named done, which the developer will call to indicate that the task is complete and that the view can hide the progress bar and display the check icon. The method does this by setting loading to false and drawCheck to true. It also cancels the rotation animation on the progress bar. Finally, done() calls invalidate() to redraw the view.

Now, you need to actually draw the check in Canvas.

Creating the perpendicular lines

Now, comes the part where you draw the check — which means it’s time for a little math.

The center of the circle is at the coordinates of measuredWidth / 2f and measuredHeight / 2f. The vertical portion of the tick has to point toward the right of the circle’s center.

Therefore, you need the following coordinates:

  • x coordinate of the starting point of the vertical line: measuredWidth / 2f + buttonRect.width() / 8
  • y coordinate: measuredHeight / 2f + buttonRect.width() / 4
  • coordinates of the final point of the vertical line: measuredWidth / 2f + buttonRect.width() / 8 and measuredHeight / 2f - buttonRect.width() / 4
  • x coordinates of the starting point of the horizontal line: measuredWidth / 2f - buttonRect.width() / 8
  • y coordinates of the starting point of the horizontal line: measuredHeight / 2f + buttonRect.width() / 4

Note that the final point of the horizontal line will be the starting point of the vertical line.

Putting everything together

Now, you have all the theory you need to build your icon. To implement it, add the following code at the end of onDraw in ProgressButton.kt:

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
  // ...
  override fun onDraw(canvas: Canvas) {
    // ...
    if (drawCheck) {
      canvas.save() // 1
      canvas.rotate(45f, measuredWidth / 2f, measuredHeight / 2f) // 2
      // 3
      val x1 = measuredWidth / 2f - buttonRect.width() / 8
      val y1 = measuredHeight / 2f + buttonRect.width() / 4
      val x2 = measuredWidth / 2f + buttonRect.width() / 8
      val y2 = measuredHeight / 2f + buttonRect.width() / 4
      val x3 = measuredWidth / 2f + buttonRect.width() / 8
      val y3 = measuredHeight / 2f - buttonRect.width() / 4
      canvas.drawLine(x1, y1, x2, y2, progressPaint) // 4
      canvas.drawLine(x2, y2, x3, y3, progressPaint) // 4
      canvas.restore() // 5
    }    
  }
  // ...
}

There are a few things going on in the code above. You:

  1. Save the state of the canvas.
  2. Rotate the canvas by 45 degrees, keeping the center of the view as the pivot.
  3. Assign the coordinates’ values according to the calculations above.
  4. Draw the horizontal line first because the final point of the horizontal line is the starting point of the vertical line.
  5. Call restore() to rotate the canvas back to its original orientation.

Now, you need to bind the animation to the adopt button in the app.

Binding the animation to the adopt button

Start by opening AnimalDetailsFragment.kt and adding the following code to the click listener on adoptButton:

@AndroidEntryPoint
class AnimalDetailsFragment : Fragment() {
  // ...
  @SuppressLint("ClickableViewAccessibility")
  private fun displayPetDetails(animalDetails: UIAnimalDetailed, adopted: Boolean) {
    // ...
    binding.adoptButton.setOnClickListener {
      binding.adoptButton.startLoading()
      viewModel.handleEvent(AnimalDetailsEvent.AdoptAnimal) // 1
    }
  }
  // ...
  @SuppressLint("ClickableViewAccessibility")
  private fun displayPetDetails(animalDetails: UIAnimalDetailed, adopted: Boolean) {
    // ...
    if (adopted) { // 2
      binding.adoptButton.done()
    }    
  }  
}  

In this code, you:

  1. Set the state to AnimalDetailsViewState.AnimalDetails with the adopted field set to true. AnimalDetailsEvent.AdoptAnimal is an event that triggers a mock method in viewmodel to adopt the pet.
  2. Call done() on ProgressButton when adopted is set to true.

Build and run, then click the Adopt button — you’ll see the full animation in action.

Manually stopping the animation

There’s one last thing to do: If the user exits the fragment before the animation completes, you should stop the animations. Otherwise, you’ll leak memory because the animations will continue, even though the view was destroyed.

To handle this, add the following method to ProgressButton.kt.

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
  // ...
  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    widthAnimator?.cancel()
    rotationAnimator?.cancel()
  }
}

The code above cancels the animations when the view detaches from the window.

Congratulations! You’ve successfully built a custom view that draws different shapes and animates them.

Enhancing performance

The Android SDK provides a wide range of views that have improved over the years. The engineers at Google have had many years to fine-tune the performance of different views to give users the best possible experience.

When you write a custom view, it’s up to you to ensure that the view performs well. With increasingly complex user interfaces, it’s very easy to focus on getting the visual part right while letting performance take a back seat.

In this section, you’ll learn a few common mistakes to avoid when it comes to view performance. In particular, you’ll see how to:

  1. Avoid creating objects in onDraw()
  2. Reduce overdraw

Creating objects inside onDraw

As a standard practice, you should avoid object creation inside methods that the app calls at a high frequency. Consider onDraw — it can be called multiple times in one second! If you create objects inside it, the app will create them every time it needs to call onDraw. That’s a lot of extra CPU work that you could easily avoid.

Consider the following code:

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  val paint = Paint()
  val rect = Rect(100, 100, 200, 200)
  canvas.drawRect(rect, paint)
}

In the code above, you create instances of Paint and Rect every time you invoke onDraw. Memory allocation for objects takes time — and because it happens on the main thread, it will slow down your custom view.

Since onDraw is called frequently, the overall time taken by object creation slows down your UI, making the app appear janky to the user.

To avoid performance issues, preallocate objects and reuse them as often as possible. For example, rewrite the code above as follows:

val paint = Paint()
val rect = Rect(100, 100, 200, 200)

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  canvas.drawRect(rect, paint)
}

In this version of the code, you allocate the objects only once, then reuse them on every onDraw call. Similarly, you should also avoid performing long-running calculations in methods like these.

Understanding overdraw

Overdraw is the number of times a pixel is redrawn in a single frame. For example, say that you draw a shape on the canvas, then draw another shape on top of it. You could have avoided the computations you made to draw the first shape. In this case, the overdraw is 1 since it was redrawn once.

Your device has a handy tool that helps you debug overdraw on your app. To find it, open Settings ▸ Developer Tools ▸ Debug GPU Overdraw ▸ Show overdraw areas. You’ll see boxes of multiple colors appear all over your screen. Each color represents the amount of overdraw:

  • True color: No overdraw.
  • Blue: Overdrawn one time.
  • Green: Overdrawn two times.
  • Pink: Overdrawn three times.
  • Red: Overdrawn four or more times.

A few commons ways to reduce overdraw are:

  • Remove unneeded background: Avoid setting a background for a view if its parent view has a similar background. For example, a TextView with a white background inside a LinearLayout with a white background makes no visual difference, but will cause overdraw.
  • Flatten view hierarchy: Avoid nested views. For example, you can convert a LienarLayout with a TextView and an ImageView to a single TextView, in most cases.
  • Reduce transparency: If you draw a transparent view on top of another view, Canvas has to render the lower view first, then apply a transparent mask on top of it. This causes overdraw.

Open the app and go to the details page for any pet. Click the Adopt button and wait for the animation to complete. You’ll see overdraw around the circular view:

Figure 13.9 — Custom View Overdraw
Figure 13.9 — Custom View Overdraw

Now, you’ll use the techniques you just learned to address this overdraw.

Reducing overdraw

Open ProgressButton.kt and check for any code that sets a background you don’t need. OK, there’s no such code here.

Next, check the location where you use the view: in this case, the XML layout. Open fragment_details.xml and check if you set a background for ProgressButton. You’ll notice that you set a white background for the view, which doesn’t make any visual difference.

To fix this, remove the following attribute from ProgressButton:

android:background="#FFFFFF"

Build and run. Now, when you click the Adopt button, you won’t see any overdraw in your custom view.

Figure 13.10 — Overdraw Removed
Figure 13.10 — Overdraw Removed

Well done! You’ve successfully improved the performance of your custom view.

Key points

  • Create custom views when you need to add features to an existing view or draw views that are too complex to implement using standard views.
  • You need to extend View to create your custom view.
  • Draw shapes with Canvas using drawLine(), drawLineRoundedRect(), etc.
  • You can save Canvas’ state, move it around and restore it to its original state using save() and restore().
  • Avoid performing long calculations and creating objects inside onDraw().
  • Avoid nested view hierarchy and unnecessary backgrounds to reduce overdraw.

In the next chapter, you’ll learn everything you need to know about themes and styles, allowing you to customize the appearance of your custom views.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.