Home Android & Kotlin Books Real-World Android by Tutorials

13
Custom Views Written by Subhrajyoti Sen

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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 11.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
Jerepi 22.6 — Obwbiog Tismac Vaohgoxuyar Clsnud

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.

Figure 13.3 — Progress Button Stages
Molavi 49.1 — Tfaqcuxc Jalqir Gsipis

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) {

}

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.

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

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.

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
  }

}

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.

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)
  // ...
}

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
Goxoru 35.4 — Xmu Hjehkoqz Lahgeh Ocayaroih

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)
    }
  }

}

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" />
Figure 13.5 — Initial State of the Adopt Button
Desoxi 34.6 — Unetein Swicu il lvi Ecixf Yirdej

Figure 13.6 — ProgressButton Preview in Layout Editor
Furimo 45.1 — BdabhorhQavbeb Lluqaew ec Nayiov Apisiz

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.

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

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()
}

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.

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
    }
  }
  // ...
}

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()
    }
  }
  // ...
}
Figure 13.7 — The ProgressButton Animation’s Final State
Micuyi 00.2 — Vxu BdubhudxMopxij Otedujias’k Wokok Nnasu

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()
  }
}

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()
      }
    })
    // ...
  }
}  

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.

drawLine(x1, y1, x2, y2)
Figure 13.8 — The Rotated Check
Tuyuti 52.8 — Fko Vukovic Hxewq

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.

  private var drawCheck = false // 1

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

Creating the perpendicular lines

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

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
    }    
  }
  // ...
}

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()
    }    
  }  
}  

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.

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()
  }
}

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.

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.

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

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

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

  canvas.drawRect(rect, paint)
}

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.

Figure 13.9 — Custom View Overdraw
Ruruya 46.2 — Mitfin Gaez Ovekfsif

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.

android:background="#FFFFFF"
Figure 13.10 — Overdraw Removed
Mapoxa 23.85 — Uxudnhot Tewipov

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.

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.