บทความนี้ก็ขอแชร์ประสบการณ์จากการทำงานจริงๆซักหน่อย เพื่อเป็นไอเดียสำหรับผู้ที่หลงเข้ามาอ่าน ที่อาจจะได้เจอปัญหาคล้ายๆกัน

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]

akexorcist/RecyclerView-DashLine
[Android] How to implement the RecyclerView with dash line between item - akexorcist/RecyclerView-DashLine