Preface
Android custom controls are widely used in the project. Many cool looking special effects or the desired effects of the product are not directly provided in the native system controls. At this time, we need custom controls. But the difficulty of custom control is different. When we meet the desired control, we first want to see if there is an open source wheel and use it directly. If we don't, we'll have to find our own way to make it. The process of making wheels may not be easy, so we need to understand the principle and process of customization. This paper summarizes the flow of custom control and several commonly used implementation methods, and makes a summary combined with the actual application of the project. For your reference only. If you have any questions, please leave a message for discussion.
The difficulty of user-defined View can also be divided into three levels. There are three commonly used implementation methods. From easy to difficult.
1. User defined View classification
1.1 inherit existing control
Inherit the specific control, such as TextView,Button,EditText, etc., and expand the function of its control.
For example, you want to customize the font, display the segmented phone number in the text input box, and so on. (refer to implementation mode I below)
1.2 combine existing controls
Inherit ViewGroup, such as LinearLayout,FrameLayout, etc., to realize more powerful controls.
There is no need to override methods such as onMeasure, onLayout, etc. Such as the implementation of title block. (refer to implementation mode II below)
1.3 rewrite View to realize new control
Inheriting View is the most difficult and powerful function. You need to master the principles and steps of drawing.
Such as the realization of countdown entry and so on. (refer to implementation mode 3 below)
2. View coordinate system of Android
First of all, we need to be clear about the acquisition of View coordinate system. The coordinate system in Android is different from that in mathematics.
Coordinate system in Android: the upper left corner of the screen is the origin of the coordinate system (0,0), the origin extends to the right in the positive X-axis direction, and the origin extends downward in the Y-axis direction.
X, Y direction:
View coordinate system:
2.1 methods in view
View gets its own coordinates:
- getTop(): gets the distance from the top of the view itself to the top of the parent container ViewGroup.
- getBottom(): gets the distance from the bottom of the view itself to the top of the parent container ViewGroup.
- getLeft(): gets the distance from the left side of the view itself to the left side of the parent container ViewGroup.
- getRight(): gets the distance from the right side of the view itself to the left side of the parent container ViewGroup.
View gets its own width and height:
- getHeight(): get the height of the View
- getWidth(): get the width of the View
2.2 methods in motionevent
- getY(): get the x-axis coordinate of the click event relative to the left side of the control, that is, the distance between the click event and the left side of the control.
- getY(): get the y-axis coordinate of the click event relative to the top edge of the control, that is, the distance between the click event and the top edge of the control.
- getRawX(): get the x-coordinate of the click event relative to the left side of the whole screen, that is, the distance between the click event and the left side of the whole screen.
- getRawY(): get the y-axis coordinate of the click event relative to the top edge of the whole screen, that is, the distance between the click event and the top edge of the whole screen.
3. User defined View process
The drawing of View is basically completed by three functions: onMeasure(), onLayout(), onDraw()
function | Effect | correlation method |
---|---|---|
onMeasure() | Measure the width and height of the View | setMeasuredDimension(),onMeasure() |
onLayout() | Calculate the location of the current View and child views | onLayout(),setFrame() |
onDraw() | Drawing of views | onDraw() |
For example, we can customize CustomView in the code
class CustomView : View { constructor(context: Context?) : super(context) /** * Called automatically when used in an xml layout file */ constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) /** * It does not automatically call, and if there is a default style, it is called in the second constructor. */ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) } }
The above code mainly uses the construction method and rewriting method to achieve the desired effect.
Explain:
1. The constructor is the entry of View, which is used to initialize some content and get custom properties.
- Whether we inherit system View or directly inherit View, we need to rewrite the constructor.
- There are multiple constructors to be compatible with the lower version. Now in the project, the third function is usually rewritten.
- In the actual project, in addition to the constructor, the other three methods are optional.
In other words, although the drawing process has three methods, namely onMeasure(), onLayout(), onDraw(), it doesn't need to be rewritten, just change the required method
Next, give an example
4. User defined View used in the project
- Segment display of phone number input box
- Encapsulate the general title block
- Achieve countdown progress bar
4.1 implementation mode 1: user defined EditText
For example, the input telephone number is displayed in sections.
//Enter the phone number and display it in segments, such as: xxx xxxx xxxx class TelEditText : EditText { var isBank = true private val addString = " " private var isRun = false constructor(context: Context) : this(context, null) constructor(context: Context, attributes: AttributeSet?) : super(context, attributes) { init() } private fun init() { addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { //These sentences need to be added. Otherwise, each input value will execute onTextChanged() twice, resulting in stack overflow if (isRun) { isRun = false return } isRun = true if (isBank) { var finalString = "" var index = 0 val telString = s.toString().replace(" ", "") if (index + 3 < telString.length) { finalString += telString.substring(index, index + 3) + addString index += 3 } while (index + 4 < telString.length) { finalString += telString.substring(index, index + 4) + addString index += 4 } finalString += telString.substring(index, telString.length) this@TelEditText.setText(finalString) //This sentence is indispensable, otherwise the input cursor will appear on the leftmost side and will not move with the input value to the right this@TelEditText.setSelection(finalString.length) } } override fun afterTextChanged(s: Editable) { } }) } // Get phone number without spaces fun getPhoneText(): String { val str = text.toString() return replaceBlank(str) } private fun replaceBlank(str: String?): String { var dest = "" if (str != null) { val p = Pattern.compile("\\s*|\t|\r|\n") val m = p.matcher(str) if (m.find()) { dest = m.replaceAll("") } } return dest } }
In the layout interface, call:
<cc.test.widget.TelEditText android:id="@+id/etPhone" android:layout_width="match_parent" android:layout_height="50dp" android:background="@null" android:inputType="phone" android:maxLength="13" android:maxLines="1" tools:text="13609213770" />
It is similar to EditText in use, except for the function expansion of properties and display.
4.2 implementation mode 2: custom title block
The renderings are as follows:
1. First draw the style of the title in xml
<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" tools:background="#1F2129" tools:layout_height="40dp" tools:layout_width="match_parent" tools:parentTag="android.widget.FrameLayout"> <ImageButton android:id="@+id/ibBack" android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center_vertical" android:layout_marginStart="40dp" android:background="@null" android:contentDescription="@null" android:src="@drawable/common_back_white" /> <TextView android:id="@+id/tvTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:textColor="#FFFFFF" android:textSize="18sp" tools:text="title" /> <TextView android:id="@+id/tvRightText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical|end" android:paddingStart="@dimen/dp_20" android:paddingTop="@dimen/dp_10" android:paddingEnd="@dimen/dp_20" android:paddingBottom="@dimen/dp_10" android:textColor="#FFFFFF" android:textSize="18sp" android:visibility="invisible" tools:text="right" tools:visibility="visible" /> <ImageButton android:id="@+id/ibRight" android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center_vertical|end" android:layout_marginEnd="15dp" android:background="@null" android:contentDescription="@null" tools:visibility="visible" /> </merge>
2. Custom attribute attr.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="TitleLayout"> <attr name="title_layout_background" format="color" /> <attr name="title_layout_statusBarBackground" format="color" /> <attr name="title_layout_titleText" format="string" /> <attr name="title_layout_titleSize" format="dimension" /> <attr name="title_layout_titleColor" format="color" /> <attr name="title_layout_rightText" format="string" /> <attr name="title_layout_rightTextSize" format="dimension" /> <attr name="title_layout_rightTextColor" format="color" /> <attr name="title_layout_rightImageSrc" format="reference|color" /> </declare-styleable> </resources>
3. Implementation in code
/** * Custom title block control */ class TitleLayout : FrameLayout { // Default background color private val defaultBackgroundColor = ResourcesUtil.getColor(R.color.common_black_1F) // Whether to interrupt and return to the previous interface? This value is called after returning to listen private var isInterruptBack = false // Return to click monitoring private var backClickListener: ((View) -> Unit)? = null constructor(context: Context) : this(context, null, 0) constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) //Implemented in this function constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) { LayoutInflater.from(context).inflate(R.layout.common_include_title, this, true) initAttr(attrs) initBackClickListener() } fun setInterruptBack(interrupt: Boolean): TitleLayout { isInterruptBack = interrupt return this } fun setOnBackClickListener(click: (View) -> Unit): TitleLayout { backClickListener = click return this } fun getBackImageButton(): ImageButton = ibBack fun hideBackImage() { invisible(ibBack) } fun getTitleTextView(): TextView = tvTitle fun setTitleColor(color: Int): TitleLayout { tvTitle.setTextColor(color) return this } fun setTitleSize(size: Float): TitleLayout { tvTitle.setTextSize(TypedValue.COMPLEX_UNIT_PX, size) return this } fun setTitleText(text: CharSequence): TitleLayout { tvTitle.text = text return this } fun setTitleText(@StringRes resId: Int): TitleLayout { setTitleText(ResourcesUtil.getString(resId)) return this } fun setLayoutBackgroundColor(color: Int): TitleLayout { setBackgroundColor(color) return this } fun getRightTextView(): TextView = tvRightText fun setRightText(text: CharSequence): TitleLayout { if (text.isNotEmpty()) { tvRightText.text = text visible(tvRightText) invisible(ibRight) } return this } fun setRightTextColor(color: Int): TitleLayout { tvRightText.setTextColor(color) return this } fun setRightTextSize(size: Float): TitleLayout { tvRightText.setTextSize(TypedValue.COMPLEX_UNIT_PX, size) return this } fun setOnRightTextClickListener(click: (View) -> Unit): TitleLayout { tvRightText.onClick { click(it) } return this } fun getRightImageButton(): ImageButton = ibRight fun setRightImageResource(id: Int) { invisible(tvRightText) visible(ibRight) ibRight.load(id) } fun setOnRightIconClickListener(click: (View) -> Unit): TitleLayout { ibRight.onClick { click(it) } return this } private fun initAttr(attrs: AttributeSet?) { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TitleLayout) val layoutBackgroundColor = typedArray.getColor( R.styleable.TitleLayout_title_layout_background, defaultBackgroundColor ) setLayoutBackgroundColor(layoutBackgroundColor) val statusBarBackgroundColor = typedArray.getColor( R.styleable.TitleLayout_title_layout_statusBarBackground, layoutBackgroundColor ) val activity = context if (activity is Activity) { BarUtil.setStatusBarColor(activity, statusBarBackgroundColor) } setTitleText(typedArray.getString(R.styleable.TitleLayout_title_layout_titleText) ?: "") val titleTextSize = typedArray.getDimension(R.styleable.TitleLayout_title_layout_titleSize, -1f) if (titleTextSize != -1f) { setTitleSize(titleTextSize) } val titleTextColor = typedArray.getColor(R.styleable.TitleLayout_title_layout_titleColor, -1) if (titleTextColor != -1) { setTitleColor(titleTextColor) } setRightText(typedArray.getString(R.styleable.TitleLayout_title_layout_rightText) ?: "") val rightTextSize = typedArray.getDimension(R.styleable.TitleLayout_title_layout_rightTextSize, -1f) if (rightTextSize != -1f) { setRightTextSize(rightTextSize) } val rightTextColor = typedArray.getColor(R.styleable.TitleLayout_title_layout_rightTextColor, -1) if (rightTextColor != -1) { setRightTextColor(rightTextColor) } val rightImageSrc = typedArray.getResourceId(R.styleable.TitleLayout_title_layout_rightImageSrc, -1) if (rightImageSrc != -1) { setRightImageResource(rightImageSrc) } typedArray.recycle() } private fun initBackClickListener() { val cxt = context ibBack.onClick { backClickListener?.invoke(it) if (!isInterruptBack && cxt is Activity) { cxt.finish() } } } }
Call in layout file:
<cc.test.package.common.widget.TitleLayout android:id="@id/titleLayout" android:layout_width="match_parent" android:layout_height="40dp" tools:title_layout_titleText="Title name" />
Call in the Activity interface:
//The title name and event can be handled here //If the return event is not overridden, the default return event is the close interface titleLayout.setOnBackClickListener { //Rewrite this method to achieve the desired return logical business }
4.3 implementation mode 3: inherit View to rewrite
If the countdown function is realized
The effect is as follows:
Implementation mode in the code:
class RoundProgressBar : View { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init(context, attrs) } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) { init(context, attrs) } /** * Reference to brush object */ private var paint = Paint() /** * Ring color */ private var roundColor = 0 /** * Ring progress color */ private var roundProgressColor = 0 /** * Color of string for intermediate progress percentage */ private var textColor = 0 /** * Font of string for intermediate progress percentage */ private var textSize = 0f /** * Width of the torus */ private var roundWidth = 0f /** * Maximum progress */ var max = 0 /** * The starting angle of the progress circle 0 is three o'clock */ private var startAngle = 0 /** * Scanning angle of progress circle */ private var sweepAngle = 0 /** * Show progress in the middle */ private var textIsDisplayable = false /** * Progress style, solid or hollow */ private var style = 0 companion object { const val STROKE = 0 const val FILL = 1 } private fun init(context: Context, attrs: AttributeSet?) { val mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.RoundProgressBar) // Get custom properties and defaults roundColor = mTypedArray.getColor(R.styleable.RoundProgressBar_process_roundColor, Color.RED) roundProgressColor = mTypedArray.getColor( R.styleable.RoundProgressBar_process_roundProgressColor, Color.GREEN ) textColor = mTypedArray.getColor(R.styleable.RoundProgressBar_process_txtColor, Color.GREEN) textSize = mTypedArray.getDimension(R.styleable.RoundProgressBar_process_txtSize, 15f) roundWidth = mTypedArray.getDimension(R.styleable.RoundProgressBar_process_roundWidth, 5f) max = mTypedArray.getInteger(R.styleable.RoundProgressBar_process_max, 100) startAngle = mTypedArray.getInteger(R.styleable.RoundProgressBar_process_startAngle, 0) sweepAngle = mTypedArray.getInteger(R.styleable.RoundProgressBar_process_sweepAngle, 360) textIsDisplayable = mTypedArray.getBoolean(R.styleable.RoundProgressBar_textIsDisplayable, true) style = mTypedArray.getInt(R.styleable.RoundProgressBar_process_style, 0) mTypedArray.recycle() } /** * Set the progress. This is a thread safe control. Due to the consideration of multiple lines, you need to synchronously refresh the interface and call postInvalidate() to refresh in a non UI thread * */ @get:Synchronized var progress = 0 @Synchronized set(progress) { @Suppress("NAME_SHADOWING") var progress = progress if (progress < 0) { progress = 0 } if (progress > max) { progress = max } if (progress <= max) { field = max - progress postInvalidate() } } //Set the color circle of inner ring solid var cirCleColor: Int get() = roundColor set(criCleColor) { this.roundColor = criCleColor postInvalidate() } //Generally, you just want to redraw the UI when the View changes. The invalidate() method system automatically calls the onDraw() method of View. var cirCleProgressColor: Int get() = roundProgressColor set(criCleProgressColor) { this.roundProgressColor = criCleProgressColor postInvalidate() } @SuppressLint("DrawAllocation") override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // Get the x coordinate of the center val centre = width / 2 val radius = (centre - roundWidth / 2).toInt() // Radius of torus with(paint) { strokeCap = Paint.Cap.ROUND isAntiAlias = true // Eliminating sawtooth isDither = true //Prevent jitter color = roundColor // Set the color of the torus style = Paint.Style.FILL // Hollow set strokeWidth = roundWidth // Set the width of the torus } canvas.drawCircle(centre.toFloat(), centre.toFloat(), radius.toFloat(), paint) // Draw circles // Limits for the shape and size of arcs used to define val oval = RectF( (centre - radius).toFloat(), (centre - radius).toFloat(), (centre + radius).toFloat(), (centre + radius).toFloat() ) /** * Draw an arc, draw the progress of a circle */ when (style) { //Current circle STROKE -> { paint.style = Paint.Style.STROKE paint.color = roundProgressColor // Set color for progress /** // drawArc - Draw an arc according to the progress // The range of the shape and size of the arc defined by the first parameter // The second parameter is used to set the angle from which the arc is drawn clockwise. 0 is the three o'clock direction // The third parameter is used to set the angle of arc sweeping (anti clockwise is required) // The fourth parameter is used to set whether our arc passes through the circle when drawing // The fifth parameter is to set the properties of our brush object */ canvas.drawArc( oval, startAngle.toFloat(), -(sweepAngle * this.progress / max).toFloat(), false, paint ) } FILL -> { paint.style = Paint.Style.FILL_AND_STROKE // Draw an arc according to the progress if (this.progress != 0) { canvas.drawArc(oval, 0f, (360 * this.progress / max).toFloat(), true, paint) } } else -> { } } } }
The way to call in layout:
<com.test.widget.RoundProgressBar android:id="@+id/rbProgress" android:layout_width="300dp" android:layout_height="300dp" app:process_max="100" app:process_roundColor="#F3F9E8" app:process_roundProgressColor="#8EC31F" app:process_roundWidth="4dp" app:process_startAngle="-90" app:process_sweepAngle="360" />
Call in the interface:
class MainActivity : AppCompatActivity() { companion object { private const val DURATION_TIME = (10 * 1000).toLong() private const val TOTAL_PROGRESS = 100 } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) mTimer.start() } private val mTimer = object : CountDownTimer(DURATION_TIME, 100) { override fun onTick(millisUntilFinished: Long) { // Call every second, how much time is left val progress = (TOTAL_PROGRESS * (DURATION_TIME - millisUntilFinished) / DURATION_TIME) rbProgress.progress = progress.toInt() mTvTime.text = "Count down ${millisUntilFinished / 1000}second" } // completion of enforcement override fun onFinish() { rbProgress.progress = TOTAL_PROGRESS mTvTime.text = "Time out" } } override fun onDestroy() { super.onDestroy() mTimer.cancel() } }
Note: when refreshing the interface, choose invalidate() or postInvalidate()?
Let's start with the difference:
- invalidate()
This method can only be called in the main UI thread, and will refresh the entire View. When the visibility of this View is VISIBLE, the onDraw() method of View will be called.
- postInvalidate()
This method can be called in the non UI thread (any thread) to refresh the UI, not necessarily in the main thread. Because in postInvalidate(), the handler is used to send the message of refreshing the interface to the main thread. Because it is implemented by sending messages, its interface refresh speed may not be as fast as calling invalidate() directly.
So when we are not sure whether the current refresh interface is in the main thread, it is better to use postInvalidate();
If you can be sure, use invalidate(), for example, onTouchEvent() in the touch feedback event is in the main thread, so it is more appropriate to use invalidate().
5. summary
So far, it summarizes the three implementation methods used in the project. However, what this article shows is only the tip of the iceberg in the custom View, which is worth further exploration by developers.
reference material: