04 December 2016

เมื่อเจ้าของบล็อกต้องทำ Recycler View กับเส้นประเจ้าปัญหา

Updated on

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

Requirement

        โดยปกติแล้วการแสดงข้อมูลเป็นรายการจำนวนเยอะๆ ก็คงไม่พ้น Recycler View (อย่างที่รู้กันว่าเจ้าของบล็อกเลิกใช้ List View แล้วนะจ๊ะ)

        แต่ทว่ามีงานหนึ่งที่เจ้าของบล็อกต้องทำ Recycler View ที่มีลักษณะประมาณนี้ (ไม่ได้เหมือนเป๊ะๆ แต่รูปแบบคล้ายๆกัน)


        อาจจะดูเหมือนเป็น 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
<?xml version="1.0" encoding="utf-8"?>
<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 จะมีคำสั่งสำคัญดังนี้ (ขอตัดโค้ดที่ไม่ค่อยจำเป็นออก เพื่อให้ดูได้สะดวก)

DashLineView.java
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Build;
import android.support.annotation.ColorInt;
import android.support.annotation.ColorRes;
import android.support.annotation.RequiresApi;
import android.support.v4.content.ContextCompat;
import android.util.AttributeSet;
import android.view.View;


public class DashLineView extends View {
    private int dotColorResourceId;
    private int dotColor;
    private int dotCount;
    private boolean isMirror;

    private Paint paint;

    ...

    private void setupCanvasComponent() {
        paint = new Paint();
        paint.setColor(getDotColor());
    }

    private void setupStyleable(AttributeSet attrs) {
        TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.DotLineView);
        dotColorResourceId = typedArray.getResourceId(R.styleable.DotLineView_dlv_dotColor, -1);
        dotColor = typedArray.getColor(R.styleable.DotLineView_dlv_dotColor, Color.RED);
        dotCount = typedArray.getInt(R.styleable.DotLineView_dlv_dotCount, 5);
        isMirror = typedArray.getBoolean(R.styleable.DotLineView_dlv_isMirror, false);
        typedArray.recycle();
    }

    ...

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float width = canvas.getWidth();
        float height = canvas.getHeight();
        float dotSpacing = ((height * 2) - (width * dotCount)) / (dotCount + 1);
        int dotCount = Math.round((float) this.dotCount / 2);
        for (int index = 0; index < dotCount; index++) {
            float cx = width / 2f;
            float cy;
            if (isMirror) {
                cy = height - (width * index) - (dotSpacing * (index + 1)) - (width / 2);
            } else {
                cy = (width * index) + (dotSpacing * (index + 1)) + (width / 2);
            }
            float 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 ใน Recycler View ก็จะเป็นแบบนี้

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginEnd="@dimen/margin_padding_large"
    android:layout_marginStart="@dimen/margin_padding_large">

    <com.akexorcist.myapplication.DashLineView
        android:id="@+id/dlv_header"
        android:layout_width="@dimen/line_width"
        android:layout_height="@dimen/line_height"
        android:layout_marginStart="@dimen/line_margin_start"
        app:dlv_dotColor="@color/orange"
        app:dlv_dotCount="@integer/dot_count"
        app:dlv_isMirror="true" />

    <TextView
        android:id="@+id/tv_content"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/dlv_header"
        android:background="@drawable/shape_card_content_background"
        android:textColor="@color/gray"
        android:textSize="@dimen/text_size" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_above="@+id/tv_content"
        android:layout_marginBottom="@dimen/card_title_margin_bottom"
        android:layout_marginStart="@dimen/margin_padding"
        android:layout_toEndOf="@+id/dlv_header"
        android:background="@drawable/shape_card_title_background"
        android:textColor="@color/white"
        android:textSize="@dimen/text_size_large" />

    <TextView
        android:id="@+id/tv_release_date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignEnd="@+id/tv_content"
        android:layout_below="@+id/tv_content"
        android:layout_marginEnd="@dimen/margin_padding"
        android:layout_marginTop="@dimen/margin_padding_small"
        android:textColor="@color/dark_gray"
        android:textSize="@dimen/text_size_small" />

    <com.akexorcist.dashlinerecyclerview.DashLineView
        android:id="@+id/dlv_footer"
        android:layout_width="@dimen/line_width"
        android:layout_height="@dimen/line_height"
        android:layout_below="@+id/tv_content"
        android:layout_marginStart="@dimen/line_margin_start"
        app:dlv_dotColor="@color/orange"
        app:dlv_dotCount="@integer/dot_count" />

</RelativeLayout>


        จะเห็นว่า Attribute XML ช่วยให้เจ้าของบล็อกสะดวกมาก สามารถปรับเปลี่ยนหรือเปลี่ยนจำนวนของเส้นประได้ตามต้องการ (ในตัวอย่างกำหนดไว้ใน Value XML ซึ่งเจ้าของบล็อกไม่ขอพูดถึงว่ากำหนดอะไรไว้ในนั้นบ้าง)

เส้นประที่ Item ตัวแรกและเส้นประที่ Item ตัวสุดท้าย

        Item แต่ละตัวจะมีเส้นประเป็นตัวเชื่อม แต่ถ้าเป็น Item ตัวแรกจะไม่มีเส้นประด้านบน และ Item ตัวสุดท้ายก็จะไม่มีเส้นประด้านล่างเช่นกัน



        ซึ่งไม่ใช่เรื่องยากอะไร เพราะเส้นประทั้ง 2 ส่วน มันคือ View คนละตัวกันอยู่แล้ว ที่เจ้าของบล็อกต้องทำก็แค่เช็คว่า Item ที่แสดงอยู่เป็นตัวแรกหรือตัวสุดท้ายหรือไม่ ถ้าใช่ก็จะให้ซ่อนเส้นประฝั่งที่กำหนดนั่นเอง

...

public class SomethingAdapter extends RecyclerView.Adapter<SomethingViewHolder> {

    ...

    @Override
    public void onBindViewHolder(SomethingViewHolder holder, int position) {
        ...
        
        holder.dlvHeader.setVisibility(position == 0 ? View.INVISIBLE : View.VISIBLE);
        holder.dlvFooter.setVisibility(position == getItemCount() - 1 ? View.INVISIBLE : View.VISIBLE);
    }

    ...
}

เสร็จแล้วจ้า Recycler View กับเส้นประเจ้าปัญหา

        ผลลัพธ์ที่ได้ก็จะเป็นแบบนี้


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

        สำหรับตัวอย่างนี้เจ้าของบล็อกไม่ได้อธิบายอะไรละเอียดมากนักเนื่องจากเป็นแค่การแชร์ไอเดียเฉยๆ ถ้าอยากดูโค้ดตัวอย่างทั้งหมดก็สามารถเข้าไปดูกันได้ที่ Android-DashLineRecyclerView [GitHub]