我正在尝试使用 Google 的 this 指南 构建展开/折叠部分动画,以获得最佳性能。文章最后提到
关于此特定变体的警告:Chrome 在动画期间在低 DPI 屏幕上出现模糊文本,因为文本的比例和反比例导致舍入错误。如果您对此详细信息感兴趣,有一个已提交的错误,您可以加注星标并关注。
尝试手动修复带有舍入错误的帧,但没有成功。
一些观察
will-change
,则会在 Chrome 中出现此问题,但显然对于 FFox 不起作用)。 will-change
,则在动画结束后它会保持“不平滑”,否则将闪烁为平滑。 由于代码较长,为了您的方便,我还提供了 Codepen,也嵌入此处。
"use strict";
class Expando {
constructor() {
this._el = document.querySelector(".content");
const toggleEl = document.querySelectorAll(".section-header");
this._toggleBtn = document.querySelector(".toggle");
this._sections = [...document.querySelectorAll(".expand-collapse-section")];
this._isExpanded = new Array(this._sections.length).fill(false);
this._createEaseAnimations();
toggleEl.forEach((el) => {
el.addEventListener("click", (e) => {
el.querySelector(".toggle").classList.toggle("expanded");
const section = e.target.closest(".expand-collapse-section");
const content = section.querySelector(".content");
const idx = this._sections.indexOf(section);
this.toggle(content, idx);
});
});
// measure single content element's margin-top (they all have the same margin in CSS)
const rgx = /(.+)px/;
const marginTopPx = window.getComputedStyle(this._el, null).marginTop;
const results = rgx.exec(marginTopPx);
this._contentTopMargin = +results[1] || 0;
}
expand(el, sectionIdx) {
if (this._isExpanded[sectionIdx]) {
return;
}
this._isExpanded[sectionIdx] = true;
this._applyAnimation(el, { expand: true });
}
collapse(el, sectionIdx) {
if (!this._isExpanded[sectionIdx]) {
return;
}
this._isExpanded[sectionIdx] = false;
this._applyAnimation(el, { expand: false });
}
toggle(el, sectionIdx) {
const expanded = this._isExpanded[sectionIdx];
if (expanded) {
return this.collapse(el, sectionIdx);
}
this.expand(el, sectionIdx);
}
_applyAnimation(el, { expand } = opts) {
function setTranslation(el, { height, start, expand } = opts) {
const translation = start ? (expand ? -height : 0) : (expand ? 0 : -height);
if (translation === 0) {
el.removeAttribute("style");
} else {
el.style.transform = `translateY(${translation}px)`;
}
}
const elInner = el.querySelector(".content-inner");
el.classList.remove("item--expanded");
el.classList.remove("item--collapsed");
elInner.classList.remove("item__contents--expanded");
elInner.classList.remove("item__contents--collapsed");
const sectionEl = el.closest(".expand-collapse-section");
const sectionContent = sectionEl.querySelector(".content");
sectionContent.style.display = "block"; // block to expand, has no effect on collapse (in the end of animation it gets set to none)
const index = this._sections.indexOf(sectionEl);
const targetContentHeight = sectionContent.offsetHeight + this._contentTopMargin;
for (let i = index + 1; i < this._sections.length; i++) {
const curr = this._sections[i];
// don't animate yet translation of adjacent sections, just set initial value for animation
curr.classList.add("notransition");
// setting section content to display block pushes the other items by its height as it has transform set, but it still occupies its original height
// initial value for animation
setTranslation(curr, { height: targetContentHeight, start: true, expand });
}
// the rest of the content below the expandable sections
const lastSectionSibling = this._sections.slice(-1)[0].nextElementSibling;
lastSectionSibling.classList.add("notransition");
setTranslation(lastSectionSibling, { height: targetContentHeight, start: true, expand });
requestAnimationFrame(() => {
if (expand) {
el.classList.add("item--expanded");
elInner.classList.add("item__contents--expanded");
} else {
el.classList.add("item--collapsed");
elInner.classList.add("item__contents--collapsed");
}
sectionEl.offsetHeight; // needed for Firefox on expand
// sectionEl.offsetHeight; -> not needed in requestAnimationFrame
for (let i = index + 1; i < this._sections.length; i++) {
const curr = this._sections[i];
// trigger translation animation of adjacent sections and rest of the content now
curr.classList.remove("notransition");
setTranslation(curr, { height: targetContentHeight, start: false, expand });
sectionEl.offsetHeight; // needed for Firefox on expand
}
lastSectionSibling.classList.remove("notransition");
setTranslation(lastSectionSibling, { height: targetContentHeight, start: false, expand });
if (!expand) {
sectionContent.addEventListener("animationend", () => {
sectionContent.style.display = "none";
for (let i = index + 1; i < this._sections.length; i++) {
const curr = this._sections[i];
// avoid unexpected animations when removing transform inline style in the end of the animation, needs reflow
curr.classList.add("notransition");
// could also be set to translateY(0)
curr.removeAttribute("style");
// should force reflow here otherwise there will be no net change in notransition class which would animate transform, which we don't want,
// we're just removing the unnecessary style attribute
sectionEl.offsetHeight;
curr.classList.remove("notransition");
}
lastSectionSibling.classList.add("notransition");
lastSectionSibling.removeAttribute("style");
sectionEl.offsetHeight;
lastSectionSibling.classList.remove("notransition");
}, { once: true });
}
});
}
_createEaseAnimations() {
let ease = document.querySelector(".ease");
if (ease) {
return ease;
}
ease = document.createElement("style");
ease.classList.add("ease");
console.log('------------- Expand animation --------------');
const expandAnimation = [];
const expandContentsAnimation = [];
const collapseAnimation = [];
const collapseContentsAnimation = [];
for (let i = 0; i <= 100; i++) {
const step = this._ease(i / 100);
// Expand animation.
this._append({
i,
step,
start: 0,
end: 1,
outerAnimation: expandAnimation,
innerAnimation: expandContentsAnimation,
});
// Collapse animation.
this._append({
i,
step,
start: 1,
end: 0,
outerAnimation: collapseAnimation,
innerAnimation: collapseContentsAnimation,
});
}
ease.textContent = `
@keyframes expandAnimation {
${expandAnimation.join("")}
}
@keyframes expandContentsAnimation {
${expandContentsAnimation.join("")}
}
@keyframes collapseAnimation {
${collapseAnimation.join("")}
}
@keyframes collapseContentsAnimation {
${collapseContentsAnimation.join("")}
}`;
document.head.appendChild(ease);
return ease;
}
_append({ i, step, start, end, outerAnimation, innerAnimation } = opts) {
let scale = start + (end - start) * step;
let invScale = scale === 0 ? 0 : 1 / scale;
if (start === 0) {
if (i === 11) {
scale = 0.373;
invScale = 2.680965147453083;
}
if (i === 23) {
scale = 0.648;
invScale = 1.5432098765432098;
}
if (i === 28) {
scale = 0.7312;
invScale = 1.3676148796498906;
}
if (i === 41) {
scale = 0.879;
invScale =1.1376564277588168;
}
if (i === 43) {
scale = 0.894;
invScale = 1.1185682326621924;
}
if (i === 44) {
scale = 0.9;
invScale = 1.1111111111111112;
}
if (i === 55) {
scale = 0.959;
invScale = 1.0427528675703859;
}
if (i === 56) {
scale = 0.96;
invScale = 1.0416666666666667;
}
if (i === 62) {
scale = 0.97914;
invScale = 1.0213044099924424;
}
if (i === 64) {
scale = 0.983;
invScale = 1.017293997965412;
}
if (i === 67) {
scale = 0.988;
invScale = 1.0121457489878543;
}
if (i === 69) {
scale = 0.9907648;
invScale = 1.0093212839212697;
}
if (i === 72) {
scale = 0.99385;
invScale = 1.0061880565477688;
}
if (i === 74) {
scale = 0.99543;
invScale = 1.0045909807821745;
}
if (i === 85) {
scale = 0.99949;
invScale = 1.0005102602327187;
}
if (i === 89) {
scale = 0.9998536;
invScale = 1.0001464214360982;
}
if (i === 90) {
scale = 0.99995;
invScale = 1.000050002500125;
}
console.log(`${i}: scale: ${scale}, inverse: ${invScale}, scale * inverse = ${scale * invScale}`);
}
outerAnimation.push(`
${i}% {
transform: scaleY(${scale});
}`);
innerAnimation.push(`
${i}% {
transform: scaleY(${invScale});
}`);
}
_clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
_ease(v, pow = 4) {
v = this._clamp(v, 0, 1);
return 1 - Math.pow(1 - v, pow);
}
}
new Expando();
* {
box-sizing: border-box;
}
html,
body {
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
.expand-collapse-section,
.example-content-below-sections {
transition: transform .7s ease-out;
will-change: transform;
}
.section-header {
border: 1px solid #efefef;
border-radius: 4px;
padding: 0.5em;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle-wrapper {
width: 36px;
height: 36px;
background: #f7f8f9;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.toggle {
background-image: url('data:image/svg+xml,<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23747878" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></svg>');
display: inline-block;
height: 24px;
width: 24px;
transition: transform 200ms;
}
.toggle.expanded {
transform: rotateZ(180deg);
}
.content {
transform: scaleY(0);
transform-origin: top left;
overflow: hidden;
will-change: transform;
margin-top: 0.5rem;
border: 1px solid black;
border-radius: 5px;
/* padding: 1em; */
display: none;
overflow: hidden;
}
.content-inner {
transform-origin: top left;
overflow: hidden;
transform: scaleY(0);
will-change: transform;
}
/* need to be margin as with padding, transform makes it push to the top while animating collapse */
.content-inner-spacer {
margin: 1em;
}
.item--expanded {
animation-name: expandAnimation;
animation-duration: 5.7s;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.item__contents--expanded {
animation-name: expandContentsAnimation;
animation-duration: 5.7s;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.item--collapsed {
animation-name: collapseAnimation;
animation-duration: .7s;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.item__contents--collapsed {
animation-name: collapseContentsAnimation;
animation-duration: .7s;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.notransition {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
transition: none !important;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,minimum-scale=1.0" />
<title>Document</title>
<link rel="stylesheet" href="style.css" />
<script src="script.js" defer></script>
</head>
<body>
<section class="expand-collapse-section first">
<div class="section-header">
<div class="section-header-title">Expand / collapse</div>
<div class="toggle-wrapper">
<div class="toggle"></div>
</div>
</div>
<div class="content">
<div class="content-inner">
<div class="content-inner-spacer">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Neque,
reiciendis quasi laboriosam delectus accusantium aliquam! Alias
voluptatum, dolore debitis aliquam maxime doloribus eligendi tempora
amet laborum quis maiores reprehenderit deleniti impedit quo quas
eius, fugiat atque non accusamus eum esse? Explicabo quam ea
reprehenderit minus officiis vel et reiciendis ex omnis expedita, ab
libero veritatis! Suscipit, magni maxime deserunt eaque laborum libero
atque nesciunt labore consequatur provident. Aut dolor necessitatibus
sint, dicta facilis sed molestiae laudantium incidunt repellat
consequuntur, officiis maxime quam, dolorum possimus expedita minus.
Error quaerat, esse magni quibusdam quis corporis, et tenetur, ullam
ipsam a ratione fugit.
</div>
</div>
</div>
</section>
<section class="expand-collapse-section">
<div class="section-header">
<div class="section-header-title">Expand / collapse</div>
<div class="toggle-wrapper">
<div class="toggle"></div>
</div>
</div>
<div class="content">
<div class="content-inner">
<div class="content-inner-spacer">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
molestiae, quo ratione voluptas iure expedita dolor ad voluptate
maxime, aspernatur error sequi hic? Harum nobis provident recusandae
dolor, ea corporis minima animi deserunt, voluptate adipisci
cupiditate repudiandae inventore quasi commodi dolorum odio saepe
consequatur nulla. Fugit quo tenetur dolores veritatis!
</div>
</div>
</div>
</section>
<section class="expand-collapse-section">
<div class="section-header">
<div class="section-header-title">Expand / collapse</div>
<div class="toggle-wrapper">
<div class="toggle"></div>
</div>
</div>
<div class="content">
<div class="content-inner">
<div class="content-inner-spacer">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
molestiae, quo ratione voluptas iure expedita dolor ad voluptate
maxime, aspernatur error sequi hic? Harum nobis provident recusandae
dolor, ea corporis minima animi deserunt, voluptate adipisci
cupiditate repudiandae inventore quasi commodi dolorum odio saepe
consequatur nulla. Fugit quo tenetur dolores veritatis!
</div>
</div>
</div>
</section>
<section class="expand-collapse-section">
<div class="section-header">
<div class="section-header-title">Expand / collapse</div>
<div class="toggle-wrapper">
<div class="toggle"></div>
</div>
</div>
<div class="content">
<div class="content-inner">
<div class="content-inner-spacer">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
molestiae, quo ratione voluptas iure expedita dolor ad voluptate
maxime, aspernatur error sequi hic? Harum nobis provident recusandae
dolor, ea corporis minima animi deserunt, voluptate adipisci
cupiditate repudiandae inventore quasi commodi dolorum odio saepe
consequatur nulla. Fugit quo tenetur dolores veritatis!
</div>
</div>
</div>
</section>
<section class="expand-collapse-section">
<div class="section-header">
<div class="section-header-title">Expand / collapse</div>
<div class="toggle-wrapper">
<div class="toggle"></div>
</div>
</div>
<div class="content">
<div class="content-inner">
<div class="content-inner-spacer">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
molestiae, quo ratione voluptas iure expedita dolor ad voluptate
maxime, aspernatur error sequi hic? Harum nobis provident recusandae
dolor, ea corporis minima animi deserunt, voluptate adipisci
cupiditate repudiandae inventore quasi commodi dolorum odio saepe
consequatur nulla. Fugit quo tenetur dolores veritatis!
</div>
</div>
</div>
</section>
<p class="example-content-below-sections">Bottom content</p>
</body>
</html>
如何克服这个问题?我在 Google 的搜索结果建议展开/折叠部分(稍微滚动到人们也会问)看到,他们使用变换来制作动画,但没有问题。
我最终在我正在制作动画的元素上使用了
perspective: 1px
。尽管动画期间有轻微的文本模糊,但这对我来说是一个可行的解决方案,因为:
perspective
我会接受我自己的答案,但我愿意接受更好的解决方案。