มาสร้าง Expected Ratio ให้ ImageView กัน

ในโลกของการสร้าง App Android ทุกคนต้องเจอ 2 คำนี้จนชินคือ “match_parent” และ “wrap_content”, คำ 2 คำที่กล่าวไปคือค่าของ “สัดส่วน” ที่มักตั้งค่าให้กับ width หรือ height ของ view — แต่ถ้าเรามีรูปภาพที่เป็นสัดส่วนของมันอยู่แล้ว แล้วอยากทำ ImageView ที่รักษาสัดส่วนของมันไว้หล่ะ เราจะทำอย่างไรได้บ้าง มาอ่าน บทความนี้กัน


Resource ที่จำเป็นสำหรับบทความนี้

ก่อนอื่นสำหรับผู้ที่สนใจทำตามบทความ สามารถเข้าไปโคลนโค้ดได้เลยเด้อ

ควรโฟกัสตรงไหนของโปรเจค

บทความนี้เกี่ยวข้องกับ Workshop2

ในบทความนี้โค้ดจะเกียวข้องกับ workshop2 นาจา ถ้าเห็นโค้ด workshop1 แล้วสนใจสามารถกด อ่านต่อ ได้เลยจ้า


โจทย์

Designer ได้ออกแบบภาพมาสวยมากกกกก — และสัดส่วนที่เหมาะสมคือ 2:1 โดยให้ความกว้างปูเต็มความกว้างจอ แล้วให้ภาพสูงเป็น 1 ส่วนของความยาวภาพ ดังรูป และไม่อยากให้ภาพโดน crop เลย ไม่ว่าจอจะขนาดใดก็ตามต้องรักษาสัดส่วนไว้เสมอ

Design เบื้องต้น

แน่นอนว่าวิธีแก้ปัญหามีหลายวิธี วันนี้จะของยกมา 3 วิธีดังนี้

  1. ใส่ค่า DP ไปโต้งๆ เลย
  2. คำนวณค่าใหม่ใน Activity
  3. สร้าง ImageView เองสิ

ใส่ค่าเป็น DP โต้งๆ ไปเลย

<ImageView
android:src="@drawable/meet"
android:layout_width="320dp"
android:layout_height="160dp"
android:scaleType="fitXY"/>

ในทำแบบนี้บ้าง ยกมือขึ้น ง่ายจบ ไม่ต้องคิดมาก และบอกได้เลยว่า บาป!!!! บาป!!! มหันต์ บาปแบบไม่น่าให้อภัย

ทำไมถึงบาป?

การเขียนโค้ด Android เราจะเขียนแบบดลลี่ไม่ได้ เราต้องสนอย่างอื่นบ้าง เราต้องสน Fragmentation ด้วย ถ้าเราไปเจอจอที่มันกว้างกว่า 320 dp เราก็จะเหลือพื้นที่สีขาวๆ เอาไว้จอดรถ แต่ถ้าเจอจอที่เล็กกว่า ภาพจะโดนตัดแบบไม่น่าให้อภัย ถ้าเลยจากจุดที่เป็น Beginner มาแล้ว ไม่ควรเขียนโค้ดบาปๆ แบบนี้นะ สะสมแต้มบุญบ้าง เผื่อได้ขึ้นสวรรค์ชั้น I/O

คำนวณค่าใหม่ใน Activity

ข้อนี้ก็ไม่มีอะไรยากแค่ต้องคำนวณค่าให้ใน Activity

val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val width = displayMetrics.widthPixels
val height = width/2
var imageView = findViewById(R.id.banner) as ImageView
var layoutParams = ViewGroup.LayoutParams(width, height)
imageView.layoutParams = layoutParams

แค่สร้าง LayoutParams ตัวใหม่แล้ว set ความกว้างให้มันเป็น Screen Width และความสูงเป็น Screen Width/2 เองง่ายๆ และมันก็จะแสดงผลถูกต้องทุก Device แล้ว เยี่ยมไปเลย แน่นอนว่าหลายคนคงเคยมาถึงจุดนี้กันก่อน แต่มันก็ยังไม่ใช่วิธีที่ดีที่สุด

ทำไมมันยังไม่เป็นวิธีที่ดีที่สุดหล่ะ?

ข้อเสียของการคำนวณเอง

  1. โค้ด Redundancy แน่นวลเลยว่าถ้ามี Excepted Ratio หลายๆ ที่ เราต้องคำนวณหลายครั้ง มันก็อาจจะเกิด Redundancy โค้ดบ่อยๆ แต่วิธีแก้ข้อนี้ไม่ยากนัก แค่รวบมันไปเป็น class ที่ทำหน้าที่คำนวณ ratio ซะก็จบ ข้อนี้อาจจะยังไม่ impact มากนัก
  2. ไม่สามารถโชว์สัดส่วนที่ถูกต้องใน Editor ได้ function preview ของ Android Studio ออกแบบมาเพื่อให้สามารถแสดงผล Preview ที่เหมือนจริงที่สุด และมันจะทำงานจริงจังมากกับ Class View แต่ การที่เราคำนวณเอง ตัว Preview ของ Android Studio มันไม่สามารถรับรู้ได้เลย ดังนั้นมันจะแสดงผลตามค่าที่เราตั้งไว้ใน Image View — ซึ่งข้อเสียนี้ทำให้เราต้อง Compile code เท่านั้นถึงจะเห็นผลลัพธ์ของการทำงาน ซึ่งมันก็เสียเวลาเป็นแน่แท้
  3. ความยืดหยุ่นต่ำ จากโค้ดจะเห็นได้ว่า Image มันอ้างอิงความกว้างของตัวเองกับความกว้างจออย่างเดียวเลย นั้นหมายความว่าถ้าเราจะทำ Gird View 2 Column แบบ Excepted ratio เราต้อง คำนวณใหม่ — หรือการที่เราจะเป็นค่าความกว้างของรูปที่ไม่อ้างอิงกับ screen เราก็ต้องคำนวณใหม่อีก
  4. onMeasure ทำงาน 2 ครั้ง ครั้งแรกคือทำตาม xml ครั้งที่สองคือทำหลักจากเราตั้งค่าความกว้างความสูงของภาพเข้าไปใหม่ แน่นอนว่า ถ้ามีภาพเยอะๆ ทำงานใน Scroll view และมี Animation ด้วย Performance ตกลงแน่ๆ
แต่วิธีนี้ก็เป็นวิธีที่ง่ายและเร็วนะ ลองตัดสินใจแล้วเลือกใช้กันดีๆ

สร้าง ImageView เองสิ

เอ๋ — ทำได้เหรอ — ได้สิง่ายๆ ด้วย งั้นเรามาเริ่มก่อนว่า ImageView ของเราจะมี Attribute อะไรให้ set บ้าง

  1. imageRatio — Attribute ที่กำหนดสัดส่วนของ imageView
  2. imageOrientation — Attribute ที่กำหนดว่าจะให้ ImageView เป็นแนวนอนหรือแนวตั้ง

เริ่มจากสร้าง Styleable ก่อน

file ที่เก็บค่า Styleable

เพิ่งสร้าง xml file ภายใต้ res/values แล้วตั้งชื่ออะไรก็ได้ ในที่นี่ขอตั้งชื่อว่า attrs.xml

attrs.xml

จากโค้ดจะเห็นว่ามีการประกาศ Styleable ชื่อ ImageRatioView โดยที่ ImageRatioView มี Attribute 2 ตัวได้แก่ imageRatio และ imageOrientation

ในโค้ดชุดนี้จะยกตัวอย่าง imageRatio 2 แบบคือ square และ W2L1 (2:1) และ imageOrientation เป็น landscape หรือ portrait

เราได้ Attribute แล้ว แต่มันไม่สามารถทำงานคนเดียวได้หรอก งั้นเรามาประกาศ class view กันเถอะ

ImageRatioView.kt

คลาสนี้ extend ImageView ดังนั้นมันจะมีคุณสมบัติต่างๆ เหมือนกับ ImageView ยกเว้นเรื่องของ ขนาดที่เราจะ Override มัน

จาก Constructor เราจะเก็บค่า imageRatio และ imageOrientation ที่ถูก set มาจาก xml ก่อน

จากนั้นใน onMeasure จะมีการคำนวณค่าใหม่ โดยมี logic ง่ายๆ คือ ถ้าภาพเป็น LANDSCAPE นั้นหมายความว่า height จะเปลี่ยนตาม width จะได้ว่า

height = calculateRatio(width)

ส่วน PORTRAIT จะทำในมุมกลับกัน

width = calculateRatio(height)

ข้อสังเกตเพิ่มเติม

width += PIXEL_PERFECT
height += PIXEL_PERFECT

width และ height จะถูกบวกเพิ่มเสมอ 1 เพื่อป้องกัน การตัดเลขคู่ เลขคี่ผิด เช่น width เป็น 121 height จะเป็น 60 แทนที่จะเป็น 60.5 มันจะทำให้เห็นภาพแหว่งๆ เป็นเส้นขาวๆ ดังนั้นเพื่อกันเหนียว +1 เสมอก็ได้นะ เพราะเกินแค่ 1 มองไม่ออก แต่ถ้าขาดหล่ะ มันเห็นชัดเจนเลย

ฟังก์ชัน calculateRatio

ฟังก์ชันนี้ก็ตามชื่อเลย เอาด้านที่ส่งมาให้คูณกับ ratio แล้วส่งผลลัพธ์กลับไป จะเห็นได้ว่า RATIO_2_1_DIVISION มีค่า = 0.5 นั้นคือทุกค่าที่ส่งมา จะถูกส่งกลับไป 1 ส่วนเสมอ ในส่วนนี้ก็เป็นเพียง Math ง่ายๆ เนอะ

โอเค เราเตรียม View กับ Attribute เสร็จเรียบร้อยแล้ว ต่อไปเราลองมาขึ้น View ใน xml กัน

วิธีใช้ใน xml

แค่เรียก tag ImageRatioView แล้ว setAttribute ต่างๆ ลงไป เป็นอันเสร็จ

ข้อดีข้อการทำวิธีนี้

  1. ความยืดหยุ่น สูงกว่า และโค้ดก็รวมอยู่ที่เดียวกัน ถ้าเราเขียน View เอง วิธีที่เราเขียน XML ก็สามารถใช้สามัญสำนึกทั่วไปในการเขียน Android ได้ ไม่ว่าจะเป็น wrap_content, match_parent, layout_weigth หรือ fixed เป็น dp สัดส่วนทุกอย่างจะจะถูกคำนวณให้อยู่ในรูปที่ เหมาะสม ไม่ต้องเขียน Logic ใดๆ เพิ่มอีก
  2. Integrate กับ Android Studio สามารถแสดง Preview ได้อย่างถูกต้อง และมี Auto Suggest ใน xml อีกด้วย

3. มันถูกคำนวณในที่เหมาะที่ควร ทำให้ Performance ไม่ตกจ้าาา


อยากเพิ่ม Ratio ต้องทำไงบ้าง

  1. เพิ่ม enum ของ imageRatio เช่น
<enum name="W16L9" value="3"/>

2. แก้ function calculateRatio

return when (imageRatio) {
SQUARE -> measureSpec
RATIO_2_1 -> (measureSpec * RATIO_2_1_DIVISION).toInt()
W16L9 -> (measureSpec * (16/9)).toInt()
else -> 0
}

เห็นมะง่ายๆ


สรุป

บางครั้งในการออกแบบ UX/UI หรือ Product มันมีความเป็นไปได้ว่าจะทำหลุดออกมา จากกรอบของ Android และมันก็เป็นหน้าที่ของพวกเราที่จะหาวิธีแก้ปัญหาที่เหมาะสมที่สุด Ratio เป็น 1 ในเรื่องที่ Android developer เจอกันเยอะ ดังนั้นไม่ว่าจะแก้ปัญหาด้วยวิธีใด ของให้คำนึงถึง Fragmentation และ Performance เป็นหลักนะจ๊ะ


ขอบคุณที่อ่านจนถึงบรรทัดนี้นะครับ