מעברים חלקים ופשוטים עם ממשק ה-API למעברים של View

Jake Archibald
Jake Archibald

תמיכה בדפדפן

  • 111
  • 111
  • x
  • x

מקור

ממשק ה-API למעבר של View מאפשר לשנות בקלות את ה-DOM בשלב אחד, תוך יצירת מעבר מונפש בין שני המצבים. הוא זמין ב-Chrome 111 ומעלה.

מעברים שנוצרו באמצעות View Migrate API. להתנסות באתר ההדגמה – נדרש Chrome 111 ואילך.

למה אנחנו צריכים את התכונה הזו?

מעברים בין הדפים לא רק נראים טוב, הם גם מתקשרים עם כיוון הזרימה ומבהירים אילו רכיבים קשורים מדף לדף. הן יכולות להתרחש אפילו במהלך אחזור הנתונים, וכך תבינו מהר יותר את הביצועים.

אבל כבר יש לנו כלי אנימציה באינטרנט, כמו מעברים בין CSS, אנימציות CSS ו-Web Animation API, אז למה אנחנו צריכים כלי חדש כדי להעביר דברים?

האמת היא שמעברים בין מצבים לא נוחים, אפילו עם הכלים שכבר יש לנו.

גם משהו כמו עמעום פשוט של מעבר הדרגתי כולל מצב שבו שני המצבים נמצאים בו-זמנית. פעולה זו מציבה אתגרים של נוחות השימוש, כמו טיפול באינטראקציה נוספת ברכיב היוצא. כמו כן, למשתמשים במכשירים מסייעים יש תקופה שבה המצב לפני ואחרי כן נמצאים ב-DOM בו-זמנית, ודברים עשויים לנוע בעץ בדרך תקינה מבחינה חזותית, אבל היא עלולה בקלות לגרום לאובדן של מיקום הקריאה ושל המיקוד.

הטיפול בשינויים במצבי הטיפול מאתגר במיוחד אם שני המצבים שונים במיקום הגלילה. בנוסף, אם אלמנט עובר ממאגר אחד למאגר אחר, עלולים להיתקל בקשיים עם overflow: hidden ובצורות אחרות של חיתוך. כלומר, צריך לשנות את מבנה ה-CSS כדי לקבל את האפקט הרצוי.

זה לא בלתי אפשרי, זה פשוט ממש קשה.

מעברים בין תצוגה מעניקים דרך קלה יותר, מאחר שהם מאפשרים לבצע שינוי DOM ללא חפיפה בין מצבים, אך ליצור אנימציית מעבר בין המצבים באמצעות תצוגות snapshot.

בנוסף, למרות שההטמעה הנוכחית מטרגטת אפליקציות של דף יחיד (SPA), התכונה הזו תורחב כדי לאפשר מעברים בין טעינות של דף מלא, פעולה שלא אפשרית כרגע.

סטטוס התקינה

התכונה נמצאת בפיתוח בקבוצת העבודה של W3C CSS בתור מפרט טיוטה.

לשביעות רצונך מעיצוב ה-API, נתחיל את התהליכים והבדיקות הנדרשים כדי לשלוח את התכונה ליציבות.

המשוב מהמפתחים חשוב מאוד, לכן אפשר לדווח על בעיות ב-GitHub ולצרף הצעות ושאלות.

המעבר הפשוט ביותר: מעבר הדרגתי בין עמעום

מעבר התצוגה המוגדר כברירת מחדל הוא מעבר הדרגתי, ולכן הוא משמש כמבוא נחמד ל-API:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // With a transition:
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

כאשר updateTheDOMSomehow משנה את ה-DOM למצב החדש. אפשר לעשות את זה בכל דרך שרוצים: להוסיף או להסיר רכיבים, לשנות שמות של כיתות, לשנות סגנונות... לא משנה.

ובדיוק כך, דפים מעמעמים:

ברירת המחדל של עמעום הדרגתי. הדגמה מינימלית. מקור.

בסדר, מעבר עמעום לא מרשים מאוד. למרבה המזל, המעברים ניתנים להתאמה אישית, אבל לפני שנגיע לזה, אנחנו צריכים להבין איך פועל הקישור הבסיסי הזה.

איך המעברים האלה פועלים

ניקח את דוגמת הקוד מלמעלה:

document.startViewTransition(() => updateTheDOMSomehow(data));

כשמתבצעת קריאה ל-.startViewTransition(), ה-API מתעד את המצב הנוכחי של הדף. האיסור הזה כולל צילום מסך.

לאחר השלמת התהליך, תתבצע קריאה חוזרת (callback) שמועברת אל .startViewTransition(). זה המקום שבו ה-DOM השתנה. לאחר מכן, ה-API מתעד את המצב החדש של הדף.

לאחר תיעוד המצב, ה-API יוצר עץ פסאודו-רכיב באופן הבא:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

ה-::view-transition נמצא בשכבת-על מעל כל שאר התוכן בדף. האפשרות הזו שימושית אם רוצים להגדיר צבע רקע למעבר.

::view-transition-old(root) הוא צילום מסך של התצוגה הישנה ו-::view-transition-new(root) הוא ייצוג פעיל של התצוגה החדשה. שניהם מופיעים כ 'תוכן שהוחלף' ב-CSS (למשל <img>).

בתצוגה הישנה יש אנימציה מ-opacity: 1 עד opacity: 0, ואילו בתצוגה החדשה יש אנימציה מ-opacity: 0 עד opacity: 1, וכך נוצר שינוי הדרגתי של עמעום.

כל האנימציה מתבצעת באמצעות הנפשות CSS, כך שניתן להתאים אותן אישית באמצעות CSS.

התאמה אישית פשוטה

ניתן לטרגט את כל הרכיבים המדומים שמפורטים למעלה באמצעות CSS, ומכיוון שהאנימציות מוגדרות באמצעות CSS, אפשר לשנות אותן באמצעות מאפייני אנימציית CSS קיימים. למשל:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 5s;
}

בעקבות השינוי הזה, תהליך העמעום איטי מאוד:

שקיפות הדרגתית ארוכה. הדגמה מינימלית. מקור.

זה עדיין לא מרשים. במקום זאת, נטמיע את המעבר לציר המשותף של Material Design:

@keyframes fade-in {
  from { opacity: 0; }
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}

@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

והנה התוצאה:

מעבר לציר משותף. הדגמה מינימלית. מקור.

העברה של רכיבים מרובים

בהדגמה הקודמת, כל הדף מעורב במעבר של הציר המשותף. אפשר לעשות את זה ברוב הדף, אבל זה לא נראה מתאים לכותרת, כי הוא מחליק החוצה כדי להחליק בחזרה פנימה.

כדי למנוע זאת, ניתן לחלץ את הכותרת משאר הדף כך שניתן יהיה להנפיש אותה בנפרד. כדי לעשות זאת, מקצים view-transition-name לרכיב.

.main-header {
  view-transition-name: main-header;
}

הערך של view-transition-name יכול להיות כל מה שרוצים (מלבד none, שמשמעותו שאין שם מעבר). משמש לזיהוי ייחודי של הרכיב במהלך המעבר.

והתוצאה של זה:

מעבר לציר משותף עם כותרת קבועה. הדגמה מינימלית. מקור.

עכשיו הכותרת נשארת במקום ומתמעטת בהדרגה.

הצהרת ה-CSS הזו גרמה לשינוי בעץ הרכיבים:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(main-header)
   └─ ::view-transition-image-pair(main-header)
      ├─ ::view-transition-old(main-header)
      └─ ::view-transition-new(main-header)

יש עכשיו שתי קבוצות מעבר. אחד לכותרת ועוד אחד לכל השאר. אפשר לטרגט אליהם באופן עצמאי באמצעות CSS, ולהגדיר מעברים שונים. למרות שבמקרה הזה main-header נשאר עם מעבר ברירת המחדל, שהוא מעבר הדרגתי (cross-fade).

בסדר, מעבר ברירת המחדל הוא לא רק מעבר הדרגתי, אלא גם ::view-transition-group:

  • מיקום וביצוע טרנספורמציה (באמצעות transform)
  • רוחב
  • גובה

זה לא היה משנה עד עכשיו, מאחר שהכותרת היא באותו גודל ומוצבת בשני הצדדים של שינוי ה-DOM. אבל אנחנו יכולים גם לחלץ את הטקסט שבכותרת:

.main-header-text {
  view-transition-name: main-header-text;
  width: fit-content;
}

נעשה שימוש ב-fit-content כך שהאלמנט יהיה גודל הטקסט, ולא יימתח לרוחב הנותר. אחרת, חץ 'הקודם' מצמצם את רכיב הטקסט של הכותרת, אבל אנחנו רוצים שהוא יהיה באותו גודל בשני הדפים.

עכשיו יש לנו שלושה חלקים לשחק עם זה:

::view-transition
├─ ::view-transition-group(root)
│  └─ …
├─ ::view-transition-group(main-header)
│  └─ …
└─ ::view-transition-group(main-header-text)
   └─ …

אבל שוב, נשתמש בברירות המחדל:

הזזת הטקסט של כותרת. הדגמה מינימלית. מקור.

עכשיו טקסט הכותרת מחליק קצת כדי לפנות מקום ללחצן 'הקודם'.

ניפוי באגים במעברים

מאחר במעברי תצוגה מבוססים על אנימציות CSS, החלונית אנימציות בכלי הפיתוח של Chrome מעולה לניפוי באגים במעברים.

באמצעות החלונית אנימציות, אפשר להשהות את האנימציה הבאה, ואז להחליק קדימה ואחורה באנימציה. במהלך פרק הזמן הזה, ניתן למצוא את הרכיבים המדומים של המעבר בחלונית רכיבים.

ניפוי באגים במעברים של תצוגה מפורטת באמצעות כלי הפיתוח של Chrome.

רכיבי מעבר לא חייבים להיות אותו רכיב DOM

עד עכשיו השתמשנו ב-view-transition-name כדי ליצור רכיבי מעבר נפרדים לכותרת ולטקסט בכותרת. בעיקרון, מדובר באותו רכיב לפני ואחרי שינוי ה-DOM, אבל ניתן ליצור מעברים במקרים שבהם זה לא המצב.

למשל, לסרטון הראשי המוטמע יכול להיות view-transition-name:

.full-embed {
  view-transition-name: full-embed;
}

לאחר מכן, כשלוחצים על התמונה הממוזערת, אפשר לתת לה את אותו view-transition-name, רק במהלך המעבר:

thumbnail.onclick = async () => {
  thumbnail.style.viewTransitionName = 'full-embed';

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = '';
    updateTheDOMSomehow();
  });
};

והתוצאה:

מתבצעת מעבר של רכיב אחד לרכיב אחר. הדגמה מינימלית. מקור.

התמונה הממוזערת עוברת עכשיו לתמונה הראשית. למרות שמדובר באלמנטים שונים מבחינה רעיונית (ובאופן מילולי), ממשק ה-API למעבר מתייחס אליהם כאותו דבר כי הם שיתפו את אותו view-transition-name.

הקוד האמיתי במקרה הזה קצת יותר מורכב מהדוגמה הפשוטה שלמעלה, כי הוא גם מטפל במעבר חזרה לדף של התמונה הממוזערת. כדי לראות את ההטמעה המלאה, עיינו במקור.

מעברים מותאמים אישית לכניסה וליציאה

נבחן את הדוגמה הבאה:

כניסה לסרגל הצד ויציאה ממנו. הדגמה מינימלית. מקור.

סרגל הצד הוא חלק מהמעבר:

.sidebar {
  view-transition-name: sidebar;
}

עם זאת, בניגוד לכותרת בדוגמה הקודמת, סרגל הצד לא מופיע בכל הדפים. אם לשני המצבים יש סרגל צד, פסאודו-אלמנטים של המעבר נראים כך:

::view-transition
├─ …other transition groups…
└─ ::view-transition-group(sidebar)
   └─ ::view-transition-image-pair(sidebar)
      ├─ ::view-transition-old(sidebar)
      └─ ::view-transition-new(sidebar)

עם זאת, אם סרגל הצד נמצא רק בדף החדש, הפסאודו-רכיב ::view-transition-old(sidebar) לא יופיע. מכיוון שאין תמונה 'ישנה' בסרגל הצד, לזוג התמונות יהיה רק ::view-transition-new(sidebar). באופן דומה, אם סרגל הצד נמצא רק בדף הישן, לזוג התמונות יהיה רק ::view-transition-old(sidebar).

בהדגמה שלמעלה, סרגל הצד עובר באופן שונה בהתאם למצב הכניסה, היציאה או ההצגה בשני המצבים. הוא נכנס על ידי החלקה מימין לשמאל ועמעום, יוצא ממנו על ידי החלקה ימינה ועמעום, והוא נשאר במקומו כשהוא מופיע בשני המצבים.

אם רוצים ליצור מעברים ספציפיים בין כניסה ויציאה, אפשר להשתמש בפסאודו-סיווג :only-child ולטרגט את הרכיב המדומה הישן/החדש כאשר הוא הילד היחיד בזוג התמונות:

/* Entry transition */
::view-transition-new(sidebar):only-child {
  animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Exit transition */
::view-transition-old(sidebar):only-child {
  animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

במקרה הזה אין מעבר ספציפי כשסרגל הצד מופיע בשני המצבים, כי ברירת המחדל היא מושלמת.

עדכוני DOM אסינכרוניים, והמתנה לתוכן

הקריאה החוזרת שמועברת אל .startViewTransition() יכולה להחזיר הבטחה, שמאפשרת לעדכוני DOM אסינכרוניים ולהמתין שהתוכן חשוב יהיה מוכן.

document.startViewTransition(async () => {
  await something;
  await updateTheDOMSomehow();
  await somethingElse;
});

ההעברה תתחיל רק אחרי שהיא תמומש. במהלך פרק הזמן הזה הדף קפוא, לכן יש לצמצם למינימום את העיכובים. באופן ספציפי, צריך לבצע אחזורים של הרשת לפני שמפעילים את .startViewTransition(), בזמן שהדף עדיין אינטראקטיבי לגמרי, ולא לבצע אותם כחלק מהקריאה החוזרת של .startViewTransition().

אם החלטתם להמתין עד שתמונות או גופנים יהיו מוכנים, הקפידו להשתמש בפסק זמן אגרסיבי:

const wait = ms => new Promise(r => setTimeout(r, ms));

document.startViewTransition(async () => {
  updateTheDOMSomehow();

  // Pause for up to 100ms for fonts to be ready:
  await Promise.race([document.fonts.ready, wait(100)]);
});

עם זאת, במקרים מסוימים עדיף להימנע לגמרי מהעיכוב ולהשתמש בתוכן שכבר נמצא ברשותכם.

הפקת התועלת המקסימלית מהתוכן שכבר יש לך

במקרה שבו התמונה הממוזערת עוברת לתמונה גדולה יותר:

מעבר ברירת המחדל הוא מעבר הדרגתי לעמעום, כלומר התמונה הממוזערת עשויה להופיע בהדרגה עם תמונה מלאה שטרם נטענה.

דרך אחת להתמודד עם מצב זה היא להמתין לטעינה מלאה של התמונה לפני התחלת המעבר. מומלץ לעשות זאת לפני הקריאה ל-.startViewTransition(), כדי שהדף יישאר אינטראקטיבי וניתן להציג סימן גרפי שפעולה מתבצעת כדי לציין למשתמש שפריטים נטענים. אבל במקרה הזה יש דרך טובה יותר:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
}

עכשיו התמונה הממוזערת לא נעלמת, היא רק נמצאת מתחת לתמונה המלאה. כלומר, אם התצוגה החדשה לא נטענה, התמונה הממוזערת תהיה גלויה לכל אורך המעבר. כלומר, המעבר יכול להתחיל מיד, והתמונה המלאה יכולה להיטען בזמנה החופשי.

הכלל לא יפעל אם התצוגה החדשה כוללת שקיפות, אך במקרה זה אנחנו יודעים שהיא לא מוצגת, ולכן נוכל לבצע את האופטימיזציה הזו.

טיפול בשינויים ביחס גובה-רוחב

לנוחותך, כל המעברים עד עכשיו היו באלמנטים עם אותו יחס גובה-רוחב, אבל זה לא תמיד המצב. מה קורה אם התמונה הממוזערת היא ביחס 1:1 והתמונה הראשית היא 16:9?

מעבר בין רכיב אחד לאחר, עם שינוי ביחס הגובה-רוחב. הדגמה מינימלית. מקור.

במעבר ברירת המחדל, האנימציה של הקבוצה הופכת מהגודל הקודם לגודל שאחרי הגודל. התצוגות הישנות והחדשות הן רוחב של 100% ביחס לקבוצה, והגובה שלהן אוטומטי. פירוש הדבר הוא שיחס הגובה-רוחב שלהן נשמר ללא קשר לגודל הקבוצה.

זוהי ברירת מחדל טובה, אבל זה לא מה שאנחנו רוצים במקרה הזה. אלה השלבים לביצוע:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* The old view is the thumbnail */
::view-transition-old(full-embed) {
  /* Maintain the aspect ratio of the view,
  by shrinking it to fit within the bounds of the element */
  object-fit: contain;
}

/* The new view is the full image */
::view-transition-new(full-embed) {
  /* Maintain the aspect ratio of the view,
  by growing it to cover the bounds of the element */
  object-fit: cover;
}

המשמעות היא שהתמונה הממוזערת נשארת במרכז האלמנט ככל שהרוחב מתרחב, אבל התמונה המלאה 'לא חתוכים' בזמן המעבר מ-1:1 ל-16:9.

שינוי המעבר בהתאם למצב המכשיר

כדאי להשתמש במעברים שונים בנייד לעומת במחשב, כמו הדוגמה הבאה, שמבצעת שקף מלא מהצד בנייד, אבל שקף עדין יותר במחשב:

מתבצעת מעבר של רכיב אחד לרכיב אחר. הדגמה מינימלית. מקור.

ניתן להשיג זאת באמצעות שאילתות מדיה רגילות:

/* Transitions for mobile */
::view-transition-old(root) {
  animation: 300ms ease-out both full-slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out both full-slide-from-right;
}

@media (min-width: 500px) {
  /* Overrides for larger displays.
  This is the shared axis transition from earlier in the article. */
  ::view-transition-old(root) {
    animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
  }

  ::view-transition-new(root) {
    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
  }
}

יכול להיות גם שתרצו לשנות את הרכיבים שמקצים view-transition-name בהתאם לשאילתות מדיה תואמות.

תגובה להעדפת 'תנועה מופחתת'

המשתמשים יכולים לציין שהם מעדיפים תנועה מופחתת דרך מערכת ההפעלה שלהם, ושההעדפה הזו נחשפת דרך CSS.

אתם יכולים לבחור למנוע מעברים של המשתמשים האלה:

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

עם זאת, אם יש העדפה ל'תנועה נמוכה יותר', זה לא אומר שהמשתמש רוצה ללא תנועה. במקום מה שצוין למעלה, אפשר לבחור באנימציה עדינה יותר, אבל כזו שעדיין משקפת את הקשר בין הרכיבים לבין זרימת הנתונים.

שינוי המעבר בהתאם לסוג הניווט

לפעמים הניווט בין סוג מסוים של דף לאחר חייב להיות מותאם במיוחד. לחלופין, ניווט 'הקודם' צריך להיות שונה מניווט 'קדימה'.

מעברים שונים ב 'חזרה'. הדגמה מינימלית. מקור.

הדרך הטובה ביותר לטפל במקרים כאלה היא להגדיר שם מחלקה ב-<html>, שנקרא גם רכיב המסמך:

if (isBackNavigation) {
  document.documentElement.classList.add('back-transition');
}

const transition = document.startViewTransition(() =>
  updateTheDOMSomehow(data)
);

try {
  await transition.finished;
} finally {
  document.documentElement.classList.remove('back-transition');
}

בדוגמה הזו נעשה שימוש ב-transition.finished, הבטחה שמסתיימת כשהמעבר יגיע למצב הסיום שלו. מאפיינים אחרים של האובייקט הזה מפורטים בהפניית API.

עכשיו אפשר להשתמש בשם המחלקה הזה ב-CSS כדי לשנות את המעבר:

/* 'Forward' transitions */
::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms
      cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Overrides for 'back' transitions */
.back-transition::view-transition-old(root) {
  animation-name: fade-out, slide-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: fade-in, slide-from-left;
}

בדומה לשאילתות מדיה, אפשר להשתמש בנוכחות של המחלקות האלה גם כדי לשנות את הרכיבים שמקבלים view-transition-name.

מעבר בלי להקפיא אנימציות אחרות

כדאי לצפות בהדגמה הזו של מיקום למעבר בין הסרטונים:

מעבר לסרטון. הדגמה מינימלית. מקור.

מצאת בה בעיה כלשהי? אל תדאג אם לא עשית זאת. כאן מהירות הטעינה מואטת:

מעבר סרטון, לאט יותר. הדגמה מינימלית. מקור.

במהלך המעבר הסרטון נראה קפוא, ואז גרסת ההפעלה שלו נעלמת. הסיבה לכך היא ש-::view-transition-old(video) הוא צילום מסך של התצוגה הישנה, ו-::view-transition-new(video) הוא תמונה פעילה של התצוגה החדשה.

אתם יכולים לתקן את זה, אבל קודם כול תשאלו את עצמכם אם כדאי לתקן. אם לא ראית את 'הבעיה' כשהמעבר פעל במהירות הרגילה, לא הייתי טורח לשנות אותו.

אם רוצים לפתור את הבעיה, אל תציגו את ::view-transition-old(video). יש לעבור ישירות אל ::view-transition-new(video). כדי לעשות את זה, צריך לשנות את הסגנונות והאנימציות שמוגדרים כברירת מחדל:

::view-transition-old(video) {
  /* Don't show the frozen old view */
  display: none;
}

::view-transition-new(video) {
  /* Don't fade the new view in */
  animation: none;
}

זהו, סיימתם.

מעבר סרטון, לאט יותר. הדגמה מינימלית. מקור.

עכשיו הסרטון מופעל במהלך המעבר.

יצירת אנימציה באמצעות JavaScript

עד עכשיו, כל המעברים הוגדרו באמצעות CSS, אבל לפעמים CSS אינו מספיק:

מעבר מעגל. הדגמה מינימלית. מקור.

באמצעות CSS בלבד לא ניתן להשיג כמה חלקים מהמעבר:

  • האנימציה מתחילה במיקום שבו לוחצים על המודעה.
  • האנימציה מסתיימת בעיגול שיש לו רדיוס מהפינה הרחוקה ביותר. אבל אני מקווה שזה יהיה אפשרי עם CSS בעתיד.

למרבה המזל, ניתן ליצור מעברים באמצעות Web Animation API!

let lastClick;
addEventListener('click', event => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );

  // With a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in',
        // Specify which pseudo-element to animate
        pseudoElement: '::view-transition-new(root)',
      }
    );
  });
}

בדוגמה הזו נעשה שימוש ב-transition.ready, הבטחה לפתור את הבעיה לאחר יצירה של פסאודו-אלמנטים של המעבר. מאפיינים אחרים של האובייקט הזה מפורטים בהפניית API.

מעברים כשיפור

ממשק ה-API למעבר של View נועד 'לעטוף' שינוי DOM וליצור מעבר עבורו מעבר. עם זאת, יש להתייחס למעבר כאל שיפור, כי האפליקציה לא אמורה לעבור למצב 'שגיאה' אם שינוי ה-DOM מצליח אבל המעבר נכשל. באופן אידיאלי, המעבר לא אמור להיכשל, אבל אם הוא נכשל, הוא לא אמור לפגוע בחוויית המשתמש.

כדי להתייחס למעברים כשיפור, חשוב להימנע משימוש בהבטחות מעבר באופן שיגרום לאפליקציה לפעול אם המעבר ייכשל.

מה אסור לעשות
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  await transition.ready;

  document.documentElement.animate(
    {
      clipPath: [`inset(50%)`, `inset(0)`],
    },
    {
      duration: 500,
      easing: 'ease-in',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

הבעיה בדוגמה הזו היא ש-switchView() ידחה אם ההעברה לא יכולה להגיע למצב ready, אבל זה לא אומר שהמעבר של התצוגה המפורטת נכשל. יכול להיות שה-DOM עודכן בהצלחה, אבל היו כפילויות של view-transition-name, ולכן המערכת דילגה על המעבר.

במקום זאת:

מה מותר לעשות
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  animateFromMiddle(transition);

  await transition.updateCallbackDone;
}

async function animateFromMiddle(transition) {
  try {
    await transition.ready;

    document.documentElement.animate(
      {
        clipPath: [`inset(50%)`, `inset(0)`],
      },
      {
        duration: 500,
        easing: 'ease-in',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  } catch (err) {
    // You might want to log this error, but it shouldn't break the app
  }
}

בדוגמה הזו נעשה שימוש ב-transition.updateCallbackDone כדי להמתין לעדכון ה-DOM ולדחות אם העדכון נכשל. switchView כבר לא דוחה אם המעבר נכשל, הוא פותר את הבעיה כשעדכון ה-DOM מסתיים, ולדחות אם נכשל.

אם ברצונך ש-switchView ייפתר לאחר שהתצוגה החדשה תהיה 'יושבת', למשל, כל מעבר עם אנימציה הושלם או דילג לסוף, מחליפים את transition.updateCallbackDone ב-transition.finished.

לא מילוי polyfill, אבל...

לדעתי אין אפשרות למלא את התכונה הזו בכל דרך שהיא, אבל לשמחתי, הוכח שזה שגוי!

עם זאת, הפונקציה המסייעת הזו מקלה מאוד בדפדפנים שאינם תומכים במעברי תצוגה:

function transitionHelper({
  skipTransition = false,
  classNames = [],
  updateDOM,
}) {
  if (skipTransition || !document.startViewTransition) {
    const updateCallbackDone = Promise.resolve(updateDOM()).then(() => {});

    return {
      ready: Promise.reject(Error('View transitions unsupported')),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
    };
  }

  document.documentElement.classList.add(...classNames);

  const transition = document.startViewTransition(updateDOM);

  transition.finished.finally(() =>
    document.documentElement.classList.remove(...classNames)
  );

  return transition;
}

ואפשר להשתמש בה כך:

function spaNavigate(data) {
  const classNames = isBackNavigation ? ['back-transition'] : [];

  const transition = transitionHelper({
    classNames,
    updateDOM() {
      updateTheDOMSomehow(data);
    },
  });

  // …
}

בדפדפנים שלא תומכים במעברים בין תצוגות מפורטות, עדיין תתבצע קריאה ל-updateDOM, אבל לא יהיה מעבר עם אנימציה.

אפשר גם לספק כמה classNames להוספה ל-<html> במהלך המעבר, וכך לשנות את המעבר בקלות בהתאם לסוג הניווט.

אפשר גם להעביר את true אל skipTransition אם לא רוצים באנימציה, גם בדפדפנים שתומכים במעברים בין תצוגות. האפשרות הזו שימושית אם באתר שלכם יש העדפת משתמש להשבית מעברים.

עבודה עם מסגרות

אם אתם עובדים עם ספרייה או framework שמפשטים את שינויי ה-DOM, החלק הקשה יודע מתי שינוי ה-DOM הושלם. הנה קבוצה של דוגמאות בעזרת העזרה שלמעלה, במסגרות שונות.

  • תגובה – המפתח כאן הוא flushSync, שמחיל קבוצה של שינויי מצב באופן סינכרוני. כן, יש אזהרה גדולה לגבי השימוש ב-API הזה, אבל דן אברמוב מבטיח לי שזה מתאים במקרה הזה. כמו תמיד עם קוד React וקוד אסינכרוני, כשמשתמשים בהבטחות השונות שהוחזרו על ידי startViewTransition, חשוב לוודא שהקוד פועל במצב הנכון.
  • Vue.js – המפתח כאן הוא nextTick, שמולא לאחר עדכון ה-DOM.
  • Svelte – דומה מאוד ל-Vue, אבל השיטה בהמתנה לשינוי הבא היא tick.
  • Lit – המפתח כאן הוא ההבטחה this.updateComplete בתוך הרכיבים, שמתקיים אחרי עדכון ה-DOM.
  • אנגרי – המפתח כאן הוא applicationRef.tick, שמוחק שינויים ב-DOM בהמתנה. החל מגרסה 17 של Angular, אפשר להשתמש ב-withViewTransitions שכלול ב-@angular/router.

הפניית API

const viewTransition = document.startViewTransition(updateCallback)

צריך להתחיל מכשיר ViewTransition חדש.

מתבצעת קריאה לפונקציה updateCallback לאחר צילום המצב הנוכחי של המסמך.

לאחר מכן, כשההבטחה שהוחזרה על ידי updateCallback תמומש, המעבר יתחיל במסגרת הבאה. אם ההבטחה שהוחזרה על ידי updateCallback תידחה, ההעברה תבוטל.

חברי המכונה של ViewTransition:

viewTransition.updateCallbackDone

הבטחה שמתקיימת כאשר ההבטחה שהוחזרה על ידי updateCallback מתממשת או נדחית כשהיא נדחית.

ה-View Migrate API אורז שינוי DOM ויוצר מעבר. עם זאת, לפעמים הצלחה/כישלון של אנימציית המעבר לא חשובה לכם, ואתם רוצים לדעת אם ומתי מתרחש שינוי ה-DOM. בתרחיש לדוגמה הזה, updateCallbackDone הוא תרחיש לדוגמה.

viewTransition.ready

הבטחה שמתממשת ברגע שיוצרים את פסאודו-רכיבים למעבר, והאנימציה עומדת להתחיל.

היא תידחה אם ההעברה לא יכולה להתחיל. המצב הזה יכול לנבוע מהגדרות שגויות, כמו ערכים כפולים של view-transition-name, או כאשר הערך של updateCallback מחזיר את הבטחה שנדחתה.

האפשרות הזו שימושית אם אתם רוצים להנפיש את רכיבי המעבר של המעבר באמצעות JavaScript.

viewTransition.finished

הבטחה שמתקיימת ברגע שמצב הסיום גלוי ואינטראקטיבי למשתמש.

התכונה דוחה רק אם updateCallback מחזירה הבטחה שנדחתה, כי המשמעות היא שמצב הסיום לא נוצר.

אחרת, אם מעבר לא מתחיל או אם מדלגים עליו במהלך המעבר, מצב הסיום עדיין יגיע למצב הסיום, כך ש-finished ימומש.

viewTransition.skipTransition()

מדלגים על החלק של האנימציה במעבר.

הפעולה הזו לא תדלג על קריאה ל-updateCallback כי השינוי ב-DOM הוא נפרד מהמעבר.

הסבר על סגנון ברירת המחדל ומעבר

::view-transition
הרכיב המדומה הבסיסי (root) שממלא את אזור התצוגה ומכיל כל ::view-transition-group.
::view-transition-group

במיקום מוחלט.

הפונקציה מעבירה width ו-height בין המצבים 'לפני' ו 'אחרי'.

הפונקציה מעבירה transform בין ריבוע התצוגה 'לפני' ו 'אחרי'.

::view-transition-image-pair

מוכנים לגמרי למלא את הקבוצה.

יש isolation: isolate להגבלת ההשפעה של מצב השילוב של plus-lighter על התצוגה הישנה והחדשה.

::view-transition-new וגם ::view-transition-old

ממוקם בדיוק בחלק השמאלי העליון של ה-wrapper.

מילוי של 100% מרוחב הקבוצה, אבל יש לו גובה אוטומטי, כך שיחס הגובה-רוחב נשמר במקום למלא את הקבוצה.

יש mix-blend-mode: plus-lighter כדי לאפשר מעבר עמעום אמיתי.

התצוגה הישנה עוברת מ-opacity: 1 ל-opacity: 0. התצוגה החדשה עוברת מ-opacity: 0 ל-opacity: 1.

משוב

משוב המפתחים חשוב מאוד בשלב זה, לכן אפשר לדווח על בעיות ב-GitHub בצירוף הצעות ושאלות.