Radio Group and Radio Button are two great components provided by the Android Framework, but unfortunately they often cannot satisfy all requirements. In this case there are several possible ways to get missing functionality, for example, use third-party libraries or create your own custom view components. In this tutorial I am going to explain the latter option. It is always a great idea to learn internals and understand what is going on under the hood.

Introduction

This tutorial is intended to explain how to create custom Radio Group and Radio Button components. There are lot of related topics addressed in this guide like Custom Views, Compound Views, View Groups. However, I won’t cover this topics in great details as far as there are lot of great tutorials out there. This tutorial supposes some basic knowledge of the Android Framework and the Java language. Furthermore you can find the link to the source code at the end of the article.

Step 1 — Creating a Custom Radio Button

First of all we need to find a design prototype to implement it in our custom Radio Button.

Design inspiration

I have found this great design prototype of the settings screen. Here you can see three radio buttons that are used as presets. This is a great choice from the UX perspective that lets a user easily choose from standard, in this case time, presets.

Settings screen design with radio buttons

We are going to create a similar custom radio button, but in addition there will be an Edit Text view in order to enter custom values, however this is not related to the main purpose of the guide.

Creating a compound view

A compound view is a group of views or even view groups created for reusability and consistency. Furthermore such views could be enhanced programmatically as well.

Let’s start by creating the hierarchy of our compound radio button.

Radio button view hierarchy

As you can see it is quite simple and contains only two Text View components.

Custom Radio Button XML File

To get started, create the /res/layout/custom_preset_button.xml XML file that will be used as the layout file for the compound view.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<merge xmlns:android="http://schemas.android.com/apk/res/android">
    <TextView
        android:id="@+id/text_view_value"
        style="@style/PresetLayoutButton_ValueText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/title_time_preset_30" />

    <TextView
        android:id="@+id/text_view_unit"
        style="@style/PresetLayoutButton_UnitText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/unit_seconds"
        android:layout_below="@+id/text_view_value" />
</merge>

Please make sure you are using the <merge> tag. This tag helps to eliminate redundant view groups when a file with the <merge> tag is included into another layout or used as the layout file for a compound view.

Implementing a custom Radio Button

Next, we need to create a compound view class that will inflate the created layout file. Let’s call it PresetValueButton. We will extend the Relative Layout view group, because it is the most appropriate view group for this purpose. Here is the final implementation, let’s discuss the most interesting parts.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
public class PresetValueButton extends RelativeLayout implements RadioCheckable {
    // Views
    private TextView mValueTextView, mUnitTextView;

    // Constants
    public static final int DEFAULT_TEXT_COLOR = Color.TRANSPARENT;

    // Attribute Variables
    private String mValue;
    private String mUnit;
    private int mValueTextColor;
    private int mUnitTextColor;
    private int mPressedTextColor;

    // Variables
    private Drawable mInitialBackgroundDrawable;
    private OnClickListener mOnClickListener;
    private OnTouchListener mOnTouchListener;
    private boolean mChecked;
    private ArrayList<OnCheckedChangeListener> mOnCheckedChangeListeners = new ArrayList<>();


    //================================================================================
    // Constructors
    //================================================================================

    public PresetValueButton(Context context) {
        super(context);
        setupView();
    }

    public PresetValueButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        parseAttributes(attrs);
        setupView();
    }

    @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB)
    public PresetValueButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        parseAttributes(attrs);
        setupView();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public PresetValueButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        parseAttributes(attrs);
        setupView();
    }

    //================================================================================
    // Init & inflate methods
    //================================================================================

    private void parseAttributes(AttributeSet attrs) {
        TypedArray a = getContext().obtainStyledAttributes(attrs,
                R.styleable.PresetValueButton, 0, 0);
        Resources resources = getContext().getResources();
        try {
            mValue = a.getString(R.styleable.PresetValueButton_presetButtonValueText);
            mUnit = a.getString(R.styleable.PresetValueButton_presetButtonUnitText);
            mValueTextColor = a.getColor(R.styleable.PresetValueButton_presetButtonValueTextColor, resources.getColor(R.color.black));
            mPressedTextColor = a.getColor(R.styleable.PresetValueButton_presetButtonPressedTextColor, Color.WHITE);
            mUnitTextColor = a.getColor(R.styleable.PresetValueButton_presetButtonUnitTextColor, resources.getColor(R.color.gray));
        } finally {
            a.recycle();
        }
    }

    // Template method
    private void setupView() {
        inflateView();
        bindView();
        setCustomTouchListener();
    }

    protected void inflateView() {
        LayoutInflater inflater = LayoutInflater.from(getContext());
        inflater.inflate(R.layout.custom_preset_button, this, true);
        mValueTextView = (TextView) findViewById(R.id.text_view_value);
        mUnitTextView = (TextView) findViewById(R.id.text_view_unit);
        mInitialBackgroundDrawable = getBackground();
    }

    protected void bindView() {
        if (mUnitTextColor != DEFAULT_TEXT_COLOR) {
            mUnitTextView.setTextColor(mUnitTextColor);
        }
        if (mValueTextColor != DEFAULT_TEXT_COLOR) {
            mValueTextView.setTextColor(mValueTextColor);
        }
        mUnitTextView.setText(mUnit);
        mValueTextView.setText(mValue);
    }

    //================================================================================
    // Overriding default behavior
    //================================================================================

    @Override
    public void setOnClickListener(@Nullable OnClickListener l) {
        mOnClickListener = l;
    }

    private void setCustomTouchListener() {
        super.setOnTouchListener(new TouchListener());
    }

    @Override
    public void setOnTouchListener(OnTouchListener onTouchListener) {
        mOnTouchListener = onTouchListener;
    }

    public OnTouchListener getOnTouchListener() {
        return mOnTouchListener;
    }

    private void onTouchDown(MotionEvent motionEvent) {
        setChecked(true);
    }

    private void onTouchUp(MotionEvent motionEvent) {
        // Handle user defined click listeners
        if (mOnClickListener != null) {
            mOnClickListener.onClick(this);
        }
    }
    //================================================================================
    // Public methods
    //================================================================================

    public void setCheckedState() {
        setBackgroundResource(R.drawable.background_shape_preset_button__pressed);
        mValueTextView.setTextColor(mPressedTextColor);
        mUnitTextView.setTextColor(mPressedTextColor);
    }

    public void setNormalState() {
        setBackgroundDrawable(mInitialBackgroundDrawable);
        mValueTextView.setTextColor(mValueTextColor);
        mUnitTextView.setTextColor(mUnitTextColor);
    }

    public String getValue() {
        return mValue;
    }

    public void setValue(String value) {
        mValue = value;
    }

    public String getUnit() {
        return mUnit;
    }

    public void setUnit(String unit) {
        mUnit = unit;
    }

    //================================================================================
    // Checkable implementation
    //================================================================================

    @Override
    public void setChecked(boolean checked) {
        if (mChecked != checked) {
            mChecked = checked;
            if (!mOnCheckedChangeListeners.isEmpty()) {
                for (int i = 0; i < mOnCheckedChangeListeners.size(); i++) {
                    mOnCheckedChangeListeners.get(i).onCheckedChanged(this, mChecked);
                }
            }
            if (mChecked) {
                setCheckedState();
            } else {
                setNormalState();
            }
        }
    }

    @Override
    public boolean isChecked() {
        return mChecked;
    }

    @Override
    public void toggle() {
        setChecked(!mChecked);
    }

    @Override
    public void addOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener) {
        mOnCheckedChangeListeners.add(onCheckedChangeListener);
    }

    @Override
    public void removeOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener) {
        mOnCheckedChangeListeners.remove(onCheckedChangeListener);
    }

    //================================================================================
    // Inner classes
    //================================================================================
    private final class TouchListener implements OnTouchListener {

        @Override
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    onTouchDown(event);
                    break;
                case MotionEvent.ACTION_UP:
                    onTouchUp(event);
                    break;
            }
            if (mOnTouchListener != null) {
                mOnTouchListener.onTouch(v, event);
            }
            return true;
        }
    }
}

We have defined the following custom attributes:

  • presetButtonValueText – Value text
  • presetButtonUnitText – Unit text
  • presetButtonValueTextColor – Value text color
  • presetButtonUnitTextColor – Unit text color
  • presetButtonPressedTextColor – Text color for both labels for the pressed state

The RadioCheckable interface

In order to define common properties and methods that a radio button must have to be a part of a radio group create the following RadioCheckable interface.

1
2
3
4
5
6
7
8
public interface RadioCheckable extends Checkable {
    void addOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener);
    void removeOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener);

    public static interface OnCheckedChangeListener {
        void onCheckedChanged(View radioGroup, boolean isChecked);
    }
}

As you can see from the code above, our interface extends the Checkable interface defined in the Android Framework.

1
2
3
4
5
public interface Checkable {
    void setChecked(boolean checked);
    boolean isChecked();
    void toggle();
}

You may wonder what is the purpose of these two methods addOnCheckChangeListener and removeOnCheckChangeListener. Let’s see the implementation in the PresetValueButton class.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
public void setChecked(boolean checked) {
    if (mChecked != checked) {
        mChecked = checked;
        if (!mOnCheckedChangeListeners.isEmpty()) {
            for (int i = 0; i < mOnCheckedChangeListeners.size(); i++) {
                mOnCheckedChangeListeners.get(i).onCheckedChanged(this, mChecked);
            }
        }
        if (mChecked) {
            setCheckedState();
        } else {
            setNormalState();
        }
    }
}

@Override
public boolean isChecked() {
    return mChecked;
}

@Override
public void toggle() {
    setChecked(!mChecked);
}

@Override
public void addOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener) {
    mOnCheckedChangeListeners.add(onCheckedChangeListener);
}

@Override
public void removeOnCheckChangeListener(OnCheckedChangeListener onCheckedChangeListener) {
    mOnCheckedChangeListeners.remove(onCheckedChangeListener);
}

I’ve decided to use multiple listeners (add instead of set method), since the default implementation of the RadioButton class contains two listeners, one is available to a user and the second one is used just by the parent RadiouGroup class. Have a look at this snippet from the CompoundButton class.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void setChecked(boolean checked) {
    if (mChecked != checked) {
        mChecked = checked;
        refreshDrawableState();
        notifyViewAccessibilityStateChangedIfNeeded(
            AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED);

        // Avoid infinite recursions if setChecked() is called from a listener
        if (mBroadcasting) {
            return;
        }

        mBroadcasting = true;
        if (mOnCheckedChangeListener != null) {
            mOnCheckedChangeListener.onCheckedChanged(this, mChecked);
        }
        if (mOnCheckedChangeWidgetListener != null) {
            mOnCheckedChangeWidgetListener.onCheckedChanged(this, mChecked);
        }

        mBroadcasting = false;
    }
}

Handling click and touch events

As you may have already noticed click and touch events are handled in the specific way.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
    mOnClickListener = l;
}

protected void setCustomTouchListener() {
    super.setOnTouchListener(new TouchListener());
}

@Override
public void setOnTouchListener(OnTouchListener onTouchListener) {
    mOnTouchListener = onTouchListener;
}

public OnTouchListener getOnTouchListener() {
    return mOnTouchListener;
}

private void onTouchDown(MotionEvent motionEvent) {
    setChecked(true);
}

private void onTouchUp(MotionEvent motionEvent) {
    // Handle user defined click listeners
    if (mOnClickListener != null) {
        mOnClickListener.onClick(this);
    }
}
private final class TouchListener implements OnTouchListener {

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                onTouchDown(event);
                break;
            case MotionEvent.ACTION_UP:
                onTouchUp(event);
                break;
        }
        if (mOnTouchListener != null) {
            mOnTouchListener.onTouch(v, event);
        }
        return true;
    }
}

This decision is the result of the peculiarity of handling click events and appearance of a button. When a user clicks on a radio button the text color should be set to presetButtonPressedTextColor and then reset to the normal state on the up action. Therefore this is handled manually, in addition if user has set its own touch handler it will be called after the switch statement.

Step 2 — Creating a Custom Radio Group

Now it is time to create a custom radio group. A lot of ideas and implementation details are taken from the default RadioGroup class. Our custom radio group class extends the LinearLayout view group as it is the most suitable container for this purpose.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
public class PresetRadioGroup extends LinearLayout {

    // Attribute Variables
    private int mCheckedId = View.NO_ID;
    private boolean mProtectFromCheckedChange = false;
    // Variables
    private OnCheckedChangeListener mOnCheckedChangeListener;
    private HashMap<Integer, View> mChildViewsMap = new HashMap<>();
    private PassThroughHierarchyChangeListener mPassThroughListener;
    private RadioCheckable.OnCheckedChangeListener mChildOnCheckedChangeListener;


    //================================================================================
    // Constructors
    //================================================================================

    public PresetRadioGroup(Context context) {
        super(context);
        setupView();
    }

    public PresetRadioGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
        parseAttributes(attrs);
        setupView();
    }

    @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB)
    public PresetRadioGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        parseAttributes(attrs);
        setupView();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public PresetRadioGroup(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        parseAttributes(attrs);
        setupView();
    }

    //================================================================================
    // Init & inflate methods
    //================================================================================
    private void parseAttributes(AttributeSet attrs) {
        TypedArray a = getContext().obtainStyledAttributes(attrs,
                R.styleable.PresetRadioGroup, 0, 0);
        try {
            mCheckedId = a.getResourceId(R.styleable.PresetRadioGroup_presetRadioCheckedId, View.NO_ID);

        } finally {
            a.recycle();
        }
    }

    // Template method
    private void setupView() {
        mChildOnCheckedChangeListener = new CheckedStateTracker();
        mPassThroughListener = new PassThroughHierarchyChangeListener();
        super.setOnHierarchyChangeListener(mPassThroughListener);
    }


    //================================================================================
    // Overriding default behavior
    //================================================================================
    @Override
    public void addView(View child, int index, ViewGroup.LayoutParams params) {
        if (child instanceof RadioCheckable) {
            final RadioCheckable button = (RadioCheckable) child;
            if (button.isChecked()) {
                mProtectFromCheckedChange = true;
                if (mCheckedId != View.NO_ID) {
                    setCheckedStateForView(mCheckedId, false);
                }
                mProtectFromCheckedChange = false;
                setCheckedId(child.getId(), true);
            }
        }

        super.addView(child, index, params);
    }

    @Override
    public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) {
        // the user listener is delegated to our pass-through listener
        mPassThroughListener.mOnHierarchyChangeListener = listener;
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // checks the appropriate radio button as requested in the XML file
        if (mCheckedId != View.NO_ID) {
            mProtectFromCheckedChange = true;
            setCheckedStateForView(mCheckedId, true);
            mProtectFromCheckedChange = false;
            setCheckedId(mCheckedId, true);
        }
    }

    private void setCheckedId(@IdRes int id, boolean isChecked) {
        mCheckedId = id;
        if (mOnCheckedChangeListener != null) {
            mOnCheckedChangeListener.onCheckedChanged(this, mChildViewsMap.get(id), isChecked, mCheckedId);
        }
    }

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof LayoutParams;
    }

    public void clearCheck() {
        check(View.NO_ID);
    }

    public void check(@IdRes int id) {
        // don't even bother
        if (id != View.NO_ID && (id == mCheckedId)) {
            return;
        }

        if (mCheckedId != View.NO_ID) {
            setCheckedStateForView(mCheckedId, false);
        }

        if (id != View.NO_ID) {
            setCheckedStateForView(id, true);
        }

        setCheckedId(id, true);
    }

    private void setCheckedStateForView(int viewId, boolean checked) {
        View checkedView;
        checkedView = mChildViewsMap.get(viewId);
        if (checkedView == null) {
            checkedView = findViewById(viewId);
            if (checkedView != null) {
                mChildViewsMap.put(viewId, checkedView);
            }
        }
        if (checkedView != null && checkedView instanceof RadioCheckable) {
            ((RadioCheckable) checkedView).setChecked(checked);
        }
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new LayoutParams(getContext(), attrs);
    }
    //================================================================================
    // Public methods
    //================================================================================


    public void setOnCheckedChangeListener(OnCheckedChangeListener onCheckedChangeListener) {
        mOnCheckedChangeListener = onCheckedChangeListener;
    }

    public OnCheckedChangeListener getOnCheckedChangeListener() {
        return mOnCheckedChangeListener;
    }


    //================================================================================
    // Nested classes
    //================================================================================
    public static interface OnCheckedChangeListener {
        void onCheckedChanged(View radioGroup, View radioButton, boolean isChecked, int checkedId);
    }
    //================================================================================
    // Inner classes
    //================================================================================
    private class CheckedStateTracker implements RadioCheckable.OnCheckedChangeListener {
        @Override
        public void onCheckedChanged(View buttonView, boolean isChecked) {
            // prevents from infinite recursion
            if (mProtectFromCheckedChange) {
                return;
            }

            mProtectFromCheckedChange = true;
            if (mCheckedId != View.NO_ID) {
                setCheckedStateForView(mCheckedId, false);
            }
            mProtectFromCheckedChange = false;

            int id = buttonView.getId();
            setCheckedId(id, true);
        }
    }

    private class PassThroughHierarchyChangeListener implements
            ViewGroup.OnHierarchyChangeListener {
        private ViewGroup.OnHierarchyChangeListener mOnHierarchyChangeListener;

        /**
         * {@inheritDoc}
         */
        public void onChildViewAdded(View parent, View child) {
            if (parent == PresetRadioGroup.this && child instanceof RadioCheckable) {
                int id = child.getId();
                // generates an id if it's missing
                if (id == View.NO_ID) {
                    id = ViewUtils.generateViewId();
                    child.setId(id);
                }
                ((RadioCheckable) child).addOnCheckChangeListener(
                        mChildOnCheckedChangeListener);
                mChildViewsMap.put(id, child);
            }

            if (mOnHierarchyChangeListener != null) {
                mOnHierarchyChangeListener.onChildViewAdded(parent, child);
            }
        }

        /**
         * {@inheritDoc}
         */
        public void onChildViewRemoved(View parent, View child) {
            if (parent == PresetRadioGroup.this && child instanceof RadioCheckable) {
                ((RadioCheckable) child).removeOnCheckChangeListener(mChildOnCheckedChangeListener);
            }
            mChildViewsMap.remove(child.getId());
            if (mOnHierarchyChangeListener != null) {
                mOnHierarchyChangeListener.onChildViewRemoved(parent, child);
            }
        }
    }
}

Listeners

There are three listeners defined in the class:

  • OnCheckedChangeListener: Listener that is set by a user in order to listen for check change events.
  • PassThroughHierarchyChangeListener: This listener is used the internal purpose only. The radio group is notified when a child view is added or removed.
  • RadioCheckable.OnCheckedChangeListener: This listener is assigned to a child radio button when it is added.

Let’s look closer at the PassThroughHierarchyChangeListener implementation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private class PassThroughHierarchyChangeListener implements
ViewGroup.OnHierarchyChangeListener {
    private ViewGroup.OnHierarchyChangeListener mOnHierarchyChangeListener;
    public void onChildViewAdded(View parent, View child) {
        if (parent == PresetRadioGroup.this && child instanceof RadioCheckable) {
            int id = child.getId();
            // generates an id if it's missing
            if (id == View.NO_ID) {
                id = ViewUtils.generateViewId();
                child.setId(id);
            }
            ((RadioCheckable) child).addOnCheckChangeListener(
                mChildOnCheckedChangeListener);
            mChildViewsMap.put(id, child);
        }

        if (mOnHierarchyChangeListener != null) {
            mOnHierarchyChangeListener.onChildViewAdded(parent, child);
        }
    }
    public void onChildViewRemoved(View parent, View child) {
        if (parent == PresetRadioGroup.this && child instanceof RadioCheckable) {
            ((RadioCheckable) child).removeOnCheckChangeListener(mChildOnCheckedChangeListener);
        }
        mChildViewsMap.remove(child.getId());
        if (mOnHierarchyChangeListener != null) {
            mOnHierarchyChangeListener.onChildViewRemoved(parent, child);
        }
    }
}

As it has been just mentioned, on line 12 mChildOnCheckedChangeListener is added to a child view, hence the radio group will be notified about child views click/change events. On line 14 we are adding a child view to the hash map in order to cache child views, as a result we do not need to call the findViewById method whenever we need a child view.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private void setCheckedStateForView(int viewId, boolean checked) {
    View checkedView;
    checkedView = mChildViewsMap.get(viewId);
    if (checkedView == null) {
        checkedView = findViewById(viewId);
        if (checkedView != null) {
            mChildViewsMap.put(viewId, checkedView);
        }
    }
    if (checkedView != null && checkedView instanceof RadioCheckable) {
        ((RadioCheckable) checkedView).setChecked(checked);
    }
}

It is worthwhile to mention that we defined the custom attribute for presetting id in the XML file, similarly to the default RadioGroup implementation. Therefore you can set a view id in the XML layout file to make this view checked by default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    // checks the appropriate radio button as requested in the XML file
    if (mCheckedId != View.NO_ID) {
        mProtectFromCheckedChange = true;
        setCheckedStateForView(mCheckedId, true);
        mProtectFromCheckedChange = false;
        setCheckedId(mCheckedId, true);
    }
}

Step 3 — Using custom components

Layout file example

Finally, let’s test our custom components. Create the following XML layout file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:background="@color/white"
    tools:context="net.crosp.customradiobtton.MainActivity">

    <net.crosp.customradiobtton.PresetRadioGroup
        android:id="@+id/preset_time_radio_group"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_margin="3dp"
        android:layout_marginBottom="13dp"
        android:orientation="horizontal"
        android:weightSum="3"
        app:presetRadioCheckedId="@+id/preset_time_value_button_60">

        <net.crosp.customradiobtton.PresetValueButton
            android:id="@+id/preset_time_value_button_30"
            style="@style/PresetLayoutButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            app:presetButtonUnitText="@string/unit_seconds"
            app:presetButtonValueText="@string/title_time_preset_30" />

        <net.crosp.customradiobtton.PresetValueButton
            android:id="@+id/preset_time_value_button_60"
            style="@style/PresetLayoutButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            app:presetButtonUnitText="@string/unit_seconds"
            app:presetButtonValueText="@string/title_time_preset_60" />

        <net.crosp.customradiobtton.PresetValueButton
            android:id="@+id/preset_time_value_button_120"
            style="@style/PresetLayoutButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            app:presetButtonUnitText="@string/unit_seconds"
            app:presetButtonValueText="@string/title_time_preset_120" />

    </net.crosp.customradiobtton.PresetRadioGroup>

    <android.support.v7.widget.AppCompatEditText
        android:id="@+id/edit_text_set_duration"
        style="@style/SettingsEditText"
        android:layout_below="@+id/preset_time_radio_group"
        android:hint="@string/title_settings_set_duration"
        android:imeOptions="actionNext"
        android:inputType="number"
        android:text="30" />
</RelativeLayout>
Values and styles

As you may noticed, there a lot of resources (strings, dimensions, styles) defined in external files (in the res/values folder). You can find all these values in the complete project source code at the end of the article.

Handling check changes

In order to get notified about check change events register the OnCheckedChangeListener on the radio group.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class MainActivity extends AppCompatActivity {
    PresetRadioGroup mSetDurationPresetRadioGroup;
    AppCompatEditText mSetDurationEditText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mSetDurationPresetRadioGroup = (PresetRadioGroup) findViewById(R.id.preset_time_radio_group);
        mSetDurationEditText = (AppCompatEditText) findViewById(R.id.edit_text_set_duration);
        mSetDurationPresetRadioGroup.setOnCheckedChangeListener(new PresetRadioGroup.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(View radioGroup, View radioButton, boolean isChecked, int checkedId) {
                mSetDurationEditText.setText(((PresetValueButton) radioButton).getValue());
                mSetDurationEditText.setSelection(mSetDurationEditText.getText().length());
            }
        });
    }
}

Running the application

If you run the application you should get the similar UI on a device.

Verify whether it works by clicking on preset buttons.

Conclusion

The purpose of this tutorial is to show how easily you can create custom components and compound views. Furthermore, there is nothing complicated about default view components, therefore don’t be afraid to look into implementation. Also I encourage you to have a look at RadioGroup and RadioButton classes and compare them with the implemented in this tutorial.

Source code