บทความนี้ก็ขอแชร์ประสบการณ์จากการทำงานจริงๆซักหน่อย เพื่อเป็นไอเดียสำหรับผู้ที่หลงเข้ามาอ่าน ที่อาจจะได้เจอปัญหาคล้ายๆกัน
Requirement
โดยปกติแล้วการแสดงข้อมูลเป็นรายการจำนวนเยอะๆ ก็คงไม่พ้น RecyclerView
แต่ทว่ามีงานหนึ่งที่เจ้าของบล็อกต้องทำ RecyclerView ที่มีลักษณะประมาณนี้ (ไม่ได้เหมือนเป๊ะๆ แต่รูปแบบคล้ายๆกัน)
อาจจะดูเหมือนเป็น Layout ธรรมดาทั่วๆไป แต่พอลองดูดีๆก็จะเห็นว่าระหว่าง Item แต่ละตัวจะมี “เส้นประเจ้าปัญหา” อยู่ด้านหลังด้วย
แล้วมันเป็นปัญหาได้ยังไง?
เพราะว่ามันเป็นเส้นประที่ดันอยู่เชื่อมระหว่าง Item และเส้นประที่ว่านั้นดันอยู่ร่วมระหว่าง Item ทั้ง 2 ตัว
Item แต่ละตัวก็จะมี Layout เป็นของตัวเอง ดังนั้นการที่จะมี View ซักตัวที่แสดงผลอยู่ระหว่าง Item ทั้ง 2 จึงเป็นไปไม่ได้ (ในแอนดรอยด์)
แล้วแบบนี้จะทำยังไงดีล่ะ?
ตอนที่เจ้าของบล็อกได้ลองทำ Layout แบบง่ายๆดูก็เพิ่งจะรู้นี่แหละว่า Shape Drawable มันแสดงผลเป็นเส้นประไม่ได้ (ทำได้เมื่อเป็นเส้นขอบของวงกลมหรือสี่เหลี่ยมเท่านั้น) ดังนั้นนักพัฒนาบางคนอาจจะใช้วิธีแยก View ของเส้นประเป็นสองส่วน แล้วใช้ภาพสองภาพที่ให้เส้นประมาต่อกันพอดีเป๊ะ ซึ่งวิธีนี้อาจจะแก้ขัดไปได้บ้าง แต่ก็จะมีปัญหาเกี่ยวกับภาพท่ีใช้ เช่นเส้นประที่ต่อกันไม่พอดีหรือเห็นช่องว่างนิดๆถ้าใช้ภาพที่ไม่พอดีกับขนาดของ View
เพราะเหตุนี้วิธีเหล่านั้นจึงข้ามไปครับ เพราะเจ้าของบล็อกต้องหาวิธีที่ดีและยืดหยุ่นกว่านี้
Canvas เอ๋ย ถึงเวลาของเจ้าแล้ว
เมื่อสิ่งที่มีอยู่ในแอนดรอยด์นั้นไม่สามารถตอบโจทย์ได้ซักเท่าไรนัก ก็ต้องยอมลงไปในระดับที่ลึกกว่านั้น นั่นก็คือการวาด Canvas ให้แสดงออกมาเป็นเส้นประเองซะเลย เพราะ Layout ที่นักพัฒนาออกแบบผ่าน Layout XML เมื่อทำงานจริงๆมันก็คือการวาด Canvas ออกมาให้เป็น View ที่ต้องการนั่นเอง ดังนั้นจะเรียกว่า Canvas เป็น Low Level ของการแสดงผลของ View ก็ว่าได้
ก่อนอื่นเจ้าของบล็อกออกแบบ View ของ Item ให้เป็นแบบนี้
จะเห็นว่าเส้นประถูกแบ่งเป็น View 2 ตัวด้วยกัน ซึ่งทั้งสองตัวนั้นจะถูกวาดด้วย Canvas อีกทีนึง
เปลี่ยน View ให้กลายเป็น Custom View
เพื่อให้โค้ดสำหรับ Canvas ไม่ปนกับโค้ดส่วนอื่นหรือการทำงานอื่นๆที่ไม่เกี่ยวข้องกัน ดังนั้นเจ้าของบล็อกจึงเลือกที่จะทำ View ของเส้นประทั้ง 2 ตัวนั้นให้กลายเป็น Custom View ไปเลย
โดย Custom View ดังกล่าวจะสมมติชื่อแบบลวกๆขึ้นมาว่า DashLineView
และ DashLineView จะมี Attribute Layout ด้วย เพื่อความยืดหยุ่นในการนำไปใช้งาน จะได้แก้ไขค่าบางอย่างได้สะดวก
<!-- attrs.xml -->
<resources>
<declare-styleable name="DashLineView">
<attr name="dlv_dotCount" format="integer" />
<attr name="dlv_dotColor" format="color" />
<attr name="dlv_isMirror" format="boolean" />
</declare-styleable>
</resources>
สามารถกำหนดได้ว่าจะเอาเส้นประกี่จุด สีอะไร และ Mirror ในแนวตั้งหรือไม่ (Mirror เป็นค่าที่จำเป็นต้องใช้ในตัวอย่างนี้)
สำหรับคลาส DotLineView จะมีคำสั่งสำคัญดังนี้ (ขอตัดโค้ดที่ไม่ค่อยจำเป็นออก เพื่อให้ดูได้สะดวก)
class DotLineView : View {
@ColorInt
private var dotColor = Color.WHITE
set(value) {
field = value
invalidate()
}
private var dotCount = 5
set(value) {
field = value
invalidate()
}
private var isMirror = false
set(value) {
field = value
invalidate()
}
private val paint = Paint()
/* ... */
private fun setupStyleable(attrs: AttributeSet) {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DotLineView)
dotColor = typedArray.getColor(R.styleable.DotLineView_dlv_dotColor, Color.WHITE)
dotCount = typedArray.getInt(R.styleable.DotLineView_dlv_dotCount, 5)
isMirror = typedArray.getBoolean(R.styleable.DotLineView_dlv_isMirror, false)
typedArray.recycle()
}
private fun setupCanvasComponent() {
paint.color = dotColor
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val width = width.toFloat()
val height = height.toFloat()
val dotSpacing = (height * 2 - width * dotCount) / (dotCount + 1)
val dotCount = (dotCount.toFloat() / 2).roundToInt()
for (index in 0 until dotCount) {
val cx = width / 2f
val cy: Float = if (isMirror) {
height - width * index - dotSpacing * (index + 1) - width / 2
} else {
width * index + dotSpacing * (index + 1) + width / 2
}
val radius = width / 2f
canvas.drawCircle(cx, cy, radius, paint)
}
}
}
การวาด Canvas ใน View ตัวใดตัวหนึ่ง สามารถทำใน onDraw(canvas: Canvas)
ได้เลย ในกรณีที่ค่ามีการเปลี่ยนแปลงและอยากอัปเดต View ใหม่ ก็จะใช้คำสั่ง invalidate()
นั่นเอง
สำหรับโค้ดหยุบหยับใน onDraw(canvas: Canvas)
ก็คือคำสั่งวาดเส้นประนั่นเอง โดยจะคำนวณจากขนาดของ View และจำนวนเส้นประที่ต้องการ
แต่ทีนี้เส้นประนั้นมีทั้งข้างบนและข้างล่าง จึงอาจจะเกิดกรณีที่เส้นประอยู่เหลื่อมกันระหว่าง View ทั้งสองตัวพอดี ดังนั้นจึงต้องมีการคำนวณการวาดเส้นประให้สามารถอยู่ระหว่าง View ทั้งสองได้ และเส้นประยังต่อกันสนิทพอดี
โดยความกว้างของเส้นประนั้นจะอิงจากความกว้างของ DashLineView เลย เพื่อความง่าย ส่วนการคำนวณเรื่องระยะห่างของเส้นประแต่ละจุดนั้นจะต้องคำนวณรวมกันทั้งฝั่งบนและล่าง ซึ่งเจ้าของบล็อกกำหนดไว้ว่าทั้งสองฝั่งจะมีความสูงเท่ากัน (เพื่อความง่ายในการเขียนโค้ดคำนวณ)
ซึ่งคำสั่งตัวอย่างนี้เขียนแบบง่ายๆ โดยเส้นประแต่ละฝั่งจะถูกวาดเหมือนๆกัน แต่ว่าเส้นประข้างล่างจะ Mirror ในแนวตั้ง เพื่อให้รอยต่อของเส้นประนั้นประกบกันเนียนกริ๊บ (ที่มาของ Attribute ที่ชื่อ isMirror
)
แต่จริงๆแล้วโค้ด Canvas ในตัวอย่างก็ยังไม่ได้สมบูรณ์มากนัก เพราะว่าเส้นประจะถูกวาดทั้งหมด ถึงแม้ว่าจะอยู่ในพื้นที่ที่มองไม่เห็น ทางที่ดีควรมีการคำนวณด้วยว่าเส้นประนั้นๆอยู่ในพื้นที่ของ View หรือไม่ เพื่อจะได้ไม่สิ้นเปลืองการทำงานเกินจำเป็น
สำหรับระยะห่างระหว่างเส้นประแต่ละจุดนั้นจะใช้วิธีคำนวณจากพื้นที่ทั้งหมดโดยอัตโนมัติ เพื่อความสะดวกและง่ายต่อการใช้งาน
นำ DashLineView ไปใช้งาน
เมื่อสร้าง Custom View สำหรับเส้นประเสร็จแล้ว การสร้าง View สำหรับ Item ใน RecyclerView ก็จะเป็นแบบนี้
<!-- view_android_info.xml -->
<androidx.constraintlayout.widget.ConstraintLayout>
<com.akexorcist.dashlinerecyclerview.DotLineView
android:id="@+id/dlv_header"
android:layout_width="5dp"
android:layout_height="30dp"
android:layout_marginStart="30dp"
app:dlv_dotColor="@color/orange"
app:dlv_dotCount="5"
app:dlv_isMirror="true"
... />
<!-- Another view -->
<com.akexorcist.dashlinerecyclerview.DotLineView
android:id="@+id/dlv_footer"
android:layout_width="5dp"
android:layout_height="30dp"
android:layout_below="@+id/tv_content"
android:layout_marginStart="30dp"
app:dlv_dotColor="@color/orange"
app:dlv_dotCount="5"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
จะเห็นว่า Attribute XML ช่วยให้เจ้าของบล็อกสะดวกมาก สามารถปรับเปลี่ยนหรือเปลี่ยนจำนวนของเส้นประได้ตามต้องการ
เส้นประที่ Item ตัวแรกและเส้นประที่ Item ตัวสุดท้าย
Item แต่ละตัวจะมีเส้นประเป็นตัวเชื่อม แต่ถ้าเป็น Item ตัวแรกจะไม่มีเส้นประด้านบน และ Item ตัวสุดท้ายก็จะไม่มีเส้นประด้านล่างเช่นกัน
ซึ่งไม่ใช่เรื่องยากอะไร เพราะเส้นประทั้ง 2 ส่วน มันคือ View คนละตัวกันอยู่แล้ว ที่เจ้าของบล็อกต้องทำก็แค่เช็คว่า Item ที่แสดงอยู่เป็นตัวแรกหรือตัวสุดท้ายหรือไม่ ถ้าใช่ก็จะให้ซ่อนนั่นเอง
// AndroidInfoViewHolder.kt
class AndroidInfoViewHolder(
val binding: ViewAndroidInfoBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(
info: AndroidInfo,
position: Int,
total: Int
) {
/* ... */
binding.dlvHeader.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE
binding.dlvFooter.visibility = if (position == total - 1) View.INVISIBLE else View.VISIBLE
}
}
เสร็จแล้วจ้า Recycler View กับเส้นประเจ้าปัญหา
ผลลัพธ์ที่ได้ก็จะเป็นแบบนี้
จะเห็นว่าในบางครั้งเจ้าของบล็อกก็ต้องเข้าไปเขียนอะไรที่มัน Low Level จริงๆเพื่อให้ตอบโจทย์กับงานที่ต้องการ ซึ่งผู้ที่หลงเข้ามาอ่านสามารถนำไอเดียนี้ไปปรับใช้งานได้ตามความต้องการ
สำหรับตัวอย่างนี้เจ้าของบล็อกไม่ได้อธิบายอะไรละเอียดมากนักเนื่องจากเป็นแค่การแชร์ไอเดียเฉยๆ ถ้าอยากดูโค้ดตัวอย่างทั้งหมดก็สามารถเข้าไปดูกันได้ที่ RecyclerView-DashLine [GitHub]