You can see the end result in this ellie. It displays a smooth removal of the clicked items. You can also remove some items from the beginning or the end.
https://ellie-app.com/cvCV93KgD56a1
An updated ellie with improved performance. This gist was not yet updated with all changes made in this version. Only the article text is somewhat updated. https://ellie-app.com/fBtqCVCjh7qa1
These are my use cases which comes to my mind:
- Fadeout animation of modal dialogs
- Fadeout animation of toasts
- Smooth removal of items in a list
- Page transitions
If you are removing an entry from a list, then the item is just gone in the view. Design guidelines require a smooth removal of entries via an animation. Elm gives no simple way to configure how the DOM node should be removed. This decision keeps the virtual dom simple and fast, but we end up with jumping elements.
The common approach is to handle the animation by giving each item a "removing" state. After the animation completes, an event generates a message, which causes the removal of the element. Still after so much work we may end up with a small jump, because we forgot to animate the spacing of the element. Which is in case of elm-ui not so easy to achieve. Is this somehow possible with mdgriffith/elm-animator/Animator.Css?
This solution uses a WebComponent custom element. It is added as the first element in the item container. The custom element is called animate-sibling-removal-behavior
. It registers a MutationObserver on its parent and reacts on the removal of child elements. From the viewpoint of the behavior element these elements are its siblings. Each item must be wrapped by another element, because a removed element must be added to the next. This keeps the removed element still visible. Now it is possible to apply an animation on this element. After the animation finishes we are removing it from the dom. Also because we are removing it from inside the container, our MutationObserver is not triggered again. For the removal of the last element we have added at the end an empty element.
In the provied elm file everything is already connected. You can also play around in the ellie, see the link at the beginning of this article.
This project focuses only on the animation of removed elm-ui elements. It should be simple to apply them also on Html msg
elements by changing the elm code accordingly. Keep in mind that the styles may break if you are not careful. For this reason i have used Element.layoutWith in every item.
To make our custom element work, we must define our assumptions about the virtual dom, so we implement it correctly.
Points marked with ⬜ are currently not used in the implementation. As you can see everything is implemented in the custom element. 😊
- On each node we can append additional elements without confusing the virtual dom. The virtual dom will ignore these elements.
- We are safe, if we are not removing and re-adding an element to a container, which we have already manipulated.
- Moving the elements will not change their appearance. If you remove an
elm-ui
element from the dom, it may alter the styles. The api must use Element.layoutWith so that there is a new<style>
block inside the elements. - We are not trying to animate wrapped elements. There is not even a Element.Keyed.
wrappedRow
which we can use. I think if an element moves in the row on top, it should slide out to the left and slide in from the right. A nice opacity mask could prevent sudden appearance in the other layer. But this feature is moved to another project, maybe this project can offer a good fundament. - A missing keyed element will always cause the removal of the DOM element. While a new keyed element will always cause a new DOM element. Only if the key is already present, it will just update the contents.
- If the virtual dom is inserting multiple elements it keeps the order by using Node.appendChild() or ChildNode.after().
- When the virtual dom is removing all elements and put some back, this must happen in the same frame, so it is in the same MutationObserver event. Otherwise we need to keep the
ElementTransferMap
inside our custom element, which can increase the likelyhood of issues. - The virtual dom is never removing our
behavior
custom element at index 0, the merged style element at index 1 and thefirst-removal-containers
element at index 2, because all changes start at index 3. - It is okay that an element can be rendered twice. One which is animating away and the new one added. In case of modals/toasts this is even desired behavior.
- The
<style>
blocks created by elm-ui uses aselectorText
which reflects itscssText
. I can disable all<style>
blocks in every item and merge them in an own<style>
element in my container<div>
. I could even look if there is already some<style>
somewhere else created by elm-ui. I need to put anMutation Observer
on every<style>
block in case that it gets updated. The change of the textNode creates a newCSSSheet
instance, which must be disabled again. - When the DOM element for the elm-ui item is generated the
<style>
block must be present. I mean the<style>
block should not be added after we processed the initial elements and freshly added items. This assumption is met by the delayed call of the MutationObserver event. Didn't looked if the elm virtual dom implementation is adding the item as the last step, but this is not needed anyway. - The layout root and its style elements are only removed together with the custom element. We do need to observe the styles of the layout root, so we can reduce the amount of css rule overrides.
onanimationend
is a bubbling event, which means if we have an animation inside our item, it may cause premature removal before the true removal animations ends. This will be protected against by checking the target of the event data.
The structure is as followed:
- Items container
animate-sibling-removal-behavior
div.merged-style-container
The content of item<style>
block will be merged into a style in this container.first-removal-containers
element, to allow the animation of the last element Contains all removed elements which were removed from the front. Moving it to the beginning simplified the new stylesheet animations.- One or more keyed item containers
- The item content root element
- On
elm-ui
here is a divelm-ui
creates here a<style>
block This style block will be disabled and observed for changes
- The user defined item content
- On
- Here may be some additional item content root elements, which are being animated
- The item content root element
The elm-ui
package places a new <style>
block inside each layoutWith
. The containing rules overlapping between the various items. This causes a lot ouf overrides and slows down everything. Luckily we can just disable all these style blocks and copy the contents to a new <style>
without duplicates. If this ability didn't exist, the only solution would be to fork elm-ui
. The selectorText
reflects the cssText
, so we only need to check if we have already copied the selectorText
. This improves the situation heavily.
When a <style>
block gets updated the underlying sheet property gets a new instance. We attach a MutationObserver
on the TextNode
so we update on change the merged style block and redisabling the sheet in the <style>
. In my test app i added controls to change the height of all items. This is done by using E.height <| E.px height
which will update the <style>
block of every item. Without merging all <style>
blocks the performance was really poor (3 FPS @ 100 items), while merged it increased to (23 FPS @ 100 items).
The main reason to merge all styles is not the app performance. It is far more important to prevent the overrides because the DevTools will get slow and cluttered with so many unnecessary entries.
After my first working version which used Element.animate() , i came up with a new approach. The special thing about Element.animate()
is, that it keeps the animation state even if removed from the dom. With the new approach the state is now assigned to the elements with css properties and classes.
This makes the animation now really flexible. It's definitely not straightforward to write removal/insert animations in lists. There were some trouble with jumping elements due to wrong margin animations. This use case should be the most complicated. The animation-delay
css property was used to control the position of each animation. The custom element translated the current state to these delay values, so it can be used within the stylesheet. With the created building blocks it is now possible to create some new animation containers.
The browser compatibility improved by it. The minimum safari version is now at 11.3 instead of 13.1. Also i don't have to come up with a new animation api, because i can just use css.
With css properties it is possible to define all animation outside of the custom element. I apply following css properties at the child elements of the item-containers
element. These are important because animations are restarting when the dom element is moved around. With the css properties you can calculate the new delay to continue the animation.
--removal-animation-delay
: The time in milliseconds since the first removal.--removal-after
: The time in milliseconds between added and removed.--added-animation-delay
: The time in milliseconds since the first time the item got added to the dom.--is-first-delay
: The time in milliseconds since the element got entered/left the first element state. The css property is updated when the change happens or if the element got added again. This enables a smooth animation of a spacing, when added in front of a list.
Also there are some classes applied to write the selectors.
There are following elements which represent no item:
animate-sibling-removal-behavior
: This is the custom element which applies the removal behavior.
On each item of the list element:
.merged-style-container
: This div contains a<style>
element which is used for merging all<style>
elements inside every item. It prevents the massive amount of css overrides caused byelm-ui
which creates a new<style>
block with thelayoutWith
function. This is actually good, that it works like this. If an item differ slightly in the<style>
the appearence would break if we try to animate the removal..first-removal-containers
: The container element if items in the front are getting removed..item-containers
: Initially every.item-containers
element contains only one child. If the virtual dom is removing an element, we need a place to insert it back. So we pick the previous sibling where we are adding the item.
Possible classes on children of an .item-containers
element:
.initial
: Is applied when an element was already available when the custom element got connected and has not entered the removing state yet..removing
: Is applied when an element was removed by the virtual dom. It was moved to another.item-containers
element so we can animate it. If it was the first item, it will get moved to the element with the.first-removal-containers
class.
If the virtual dom decides to remove elements there are some ways how it could happen:
- Just removes a single element
- Just removes multiple elements
- It removes many elements and adds back the elements which are left
- It removes many elements then it adds multiple new elements and then adds back the removed elements.
The first case is quite simple. We take the element and but the content to the next sibling and apply the animation if not yet started. The other ones are more complicated, but are somewhat connected in the implementation.
Here were some details how the virtual dom is operating. Because i have the additional block from the back to the front the description was not up to date anymore.
One of my projects are delivered on tablets with the application preinstalled. The tablet has at least a Chromium WebView at version 80.
I list all used API in the custom element with the minimum required versions in some browsers.
API | Chrome/WebView | Firefox | IE | Edge | Opera/Android | Safari WebKit/iOS |
---|---|---|---|---|---|---|
ES6 Classes | 49 | 45 | - | 12 | 36/59 | 9 |
MutationObserver | 26 | 14 | 11 | 79 | 15 | 6 |
- | ||||||
StyleSheet.disabled | 1 | 1 | 9 | 12 | 1 | 1 |
CSS properties and var() | 49 | 55 | - | 15 | 36 | 11.1/11.3 |
CSS @keyframes animation | 43 | 16 | 10 | 12 | 30 | 9 |
Element.onanimationend() | 79 | 18 | - | 18 | 66/57 | 9 |
CustomElementRegistry.define() | 54 | 63 | - | 79 | 41/47 | 10.3 |
Maybe for older browsers there are already polyfills. I think i have used nothing else which would not have been already covered by ES6. On IE11 when i only use elm-ui i get already render issues.
If issues due browser compatibility does not interfere with the virtual dom, then the worst what happens is, that the animation will not getting executed. The most common interfering would be thrown exception which enters the elm virtual dom. Maybe these are already catched by the MutationObserver
and can not enter the elm virtual dom.
I think in a later version the Element.animate()
will not be used, if i get all animation working with css keyframes. With css keyframes i do not need to think about a nice api with the custom element. So this is preferable anyway.
Besides the working ellie the relevant source code is also attached to this gist. I have written the behavior
custom element in TypeScript or i would get completely exhausted if i try to refactor it in JavaScript directly. The resulting JavaScript is attached too and is compiled for ES6
/ES2015
. The only reason to use this version, that i get otherwise an exception in the super() function called inside the constructor:
Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function`.
I am quite sure that i covered the behavior of the elm virtual dom. There should be no problems. If you overcome potentional browser incompatiblities this should work, at least until the virtual dom gets changed. Please tell me if you find an issue within my implementation. Currently i am planning to use this in predefined environments like Chrome 80+ or Edge 86+. If there are no problems in the common webbrowsers maybe this is also usable for the web.
I only wanted the basics written down, so i can move on with the next project. There is still room for improvements.
- The animation is hard coded. Changing to vertical scaling should be ease. Just replace
scale(0.0, 1.0)
withscale(1.0, 0.0)
in theindex.ts
orindex.js
. The required changes inMain.elm
are a little more. Also this could be used for fadein/fadeout modal dialogs. Just keep opacity animation in theindex.ts
orindex.js
and change the elm code so it fills out the space. Also we could introduce more elements to configure the animations directly from elm code, so we never need to touch the TypeScript/JavaScript code again. - Publishing this as an elm package for the elm api with the need to install the WebComponent code manually. This would increase the visibility with this approach. I don't think i will do this anytime soon, if i am not needing it in multiple projects. Still never have created a github repository.
- Make an
animatableWrappedRow
version, without letting them fly over the whole page. They should slide out in the current row and slide in on the new row.
There may be some more, but my time begins to run out.
For this project i have used the provided information on the MDN Web Docs. How the elm virtual dom operates was obtained by the logs of the MutationObserver
event handler. You can enable the log if you switch on the constant AnimateSiblingRemovalBehavior.activateMutationLogging
. Of course i used the elm/typescript compiler and some elm packages, which you can find in the elm.json.
All other informations used are obtained by experience in the past years and are not backtracable.
I don't know if i need to attach a license to enable others to use this.
So this gist, which contains the article text and the attached code files are licensed in terms of the MIT license. Taken from opensource.org:
Copyright 2021 Yarith@GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.