כולנו נתקלנו באלמנטים שניתן לגרור על גבי העמוד באחת מהאפליקציות ווב בהן אנו משתמשים.
מדוע נרצה לאפשר למשתמש לגרור אלמנט ברחבי העמוד?
ככל הנראה אותו אלמנט הוא חלק אינטגרלי מהמוצר אך עדיין יכול להפריע לגולש בשימוש.
לדוגמא, תפריט צף בתוכנת עריכה גרפית כלשהי שמסתיר חלק מהמסך.
במדריך הבא אסביר כיצד ליצור אלמנט בר-גרירה בדרך הטובה ביותר ומבלי לפגוע בביצועים של האתר.
תוכנית פעולה
JavaScript מאפשר לנו להאזין לפידבקים הגולש מבצע באתר בעזרת העכבר, המקלדת וכו׳.
לצורך המדריך הזה אנחנו נשתמש באיבנטים של mousedown
, mousemove
ו- mouseup
על גבי האלמנט הנגרר כדי לדעת מתי להתחיל ולסיים את חישוב המיקום החדש ביחס לעכבר ונעשה זאת בעזרת שינוי כמה חוקי CSS כמו left
ו- top
וגם transform
.
הספריות בהן נשתמש להעשרת ה- UI הן:
Bootstrap שתעזור לנו עם סטייל בסיסי וfont-awesome בשביל האייקונים.
הכנת האלמנטים
אני אדגים את אפקט הגרירה שלנו על תפריטים צפים המזכירים תפריט עריכה כמו בדוגמא למעלה.
ההבדל בין שני התפריטים הוא שבאחד המשתמש יכול לגרור אותו מכל נקודה על האלמנט
ומנגד בתפריט השני נוכל לעשות זאת רק ע״י גרירת האלמנט מתוך עוגן פנימי.
להלן מבנה ה- HTML שבו נשתמש לאורך ההסבר:
<div class="draggable-menu shadow-sm border rounded">
<div class="menu-header border-bottom px-3 py-2 d-flex align-items-center">
<h1 class="h6 mb-0 me-5">Draggable menu with no anchor</h1>
</div>
<ul class="list-unstyled m-0 py-2">
<li><i class="fas fa-copy"></i> Copy</li>
<li><i class="fas fa-cut"></i> Cut</li>
<li><i class="fas fa-paste"></i> Paste</li>
<li><i class="fas fa-pen"></i> Edit</li>
<li><i class="fas fa-trash"></i> Remove</li>
</ul>
</div>
<div class="draggable-menu with-anchor shadow-sm border rounded">
<div class="menu-header border-bottom px-3 py-2 d-flex align-items-center">
<h1 class="h6 mb-0 me-5">Draggable menu with an anchor</h1>
<button class="btn btn-light anchor">
<i class="fas fa-arrows-alt"></i>
Drag me!
</button>
</div>
<ul class="list-unstyled m-0 py-2">
<li><i class="fas fa-copy"></i> Copy</li>
<li><i class="fas fa-cut"></i> Cut</li>
<li><i class="fas fa-paste"></i> Paste</li>
<li><i class="fas fa-pen"></i> Edit</li>
<li><i class="fas fa-trash"></i> Remove</li>
</ul>
</div>
וזה ה- style שנוסיף כדי לסדר טיפה את האלמנטים הפנימיים:
.draggable-menu {
border-radius: 8px;
position: absolute;
background-color: #fff;
top: 0;
left: 0;
}
.draggable-menu + .draggable-menu {
left: 400px;
}
.draggable-menu ul li {
display: flex;
align-items: center;
padding: .5rem 1rem;
flex: 1;
}
.draggable-menu ul li:hover {
background-color: lightgray;
}
.draggable-menu ul li .fas {
margin-right: .5rem;
}
קדימה לעבודה!
נתחיל מהגדרת הפונקציה הראשית setDtaggableElement
שמקבלת את ה-selector של האלמנט הנגרר ובמידה וקיים אז גם את ה- selector של העוגן.
function setDraggableElement(selector: string, anchor?: string) {
let draggingPoint: HTMLElement | null = null;
let currentX = 0;
let currentY = 0;
const element = document.querySelector(selector);
if (!element) {
throw new Error('element doesn\'t exist');
}
let initialX = element.offsetLeft;
let initialY = element.offsetTop;
if (anchor) {
draggingPoint = element.querySelector(anchor);
}
if (!draggingPoint) {
draggingPoint = element
}
draggingPoint.addEventListener('mousedown', bindDrag);
draggingPoint.style.cursor = 'all-scroll'
}
- בפנים נגדיר ערך התחלתי של 0 להגדרת המיקום העתידי של האלמנט על ציר ה-y וה-x.
- נבדוק שהאלמנט אותו אנחנו רוצים לגרור באמת קיים ואם לא נקפיץ שגיאה שמתריעה על כך.
- לאחר שוידינו שהאלמנט קיים נשמור את המרחק ההתחלתי שלו מצד שמאל ומתחילת המסך (אלה בעצם הצירים).
- במידה וקיבלנו selector לעוגן נשמור אותו לשימוש עתידי.
- נצמיד פונקציה שתהיה אחראית על גרירת האלמנט ברגע שהיוזר לוחץ על העוגן שלנו וכמובן נשנה את סמן העכבר ברגע שהמשתמש עובר מעל אותו עוגן כדי לתת לו את התחושה שאותו אלמנט הוא בר גרירה.
- שימו לב שהמשתנה
draggingPoint
מחזיר את העוגן שהוגדר, אך במידה והוא לא הוגדר נחזיר את האלמנט עצמו, המטרה היא שיהיה מקור אחד שעליו נוכל לסמוך שיחזיר לנו את האלמנט ממנו מתחילים את הגרירה.
האזנה והסרה של האיבנטים
לאחר שהגדרנו את נקודת הגרירה ובדקנו שהאלמנט הנגרר אכן קיים, נגדיר את הפונקציות bindDrag
ו- unbindDrag
שבהם נוסיף ונסיר בהתאמה את את ה- callbacks שאחראיים לשינוי המיקום של האלמנט הראשי.
שימו לב שברגע שהמשתמש לוחץ על נקודת הגרירה ״mousedown
״ אנחנו מצמידים את ה-callbacks דרך bindDrag
, שם נצמיד callbacks נוספים שיאזינו להזזה של העכבר ״mousemove
״ עד לרגע שהמשתמש עוזב את העכבר ״mouseup
״, כאשר המשתמש עוזב את העכבר נקרא ל- unbindDrag
שם נסיר את ה- callbacks שהצמדנו ב- bindDrag
משום שאין לנו יותר צורך בהאזנה לאירועים שקורים ע״י העכבר אם הם לא קורים בגזרת האלמנט שלנו.
function setDraggableElement(selector: string, anchor?: string) {
// ...
draggingPoint.addEventListener('mousedown', bindDrag);
draggingPoint.style.cursor = 'all-scroll'
function bindDrag(): void {
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', unbindDrag)
}
function unbindDrag(): void {
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', unbindDrag);
}
}
שינוי מיקום האלמנט הראשי
לסיום נוסיף את פונקציית drag
, דרכה אנו משנים את מיקום האלמנט הראשי.
function setDraggableElement(selector: string, anchor?: string) {
// ...
function drag(e: Event) {
// if we use an anchor we should take into
// consideration it's offset from it's parent element
const calculatedY = (e.clientY - (anchor ? draggingPoint?.offsetTop : 0));
const calculatedX = (e.clientX - (anchor ? draggingPoint?.offsetLeft : 0));
currentY = initialY - calculatedY;
currentX = initialX - calculatedX;
initialY = calculatedY;
initialX = calculatedX;
const top = (element.offsetTop - currentY) + "px";
const left = (element.offsetLeft - currentX) + "px";
element.style.cssText += `; top: ${top}; left: ${left}`;
}
}
[שורות 6-7] תחילה נחשב את המיקום החדש של האלמנט על ציר ה- x וה- y ע״י מציאת אותם הפרמטרים של סמן העכבר בחלון הדפדפן, שימו לב באותן שורות במידה ומוגדר לנו עוגן נרצה לקזז את המרחק שלו מצד שמאל ומלמעלה(הצירים) ביחס לאלמנט בו הוא ממוקם.
[שורות 8-9] אחרי שחישבנו את המיקום החדש נפחית אותו מהמיקום הראשוני של האלמנט בחלון הכללי,
כלומר, במידה והאלמנט הראשי במיקום שונה מ-0,0 על הצירים נתחשב בזה כדי שלא תהיה קפיצה.
[שורות 10-11] לאחר שינוי מיקום האלמנט נגדיר את המיקום הראשוני שלו כך שיהיה שווה למיקום החדש
כך שבגרירה הבאה הוא יהיה שווה למקום ממנו עצרנו.
[שורות 12-14] ולבסוף נשנה את ה- left
וה- top
בסטייל של האלמנט כדי ששינוי המיקום יהיה ויזואלי.
התוצאה הסופית
מקצה שיפורים
מכיוון שאנו רוצים להבטיח את הביצועים הכי טובים כדי שחוויית המשתמש לא תיפגע חשוב שנכיר את המושגים Repaint & Reflow, כאשר אלו קורים מספר פעמים ובתדירות גבוהה יווצר עומס על המעבד של המשתמש ובכך יגרמו ל״תקיעות״ במסך.
הסיבה שאני מזכיר את הנושא היא ששינוי מיקום האלמנט ע״י left
& top
יכול לגרום ל- reflow ובנוסף כשזה קורה בתדירות גבוהה בכל תזוזת עכבר.
הפתרון הוא ליישם את תזוזת האלמנט בעזרת חוק CSS שנקרא transform
עם פונקציית translate
שמשתמשת גם בצירים, את השינוי המהותי נבצע בפונקציית drag
.
function setDraggableElement(selector: string, anchor?: string) {
// ...
function drag(e: Event) {
const top = (e.clientY - initialY) + "px";
const left = (e.clientX - initialX) + "px";
element.style.transform = `translate(${left}, ${top})`;
}
}
כמו ששמתם לב עכשיו אנחנו צריכים לקחת בחשבון רק את מיקום העכבר ואת המיקום ההתחלתי, הסיבה היא שבעזרת translate
אנחנו משנים את מקום האלמנט ביחס לעצמו ולא למיקום שלו בתוך האלמנט המכיל אותו.
השינוי שעשינו העביר את עיקר העבודה ל-GPU מה-CPU, ישנה הדגמה מעולה בפוסט של פול אייריש.
זהו עכשיו יש לנו אלמנט בר-גרירה !!!