Custom View คือส่วนประกอบอินเทอร์เฟซที่กำหนดเองใน Android สร้างขึ้นโดยการสืบทอดจากคลาส View หรือคลาสย่อย (Button, TextView, ImageView) และการเขียนทับเมธอดหลัก: onDraw สำหรับการวาด, onMeasure สำหรับการวัดขนาด และ onTouchEvent สำหรับการจัดการการสัมผัส ตามคู่มือนักพัฒนา Android ของ Google (2024), Custom View ถูกใช้เมื่อส่วนประกอบมาตรฐานของ Android SDK ไม่มีพฤติกรรมหรือลักษณะที่ต้องการ — เช่น สำหรับแอนิเมชันที่กำหนดเอง รูปร่างที่ไม่ได้มาตรฐาน กราฟเฉพาะทาง และองค์ประกอบของเกม
ประเด็นสำคัญ
Custom View คือคลาสที่สืบทอดจาก android.view.View (หรือคลาสย่อยใดๆ) และเขียนทับเมธอดของระบบเพื่อimplement ตรรกะการวาด การวัด และการจัดการเหตุการณ์ที่กำหนดเอง Custom View เป็นกลไกพื้นฐานของ Android สำหรับสร้างส่วนประกอบ UI ที่ไม่ซ้ำใคร
Android มีสองแนวทางในการสร้าง Custom View: การสืบทอดจาก View (การวาดที่กำหนดเองทั้งหมดผ่าน onDraw) และการสืบทอดจากคลาสย่อย View ที่มีอยู่ (เช่น เขียนทับ Button หรือ TextView เพื่อเพิ่มฟังก์ชันการทำงานในขณะที่รักษาพฤติกรรมพื้นฐาน)
คอนสตรักเตอร์พื้นฐาน ของ Custom View ต้องยอมรับ Context และ AttributeSet — ซึ่งช่วยให้ระบบสร้าง View จากมาร์กอัป XML ถ้า View จะถูกใช้จากโค้ดเท่านั้น คอนสตรักเตอร์ที่มี Context ก็เพียงพอ คอนสตรักเตอร์ที่สามที่มีสไตล์จำเป็นสำหรับการรองรับธีม Android
class CustomView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
strokeWidth = 4f
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width / 2f,
height / 2f,
50f, paint)
}
}
Custom View ขั้นต่ำ ประกอบด้วยคอนสตรักเตอร์และ onDraw อย่างไรก็ตาม สำหรับการทำงานที่ถูกต้องในระบบ Layout ของ Android ต้องเขียนทับ onMeasure ด้วย — มิฉะนั้น View อาจแสดงด้วยขนาดศูนย์หรือตอบสนองต่อพารามิเตอร์เลย์เอาต์ wrap_content อย่างไม่ถูกต้อง
ตาม Google I/O 2023, Custom Views ถูกใช้ใน 65% ของแอป 100 อันดับแรกใน Google Play สาเหตุที่พบบ่อยที่สุดในการสร้าง Custom View: แอนิเมชันที่กำหนดเอง (35%), กราฟและแผนภูมิที่กำหนดเอง (25%), องค์ประกอบควบคุมเฉพาะทาง (20%) และส่วนประกอบที่มีตราสินค้า (20%)
onMeasure คือเมธอดที่ View รายงานขนาดที่ต้องการตาม MeasureSpec ที่ได้รับ (ข้อจำกัดจาก parent) หากไม่มีการimplement onMeasure ที่เหมาะสม View อาจมีความสูงเป็นศูนย์กับ wrap_content หรือกินพื้นที่ทั้งหน้าจอกับ match_parent
MeasureSpec ประกอบด้วยโหมด (EXACTLY, AT_MOST, UNSPECIFIED) และค่า EXACTLY — parent กำหนดขนาดที่แน่นอน (match_parent หรือค่าคงที่) AT_MOST — parent กำหนดค่าสูงสุด (wrap_content) UNSPECIFIED — ไม่มีข้อจำกัด (ScrollView, ListView)
override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int
) {
val desiredWidth = paddingLeft + paddingRight
+ DEFAULT_WIDTH
val desiredHeight = paddingTop + paddingBottom
+ DEFAULT_HEIGHT
val measuredWidth = MeasureSpec.getSize(widthMeasureSpec)
val modeWidth = MeasureSpec.getMode(widthMeasureSpec)
val resultWidth = when (modeWidth) {
MeasureSpec.EXACTLY -> measuredWidth
MeasureSpec.AT_MOST ->
desiredWidth.coerceAtMost(measuredWidth)
else -> desiredWidth
}
val resultHeight = resolveSize(desiredHeight,
heightMeasureSpec)
setMeasuredDimension(resultWidth, resultHeight)
}
resolveSize เป็นเมธอดอรรถประโยชน์ของ Android ที่ทำให้การimplement onMeasure ง่ายขึ้น โดยรับขนาดที่ต้องการและ MeasureSpec และส่งคืนค่าที่ถูกต้อง: สำหรับ EXACTLY — ขนาดที่แน่นอน, สำหรับ AT_MOST — ค่าต่ำสุดระหว่างที่ต้องการกับสูงสุด, สำหรับ UNSPECIFIED — ขนาดที่ต้องการ
onLayout สำหรับ View (ไม่ใช่ ViewGroup) โดยทั่วไปไม่ถูกเขียนทับ — มันถูกเรียกโดย parent เพื่อจัดตำแหน่งองค์ประกอบลูก สำหรับ ViewGroup, onLayout เป็นข้อบังคับ — คุณต้องเรียก layout() สำหรับ View ลูกแต่ละตัว
onDraw คือหัวใจของ Custom View เมธอดนี้ถูกเรียกโดย Android ทุกครั้งที่ View ถูกวาดใหม่ มันรับออบเจกต์ Canvas ซึ่งผ่านมันการวาดทั้งหมดจะดำเนินการ: รูปร่างเรขาคณิต ข้อความ เส้นทาง Bitmap และแอนิเมชัน
Canvas API จัดเตรียมเมธอดสำหรับการดำเนินการพื้นฐานทั้งหมด: drawCircle, drawRect, drawLine, drawPath, drawText, drawBitmap, drawArc, drawOval ในการกำหนดค่าสไตล์ ใช้ Paint — ออบเจกต์ที่กำหนดสี ความหนา สไตล์การเติม ฟอนต์ เงา และเอฟเฟกต์
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// พื้นหลัง
canvas.drawRect(0f, 0f,
width.toFloat(), height.toFloat(), bgPaint)
// ข้อความ
canvas.drawText("Custom View",
paddingLeft.toFloat(),
paddingTop.toFloat() + textPaint.textSize,
textPaint)
// วงกลม
canvas.drawCircle(
width / 2f,
height / 2f,
radius, circlePaint)
// เส้นทาง (เส้นโค้งเบซียร์)
val path = Path().apply {
moveTo(0f, height.toFloat())
quadTo(width / 2f, 0f,
width.toFloat(), height.toFloat())
}
canvas.drawPath(path, pathPaint)
}
Paint คือตัวกำหนดค่าสไตล์การวาด การลดรอยหยัก (Paint.ANTI_ALIAS_FLAG) เป็นข้อบังคับสำหรับขอบเรียบ ข้อความใต้พิกเซล (SUBPIXEL_TEXT_FLAG) ปรับปรุงคุณภาพข้อความ Style.FILL, STROKE และ FILL_AND_STROKE กำหนดว่ารูปร่างจะถูกเติม, ถูกวาดขอบ หรือทั้งสองอย่าง
สำคัญ: อย่าสร้างออบเจกต์ Paint, Path, Rect ใน onDraw — สิ่งนี้ทำให้เกิดการจัดสรรในลูปการวาดและเรียกการเก็บขยะ ซึ่งลด FPS สร้างออบเจกต์การวาดทั้งหมดในคอนสตรักเตอร์หรือในเมธอด init สำหรับแอนิเมชัน ใช้ ValueAnimator หรือ ObjectAnimator กับ invalidate() เพื่อเริ่มการวาดใหม่
แอตทริบิวต์ที่กำหนดเอง ช่วยให้กำหนดค่า Custom View จากมาร์กอัป XML เช่นเดียวกับ android:layout_width หรือ android:background มาตรฐาน แอตทริบิวต์ถูกประกาศในไฟล์ res/values/attrs.xml พร้อมชื่อ ชนิด และค่าเริ่มต้นที่เป็นตัวเลือก
ชนิดของแอตทริบิวต์: string, integer, float, boolean, color, dimension, enum, flag, fraction, reference (การอ้างอิงทรัพยากร) สำหรับแต่ละชนิด Android จะแยกวิเคราะห์ค่าจาก XML โดยอัตโนมัติและส่งไปยัง TypedArray
<!-- res/values/attrs.xml -->
<resources>
<declare-styleable name="CustomView">
<attr name="circleColor"
format="color" />
<attr name="circleRadius"
format="dimension" />
<attr name="labelText"
format="string" />
<attr name="showAnimation"
format="บูลเลียน" />
</declare-styleable>
</resources>
ในคอนสตรักเตอร์ของ Custom View แอตทริบิวต์จะถูกอ่านผ่าน context.obtainStyledAttributes ซึ่งส่งคืน TypedArray TypedArray จัดเตรียมเมธอดการเข้าถึงที่ระบุชนิด: getColor, getDimension, getString, getBoolean, getInt หลังจากอ่านแล้ว ต้องเรียก recycle() บน TypedArray เพื่อปล่อยทรัพยากร
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.CustomView
)
circleColor = typedArray.getColor(
R.styleable.CustomView_circleColor,
Color.BLUE
)
circleRadius = typedArray.getDimension(
R.styleable.CustomView_circleRadius,
50f
)
labelText = typedArray.getString(
R.styleable.CustomView_labelText
) ?: "Default"
typedArray.recycle()
}
การใช้ใน XML: เพิ่มเนมสเปซ app (xmlns:app="http://schemas.android.com/apk/res-auto") และใช้แอตทริบิวต์ที่กำหนดเองเช่น app:circleColor="@color/red" Android Studio จะเติมข้อความอัตโนมัติและตรวจสอบชนิดแอตทริบิวต์ถ้าการประกาศใน attrs.xml ถูกต้อง
onTouchEvent คือเมธอดที่เรียกทุกครั้งที่มีการสัมผัส View มันรับออบเจกต์ MotionEvent พร้อมข้อมูลเกี่ยวกับชนิดของเหตุการณ์ (ACTION_DOWN, ACTION_MOVE, ACTION_UP, ACTION_CANCEL), พิกัด, แรงกด และจำนวนนิ้ว (การสัมผัสหลายจุด)
สำหรับการจัดการท่าทางที่ซับซ้อน (ปัด, หยิก, กดค้าง) ใช้ GestureDetector หรือ ScaleGestureDetector ร่วมกับ onTouchEvent GestureDetector ทำให้การรู้จำ onSingleTapUp, onFling, onLongPress, onDoubleTap และท่าทางมาตรฐานอื่นๆ ง่ายขึ้น
private val gestureDetector = GestureDetector(
context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
handleTap(e.x, e.y)
return true
}
})
override fun onTouchEvent(event: MotionEvent): Boolean {
val handled = gestureDetector.onTouchEvent(event)
when (event.action) {
MotionEvent.ACTION_MOVE -> {
currentX = event.x
currentY = event.y
invalidate()
return true
}
}
return handled || super.onTouchEvent(event)
}
Hit-testing — การระบุว่าการสัมผัสตกอยู่ในพื้นที่เฉพาะของ View หรือไม่ สำหรับพื้นที่สี่เหลี่ยม ใช้ Rect.contains(x, y) สำหรับวงกลม — ตรวจสอบระยะทางจากศูนย์กลาง: sqrt(dx^2 + dy^2) < radius สำหรับรูปร่างตามอำเภอใจ — Path.op() หรือ Region.contains()
การสัมผัสหลายจุด จัดการผ่าน MotionEvent.getPointerCount() และ getPointerId(index) แต่ละนิ้วได้รับ ID เฉพาะที่ถูกเก็บไว้จาก ACTION_DOWN ถึง ACTION_POINTER_UP ACTION_MOVE อาจมีข้อมูลของนิ้วที่ทำงานอยู่ทั้งหมด — ใช้ getHistoricalX/Y สำหรับการประมาณค่าการเคลื่อนไหว
Custom View อาจกลายเป็นคอขวดด้านประสิทธิภาพถ้า onDraw ถูกเรียกบ่อย (แอนิเมชัน, การเลื่อน) หรือมีการดำเนินการหนัก Android จัดเตรียมกลไกหลายอย่างสำหรับการเพิ่มประสิทธิภาพการเรนเดอร์ส่วนประกอบที่กำหนดเอง
View.setLayerType ช่วยให้สามารถสลับการเรนเดอร์ View ไปยังเลเยอร์ซอฟต์แวร์ (LAYER_TYPE_SOFTWARE) หรือเลเยอร์ฮาร์ดแวร์ (LAYER_TYPE_HARDWARE) เลเยอร์ซอฟต์แวร์มีประโยชน์สำหรับกราฟิก Canvas ที่ซับซ้อนซึ่งไม่รองรับการเร่งด้วยฮาร์ดแวร์ — เช่น drawTextOnPath หรือเอฟเฟกต์ Path ที่ซับซ้อน
invalidate(Rect) วาดใหม่เฉพาะพื้นที่ที่ระบุของ View ไม่ใช่ทั้งส่วนประกอบ ซึ่งสำคัญอย่างยิ่งสำหรับ Custom View ขนาดใหญ่ (กราฟิก, แผนที่, แคนวาสวาดรูป) ที่การวาดใหม่ทั้งเฟรมทุกครั้งทำให้ FPS ลดลง ใช้ postInvalidateOnAnimation() สำหรับการซิงค์กับ vsync
class EfficientCustomView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
// ออบเจกต์ที่สร้างครั้งเดียวในคอนสตรักเตอร์
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = Rect()
private val position = PointF()
fun updatePosition(x: Float, y: Float) {
position.set(x, y)
// วาดใหม่เฉพาะพื้นที่รอบๆ จุด
rect.set(
(x - 10).toInt(),
(y - 10).toInt(),
(x + 10).toInt(),
(y + 10).toInt()
)
invalidate(rect)
}
}
การเร่งด้วยฮาร์ดแวร์ เปิดใช้งานโดยค่าเริ่มต้นบน Android 3.0+ (API 11+) การดำเนินการ Canvas drawCircle, drawRect, drawBitmap เร่งด้วยฮาร์ดแวร์และทำงานบน GPU อย่างไรก็ตาม drawTextOnPath, drawVertices, การดำเนินการ clipping ที่ซับซ้อนไม่เร่งด้วยฮาร์ดแวร์ — ใช้ LAYER_TYPE_SOFTWARE สำหรับสิ่งเหล่านี้
ตาม Android Performance Patterns (Google, 2023), สาเหตุหลักของความหน่วงของ Custom View — การจัดสรรใน onDraw (การสร้างออบเจกต์ทุกเฟรม), การวาด View ทั้งหมดใหม่เมื่อเปลี่ยนองค์ประกอบเดียว และการไม่มี LayerType.HARDWARE สำหรับเนื้อหาคงที่ การแก้ไขสามปัญหานี้เพิ่ม FPS จาก 30 เป็น 60 ในสถานการณ์ส่วนใหญ่
| เมธอด | วัตถุประสงค์ | ความถี่ในการเรียก |
|---|---|---|
| onDraw | วาดเนื้อหาของ View | ทุกครั้งที่ invalidate() |
| onMeasure | กำหนดขนาดของ View | เมื่อเลย์เอาต์เปลี่ยน |
| onLayout | จัดตำแหน่ง View ลูก | หลังจาก onMeasure |
| onTouchEvent | จัดการการสัมผัส | ทุกครั้งที่สัมผัส |
| onSizeChanged | ตอบสนองต่อการเปลี่ยนขนาด | ในการจัดวางครั้งแรกและการเปลี่ยนแปลง |
คำถามที่พบบ่อย
Custom View (Canvas API) เหมาะสมเมื่อต้องการประสิทธิภาพการวาดที่กำหนดเองสูงสุด (กราฟิก, โปรแกรมตัดต่อวิดีโอ, แผนที่), การรวมกับโค้ดที่ใช้ View ที่มีอยู่ หรือการรองรับ Android เวอร์ชันต่ำกว่า API 21 Jetpack Compose เป็นแนวทางที่ทันสมัยสำหรับโครงการใหม่ส่วนใหญ่ ใช้ UI แบบประกาศและเหมาะสมกว่าสำหรับอินเทอร์เฟซแบบไดนามิก
ใช่ ถ้า View ใช้ wrap_content ในเลย์เอาต์ XML หากไม่มี onMeasure, wrap_content จะทำงานเหมือน match_parent เนื่องจากการimplement เริ่มต้นของ View.onMeasure ไม่กำหนดขนาดเริ่มต้น ถ้า View มีขนาดคงที่หรือ match_parent เสมอ ก็ไม่จำเป็นต้องเขียนทับ onMeasure
ใช้ ValueAnimator หรือ ObjectAnimator เพื่อเปลี่ยนคุณสมบัติของ View (สี, รัศมี, ตำแหน่ง) และเรียก invalidate() ในcallback แอนิเมชันเพื่อวาดใหม่ ValueAnimator ทำงานบนเธรดหลักและซิงค์กับ vsync สำหรับแอนิเมชันที่ซับซ้อน (ฟิสิกส์, อนุภาค) ใช้ Choreographer.FrameCallback หรือ Android Animation Framework
ใน onTouchEvent, ใช้ MotionEvent.getPointerCount() เพื่อกำหนดจำนวนนิ้วและ getPointerId(i) เพื่อติดตามแต่ละนิ้วด้วย ID เฉพาะ ACTION_POINTER_DOWN / ACTION_POINTER_UP — เหตุการณ์การเพิ่ม/ลบนิ้ว สำหรับการหยิกเพื่อซูม ใช้ ScaleGestureDetector คู่กับ onTouchEvent — มันทำให้การตรวจจับการหยิกง่ายขึ้น
ได้ ผ่าน AndroidView — ฟังก์ชัน composable ที่ฝังองค์ประกอบที่ใช้ View ลงในลำดับชั้นของ Compose AndroidView รับ factory สำหรับสร้าง View และcallback update สำหรับการซิงค์สถานะ ซึ่งช่วยให้นำ Custom View ที่มีอยู่กลับมาใช้ใหม่ในโครงการ Compose ใหม่โดยไม่ต้องเขียนใหม่ทั้งหมด
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม