Theming: Default Styles on Android
April 25, 2020
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 .
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)
Values passed in layout xml which will be available in
AttributeSet.Style resource referenced in layout xml (for instance,
style=@style/foo). These will also be available in theAttributeSet.Default style attribute set by
defStyleAttr.Default style resource referenced by
defStyleResONLY IF there was nodefStyleAttrspecified. In the above custom view code, defStyleRes will not be used sincedefStyleAttrwas specified.Any matching values you define in the theme itself (personally, I don’t anticipate using this).
Full source code can be found here