การสร้างอ็อบเจ็กต์ที่ลากได้
(redblobgames.com)- ไดอะแกรมแบบอินเทอร์แอคทีฟของ Red Blob Games ต้องตอบสนองบนหน้าจอทันทีเมื่อผู้อ่านขยับอ็อบเจ็กต์เอง จึงจัดการอินพุตโดยยึด Pointer Events เป็นหลัก เพื่อรองรับทั้งเมาส์และการสัมผัส
- วิธีเดิมที่ใช้
mousedown/mouseup/mousemoveอาจทำให้ สถานะการลากสับสนได้ เมื่อพอยน์เตอร์หลุดออกนอกอ็อบเจ็กต์หรือปล่อยปุ่มจากด้านนอก จึงต้องมีการเสริมอย่าง global handler - อินพุตแบบสัมผัสมีวิธีส่งต่ออีเวนต์ต่างจากเมาส์ และการลากด้วยนิ้วเดียวอาจทำให้หน้าเลื่อนไปด้วย จึงเรียก
preventDefault()ในtouchstartเพื่อลดการชนกัน - สูตรสุดท้ายผสาน
pointerdown,pointermove,pointerup,pointercancelกับsetPointerCapture()และตรวจสอบปุ่มซ้ายกับออฟเซ็ตเริ่มต้น เพื่อไม่ให้อ็อบเจ็กต์กระโดดตำแหน่งกะทันหัน - กรณีอย่างอ็อบเจ็กต์ที่มีข้อความหรือรูปภาพ, multi-touch, การลากซ้อนกัน,
<canvas>, การใช้ hover ร่วมกับ drag และ ข้อยกเว้นเฉพาะแพลตฟอร์ม เช่นlostpointercaptureจำเป็นต้องจัดการแยกต่างหากใน UI จริง
เป้าหมายของตัวจัดการอินพุตการลาก
- หน้าอินเทอร์แอคทีฟจำนวนมากมี อ็อบเจ็กต์ที่ลากได้ ซึ่งผู้อ่านขยับเอง และไดอะแกรมต้องตอบสนองต่อการเปลี่ยนตำแหน่งของอ็อบเจ็กต์
- เป้าหมายคือการสร้างตัวจัดการอีเวนต์อินพุตที่ทำงานได้ทั้งกับเมาส์และอินพุตแบบสัมผัส
- โฟลว์พื้นฐานคือเริ่มลากที่
pointerdown, อัปเดตตำแหน่งที่pointermoveและจบการลากที่pointerupหรืออีเวนต์ยกเลิก - ในการใช้งานจริง จะซับซ้อนกว่าโมเดลง่าย ๆ เพราะมีปุ่มเมาส์, การสัมผัสหลายนิ้ว, เป้าหมายที่รับอีเวนต์ และเมนูบริบทจากการคลิกขวา
- สูตรพื้นฐานประกอบจากองค์ประกอบต่อไปนี้
- ใน
pointerdownถ้าevent.button !== 0ให้ return เพื่อจัดการเฉพาะ ปุ่มหลัก - แปลงพิกัดของอีเวนต์เป็นพิกัดของไดอะแกรม
- ตอนเริ่ม ให้บันทึก
dx,dyซึ่งเป็นผลต่างระหว่างตำแหน่งอ็อบเจ็กต์กับตำแหน่งพอยน์เตอร์ - ใช้
el.setPointerCapture(event.pointerId)เพื่อจับพอยน์เตอร์ - ใน
pointermoveให้นำออฟเซ็ตที่บันทึกไว้ไปบวกแล้วอัปเดตstate.pos - ใน
pointerup,pointercancelให้ตั้งstate.dragging = null - เรียก
preventDefault()ในtouchstart
- ใน
- สูตรนี้เป็นตัวจัดการอีเวนต์อินพุต ส่วนการประมวลผลเอาต์พุตจริงต้องเชื่อมกับตัวจัดการสถานะแยกต่างหาก
- โค้ดฉบับสมบูรณ์อยู่ที่ examples page และยังมีตัวอย่างการลากตัวเลขซ้ายขวา, การวาดบนแคนวาส และการลากแบบมีขอบเขตจำกัด ซึ่งไม่ใช่การย้ายอ็อบเจ็กต์
- ขอบเขตการทดสอบคือ Gecko/Firefox, Blink/Chrome, WebKit/Safari บน Mac, Windows, Linux, Android, iPhone และ iPad
- ไม่ได้ทดสอบ stylus ที่ hover ได้, อุปกรณ์ไฮบริด touch+mouse และอินพุตเสียง
- วิธีนี้ไม่ใช่ HTML Drag and Drop API สำหรับวางองค์ประกอบไว้เหนือองค์ประกอบอื่น และในไดอะแกรมจำเป็นต้องอ่านอีเวนต์เมาส์/สัมผัสโดยตรง
ข้อจำกัดเมื่อใช้เฉพาะ Mouse Events
- การใช้งานช่วงแรกคือผูก
mousedown,mouseup,mousemoveเข้ากับองค์ประกอบที่ลากได้โดยตรง - ดูเผิน ๆ เหมือนทำงานได้ แต่เกิดปัญหา 2 อย่าง
- ถ้าขยับพอยน์เตอร์เร็ว ๆ พอยน์เตอร์จะไม่ได้อยู่เหนือวงกลมอีกต่อไป จึงไม่ได้รับอีเวนต์
- ถ้าปล่อยปุ่มนอกวงกลม อาจติดค้างอยู่ในสถานะ
dragging
- วิธีปรับปรุงคือรับ
mousedownจากวงกลม และระหว่างลากให้ผูกmousemoveกับmouseupเข้ากับdocumentชั่วคราว - เมื่อ
mouseupลบ handler ของmousemoveและmouseupที่ผูกไว้กับdocumentออก การลากด้วยเมาส์จะทำงานเสถียรกว่าเดิม - วิธีนี้จัดการเฉพาะเมาส์ และไม่ได้จัดการ Touch Events
ความแตกต่างของ Touch Events
- อินพุตแบบสัมผัสใช้
touchstart,touchend,touchmoveแทนmousedown,mouseup,mousemove - หลัง
touchstartแล้ว Touch Events จะถูก capture โดยอัตโนมัติ ทำให้touchmoveทั้งหมดถูกส่งไปยังองค์ประกอบเดิม - ด้วยคุณสมบัตินี้ จึงไม่จำเป็นต้องผูก global event handler เข้ากับ
documentชั่วคราวเหมือน Mouse Events - เมื่อเบราว์เซอร์ต้องยกเลิกลำดับการสัมผัส จะส่ง
touchcancel - การใช้งานลากแบบสัมผัสจะเรียก
preventDefault()ในtouchstartและในtouchmoveจะอัปเดตตำแหน่งด้วยพิกัดevent.changedTouches[0] - วิธีนี้จัดการเฉพาะ touch และไม่ได้จัดการ Mouse Events
รวมเมาส์และ touch ด้วย Pointer Events
- หากต้องการจัดการทั้งเมาส์และ touch จะต้องมี event handler จำนวนมาก และก่อนปี 2021 ก็ใช้วิธีเช่นนั้น
- ในช่วงปี 2011–2014 โปรเจกต์ d3 ใช้ d3-drag ส่วนโปรเจกต์ที่ไม่ใช้ d3 ก็สร้างโค้ดเมาส์+touch แยกเอง
- Pointer Events รวมและทำให้การจัดการเมาส์กับ touch ง่ายขึ้น
- MS IE เพิ่มการรองรับในปี 2012
- Chrome เพิ่มการรองรับในปี 2017, Firefox ในปี 2018 และ Safari ในปี 2020
- พฤติกรรมของเบราว์เซอร์เปลี่ยนไปตามเวลา และในปี 2017 Chrome เปลี่ยนอีเวนต์บางส่วนให้เป็น passive mode โดยค่าเริ่มต้น ทำให้เกิดปัญหาหน้าเลื่อนเมื่อพยายามลากอ็อบเจ็กต์
- Safari และ Firefox ก็ใช้การเปลี่ยนแปลงเดียวกันในปี 2018
- เมื่อใช้ pointer capture จะจัดการได้ด้วยตรรกะที่เรียบง่าย โดยไม่ต้องเพิ่ม/ลบ global handler ของ
documentที่เคยจำเป็นใน Mouse Events
รักษาการติดตามพอยน์เตอร์ด้วย Pointer Capture
- Pointer capture ทำให้ติดตามต่อได้แม้พอยน์เตอร์จะหลุดออกนอกวงกลม, ไดอะแกรม หรือหน้าต่างเบราว์เซอร์
- ใน Mouse Events ต้องผูก event handler เข้ากับ
documentเพื่อให้ได้ผลแบบเดียวกัน แต่ใน Pointer Events การใช้setPointerCapture(event.pointerId)ง่ายกว่า - ถ้าไม่มี pointer capture การลากอาจหยุดเมื่อคุณลากเร็ว ๆ หรือออกนอกไดอะแกรมแล้วกลับเข้ามา
- กรณีปล่อยปุ่มนอกไดอะแกรม หรือย้ายไปหน้าต่างอื่นด้วย Alt+Tab การจัดการการจบลากก็จะแตกต่างกันตามว่ามี capture หรือไม่
- บนอุปกรณ์ touch นั้น pointer capture เป็นพฤติกรรมเริ่มต้น แต่สำหรับเมาส์ต้องตั้งค่าอย่างชัดเจน
การเลื่อนด้วย touch และ preventDefault()
- บนอุปกรณ์ touch การลากด้วยนิ้วเดียวโดยค่าเริ่มต้นจะทำให้หน้าเลื่อน
- หากการลากด้วยนิ้วเดียวแบบเดียวกันทำให้วงกลมขยับด้วย ก็จะเกิด การเลื่อนและการลากอ็อบเจ็กต์ พร้อมกัน
- วิธีแก้ที่ง่ายที่สุดคือใช้ CSS
touch-action: noneกับไดอะแกรม แต่กรณีนี้จะไม่สามารถเลื่อนได้จากบริเวณใดของไดอะแกรมเลย - พฤติกรรมที่ต้องการคือปิดการเลื่อนเฉพาะตอนลากวงกลม และยังยอมให้เลื่อนเมื่อ dragging ที่ตัวไดอะแกรมเอง
- ผลการเปรียบเทียบมีดังนี้
touch-action: noneกับทั้งไดอะแกรม: หยุดการเลื่อนทั้งการลากวงกลมและการลากไดอะแกรม- ค่าเริ่มต้น: ทั้งสองแบบไม่สามารถกันการเลื่อนได้
touch-action: noneเฉพาะกับวงกลม: ไม่ทำงานตามที่คาดpreventDefault()ในtouchstart: กันเฉพาะการเลื่อนระหว่างลากวงกลม และยอมให้เลื่อนไดอะแกรม
preventDefault()ผูกกับtouchstartไม่ใช่pointerstart- วิธีนี้ทำงานได้ในสถานการณ์ส่วนใหญ่ แต่ไม่ทำงานใน “mobile emulation mode” ของ desktop Firefox และทำงานได้ใน mobile Firefox บน Android
การบันทึกออฟเซ็ตเมื่อเริ่มลาก
- หากจับขอบของอ็อบเจ็กต์แล้วลาก แล้วจุดศูนย์กลางของอ็อบเจ็กต์กระโดดไปยังตำแหน่งของพอยน์เตอร์ทันที จะทำให้ประสบการณ์การใช้งานแย่ลง
- วิธีแก้คือเมื่อเริ่มลาก ให้บันทึก ตำแหน่งสัมพัทธ์ระหว่างจุดศูนย์กลางของอ็อบเจ็กต์กับตำแหน่งของพอยน์เตอร์ ไว้
- จากนั้นเมื่อเคลื่อนที่ ให้คำนวณตำแหน่งของอ็อบเจ็กต์โดยนำ
dx,dyที่บันทึกไว้ไปบวกกับพิกัดพอยน์เตอร์ปัจจุบัน - วิธีนี้เปลี่ยน
state.draggingจากค่าtrue/falseธรรมดาให้เป็นข้อมูลตำแหน่งสัมพัทธ์อย่าง{dx, dy} - บนเมาส์จะเห็นความต่างจากการกระโดดได้ชัดเมื่อจับจากขอบ ส่วนบนระบบสัมผัสอาจเห็นน้อยกว่าเพราะนิ้วบังอยู่
- รายละเอียดเล็ก ๆ ที่เกี่ยวข้องถูกรวบรวมไว้ในหน้า little details ด้วย
เมนูบริบทและปุ่มเมาส์
- วิธีเปิดเมนูบริบทแตกต่างกันไปตามแพลตฟอร์ม
- Windows: คลิกขวา down+up, Shift+F10
- Linux: ปุ่มขวา down, Shift+F10
- Mac: ปุ่มขวา down, Ctrl+คลิกซ้าย
- iOS: long press เฉพาะบนข้อความ
- Android: long press ได้ทุกที่
- ปัญหาคือมีบางกรณีที่เห็น
pointerdownแต่ไม่ได้เห็นpointerupเสมอไป - แม้ปุ่มจะถูกปล่อยขึ้นจริงแล้ว แต่โค้ดอาจยังตัดสินว่าอยู่ในสถานะถูกกดอยู่
- วิธีรับมือคือเพิกเฉยต่อปุ่มขวา และตั้งสถานะ
draggingเฉพาะเมื่อเป็น ปุ่มเมาส์ซ้าย เท่านั้น - ในพร็อพเพอร์ตี
buttonของสเปก Pointer Events ค่าbutton = 0หมายถึง primary button และใช้ค่านี้เพื่อยกเว้นปุ่มกลางกับปุ่มขวา - ใน Chrome และ Safari บน Mac การ Ctrl+คลิกอาจเข้ามาเหมือน
pointerdownของปุ่มซ้าย จึงต้องตรวจสอบevent.ctrlKeyด้วย - ตัวเลือกมีดังนี้
- สามารถบล็อกเมนูด้วย
preventDefault()ในcontextmenuได้ แต่อาจชนกับพฤติกรรมคลิกกลางหรือการลากพื้นฐาน - หากลากด้วยปุ่มซ้ายเท่านั้น จะหลีกเลี่ยงความแตกต่างของคลิกขวาส่วนใหญ่ได้
- หากต้องการหลีกเลี่ยง Ctrl+คลิกบน Mac ด้วย ต้องยกเว้น
ctrlKeyด้วย
- สามารถบล็อกเมนูด้วย
- แม้การเปลี่ยนสถานะปุ่มจะไม่ถูกส่งผ่าน
pointerdownหรือpointerupก็ยังตรวจสอบได้บางส่วนจากสถานะปุ่มในpointermove
อ็อบเจ็กต์ที่มีข้อความและรูปภาพ
- หากมีข้อความหรือรูปภาพอยู่ภายในองค์ประกอบที่ลากได้ จำเป็นต้องมีการจัดการเพิ่มเติม
- ระหว่างลากวงกลม ข้อความด้านในอาจถูกเลือกได้
- วิธีแก้คือใช้ CSS
user-select: none- หากใช้ตลอดเวลา ข้อความด้านในจะไม่สามารถถูกเลือกได้เลย
- หากใช้เฉพาะขณะลาก ตามปกติยังสามารถเลือกข้อความได้
- ณ ต้นปี 2023 Safari ยังไม่รองรับ
user-selectแบบไม่มี prefix จึงต้องตั้งค่าwebkitUserSelectควบคู่กันด้วย - Windows, Linux และ Mac รองรับ drag and drop ข้อความและรูปภาพระหว่างแอปพลิเคชัน ซึ่งขัดกับการลากอ็อบเจ็กต์ได้
- หากเรียก
preventDefault()ในdragstartจะทำให้การลากอ็อบเจ็กต์มีความสำคัญเหนือกว่าการลากข้อความหรือรูปภาพที่ถูกเลือก - พฤติกรรมจะแตกต่างกันเล็กน้อยตามเบราว์เซอร์และระบบปฏิบัติการ
พอยน์เตอร์หลายตัวและการลากพร้อมกัน
- การที่สามารถลากอ็อบเจ็กต์หลายชิ้นพร้อมกันด้วยหลายนิ้วหรือหลายเมาส์ได้หรือไม่ เป็นกรณีขอบ
- บนระบบสัมผัส โค้ดที่นำเสนอทำงานสำหรับการลากหลายอ็อบเจ็กต์พร้อมกันอยู่แล้ว
- กรณีลากอ็อบเจ็กต์เดียวกันด้วยสองนิ้วจำเป็นต้องจัดการแยกต่างหาก
- วิธีแก้คือบันทึก
event.pointerIdไว้ในstate.draggingหรือสถานะเมื่อเกิดpointerdownแล้วในpointermoveให้เพิกเฉยหากไม่ใช่pointerIdเดียวกัน - ตัวอย่างการใช้งานนี้ยังไม่ได้ถูกนำไปใช้ครบถ้วนในเนื้อหา แต่ดูได้ที่ canvas dragging test
- สำหรับเมาส์ สเปก Pointer Events ระบุว่าระบบปฏิบัติการทั่วไปและ user agent มักไม่มีแนวคิดของอินพุตจากเมาส์หลายตัว
- แม้จะมีทั้งแทร็กแพดและเมาส์ภายนอก โดยทั่วไปก็ถือว่าเป็นพอยน์เตอร์เมาส์ตัวเดียว จึงมองว่าไม่มีวิธีลากอ็อบเจ็กต์คนละชิ้นด้วยเมาส์ต่างตัวกัน
เมื่อกดปุ่มเมาส์หลายปุ่มพร้อมกัน
- เมื่อกดปุ่มเมาส์หลายปุ่มพร้อมกัน พฤติกรรมของ Pointer Events และ Mouse Events จะแตกต่างกัน
- Mouse Events จะส่ง
mousedownทุกครั้งที่กดแต่ละปุ่ม และส่งmouseupทุกครั้งที่ปล่อยแต่ละปุ่ม - ตามสเปก Pointer Events จะเกิด
pointerdownที่ปุ่มแรกที่ถูกกด และเกิดpointerupที่ปุ่มสุดท้ายที่ถูกปล่อย - ด้วยเหตุนี้
pointerdownกับpointerupอาจเกิดในสถานะปุ่มที่แตกต่างกันได้ - ตัวอย่างเช่น หากกดปุ่มซ้าย กดปุ่มขวา แล้วปล่อยปุ่มซ้าย อ็อบเจ็กต์อาจยังคงอยู่ในสถานะถูกลากต่อไป
- วิธีแก้คือใน
pointermoveให้ตรวจสอบสถานะปุ่มซ้ายด้วยevent.buttons & 1และหากไม่ได้ถูกกดอยู่อีกต่อไป ให้เรียกend(event) - pointer capture อาจดำเนินต่อไปจนกว่าจะปล่อยทุกปุ่ม และอาจคงอยู่ต่อไปหากไม่ได้ปล่อย capture อย่างชัดเจน
- การใช้งานนี้ไม่ได้จัดการปัญหานี้และกรณีขอบหลาย ๆ อย่างทั้งหมด
องค์ประกอบลากได้ที่ซ้อนกัน
- หากมีองค์ประกอบลากได้อีกตัวอยู่ภายในองค์ประกอบที่ลากได้ องค์ประกอบด้านในและด้านนอกจะจัดการการลากทั้งคู่
- วิธีแก้คือเรียก
event.stopPropagation()ในองค์ประกอบลากด้านใน เพื่อไม่ให้อีเวนต์ไหลขึ้นไปยังองค์ประกอบลากด้านนอก - มักใช้วิธีหยุดการแพร่กระจายทั้งใน
pointerdownและpointermove - แม้ไม่มีเดโมในเนื้อหา แต่สามารถดูกรณีที่องค์ประกอบลากสีแดงเป็นลูกขององค์ประกอบลากสีเหลืองได้ใน ตัวอย่าง Vue playground
การลากใน Canvas
- โดยทั่วไปมักใช้ SVG แต่ใน
<canvas>ไม่สามารถตั้งค่า event handler หรือรูปแบบของตัวชี้เมาส์ให้กับองค์ประกอบที่ลากได้โดยตรง - ใน 2D Canvas หรือ WebGL ต้องผูก event handler ไว้กับ
<canvas>แล้วตรวจสอบเองว่าพิกัดอยู่บนอ็อบเจ็กต์ที่ลากได้หรือไม่ - วิธีจัดการมีดังนี้
pointerdown,touchstart,dragstart: ถ้าไม่ได้อยู่บนอ็อบเจ็กต์ที่ลากได้ ให้ return ทันทีpointermove: ตั้งค่าเคอร์เซอร์ตามว่ากำลังอยู่บนอ็อบเจ็กต์ที่ลากได้หรือไม่
- เดโมอยู่ที่ examples page
การผสม Mouse Hover กับ Touch Drag
- ไดอะแกรมบางแบบตอบสนองต่อ hover โดยไม่ต้องกดปุ่มเมื่อใช้เมาส์ แต่บนอุปกรณ์สัมผัสไม่มี hover จึงต้องตอบสนองด้วยการ drag
- สูตรพื้นฐานคาดหวังให้ทั้งเมาส์และทัชใช้การ drag
- หากต้องการใช้ mouse hover ร่วมกับ touch drag ให้ลบเงื่อนไข
if (!state.dragging)ในpointermove - ใน Responsive Design page เมื่อใช้เมาส์ hover เหนือแถว layout จะเปลี่ยน และเมื่อใช้ทัช layout จะเปลี่ยนด้วยการ drag
- ใน Hexagons Guide ก็มีไดอะแกรมจำนวนมากที่ใช้ mouse hover ร่วมกับ touch drag
- บนทัช เมื่อ drag จากรายการ A ไปยัง B หากยังคง capture อยู่ move event จะถูกส่งไปยัง A ดังนั้นหากต้องการ highlight B อาจจำเป็นต้องปล่อย capture
Toggle Paint และการขยายสถานะการลาก
- ใน Rounded Cell Painter
pointerdownจะจับค่าสี paint เริ่มต้นไว้ - หลังจากนั้น
pointermoveทั้งหมดจนถึงpointerupจะใช้สี paint เดิม - เพื่อทำเช่นนี้
state.draggingต้องรวมไม่ใช่แค่x,yเริ่มต้น แต่รวมถึง สี paint เริ่มต้น ด้วย - เดโมอยู่ที่ examples page
กรณีขอบของ lostpointercapture
lostpointercaptureสามารถใช้ตรวจจับได้ว่าสูญเสีย pointer capture หรือไม่- ยังไม่ได้สรุปครบถ้วนว่าเกิดขึ้นในสถานการณ์ใดและควรทำอย่างไร
- สถานการณ์ที่กล่าวถึงในความคิดเห็นมีดังนี้
- เริ่ม drag วงกลมด้วยปุ่มซ้าย
- ขณะกดปุ่มซ้ายค้างไว้ ให้เลื่อนออกนอกพื้นที่ drag
- กดปุ่มขวา แล้วปล่อยปุ่มซ้าย
- คลิกที่ใดก็ได้ภายนอกพื้นที่ drag
- standard ระบุว่า หลังจาก pointer capture ถูกปล่อยแล้ว user agent ต้องส่ง event
lostpointercapture - พฤติกรรมจริงของเบราว์เซอร์แตกต่างกันมาก โดยบางระบบเกิด
lostpointercaptureแต่บางระบบจะไม่เกิดจนกว่าจะคลิกที่อื่น
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เป็นบทความเกี่ยวกับการลาก และผมก็เจอกับดักแทบทั้งหมดที่ Amit พูดถึง แล้วก็ไปถึงวิธีแก้แบบเดียวกัน เป็นบทความที่ยอดเยี่ยม
จากมุมมองผู้ใช้ การลากเพื่อเปลี่ยนลำดับ ดูเป็นธรรมชาติมาก แต่พอมีรายการที่ขนาดไม่เท่ากัน การสร้าง placeholder ระหว่างรายการ การตรวจจับขอบ ไปจนถึงหลุมกระต่ายของอัลกอริทึมจัดให้พอดีกับกล่อง ก็จัดว่าเป็นงานที่ค่อนข้างยากถ้าจะลงมือทำเอง
ทุกครั้งที่เมาส์ขยับ ก็เลือกตำแหน่งที่ใกล้กับตำแหน่งวัตถุที่กำลังลากอยู่มากที่สุด แล้ว render เลย์เอาต์ที่ถูกเลือก วิธีนี้ทำงานได้ให้ความรู้สึกดี แม้กับเลย์เอาต์ซับซ้อนหรือมี reflow
สุดท้ายแล้ว เมื่อหยิบการ์ดขึ้นมา ผมคำนวณเลย์เอาต์เหมือนกับว่าการ์ดใบนั้นถูกลบออกจากบอร์ดแล้ว ซึ่งทำให้ง่ายขึ้น แค่ดันสิ่งที่อยู่ตรงตำแหน่งที่เมาส์ชี้อยู่ให้หลบไปก็พอ
ถึงอย่างนั้นก็ยังมีกรณียกเว้นจำนวนมาก โดยเฉพาะตอนเริ่มแก้ไขการ์ดใหม่ที่ยังไม่ได้ “commit” ลงใน data model นั้นจัดการยากมาก ต้องเพิ่มตัวเลือกให้ดันการ์ดเดิมออกไปเพื่อสร้างที่ว่างสำหรับ การ์ดผี ด้วย
การยอมรับว่ามีกรณียกเว้นจำนวนมากที่ต้องจัดการเองนั้นช่วยได้ ถ้าเข้าหาด้วยความคิดว่าควรมีวิธีแก้ที่ทั่วไปและสม่ำเสมอกว่านี้ ก็จะวนไปวนมาในการออกแบบ สักวันคงต้องรวบรวมกรณียกเว้นต่าง ๆ เขียนเป็นบล็อกโพสต์
ผมยังพบวิธีจัดเลย์เอาต์ที่ดีกว่า ซึ่งช่วยลดการคำนวณที่ไม่จำเป็น และทำให้ layout engine ยืดหยุ่นและเป็นมิตรกับผู้ใช้มากขึ้น น่าแปลกใจมากว่าการเลือกวิธีแทนข้อมูลใน data model ส่งผลต่อวิธีแก้มากแค่ไหน
[1] https://www.ussherpress.com/cardbuddy/
ดูเหมือนบทความจะไม่ได้พูดถึง การจัดการการยกเลิก ตัวอย่างเช่นบน Windows มีธรรมเนียมว่ากด Escape แล้วจะยกเลิกการลาก
บางครั้งเราอาจอยากให้การปล่อยเมาส์นอกพื้นที่ที่กำหนดยกเลิกการลากด้วย การยกเลิกเป็นทางออกที่ช่วยให้ผู้ใช้ undo ได้เร็วขึ้น หรือเป็นทางหนีแบบ “อ้อ จริง ๆ แล้วไม่ได้ตั้งใจจะลากนี่นา”
ดังนั้นแม้จะไม่มีฟีเจอร์ undo แยกต่างหาก ก็ควรบันทึกสถานะเดิมตอนเริ่มลากไว้ แล้วคืนค่าถ้ามีการยกเลิก
ในกรณียกเลิกการลากเมื่อออกนอกพื้นที่ ยังมี การเลิกยกเลิก ด้วย คือเมื่อ pointer อยู่นอกพื้นที่ จะแสดงผลทางสายตาเหมือนกลับสู่สถานะเดิม แต่เมื่อ pointer กลับเข้ามาในพื้นที่ ก็ลากต่อ วิธีนี้การยกเลิกจริงจะเกิดตอนปล่อยเมาส์ แต่ระหว่างลากก็แจ้งให้เห็นล่วงหน้าทางสายตาว่าจะถูกยกเลิก
ไม่น่าเชื่อว่า Microsoft ยังเชื่อว่านี่เป็น การออกแบบ UI ที่ดีอยู่ มันทำให้ผู้ใช้สับสนสุด ๆ และอาจเห็นสถานการณ์ที่ผู้ใช้ไม่รู้ว่าเกิดอะไรขึ้น จนกลัวจะปล่อยปุ่มเมาส์ซ้าย หรือทำซ้ำไปมาเหมือนโยโย่
เห็นด้วยว่าการยกเลิกเป็นปัญหาใหญ่ที่มักถูกมองข้าม และไม่ใช่เรื่องง่าย ผู้ใช้ทั่วไปไม่รู้ด้วยซ้ำว่า ESC ช่วยได้
มีรายละเอียดเพิ่มเติมที่ได้เรียนรู้จากการลากวัตถุสารพัดใน GUI ของ Ardour [0] มาตลอด 20 ปี: ต้องจัดการทุกเหตุการณ์กดและปล่อยปุ่มเป็น เหตุการณ์ลาก ที่ไม่มีการเคลื่อนที่
กล่าวคือ การกดจะเริ่มการลากเสมอและจบด้วยการปล่อย ส่วนโค้ดที่จัดการการปล่อยจะจัดการกรณีพิเศษเมื่อไม่มีการเคลื่อนที่
[0] https://ardour.org/
ผมมี handler
mousedownและmouseupแบบทั่วไป และใช้ timeout ที่ตั้งค่าได้ราว 150ms เพื่อพิจารณาว่าเป็นคลิก เริ่มลาก หรือการกระทำอื่น ถ้าเกิดmouseupหรือเมาส์ออกนอกหน้าจอ และมีการลากที่กำลังดำเนินอยู่ ก็stopdragสำหรับผม การลากเป็นเพียงหนึ่งในกรณีพิเศษหลาย ๆ แบบเท่านั้นนานมากแล้ว ใน Minneapolis เคยมี geometry center ที่ได้รับการสนับสนุนจาก NSF มีการประชุมด้าน computational group theory และเชิญคนสองสามคนจากสาขาใกล้เคียงที่เหมือนเป็นมาสคอตมาร่วมด้วย
ผมได้รับเชิญเพราะเขียนระบบพีชคณิตเรขาคณิต และตื่นเช้ามากท่ามกลางอากาศหนาว -20°F เพื่อไปจองเวิร์กสเตชัน Silicon Graphics ที่จะใช้ในวันนั้น แล้วเขียนเกมเพื่อทำความเข้าใจตัวสร้างของกรุ๊ปและความสัมพันธ์ให้ดีขึ้น
เกมนั้นมีการลากอยู่ด้วย ผลลัพธ์คือบทสนทนาดี ๆ ที่ต่อเนื่องไปจนถึงตี 2 ความคิดของผมคือ การลากไม่จำเป็นต้องเป็นไปตามกฎฟิสิกส์ของโลกจริง และการลากควรให้ความรู้สึกเหมือน LSD แรง ๆ หนึ่งเม็ด ทุกคนอินกับเรื่องนี้และต่างก็เสนอไอเดียของตัวเอง
หากไม่นับข้อยกเว้นต่าง ๆ ที่เกิดขึ้นในเว็บเบราว์เซอร์ บทความนี้ก็สาธิตการทำ drag แบบค่อนข้างพื้นฐาน อยากรู้ว่ามีแหล่งข้อมูลเกี่ยวกับการ drag ที่มีข้อจำกัดต่าง ๆ ไหม เช่น การ snap เข้ากับ guideline, การป้องกันการชนกับขอบเขตหรือวัตถุอื่น, หรือ drop target แบบแอนิเมชันที่ขนาดหรือตำแหน่งเปลี่ยนไปตามการลาก
ก่อนหน้านี้เคยอยากทำ UI ตัวจับเวลา Pomodoro แบบกำหนดเองที่แบ่งนาฬิกาวงกลมออกเป็นชิ้นลิ่มความยาวต่าง ๆ เพื่อกำหนดช่วงโฟกัส/พัก แต่การทำให้เปลี่ยนลำดับชิ้นลิ่มเหล่านั้นด้วยการลากยังไม่ค่อยคืบหน้า
อยากแยกระบบข้อจำกัดออกจากระบบจัดการอีเวนต์ ไลบรารีอย่าง jquery-ui จะผูกสองอย่างนี้เข้าด้วยกัน ทำให้ระบบจัดการอีเวนต์ต้องรู้จักข้อจำกัดทั้งหมดที่เป็นไปได้ jquery-ui รองรับ bounding box, axis, square grid และการ snap ไปยัง DOM element แต่ถ้าอยาก snap ไปยัง hex grid, logarithmic scale grid หรือขอบเขตวงกลม ก็ไม่รองรับ
ในโค้ดจะเห็น
state.pos = …ซึ่งตรงนั้นคือจุดที่ตั้งค่า state สำหรับข้อจำกัดจะซ่อนposไว้หลัง setter แบบนั้นโค้ดจัดการอีเวนต์ drag ก็ไม่จำเป็นต้องรู้ว่าต้องใช้ข้อจำกัดอะไร เพราะ setter สามารถนำข้อจำกัดไปใช้ได้เองคงต้องอัปเดตหน้าเพื่อแสดงตัวอย่างข้อจำกัด ลืมพูดถึงประเด็นนี้ใน code recipe ไปเสียสนิท
ตัวอย่างข้อจำกัดเก่าอยู่ที่ https://www.redblobgames.com/articles/curved-paths/making-of... และตัวอย่างการป้องกันการชนอยู่ที่ https://redblobgames.github.io/circular-obstacle-pathfinding... แต่ยังไม่เคยลองทำการเปลี่ยนลำดับด้วยการลากหรือ drop target แบบแอนิเมชัน
ผมก็รู้สึกขอบคุณเว็บไซต์นี้เหมือนกัน ตอนออกแบบ ระบบหกเหลี่ยม สำหรับการวิเคราะห์เชิงภูมิศาสตร์ที่ Uber ได้อ้างอิง https://www.redblobgames.com/grids/hexagons/ เยอะมาก
เป็นบทความที่สรุปหลุมพรางและข้อควรระวังเมื่อทำ interaction ให้ดีได้อย่างยอดเยี่ยม
ถ้าอยากได้แบบ “พร้อมใช้” มากขึ้น ผมใช้ interactjs ในหลายโปรเจกต์มานานพอสมควร
ขอบคุณทุกคน วันนี้แปลกใจที่เห็นขึ้น HN และยินดีตอบคำถาม
draggable="true"กับ drag event หรือยังและอยากรู้ว่าฟีเจอร์ในตัวมีข้อด้อยอะไรเมื่อเทียบกับโค้ดนี้บ้าง
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_att...
มีคำถามสั้น ๆ คือ ถ้าจะจำกัดการเคลื่อนไหวขณะลากให้เหลือแกนเดียวด้วย DOM event ในตัวอย่าง
dragstartควรทำอย่างไร ผมทำฟีเจอร์ drag and drop ด้วย eventdragstart/dragenter/dragover/dropแล้ว แต่ยังหาวิธีเร็ว ๆ ในการจำกัดการเคลื่อนไหวให้อยู่บน แกน x ไม่ได้ จำได้ว่า API drag and drop ของ JQuery รองรับสิ่งนี้ ผมพยายามใช้เฉพาะ native DOM event/API เลยอยากได้ข้อมูลหรือคำชี้แนะที่เกี่ยวข้องเมื่อคำนึงว่า Safari เพิ่งเพิ่มการรองรับในปี 2020 อยากรู้ว่าไม่กังวลหรือว่าบราวเซอร์อาจไม่รองรับ ผู้ใช้ macOS Mojave ก็น่าจะใช้ Safari 13 หรือ 14 กันอยู่ ดังนั้นก็หวังว่า Safari 12 จะไม่ได้ถูกใช้งานจริงแล้ว แต่ถ้าฟีเจอร์ง่าย ๆ อย่างการลากใช้ไม่ได้ใน production app ที่เจาะผู้ใช้เว็บทั่วไป ก็คงแย่พอสมควร
การเพิ่ม event handler บน
documentระหว่างลากเป็นแนวปฏิบัติเก่าแก่ และเบราว์เซอร์ก็เพิ่มฟีเจอร์ใหม่ ๆ ที่พยายามทำให้ use case บางอย่างง่ายขึ้นอยู่เรื่อย ๆ แต่แต่ละอย่างก็มีข้อยกเว้นและหลุมพรางของตัวเอง ในบทความเองก็บอกว่ายังไม่ได้ครอบคลุมทั้งหมด ผลลัพธ์สุดท้ายก็ยังเหลือการผสมกันระหว่าง pointer event กับ touch eventอยากรู้ว่า “ความเรียบง่าย” ในแง่ที่โค้ดน้อยลงนั้นคุ้มค่าพอไหม เมื่อเทียบกับต้นทุนของข้อยกเว้นที่เพิ่มขึ้น การรองรับเบราว์เซอร์ที่ต่ำกว่า และการที่นักพัฒนาต้องเข้าใจรายละเอียดกับความแตกต่างระหว่างเบราว์เซอร์ของ pointer event ซึ่งอาจไม่เป็นที่เข้าใจและมีเอกสารน้อยกว่า mouse event
มีสิ่งหนึ่งที่ขาดไป: การเข้าถึงได้ แล้วควรทำให้ลากด้วยการควบคุมผ่านคีย์บอร์ดได้อย่างไร?
ในตัวอย่างของผม คำตอบด้านการเข้าถึงคือการจัดให้มี การควบคุมด้วยคีย์บอร์ด เพิ่มเติมเพื่อทำงานนั้นให้เสร็จ ถ้าเป็นไปได้ควรเป็นวิธีที่ไม่ต้องกดปุ่มค้างไว้ เช่น ใช้คีย์ลัดเข้าสู่โหมดแพน แล้วใช้ปุ่มลูกศรย้าย viewport ไปรอบ ๆ แคนวาส ก็แก้ได้
สำหรับวัตถุที่ลากได้ งานคือการเปลี่ยนลำดับ หากต้องการให้เข้าถึงได้ ก็ให้จัดเตรียมวิธีทางเลือกในการเปลี่ยนลำดับ เช่น ใช้ TAB วนโฟกัสระหว่างวัตถุต่าง ๆ ใช้ปุ่มเลือกวัตถุที่โฟกัสอยู่ จากนั้นใช้ปุ่มลูกศรย้ายตัวอย่างก่อนลากไปยังตำแหน่งที่ถูกต้องที่ใกล้ที่สุดในทิศทางนั้น
กล่าวคือ อย่าพยายามยัดการรองรับคีย์บอร์ดเข้าไปใน implementation ของการลาก แต่ควรเพิ่มฟังก์ชันคีย์บอร์ดที่มีความหมาย แยกจากฟีเจอร์ลาก
ดีเลย ช่วงต้นสัปดาห์นี้ผมกำลัง implement การลากองค์ประกอบ SVG ในแอป Vue อยู่พอดี และต้องค้นพบด้วยตัวเองแทบทุกอย่างที่ผู้เขียนอธิบาย ตามลำดับเดียวกันด้วย สุดท้ายก็ได้คอมโพเนนต์ที่แทบเหมือนกัน
แต่ผมเขียนเป็นยูทิลิตี composable ชื่อ
useDraggingแทนที่จะใช้ functional component