sortable list of collapsible div elements / anticipating final values of animations
Asked Answered
B

1

0

I try to implement a resortable list of collapsible div elements in vanilla JS. The general sorting is static but any of the elements is moved to the top. The top item is to be exchangeable. Transitions between different top items and the folding of the elements itself are supposed to be animated to clearly show the change.

My approach is like this:

  • There is a styling class .top for the top item and .before-top for all blocking items (i.e. items which in general sorting order placed before the top item). They contain the distance --offset these elements have to be moved.
  • If the top item changes, assignment of these classes to the elements is updated.
  • If the top item changes or some folding occurs the --offsets are recalculated and the CSS rules updated.

Unfortunately this only works without animations, that means instant placement. Else if some middle element is on top then folding any .top or .before-top element messes up the layout (e.g. if C is on top, folding A, B or C causes problems).
Obviously the calculations of upDelta and downDelta is wrong. They consider the current heights of elements not the one when folding has finished.

The code (excuse the wall of text but it's already boiled down):

function assignClasses() {
    const input = document.getElementById('input').value;
    const items = [...document.getElementsByClassName('item')];
    const topIdx = items.findIndex(elem => elem.children[0].textContent == input);
    if (topIdx < 0) return;

    items.forEach((elem, idx) => {
        elem.classList.toggle('top', idx == topIdx);
        elem.classList.toggle('before-top', idx < topIdx);
    });
}

function adjustDeltas() {
    const input = document.getElementById('input').value;
    const topItem = Array.prototype.find.call(
        document.getElementsByClassName('item'),
        elem => elem.children[0].textContent == input
    );
    if (!topItem) return;

    const firstItem = document.querySelector('.item');
    const upDelta = firstItem.offsetTop - topItem.offsetTop;
    const downDelta = topItem.nextElementSibling != null
                    ? topItem.nextElementSibling.offsetTop - topItem.offsetTop
                    : topItem.previousElementSibling != null
                    ?   topItem.offsetTop 
                      + topItem.offsetHeight
                      - topItem.previousElementSibling.offsetTop
                      - topItem.previousElementSibling.offsetHeight
                    : 0;

    function updateCssRule(selector, delta) {
        const sheet = document.styleSheets[0];
        const topIdx = Array.prototype.findIndex.call(sheet.cssRules, r => r.selectorText == selector);
        if (topIdx) {
            sheet.deleteRule(topIdx);
        }
        sheet.insertRule(`${selector} { --offset: ${delta}px; }`, sheet.cssRules.length);
    }
    updateCssRule('.top', upDelta);
    updateCssRule('.before-top', downDelta);
}

function toggleFolding(target) {
    let foldable = target.nextElementSibling;
    foldable.style.maxHeight = foldable.style.maxHeight
                             ? null
                             : foldable.scrollHeight + 'px';
    adjustDeltas();
}

Array.prototype.forEach.call(
    document.getElementsByClassName('item'),
    elem => elem.addEventListener('click', event => toggleFolding(event.target))
);
document.getElementById('input').addEventListener('change', event => {
    assignClasses();
    adjustDeltas();
});
.container {
    position: relative;
}

.item {
    top: 0;
    width: 10rem;
    margin: .5rem;
    text-align: center;
    line-height: 3rem;
    opacity: 0.7;
}

.head {
    height: 3rem;
}

.body {
    height: 2rem;
    max-height: 0;
    overflow: hidden;
    transition: max-height 1s ease;
}

.top,
.before-top {
    translate: 0 var(--offset, 0);
    transition: translate 1s ease;
}

.top        { --offset: 0; }
.before-top { --offset: 0; }

#A { background: green;  }
#B { background: yellow; }
#C { background: purple; }
#D { background: blue;   }
#E { background: teal;   }
<input id="input" type="text" placeholder="Enter top item!" autofocus />

<div class="container">
  <div id="A" class="item"> <div class="head">A</div> <div class="body">aa</div> </div>
  <div id="B" class="item"> <div class="head">B</div> <div class="body">bb</div> </div>
  <div id="C" class="item"> <div class="head">C</div> <div class="body">cc</div> </div>
  <div id="D" class="item"> <div class="head">D</div> <div class="body">dd</div> </div>
  <div id="E" class="item"> <div class="head">E</div> <div class="body">ee</div> </div>
</div>

I also tried to manually consider the expected change in height:

function adjustDeltas(additionalOffsetPx=0, elemClass=null) {
    // ...
    const upDelta = firstItem.offsetTop - topItem.offsetTop
                  - (elemClass == 'before-top') * additionalOffsetPx;
    const downDelta = (topItem.nextElementSibling != null
                    // ...
                    : 0) + (elemClass == 'top') * additionalOffsetPx;
    // ...
}

function toggleFoldingV1(target) {
    // assuming the difference will be exactly the hidden content
    // ...
    const heightChangePx = (foldable.style.maxHeight ? -1 : 1) * foldable.scrollHeight;
    // ...
    adjustDeltas(heightChangePx, parentClass /* .top or .before-top or null*/);
}

function toggleFoldingV2(target) {
    // ...
    const parentClone = foldable.parentElement.cloneNode(true);
    parentClone.style.transition = "none";
    parentClone.style.display = "none";
    foldable.parentElement.parentElement.appendChild(parentClone);
    const oldHeight = window.getComputedStyle(parentClone).height;
    parentClone.lastElementChild.style.maxHeight = foldable.style.maxHeight
                                                 ? null
                                                 : foldable.scrollHeight + 'px';
    const heightChangePx = window.getComputedStyle(parentClone).height - oldHeight;
    parentClone.remove();
    // ...
    adjustDeltas(heightChangePx, parentClass /* .top or .before-top or null*/);
}

Version 2 is adopting this SO thred.

How can I correctly anticipate the offsets? Is there any member of Element which allows a correct calculation? I am also open to other vanilla-JS approaches.

Thanks in advance.

Biochemistry answered 12/10, 2023 at 13:5 Comment(2)
It seems like you're attempting a FLIP(First,Last,Inverse,Play) animation by hand. I think rolling your own code for that is fine, though maybe you're would like to go in the direction of a generic approach, Flipping.js implements such a generic approach. see it in action here. It would allow you to either change the order of nodes in the dom, or animate css changes that generally are not animatable. At a source of 11k characters, or 4kb gzipped, i would argue you're still close to vanilla.Curriculum
Bedankt. The result looks exactly like I want it to be. Maybe I even learn how they do it by looking at their source - and if not, I just use the lib itself.Biochemistry
B
1

Pointing to the Flipping.js library but even more reading their explanation of the FLIP technique was the key to solve it even in vanilla JS.

The key points:

  • using flex-container and order to sort the elements as wanted:
    just use order=0 for the top item, no .before-top for manual shifting needed anymore
  • implementing a FLIP transition by hand:
    this means storing the old coordinates, straight away re-positioning of the elements (!!), read the new positions and trigger an animation which shows the smooth change from the old to the new positions

It's enough to do this just when re-ordering the list. The folding animations don't interfere anymore because the re-positioning is instant. Basically the animation is shown when the new layout is already implemented.

Here is the code snippet.

function assignClasses() {
    const input = document.getElementById('input').value;
    const items = [...document.getElementsByClassName('item')];
    const topIdx = items.findIndex(elem => elem.children[0].textContent == input);
    if (topIdx < 0) return;

    oldState = items.map(elem => elem.getBoundingClientRect());
    items.forEach((elem, idx) => elem.classList.toggle('top', idx == topIdx));
    newState = items.map(elem => elem.getBoundingClientRect());

    items.forEach((elem, idx) => {
      const deltaX = oldState[idx].left - newState[idx].left;
      const deltaY = oldState[idx].top - newState[idx].top;
      elem.animate(
        [{transformOrigin: 'top left', transform: `translate(${deltaX}px, ${deltaY}px)`},
         {transformOrigin: 'top left', transform: 'none'}],
        {duration: 1000, easing: 'ease', fill: 'both'}
      );
    })
}

function toggleFolding(target) {
    let foldable = target.nextElementSibling;
    foldable.style.maxHeight = foldable.style.maxHeight
                             ? null
                             : foldable.scrollHeight + 'px';
}

Array.prototype.forEach.call(
    document.getElementsByClassName('item'),
    elem => elem.addEventListener('click', event => toggleFolding(event.target))
);
document.getElementById('input').addEventListener('change', assignClasses);
.container {
  display: flex;
  flex-direction: column;
}
.item {
  top: 0;
  width: 10rem;
  margin: .5rem;

  text-align: center;
  line-height: 3rem;
  opacity: 0.7;
}

.head {
  height: 3rem;
}
.body {
  height: 2rem;
  max-height: 0;
  overflow: hidden;
  transition: max-height 1s ease;
}

.top {
  order: -1;
}

#A { background: green;  }
#B { background: yellow; }
#C { background: purple; }
#D { background: blue;   }
#F { background: teal;   }
<input id="input" type="text" placeholder="Enter top item!" autofocus"/>

<div class="container">
  <div id="A" class="item"><div class="head">A</div><div class="body">aa</div></div>
  <div id="B" class="item"><div class="head">B</div><div class="body">bb</div></div>
  <div id="C" class="item"><div class="head">C</div><div class="body">cc</div></div>
  <div id="D" class="item"><div class="head">D</div><div class="body">dd</div></div>
  <div id="F" class="item"><div class="head">F</div><div class="body">ff</div></div>
</div>

Thanks once more @Lars!

Biochemistry answered 13/10, 2023 at 13:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.