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.
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
Viewand 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:
-
Compose existing
Views in a custom way using a custom layout. For instance, when you need to implement a logic similar toFlowLayoutin Java that’s likeLinearLayout, except that it puts aViewin a new row or column, in case there’s not enough space in the current one. -
Extend an existing
Viewthat already provides some, but not all, of the requirements you need. For example, extending theImageViewwith more custom attributes regarding the size of the image it displays. -
Extend
Viewand 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:
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.
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:
- Create the
ProgressButtonclass, extending directlyView. - Define the custom attributes.
- Access the custom attribute values from
ProgressButton. - Initialize the
Paintobjects. - Design the animation you want to apply.
- Paint your shape on Canvas.
- Check your job with a simple preview document.
- Add the animation.
- Draw the check icon on the
ProgressButton’s final state. - 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:
- Create
ProgressButton, which extendsViewand uses@JvmOverloadsto overload the multiple constructors that allViews require. You’ll learn about constructors in detail in the next chapter. For now, keep in mind that the constructor has three parameters. - Define
context, which is the only parameter everyViewneeds. It allows you to access resources. - Every component has some attributes encapsulated into an object of type
AttributeSet, which you receive as a second primary constructor parameter. - 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:
- Declares a
styleableresource specific toProgressButton. The custom view uses this to read the attributes. - Adds an attribute named progressButton_text with the format
string.
The format of the attributes has two main purposes, letting you:
- Read values from attributes in a type-safe way.
- 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:
- By invoking
obtainStyledAttributes()on theContext, you access theTypedArraythat contains the array of attribute values. To do this, you pass theattrsyou receive in the constructor as the first parameter andR.styleable.ProgressButtonas the second parameter. Note how the name of the constants is the same as the styleable resource you created earlier. -
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 usegetString()and passR.styleable.ProgressButton_progressButton_textas a parameter. Note how the name for this resource conforms to the template<ComponentName>_<CustomProperty>.TypedArrayprovides different methods likegetBoolean(),getFont()and many others to access properties of different types. Note that all attribute references are prefixed withstyleable. - Finally, you invoke
recycle()onTypedArray. 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:
- Initialize
Paintobjects to use for the text, background and progress state. - Set
isAntiAliastotrue. Antialiasing is a technique that smooths the edges of shapes you draw on the screen. You’ll almost always want to enable it. - Use
styleto specify whetherPaintwill draw only the shape outline (STROKE) or fill the shape with the current color (FILL). - 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 fromdptopx. This is handy because developers are accustomed to providing values indp, butCanvasonly understandspx. - Initialize the
RectFthat will contain the button and the progress, respectively.RectFis a class that allows you to useFloatto set the position of theleft,top,rightandbottomvertexes.
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:
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:
- Define an override for
onDraw, which is the method where the drawing happens. It has a parameter of typeCanvas, where all your drawing operations will take place. - Initialize
buttonRadiusas half the value ofmeasuredHeightso when the button shrinks, it becomes a circle and not an oval.measuredHeightrepresents the height of the component as defined by theLayoutInflator, while inflating the view from XML. - Set the actual edges for the
buttonRectusingmeasuredHeightandmeasuredWidth. Of course,measuredWidthrepresents the width of the component after the inflate. - Draw a rectangle with rounded edges using
drawRoundedRect. The first parameter is theRectFinstance 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 aPaintinstance. - 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.
drawTextdraws a string at the given x and y coordinates using the providedpaint.textXandtextYuse standard calculations that align the text in the center of the view. Note thattextXandtextYrepresent the coordinates of the top-left corner of the drawn text. - Use
drawTextonCanvasfor the actual drawing of thebuttonTextusing thetextPaintobject.
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.
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:
- Open fragment_details.xml in Preview Editor and select the Design view.
- Only display the Design view of the layout.
- Select Transition in the Motion Editor.
- Move the animation indicator to the end of the transition.
- Find the
ProgressButtonat the bottom of the layout.
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:
- Define
startLoading, which animates the button to shrink in width. It usesValueAnimatorto animate between 0 and 1 over 200 milliseconds. - Use
addUpdateListenerto add a listener that gets a callback every timeValueAnimatorchanges the value. When the value changes, you update the offset to a fraction of the final required value. - Call
invalidate, which tells Canvas that it needs to redraw the view. Canvas will respond by invokingonDraw. - Set
loadingtotrueto informonDrawthat it needs to redraw the progress bar. - You also set
isClickabletofalseso 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 :
- First, check if
loadingistrueand if the offset has reached its required final value — in other words, if the button is now a circle. - If both the conditions are
true, you set the coordinates of the edges of therectfor your progress bar. Take a closer look at the calculations and you’ll notice that therectis 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. - Use
drawArcto draw an arc of a given sweep angle starting from an initial angle. The curve is tangential to the edges of therect. 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.
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:
-
Create an instance of
ValueAnimatorand use it to animate between 0 and 360. -
Every time the value animates, you assign the new value to
startAngleand callinvalidate. This causes the canvas to draw the new starting angle and renders the illusion of rotation. -
Assign
INFINITEtorepeatCount, 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. -
Set
LinearInterpolatoras theinterpolatorsince you want to animate the values linearly. This gives the animation a smooth, rather than staggered, look. -
When the animation ends, you set
loadingto false and invokeinvalidate()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:
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:
- Rotate Canvas by 45 degrees.
- Draw the simpler version of the tick.
- 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:
- Declare a flag named
drawCheckand initialize it tofalse. You’ll use this flag to indicate whether Canvas should draw the check icon. - 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 settingloadingtofalseanddrawChecktotrue. It also cancels the rotation animation on the progress bar. Finally,done()callsinvalidate()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() / 8andmeasuredHeight / 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:
- Save the state of the canvas.
- Rotate the canvas by 45 degrees, keeping the center of the view as the pivot.
- Assign the coordinates’ values according to the calculations above.
- Draw the horizontal line first because the final point of the horizontal line is the starting point of the vertical line.
- 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:
- Set the state to
AnimalDetailsViewState.AnimalDetailswith theadoptedfield set totrue.AnimalDetailsEvent.AdoptAnimalis an event that triggers a mock method inviewmodelto adopt the pet. - Call
done()onProgressButtonwhenadoptedis set totrue.
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:
- Avoid creating objects in
onDraw() - 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
TextViewwith a white background inside aLinearLayoutwith a white background makes no visual difference, but will cause overdraw. -
Flatten view hierarchy: Avoid nested views. For example, you can convert a
LienarLayoutwith aTextViewand anImageViewto a singleTextView, 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:
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.
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
Viewto 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()andrestore(). - 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.