前言
Android自定義控件,在項目中運用十分廣泛,好多看上去炫酷的特效或是產品想要的效果,原生的系統控件中沒有直接提供,此時我們就需要自定義控件。而自定義控件的難易程度不同,遇到想要的控件,我們首先想到是的看看有沒有開源的輪子,有就直接拿來用。如果沒有的話,我們就要自己想辦法造。造輪子的過程可能並不容易,此時我們就需要了解一下自定義的原理和流程。本文總結了自定義控件的流程和幾種常用的實現方式,同時結合項目實際運用做一個小結。僅供你參考,如有疑問,歡迎留言討論。
文章目錄
自定義View的難度也分等級,常用的有三種實現方式。實現方式由易到難。
1.自定義View分類
1.1 繼承現有控件
繼承具體的控件,如TextView
,Button
,EditText
等等,對其控件的功能進行拓展。
如想要實現自定義字體、文本輸入框中顯示分段的電話號碼等。(參考下文實現方式一)
1.2 將現有控件進行組合
繼承ViewGroup,如LinearLayout
,FrameLayout等等
,實現功能更加強大控件。
這裏不用重寫onMeasure
、onLayout
等方法。如實現標題欄等。(參考下文實現方式二)
1.3 重寫View實現全新的控件
繼承View,這個難度最大,功能也最強大。需要掌握繪製的原理和步驟。
如實現倒計時進條等。(參考下文實現方式三)
2. Android之View座標系
首先要明確一下View座標系的獲取,Android中的座標系和數學中的座標系是不同的。
Android中的座標系統 :屏幕的左上角是座標系統原點(0,0),原點向右延伸是X軸正方向,原點向下延伸是Y軸方向。
X、Y軸方向:
視圖座標系:
2.1 View 當中的方法
View獲取自身的座標:
- getTop(): 獲取 view 本身頂部到父容器 ViewGroup 頂部的距離。
- getBottom(): 獲取 view 本身底部到父容器 ViewGroup 頂部的距離。
- getLeft(): 獲取 view 本身左側到父容器 ViewGroup 左側的距離。
- getRight(): 獲取 view 本身右側到父容器 ViewGroup 左側的距離。
View獲取自身寬高:
- getHeight():獲取View的高度
- getWidth():獲取View的寬度
2.2 MotionEvent當中的方法
-
getX(): 獲取點擊事件相對控件左邊的x軸座標,即點擊事件距離控件左邊的距離。
-
getY():獲取點擊事件相對控件頂邊的y軸座標,即點擊事件距離控件頂邊的距離。
-
getRawX():獲取點擊事件相對整個屏幕左邊的x軸座標,即點擊事件距離整個屏幕左邊的距離。
-
getRawY():獲取點擊事件相對整個屏幕頂邊的y軸座標,即點擊事件距離整個屏幕頂邊的距離。
3. 自定義View的流程
View的繪製基本由onMeasure()
、onLayout()
、onDraw()
這個三個函數完成
函數 | 作用 | 相關方法 |
---|---|---|
onMeasure() |
測量View的寬高 | setMeasuredDimension() ,onMeasure() |
onLayout() |
計算當前View以及子View的位置 | onLayout() ,setFrame() |
onDraw() |
視圖的繪製工作 | onDraw() |
如我們在代碼中自定義CustomView
class CustomView : View {
constructor(context: Context?) : super(context)
/**
* 在xml佈局文件中使用時自動調用
*/
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
/**
* 不會自動調用,如果有默認style時,在第二個構造函數中調用
*/
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)
}
}
上面的代碼是主要用到了構造方法和重寫的方法來實現想要的效果。
說明:
- 構造函數是View的入口,用於初始化一些的內容,和獲取自定義屬性。
- 無論是我們繼承系統View還是直接繼承View,都需要對構造函數進行重寫。
- 構造函數有多個,是爲了兼容低版本,現在在項目中,一般重寫第三個函數即可。
-
在實際項目中 除了構造函數是必須實現的,其他三個方法根據情況可選實現即可。
也就是說,繪製的流程雖然有
onMeasure()
、onLayout()
、onDraw()
這三個方法,不一定都需要重寫,只需要改變需要的方法就行了接下來,進行實例說明。
4. 項目中用到的自定義View
-
電話號碼輸入框分段顯示
-
封裝通用的標題欄
-
實現倒計時進度條
4.1 實現方式一:自定義EditText
如實現輸入電話號碼 分段顯示。
//輸入電話號碼 分段顯示 如: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) {
//這幾句要加,不然每輸入一個值都會執行兩次onTextChanged(),導致堆棧溢出
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@TelEditText.setSelection(finalString.length)
}
}
override fun afterTextChanged(s: Editable) {
}
})
}
// 獲得不包含空格的手機號
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
}
}
在佈局界面中調用:
<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" />
使用時跟EditText
類似,只是對屬性和顯示做了功能擴展。
4.2 實現方式二:自定義標題欄
效果圖如下:
1.先在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.自定義屬性 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.在代碼中實現
/**
* 自定義標題欄控件
*/
class TitleLayout : FrameLayout {
// 默認背景顏色
private val defaultBackgroundColor = ResourcesUtil.getColor(R.color.common_black_1F)
// 是否打斷返回上一個界面,這個值被調用在返回監聽之後
private var isInterruptBack = false
// 返回點擊監聽
private var backClickListener: ((View) -> Unit)? = null
constructor(context: Context) : this(context, null, 0)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
//在該函數中實現
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()
}
}
}
}
在佈局文件中調用:
<cc.test.package.common.widget.TitleLayout
android:id="@id/titleLayout"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:title_layout_titleText="標題名字" />
在Activity界面中調用:
//標題的名字及事件 可放在此處處理
//如果不重寫該返回事件,則默認返回事件爲關閉界面
titleLayout.setOnBackClickListener {
//重寫該方法,實現想要的返回邏輯業務
}
4.3 實現方式三:繼承View進行重寫
如實現倒計時功能
效果中下所示:
代碼中實現方式:
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)
}
/**
* 畫筆對象的引用
*/
private var paint = Paint()
/**
* 圓環的顏色
*/
private var roundColor = 0
/**
* 圓環進度的顏色
*/
private var roundProgressColor = 0
/**
* 中間進度百分比的字符串的顏色
*/
private var textColor = 0
/**
* 中間進度百分比的字符串的字體
*/
private var textSize = 0f
/**
* 圓環的寬度
*/
private var roundWidth = 0f
/**
* 最大進度
*/
var max = 0
/**
* 進度圓環的起始角度 角度 0爲三點鐘方向
*/
private var startAngle = 0
/**
* 進度圓環的掃描角度
*/
private var sweepAngle = 0
/**
* 是否顯示中間的進度
*/
private var textIsDisplayable = false
/**
* 進度的風格,實心或者空心
*/
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)
// 獲取自定義屬性和默認值
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()
}
/**
* 設置進度,此爲線程安全控件,由於考慮多線的問題,需要同步 刷新界面調用postInvalidate()能在非UI線程刷新
*
*/
@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()
}
}
//設置內環圈圈實心的顏色 圓形
var cirCleColor: Int
get() = roundColor
set(criCleColor) {
this.roundColor = criCleColor
postInvalidate()
}
//一般只是希望在View發生改變時對UI進行重繪。invalidate()方法系統會自動調用 View的onDraw()方法。
var cirCleProgressColor: Int
get() = roundProgressColor
set(criCleProgressColor) {
this.roundProgressColor = criCleProgressColor
postInvalidate()
}
@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 獲取圓心的x座標
val centre = width / 2
val radius = (centre - roundWidth / 2).toInt() // 圓環的半徑
with(paint) {
strokeCap = Paint.Cap.ROUND
isAntiAlias = true // 消除鋸齒
isDither = true //防止抖動
color = roundColor // 設置圓環的顏色
style = Paint.Style.FILL // 設置空心
strokeWidth = roundWidth // 設置圓環的寬度
}
canvas.drawCircle(centre.toFloat(), centre.toFloat(), radius.toFloat(), paint) // 畫出圓環
// 用於定義的圓弧的形狀和大小的界限
val oval = RectF(
(centre - radius).toFloat(),
(centre - radius).toFloat(),
(centre + radius).toFloat(),
(centre + radius).toFloat()
)
/**
* 畫圓弧 ,畫圓環的進度
*/
when (style) {
//當前的圓形
STROKE -> {
paint.style = Paint.Style.STROKE
paint.color = roundProgressColor // 設置進度的顏色
/**
// drawArc - 根據進度畫圓弧
// 第一個參數定義的圓弧的形狀和大小的範圍
// 第二個參數的作用是設置圓弧是從哪個角度來順時針繪畫的 0爲三點鐘方向
// 第三個參數 這個參數的作用是設置圓弧掃過的角度 (要求逆時針)
// 第四個參數 這個參數的作用是設置我們的圓弧在繪畫的時候,是否經過圓形
// 第五個參數 這個參數的作用是設置我們的畫筆對象的屬性
*/
canvas.drawArc(
oval,
startAngle.toFloat(),
-(sweepAngle * this.progress / max).toFloat(),
false,
paint
)
}
FILL -> {
paint.style = Paint.Style.FILL_AND_STROKE
// 根據進度畫圓弧
if (this.progress != 0) {
canvas.drawArc(oval, 0f, (360 * this.progress / max).toFloat(), true, paint)
}
}
else -> {
}
}
}
}
在佈局中調用方式:
<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" />
在界面中調用:
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) {
// 每隔一秒調用一次,剩餘多少時間
val progress = (TOTAL_PROGRESS * (DURATION_TIME - millisUntilFinished) / DURATION_TIME)
rbProgress.progress = progress.toInt()
mTvTime.text = "倒計時${millisUntilFinished / 1000}秒"
}
// 執行完畢
override fun onFinish() {
rbProgress.progress = TOTAL_PROGRESS
mTvTime.text = "時間到"
}
}
override fun onDestroy() {
super.onDestroy()
mTimer.cancel()
}
}
說明:刷新界面時選擇invalidate()
還是postInvalidate()
呢?
首先說一下區別:
- invalidate()
該方法只能在UI主線程中去調用,會刷新整個View,並且當這個View的可見性VISIBLE
的時候,View的onDraw()
方法將會被調用。
-
postInvalidate()
該方法是可以在非UI線程(任何線程)中去調用刷新UI,不一定是在主線程。因爲在postInvalidate()中是利用handler給主線程發送刷新界面的消息來實現的。而正是因爲它是通過發送消息來實現的,所以它的界面刷新速度可能沒有直接調用invalidate()那麼快。
所以當我們不確定當前刷新界面的位置所處的線程是不是在主線程時,用 postInvalidate()爲好;
如果能確定就用 invalidate(),比如在觸摸反饋事件中 onTouchEvent()是在主線程中的,所以用invalidate()更合適。
5. 總結
本文總結了項目中用到的三種實現方式。不過,文中所展示的僅僅是自定義View中的冰山一角,自定義View值得開發人員深入挖掘。後期會持續更新項目用到的自定義View。To be continued……
參考資料: