สร้าง Curve Seek Bar ใช้เองบน React

Akexorcist
Nextzy
Published in
8 min readJun 7, 2018

นั่งหาโค้ดชาวบ้านมันเสียเวลา เขียนเองไวกว่าเยอะ

อย่างที่หลายๆคนรู้กันว่าตัวผมนั้นแอบไปเขียน React เล่นอยู่เป็นระยะๆ ส่วนหนึ่งก็เพราะว่างานที่ดูแลอยู่ในตอนนี้ใช้เป็น React ด้วยแหละ ก็เลยได้ลองทำอะไรดูบ้าง

แต่ทีนี้โจทย์ของผมก็คือต้องทำ Seek Bar หรือแถบเลื่อนเป็นแบบโค้งๆกลายๆกับ Circular หรือ Gauge ที่มีลักษณะหน้าตาแบบนี้

UI ของจริงจะเป็นอีกแบบ แต่นั่นมันคืองานของลูกค้า เพราะงั้นเอาหน้าตาแบบนี้ไปละกัน

บางคนก็อาจจะเรียกว่า Gauge View เพราะว่าแถบแสดงข้อมูลมีลักษณะเป็นเสี้ยวหนึ่งของวงกลม และมีเข็มชี้ระดับเพื่อแสดงค่าบางอย่างแทนตัวเลขหรือข้อมูลดิบๆ

แต่สำหรับผม หน้าตาเหมือน Gauge View น่ะแหละ แต่ว่ามันคือ Seek Bar นะ เพราะ User ต้องลากเข็มเพื่อเลื่อนไปที่ระดับอื่นๆได้ หรือแตะที่ไหนก็ได้ในนี้แล้วเข็มจะชี้ไปทางนั้น ไม่ได้เอาไว้แสดงผลอย่างเดียว

ที่ผมต้องทำก็จะประมาณนี้น่ะแหละ

จุดที่ยากสำหรับผมก็คือแถบแสดงระดับ (Level Bar) กับเข็ม (Pin) นั้นเป็นคนละ <img/> และผมไม่ค่อยพิศมัยกับวิธีการทำให้ HTML Tag แต่ละตัวซ้อนกันซักเท่าไร ไม่เหมือนกับการเขียนบน Android หรือ iOS ที่ View เหล่านั้นรองรับโดย Native อยู่แล้ว ไม่ต้องใช้ท่าที่ดูแปลกๆแบบเว็ป และต้องใช้ Level Bar เป็นตัวจับ Mouse Event หรือ Touch Event แล้วสั่งให้ Pin หมุนแทน

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

และสุดท้ายก็คือ Pivot หรือจุดหมุนของตัวเข็มครับ เพราะว่าไม่ได้ทำเป็นแบบวงกลม ดังนั้นเราต้องคำนวณตำแหน่งของจุดหมุนเพิ่ม เพื่อให้ตัวเข็มมีจุดหมุนที่ถูกต้องด้วย

แล้วทำไมไม่หา Library มาใช้?

ถ้าลองค้นหา Library ใน Google ก็จะพบว่ามีคล้ายๆกันอยู่นะ จะใช้ Pedometer, Gauge Bar หรือ Seek Bar ก็ได้ แต่ว่าแทบทุกอันไม่ค่อยตรงกับที่ผมต้องการซักเท่าไร อันที่ดูใกล้เคียงที่สุดก็ดันกำหนดค่าเป็นสีเท่านั้น กำหนดภาพเข้าไปในนั้นไม่ได้

ดังนั้นเหตุผลหลักก็คือมันไม่ได้หาแบบที่ต้องการได้ง่ายขนาดนั้น เพราะ Curve Seek Bar แบบนี้ไม่ค่อยมีใครทำซักเท่าไร และอยากจะได้เป็นแบบ React Component ด้วย

บางคนอาจจะใช้วิธีนั่งหา Library ของชาวบ้านมาแก้เพิ่มเพื่อให้ทำงานได้ตามต้องการ แต่ของผมเป็น Design ที่ Fixed มาเลยว่าต้องเป็นแบบนี้เท่านั้นนะ ดังนั้นจะต้อง Custom เยอะกว่าชาวบ้านเค้า เงื่อนไขการทำงานก็ไม่ได้เหมือนทั่วๆไป ดังนั้นการหา Library มาแก้ไขจึงมีความเสี่ยงที่จะไม่เสร็จสูงกว่า (ลองแล้วไม่เวิร์กก็ต้องมานั่งลองตัวอื่นไปเรื่อยๆอีก)

อีกเหตุผลนึงก็คือ ผมรู้อยู่วิธีการคำนวณเวลาจะสร้าง View ลักษณะแบบนี้อยู่แล้ว เลยรู้สึกว่าเขียนเองจะไวกว่าการไปนั่งทำความเข้าใจโค้ดชาวบ้านเค้า แต่ที่ผมต้องดูเพิ่มก็คือการลักษณะการทำงานของ Web Component เหล่านี้ว่าจะต้องใช้อะไรบ้าง สร้างมันยังไง

เริ่มจากทำ POC ก่อนเสมอ

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

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

ภาพ Level Bar กับภาพ Pin ที่ใช้ตอนทำ POC

และเพื่อไม่ให้เสียเวลาตอนทำ POC ดังนั้นจึงใช้ภาพง่ายๆโง่ๆที่สุดในการลองทำ ก็เลยวาดใน Illustrator ออกมาเป็นคล้ายกับที่ต้องการจะทำซะ

ยินดีต้อนรับสู่การคำนวณ (แบบง่ายๆ)

เนื่องจากการทำ Curve Seek Bar ต้องมีการคำนวณองศา คำนวณตำแหน่งจุดหมุน ระยะห่างระหว่าง Pin กับ Level Bar ก็ต้องเริ่มจากจำลองค่าจริงขึ้นมาก่อนครับ เพื่อให้สามารถเขียนใน POC ได้ และพอจะเอาไปใช้งานจริงก็ทำให้ค่าเหล่านี้มันแก้ไขหรือ Dynamic ได้

Component ที่จะสร้างมีขนาด, มุมและระยะต่างๆประมาณนี้ (ใช้ Illustrator ทำจริงๆนะ)

จากนั้นก็เริ่มจากจัดวางให้ตรงตำแหน่งเสียก่อน

// CurveSeekBar.js
render() {
return (
<Fragment>
<div>
<img
id="level"
className="curve-seek-bar-level"
...
/>
</div>
<img
id="pin"
className="curve-seek-bar-pin"
...
/>
</Fragment>
)
}
// CurveSeekBar.css
.curve-seek-bar-leve {
width: 250px;
height: auto;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
}
.curve-seek-bar-pin {
pointer-events: none;
width: 45px;
height: auto;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
position: relative;
top: -107.5px;
}

ได้หน้าตาประมาณนี้

ใกล้เคียงกับที่ Design ไว้อยู่นะ

สาเหตุที่ต้องใส่พวก -webkit-user-drag, -khtml-user-drag, -moz-user-drag และ -o-user-drag ไว้เป็น none เพราะว่าไม่ต้องการให้เกิด Ghost ตอนที่ Drag ภาพนั้นๆ

คงตลกดีถ้า Drag บน Curve Seek Bar แล้วโผล่เป็น Ghost แบบนี้

แต่การทำแบบนี้ก็ทำให้บน Desktop สามารถดัก Mouse Event ได้เท่านั้น ส่วน Mobile จะดักได้ทั้ง Mouse Event และ Touch Event เลย

สำหรับ ID ของ Pin ผมกำหนดเป็น pin ส่วน Level Bar กำหนดเป็น level เผื่อจะต้องเรียกใช้ใน JavaScript ทีหลัง

ต่อไปก็เพิ่มคำสั่งเพื่อทำให้ Pin หมุนได้ก่อนเลย เวลาทดสอบจะได้ลองแล้วเห็นผลได้ทันทีว่าองศาที่ได้นั้นถูกต้องหรือป่าว

// CurveSeekBar.js
const PIVOT_X = 0.5
const PIVOT_Y = 0.8333
...
render() {
return (
<Fragment>
<div>
<img className="curve-seek-bar-gauge".../>
</div>
<img
className="curve-seek-bar-pin"
style={{
transform: `rotate(${this.state.angle}deg)`,
transition: 'transform 100ms ease-in',
transformOrigin: `${PIVOT_X * 100}% ${PIVOT_Y * 100}%`
}}
.../>
</Fragment>
)
}

โดยที่ PIVOT_X กับ PIVOT_Y จะคำนวณจากขนาดภาพเทียบกับจุดหมุนของ Pin ซึ่งเป็นการคำนวณมือ ไว้ตอนใส่ภาพของจริงก็ควรคำนวณใหม่อีกที

คำนวณตำแหน่งของ Pivot ให้กลายเป็น % ซะ จะได้ยืดหยุ่นและทำเป็นตัวคูณในโค้ดได้เลย

การหมุนของ Pin จะใช้ Rotate transform ซึ่งง่ายมาก เพราะกำหนดองศาได้เลย และกำหนดระยะเวลาหรือลักษณะของการ Transform ได้ด้วย และกำหนด Pivot ไว้ใน transformOrigin ได้เลย โดยค่าองศาจะผูกเข้ากับ State ที่ชื่อว่า angle เมื่อคำนวณผ่านโค้ดเสร็จก็จะโยนค่าเข้า State ตัวนี้เพื่อให้ Pin หมุนทันที

และการวาง Pin เป็นแนวตั้งข้อดีคือสามารถคำนวณองศาในการหมุนได้ง่าย เพราะกึ่งกลางในแนวตั้งคือ 0° หมุนตามเข็มนาฬิกาก็เป็นบวก หมุนทวนเข็มนาฬิกาก็จะเป็นลบ

อยากจะให้หมุนไปเท่าไรก็กำหนดเป็นองศาได้เลย ส่วนทิศทางก็ใช้เครื่องหมาย ±ในการระบุ

และเนื่องจากผมใช้วิธีการใส่ Event ไว้ที่ Level Bar แล้วใช้วิธีวาง Pin ซ้อนไว้ให้ตำแหน่งมันพอดี ดังนั้นผมต้องคำนวณมือเพื่อหาตำแหน่งจุดหมุนของ Pin อยู่ที่ตำแหน่งเท่าไรเมื่อเทียบกับ Level Bar

เอาไว้ใช้คำนวณว่า Pin จะต้องหมุนไปกี่องศา
// CurveSeekBar.js
const REFERENCE_PIVOT_X = 0.5
const REFERENCE_PIVOT_Y = 0.8666

ใส่ Mouse/Touch Event ให้กับ Level Bar

อันนี้เป็นเรื่องพื้นฐานของ HTML ครับ ที่มี Mouse Event และ Touch Event ให้ใช้อยู่แล้ว และสามารถใช้ใน React Component ได้เลย

// CurveSeekBar.js
onTouchEvent = event => {
const touch = event.touches[0]
const x = touch.clientX
const y = touch.clientY
// Do something here
event.preventDefault()
}
onMouseEvent = event => {
const x = event.clientX
const y = event.clientY
// Do something here
event.preventDefault()
}
render() {
return (
<Fragment>
<div>
<img
onTouchStart={this.onTouchEvent}
onTouchMove={this.onTouchEvent}
onClick={this.onMouseEvent}
...
/>
</div>
<img .../>
</Fragment>
)
}

เรื่องน่าเศร้านิดหน่อยที่ตำแหน่ง x กับ y ที่เราได้มาจาก Mouse/Touch Event มันดันเป็นตำแหน่งที่อ้างอิงกับขนาดหน้าจอที่เปิดอยู่ ณ ตอนนั้น

เฮ้ย อะไรวะเนี่ย! เขียน Mobile Native มาตั้งนาน ไม่เคยเจออะไรแบบนี้มาก่อน คนเขียนเว็ปเค้าทนเขียนอะไรแบบนี้กันได้ยังไงเนี่ย มันน่าจะดึงค่าโดย Reference ตำแหน่งจากรูปของมันได้เลยดิ! ทำไมต้องมานั่งคำนวณบวกลบกับหน้าจอทั้งหมดล่ะ!?

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

นั่นหมายความว่าผมต้องเอาขนาดของหน้าเว็ปมาหักลบกับตำแหน่งที่ได้จาก Event เอง

calculatePinAngle = (x, y, defaultValue) => {
const rect = document.getElementById('level').getBoundingClientRect()
const centerX = rect.left + rect.width * REFERENCE_PIVOT_X
const centerY = rect.top + rect.height * REFERENCE_PIVOT_Y
// Do something
}

โดยผมจะดึงค่าที่เรียกว่า Bounding Client Rect ที่ใช้ ID ในการระบุ เพื่อเช็คว่า Level Bar ของผมอยู่ที่ตำแหน่งเท่าไร โดยค่าที่ได้จะบอกเป็น width, height, top, bottom, right และ left ตามแบบฉบับของ Rect ซึ่งผมก็จะเอา top กับ left ไปหักลบกับ width และ height แล้วคูณด้วย REFERENCE_PIVOT_X กับ REFERENCE_PIVOT_Y ที่ผมคำนวณมือไว้ในตอนแรก ผมก็จะได้ตำแหน่ง centerX และ centerY ของ Pin ที่อยู่บนหน้าเว็ป ณ ตอนนั้น

ถ้ามันอิงจากตัวภาพตั้งแต่แรก ก็ไม่ต้องทำอะไรแบบนั้นหรอก…

คำนวณหาว่าต้องหมุนไปกี่องศา

เมื่อรู้แล้วว่า x กับ y คือตำแหน่งอะไร และจุดหมุนของ Pin ที่เราใช้เปรียบเทียบเพื่อหาองศาคือ centerX กับ centerY เราก็สามารถใช้คณิตศาสตร์พื้นฐานที่เราเคยเรียนสมัยมัธยมเพื่อใช้คำนวณหาได้ว่ามีค่ากี่องศา

นั่นก็คือเรื่องตรีโกณมิตินั่นเองงงงงง (ยังจำกันได้ใช่มั้ยนะ?)

ข้ามฉาก ชิดฉาก ข้ามชิด!!!

เมื่อเราใช้ตรีโกณมิติเพื่อคำนวณหาองศาที่จุด (centerX, centerY) เราก็จะได้โค้ดออกมาหน้าตาแบบนี้

// CurveSeekBar.js
getPointAngle = (x, y, centerX, centerY, currentAngle) => {
const distanceX = Math.abs(x - centerX)
const distanceY = Math.abs(y - centerY)
let angle = Math.atan2(distanceX, distanceY) * 180 / Math.PI
if (x < centerX) {
angle *= -1
}
return angle
}

ขอบคุณ Thai Pangsakulyanont ที่ทำให้รู้ว่าควรใช้ Math.atan2(..) ฮาๆ

เนื่องจากค่าที่คำนวณได้จะออกมาเป็นหน่วย Radian ก็เลยต้องคูณด้วย 180 แลัวหารด้วยค่า PI เพื่อแปลงเป็นหน่วย Degree จะได้เอาไปกำหนดให้กับ Pin ได้

และให้สังเกตที่ตรงนี้

let angle = Math.atan2(distanceX, distanceY) * 180 / Math.PI
if (x < centerX) {
angle *= -1
}

เป็นจุดที่ต้องอธิบายเพิ่มนิดหน่อย

ทำไมต้องใช้ Math.atan2(…) ?

โดยปกติแล้วเราคุ้นเคยกับการอ้างอิงองศาแบบนี้กันเนอะ

ขวา 0°, บน 90°, ซ้าย 180° และล่าง 270° ตามเงื่อนไขของวงกลม 360°

แต่ Seek Bar ของผมไม่ได้ต้องการแบบนี้น่ะสิ อยากได้แบบนี้ต่างหาก

อยากได้แบบนี้

ก็เลยต้องใช้ Math.atan2(…) เพื่อคำนวณหาองศาโดยให้ผลลัพธ์ออกมาเป็น Quadrand ในตัวเลย ไม่ต้องคำนวณเองให้วุ่นวาย

ยังจำกันได้อยู่ใช่มั้ยนะ…

ฟังก์ชัน Math.atan2(y, x) ไม่ใช่หรอ ทำไมถึงใส่ค่าเป็น (x, y) ?

เพราะว่าค่าองศาตามแบบฉบับของ Quadrant เราจะได้แนวนอน 0° และแนวตั้ง 90° แต่ว่าที่ผมอยากได้นั้นกลับกัน เพราะว่าแนวตั้งควรจะเป็น 0° ตามทิศทางของ Pin ในตอนแรก

ดังนั้นถ้าใส่เป็น Math.atan2(y, x) ก็จะได้เป็นแบบภาพซ้ายมือครับ แต่ถ้าใส่ Math.atan2(x, y) (ตั้งใจให้มันสลับกัน) ก็จะได้ออกมาเป็นแบบภาพขวามือแทน จะได้ไม่ต้องไปคำนวณอะไรต่ออีกให้วุ่นวาย

อยากได้ฝั่งซ้ายเป็นค่าลบส่วนฝั่งขวาเป็นค่าบวก

ก็เลยต้องเพิ่ม

if (x < centerX) {
angle *= -1
}

เข้าไปเพื่อให้ค่าฝั่งซ้ายออกมาเป็นลบ เพื่อที่จะได้สั่ง Pin ให้หมุนได้ทันที

เพิ่มโค้ดเข้าไปอีกหน่อยเพื่อการทำงานที่ดีขึ้น

เนื่องจากว่า Seek Bar แบบที่ผมจะทำนั้นไม่ได้เป็นวงกลมหรือครึ่งวงกลม แต่มันเป็นแค่เสี้ยวหนึ่งวงกลมเท่านั้น ดังนั้นผมก็ต้องเขียนไม่ให้ทำอะไรถ้าลากเกินขอบเขตของ Level Bar

ดังนั้นสิ่งที่ผมต้องคำนวณด้วยมืออีกครั้งก็คือองศาทั้งหมดของ Level Bar

ช่องละ 22.5°
// CurveSeekBar.js
const METER_TOTAL_ANGLE = 128
const SLOT_COUNT = 6
const SLOT_ANGLE = METER_TOTAL_ANGLE / SLOT_COUNT

เมื่อเรารู้ขอบเขตที่ไม่ต้องการแล้ว เราก็สามารถเช็คได้ว่าองศาเกินที่กำหนดไว้หรือไม่ โดยคำนวณจาก 135°/2 = ±67.5° และเนื่องจากเป็นเสี้ยววงกลมฝั่งบน ดังนั้นถ้าค่าเกิน centerY ก็จะถือว่าเป็นนอกขอบเขตทันทีเช่นกัน

// CurveSeekBar.js
getPointAngle = (x, y, centerX, centerY, currentAngle) => {
const distanceX = Math.abs(x - centerX)
const distanceY = Math.abs(y - centerY)
let angle
if (x < centerX) {
angle = (Math.atan(-distanceX / distanceY) * 180) / Math.PI
} else if (x > centerX) {
angle = (Math.atan(distanceX / distanceY) * 180) / Math.PI
} else {
angle = 0
}
if (Math.abs(angle) < METER_TOTAL_ANGLE / 2 && y < centerY) {
return angle
}
return currentAngle
}

ในกรณีที่ค่าองศาอยู่นอกขอบเขตก็จะให้ส่งค่าเก่าไปแทน Pin จะได้ไม่ต้องขยับอะไร ดังนั้นคำสั่ง getPointAngle(…) ของผมก็จะต้องรับ Parameter เพิ่มอีก 1 ตัวคือ currentAngle หรือก็คือค่าองศาของ Pin ก่อนที่จะคำนวณใหม่นั่นเอง

การคำนวณยังไม่จบ เพราะเราต้องคำนวณเพื่อทำให้ Pin ชี้ไปที่กึ่งกลางของแต่ละช่องเท่านั้น

ตามนั้นครับ งานของเรายังไม่จบ เพราะต้องคำนวณต่อเพื่อให้ Pin ชี้ไปที่กึ่งกลางของแต่ละช่องให้ได้

และนอกจากคำนวณองศาอีกรอบแล้ว ต้องคำนวณหาด้วยว่าช่องที่ชี้คือ Index เท่าไร เพื่อที่จะได้โยนออกไปเป็น Callback ผ่าน Prop ให้ Parent Component เอาค่าไปใช้งาน

// CurveSeekBar.js
getSelectedSlot = angle => {
let lastAngle = 0
let lastDiffAngle = 360
let lastSelectedIndex = 0
let index = 0
for (
let compareAngle = SLOT_ANGLE / 2;
compareAngle < METER_TOTAL_ANGLE;
compareAngle += SLOT_ANGLE
) {
const newDiffAngle = Math.abs(
compareAngle - METER_TOTAL_ANGLE / 2 - angle
)
if (newDiffAngle < lastDiffAngle) {
lastDiffAngle = newDiffAngle
lastAngle = compareAngle - METER_TOTAL_ANGLE / 2
lastSelectedIndex = index
}
index++
}
return { angle: lastAngle, index: lastSelectedIndex }
}

โค้ดข้างบนนี้จะใช้วิธีวนลูปตามจำนวนช่องที่มีเพื่อหาว่ากึ่งกลางของแต่ละช่องคือกี่องศา และตำแหน่ง x และ y ของ Event อยู่ในช่องไหน

ในที่สุดก็ได้ค่าที่สามารถเอาไปใช้งานได้แล้ว

// CurveSeekBar.js
const { angle, index } = this.getSelectedSlot(rawAngle)

ค่า angle เอาไปกำหนดเป็น State ให้กับ Pin ส่วนค่า index เอาไปกำหนดเป็น State เพื่อเรียกใช้ทีหลังและกำหนดเป็น Prop เพื่อส่งให้ Parent Component ใช้งานต่อ

// CurveSeekBar.js
this.setState({
angle: angle,
selectedIndex: index
})
if (this.props.onMeterChanged) {
this.props.onMeterChanged(index)
}

ต้องทำ Highlight ให้กับ Level Bar

ความสนุกยังไม่จบ เพราะเมื่อ Pin ชี้ไปที่ Level Bar ช่องไหน ช่องนั้นจะต้อง Highlight หรือเปลี่ยนภาพด้วย

มาถึงจุดนี้แล้ว จะให้ Render แยกทีละช่องเลยก็ยากเกินไปละ เพราะต้องไปคำนวณตำแหน่งเพื่อวางภาพของแต่ละช่องอีก ความเสี่ยงในการคำนวณพลาดก็จะเพิ่มมากขึ้นอีก

ดังนั้นภาพของ Level Bar ผมเลยใช้วิธีเตรียมไว้หลายๆอันแบบนี้แทน

แล้วใช้วิธีเปลี่ยนภาพที่แสดงใน <img /> แทน ซึ่งแบบนี้ทำง่ายกว่า ใช้เวลาน้อยกว่า อาจจะขัดใจนิดหน่อยตรงที่เราต้องเตรียมภาพเท่ากับจำนวนช่องที่มี แต่เพราะว่าภาพเหล่านี้ผมใช้เป็น SVG ทั้งหมด ก็เลยไม่ห่วงอะไรมากนัก อย่างน้อยไฟล์ภาพที่ต้องโหลดก็ไม่ได้เยอะ

// CurveSeekBar.js
import Level1 from '../../assets/images/level_1.svg'
import Level2 from '../../assets/images/level_2.svg'
import Level3 from '../../assets/images/level_3.svg'
import Level4 from '../../assets/images/level_4.svg'
import Level5 from '../../assets/images/level_5.svg'
import Level6 from '../../assets/images/level_6.svg'
getLevelImage = index => {
return [Level1, Level2, Level3, Level4, Level5, Level6][index]
}
render() {
return (
<Fragment>
<div>
<img
id="gauge"
src={this.getLevelImage(this.state.selectedIndex)}
...
/>
</div>
<img .../>
</Fragment>
)
}

แต่วิธีแบบนี้จะทำให้…

Level Bar กระพริบเพราะโหลดภาพแต่ละอันไม่ทัน

เวลาที่เราปรับตำแหน่งของ Pin ไปมา ก็จะพบว่าครั้งแรกสุดที่เริ่มปรับ จะสังเกตเห็นว่า Level Bar มีการกระพริบเพราะว่าตอนที่เปลี่ยนภาพ Level Bar ก็คือการโหลดภาพใหม่นั่นเอง

ซึ่งตอนทดสอบในเครื่องตัวเองจะไม่สังเกตเห็นอยู่แล้ว เพราะว่ามันโหลดไวมาก แต่ Deploy ขึ้น Server เมื่อไรก็จะเห็นทันทีตอนที่โหลดภาพช้าๆ

ให้สังเกตภาพใน Developer Tools จะเห็นว่าภาพจะโหลดเพิ่มก็ต่อเมื่อกดเปลี่ยนระดับ

ดังนั้นผมจึงต้องแก้ปัญหาต่อด้วยการทำ Preload ภาพเหล่านี้ เลยได้ออกมาเป็นท่าแบบนี้

// CurveSeekBar.js
doLevelPreload = () => {
return (
<Fragment>
{[Level1, Level2, Level3, Level4, Level5, Level6].map(item => {
return <link rel="preload" href={item} as="image" />
})}
</Fragment>
)
}
render() {
return (
<Fragment>
{this.doLevelPreload()}
<div>
<img .../>
</div>
<img .../>
</Fragment>
)
}

ใช้ <link /> เข้ามาช่วยนั่นเอง เพราะว่า <link /> สามารถทำ Preload ได้ จึงสั่งให้ Preload แต่ละภาพรอไว้ซะ แล้วปัญหา Level Bar กระพริบก็จะหมดไป

แต่ท่านี้ก็ดูแปลกๆตรงที่ต้องเรียก Method ใน render() ทุกครั้ง ทั้งๆที่มันไม่ควรเรียกซ้ำซากอะไรขนาดนั้น แค่ครั้งเดียวก็พอแล้วป่ะ? Supakorn Thongtra ก็เลยแนะนำว่าให้ใช้ท่านี้แทน

// CurveSeekBar.js
componentDidMount() {
...
this.preloadLevelImage()
}
preloadLevelImage = () => {
[Level1, Level2, Level3, Level4, Level5, Level6].map(item => {
const img = new Image()
img.src = item
})
}

ท่านี้ดีกว่าตรงนี้เรียกเพียงครั้งเดียวใน componentDidMount() มันจึงไม่สิ้นเปลืองเท่าการเรียกใน render() (ไม่งั้นก็ต้องมานั่งเขียน Logic เช็คให้วุ่นวายอีก)

ซึ่งเป็นวิธีที่ Dan Abramov แนะนำด้วยล่ะ คือถ้าไม่เชื่อคนนี้ก็คงไม่ต้องเขียน React แล้วล่ะ 😆

จะวิธีไหนก็ทำให้ภาพถูกโหลดเตรียมไว้ตั้งแต่ Component ตัวนั้น Render หรือ Mount แล้ว

POC เสร็จแล้ว~

เมื่อ POC ทำเสร็จแล้ว และทำงานได้แล้ว ก็พร้อมที่จะเอาไปใส่ในโปรเจคหลักพร้อมกับใส่ภาพต่างๆ ตั้งค่า Constant บางอย่างใหม่ซะ (องศาทั้งหมดของ Level Bar, ตำแหน่งจุดหมุนของ Pin, จำนวนช่อง, บลาๆ)

เตรียมไว้เรียบร้อยแล้ว

เท่านี้เราก็ได้ Curve Seek Bar ที่พร้อมใช้งานจริงแล้ว เย้!

สรุป

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

และถ้าอยากดูว่าโค้ดทั้งหมดเป็นยังไง กดดูได้จากที่นี่เลย

--

--

Akexorcist
Nextzy

Lovely android developer who enjoys learning in android technology, habitual article writer about Android development for Android community in Thailand.