Theming: Default Styles on Android

April 25, 2020

poconos.JPG

Yesterday, I came across this video by @ataul where he describes setting up default styles on Android and uses a custom view as an example.

I thought the video was great and I decided to create an example of using default styles of my own.

Why Default Styles?

Setting up a default style is useful when you need a widget (custom view, android view) styled consistently within your application. This way, you can define a view like so:

    <com.example.defaultstyles.CustomCircleView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

Instead of referencing some style:

    <com.example.defaultstyles.CustomCircleView
        style="@style/Widget.DefaultStyles.CustomCircleView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

Not only does it reduce boilerplate code but also helps ensure that your views are consistent throughout the app. You don’t need to remember to apply a default style every time you use a widget in your layout.

To accomplish this, we need a way to associate our view with its default style. In the example above, we need to associate our view com.example.defaultstyles.CustomCircleView with style Widget.DefaultStyles.CustomCircleView.

Adding support for default styles

Let’s build a view that draws a gray circle without a border by default but make it configurable so the caller can add a border, if desired .

default-styles.png

The custom view above is rendered 3 times. The second time it’s rendered (the middle circle) it adds a border by using app:hasBorder="true"

Layout

Here is the layout xml for the above:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">

    <com.example.defaultstyles.CustomCircleView
        android:id="@+id/circle1"
        android:layout_width="@dimen/circle_size"
        android:layout_height="@dimen/circle_size"
        android:layout_margin="10dp" />

    <com.example.defaultstyles.CustomCircleView
        android:id="@+id/circle2"
        android:layout_width="@dimen/circle_size"
        android:layout_height="@dimen/circle_size"
        android:layout_margin="10dp"
        app:hasBorder="true" />

    <com.example.defaultstyles.CustomCircleView
        android:id="@+id/circle3"
        android:layout_width="@dimen/circle_size"
        android:layout_height="@dimen/circle_size"
        android:layout_margin="10dp" />

</LinearLayout>

Custom view

Here is the code for the custom view:

package com.example.defaultstyles

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View

private const val DEFAULT_STYLE_ATTR = R.attr.circleViewStyle
private const val DEFAULT_STYLE_RES = R.style.Widget_DefaultStyles_CustomCircleView

private const val DEFAULT_HAS_BORDER = false

class CustomCircleView : View {

    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, DEFAULT_STYLE_ATTR)
    constructor(
        context: Context,
        attrs: AttributeSet?,
        defStyleAttr: Int
    ) : this(context, attrs, defStyleAttr, DEFAULT_STYLE_ATTR)

    constructor(
        context: Context,
        attrs: AttributeSet?,
        defStyleAttr: Int,
        defStyleRes: Int
    ) : super(context, attrs, defStyleAttr, defStyleRes) {
        val ta = context.obtainStyledAttributes(
            attrs,
            R.styleable.CustomCircleView,
            DEFAULT_STYLE_ATTR,
            DEFAULT_STYLE_RES
        )
        hasBorder = ta.getBoolean(R.styleable.CustomCircleView_hasBorder, DEFAULT_HAS_BORDER)
        ta.recycle()
    }

    private var hasBorder: Boolean = DEFAULT_HAS_BORDER

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.LTGRAY
        style = Paint.Style.FILL
    }

    private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLACK
        style = Paint.Style.STROKE
        // circle border width in pixels
        strokeWidth = 10.0f
    }

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

        val size = width

        // draw the circle
        val radius = size / 2f
        canvas.drawCircle(size / 2f, size / 2f, radius, paint)

        if (hasBorder) {
            // draw the border around the circle
            canvas.drawCircle(
                size / 2f,
                size / 2f,
                radius - borderPaint.strokeWidth / 2f,
                borderPaint
            )
        }
    }
}

Styles

Here is our styles.xml

<resources>

    <!-- Base application theme. -->
    <style name="Theme.DefaultStyles" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="circleViewStyle">@style/Widget.DefaultStyles.CustomCircleView</item>
    </style>

    <style name="Widget.DefaultStyles.CustomCircleView" parent="android:Widget">
        <item name="hasBorder">false</item>
    </style>

</resources>

Custom attributes

This is the contents of the attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DefaultStylesTheme">
        <attr name="circleViewStyle" format="reference" />
    </declare-styleable>

    <declare-styleable name="CustomCircleView">
        <attr name="hasBorder" format="boolean" />
    </declare-styleable>
</resources>

Having circleViewStyle defined and referenced by the theme allows us to easily have a default style for all circles in our app and every time we inflate this view in layout xml, we don’t need to reference the style explicitly.

Attribute resolution precedence

Here is how obtainStyledAttributes() derives the value of our custom hasBorder attribute (top to bottom in the order of precedence)

  1. Values passed in layout xml which will be available in AttributeSet.

  2. Style resource referenced in layout xml (for instance, style=@style/foo). These will also be available in the AttributeSet.

  3. Default style attribute set by defStyleAttr.

  4. Default style resource referenced by defStyleRes ONLY IF there was no defStyleAttr specified. In the above custom view code, defStyleRes will not be used since defStyleAttr was specified.

  5. Any matching values you define in the theme itself (personally, I don’t anticipate using this).

Full source code can be found here

 
Previous
Previous

Injecting Coroutine Dispatcher with Dagger or Hilt

Next
Next

Writing a custom Moshi Adapter