19

Ultimate Guide To Android Custom View

 4 years ago
source link: https://vladsonkin.com/ultimate-guide-to-android-custom-view/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Ultimate Guide To Android Custom View

December 19th, 2020

Android has lots of standard views to cover all our needs in the app. But sometimes, the designers come up with some new UI elements, and the only way to implement it is by creating an Android Custom View.

If this new UI element looks like some improved standard view or contains multiple standard views, we can create the Custom View by extending the existing View. This is the simplest way to create a Custom View – we use the existing ones and modify them.

However, we might not be so lucky, so we need to create this new Custom View from scratch. In this article, we will build a custom animated loader and cover such topics as a View Lifecycle, Constructors, Attributes, and Animations.

Android Custom View Animated Loader

Android View Lifecycle

Android Views have their lifecycle, and you’ll not find it in the official documentation. The important part of this lifecycle looks like this:

android view lifecycle

When the activity comes into the foreground, Android asks for the root view, and then it draws the views from the top to bottom. The drawing is happening in 3 stages:

  1. onMeasure(), where we need to understand the size of the View
  2. onLayout(), where we find the right position for this View
  3. onDraw(), in which we draw the View with a known size and position.

Note: because of this process, it’s recommended to keep your layout as flat as possible, so the system can save resources on calculating the size and position of the inner ViewGroups.

Let’s implement the custom view and see this lifecycle at work. The creation of the custom view starts with the constructors.

Android Custom View Constructors

Create the LoadingView class and extend it from the general View:

class LoadingView : View {
}

Android Studio will highlight this and say that we need to provide the constructors for the View. The View has lots of constructors:

  1. constructor(context: Context)
    This constructor is used when we create the View from our code programmatically.
  2. constructor(context: Context, @Nullable attrs: AttributeSet?)
    This constructor is used when we create the View from an XML layout
  3. constructor(context: Context, @Nullable attrs: AttributeSet?, defStyleAttr: Int)
    This constructor is used when we create the View from the XML layout and use the theme attribute style.
  4. constructor(context: Context, @Nullable attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int)
    This constructor is similar to the previous one, but it additionally can take the style resource.

The advice is to always start with using the first 2 constructors. And add the 3rd or 4th only if you will use the theme or style. 

class LoadingView : View {
  constructor(context: Context) : super(context)
  constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs)
}

Now let’s see this lifecycle in practice.

onMeasure()

At this stage, the Android system is calculating the size of the view. There is a default implementation, where the view is measured based on the provided width and heights, and in most cases, it’s enough. So if you create the custom view like this:

<com.vladsonkin.customview.LoadingView
  android:id="@+id/loading"
  android:layout_width="100dp"
  android:layout_height="100dp"
  android:layout_marginTop="24dp"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  app:layout_constraintEnd_toEndOf="parent"/>

The default onMeasure() implementation will calculate the correct size 100×100, and you don’t need to implement this function.

But if it’s not your case, then you can override the onMeasure() where you basically do 2 things:

  1. Calculate the size of the View
  2. Call the setMeasuredDimension(int, int) with the size from step 1

onLayout()

At this stage, Android takes the measurement from the previous step and assigns the size and position for each View. Same as onMeasure(), the default implementation will do fine in most cases, and it’ll take the position from the XML. 

onDraw()

This is the primary function in any custom view, and here we draw it. To do this, we have 2 objects: 

  1. Canvas. This object is provided as an argument in onDraw(), and it is responsible for drawing the View on the screen.
  2. Paint. We create this object, and it describes the style of the View.

Back to our example, we need to create a circular loading view, so we need to draw a circle with a stroke:

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)
  val circleRadius = 100F
  val paint = Paint().apply {
    color = ContextCompat.getColor(context, R.color.teal_700)
    style = Paint.Style.STROKE
    strokeWidth = 20F
  }
  canvas.drawCircle(width / 2F, height / 2F, circleRadius, paint)
}

Here the Canvas is responsible for drawing the circle with a drawCircle() and Paint is responsible for the circle styling. Add this custom view to some layout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:padding="16dp"
  tools:context=".MainActivity">
  <com.vladsonkin.customview.LoadingView
    android:id="@+id/loading"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="24dp"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

And check the results:

Android Custom View

onDraw() is called many times, so the general advice is to move the Paint object to another place and just reuse it in onDraw(). For example, you can do something like this:

class LoadingView : View {
  private lateinit var paint: Paint
  private var circleRadius = 100F
  constructor(context: Context) : super(context) {
    init(context, null)
  }
  constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs) {
    init(context, attrs)
  }
  private fun init(context: Context, attributeSet: AttributeSet?) {
    paint = Paint().apply {
      color = ContextCompat.getColor(context, R.color.teal_700)
      style = Paint.Style.STROKE
      strokeWidth = 20F
    }
  }
  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawCircle(width / 2F, height / 2F, circleRadius, paint)
  }
}

Here we extracted the init() function that takes all the constructor parameters and creates the Paint object reused in onDraw().

Custom View Update

You may have noticed that we also have invalidate() and requestLayout() functions on the lifecycle diagram. They have the same goal – to redraw the View if something is changed. The only difference is that when you call requestLayout(), the Android will recalculate the View’s size and position.

If an update doesn’t affect the size of the View, call invalidate() and otherwise, call the requestLayout().

Now we have covered all the view lifecycle and drew our simple circle custom view. At this point, we can achieve the same result by just using the ImageView with a circle image. Fair enough, it’s way easier. But the custom view can do much more, and one of the examples is the animations.

Custom View Animations

We should handle the animations for the custom view step by step for each change. For each change, we call the invalidate(), and View draws again. Do this repeatedly, and you achieved an animation.

We want to animate our circle, so it shrinks and grows again infinitely until we cancel the animation. What we need to do is to change the radius value gradually, redraw the View and repeat it. Sounds tricky, but luckily we have a ValueAnimator:

class LoadingView : View {
  private var valueAnimator: ValueAnimator? = null
  ...
  fun showLoading() {
    isVisible = true
    valueAnimator = ValueAnimator.ofFloat(10F, circleRadius).apply {
      duration = 1000
      interpolator = AccelerateDecelerateInterpolator()
      addUpdateListener { animation ->
        circleRadius = animation.animatedValue as Float
        animation.repeatCount = ValueAnimator.INFINITE
        animation.repeatMode = ValueAnimator.REVERSE
        invalidate()
      }
      start()
    }
  }
  fun hideLoading() {
    isVisible = false
    valueAnimator?.end()
  }
}

Here we change the circleRadius value from 10 to 100 gradually at a 1-second interval. ValueAnimator is responsible for changing this value, and for each change, we call the invalidate() in addUpdateListener.

Let’s add a couple of buttons that will trigger the showLoading() and hideLoading():

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:padding="16dp"
  tools:context=".MainActivity">
  <Button
    android:id="@+id/showLoading"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Show Loading"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintEnd_toStartOf="@id/hideLoading"/>
  <Button
    android:id="@+id/hideLoading"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hide Loading"
    app:layout_constraintStart_toEndOf="@id/showLoading"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintEnd_toEndOf="parent"/>
  <com.vladsonkin.customview.LoadingView
    android:id="@+id/loading"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="24dp"
    android:visibility="gone"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/showLoading"
    app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

And call the functions in Activity:

class MainActivity : AppCompatActivity() {
 private lateinit var ui: ActivityMainBinding
 override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   ui = ActivityMainBinding.inflate(layoutInflater).apply { setContentView(root) }
   ui.showLoading.setOnClickListener { ui.loading.showLoading() }
   ui.hideLoading.setOnClickListener { ui.loading.hideLoading() }
 }
}

Run it, and you should see the animated loader:

Android Custom View Animated Loader

Android Custom View Custom Attributes

Right now the stroke color, stroke width, and radius are hardcoded in the Custom View, but we can make it dynamic with custom attributes. For this we need to create the add the desired attributes in the attrs.xml resource file:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="LoadingView">
    <attr name="lv_color" format="color"/>
  </declare-styleable>
</resources>

Here we declare the lv_color attribute, which is responsible for the circle color. A good tone is to add a unique prefix for the attribute to avoid naming conflicts, and usually, it’s an abbreviation of the custom view.

These attributes are passed through our constructor and we can use them in our init() function:

private fun init(context: Context, attributeSet: AttributeSet?) {
  val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.LoadingView)
  val loadingColor = typedArray.getColor(
    R.styleable.LoadingView_lv_color,
    ContextCompat.getColor(context, R.color.teal_700)
  )
  paint = Paint().apply {
    color = loadingColor
    style = Paint.Style.STROKE
    strokeWidth = 20F
  }
  typedArray.recycle()
}

Don’t forget to call recycle() when you’re done with attributes because this data is not needed anymore.

Now you don’t need to touch the Custom View when the designer wants to change the color of it. Simply use this new attribute in the XML:

<com.vladsonkin.customview.LoadingView
  android:id="@+id/loading"
  android:layout_width="100dp"
  android:layout_height="100dp"
  android:layout_marginTop="24dp"
  app:lv_color="@color/black"
  android:visibility="gone"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@id/showLoading"
  app:layout_constraintEnd_toEndOf="parent"/>

Android Custom View Summary

In this article, we saw how powerful the Android custom view could be. You can create everything you want, and the only limit is your imagination (or imagination of designers).

Draw everything you want, give it some life with animations, and style it with custom attributes. Do you have any custom views in your project, or you want to share some of your favorites? I would love to see your examples in the comments below. Stay safe out there, and Fijne Feestdagen!


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK