In Compose HTML, how would you animate an element ...
# compose-web
m
In Compose HTML, how would you animate an element that is getting removed from the DOM?
๐Ÿ‘€ 1
d
One way is to tag the element with a classname that causes it to fade out, and then when the transition is complete (you can listen to the
transitionend
event for this I believe) only then do you remove the element from the DOM
You can do something similar with animations as well
Here's some code which is using Kobweb, not raw Compose HTML code, but it demonstrates the rough idea of the second approach -- mark an element for removal, animate it out, and then remove it:
Copy code
val BoxStyle by ComponentStyle.base {
    Modifier
        .size(200.px)
        .background(Colors.Magenta)
        .cursor(Cursor.Pointer)
}

val FadeOut by Keyframes {
    0.percent { Modifier.opacity(1.0) }
    100.percent { Modifier.opacity(0.0) }
}

enum class BoxState {
    NORMAL,
    REMOVING,
    REMOVED
}

@Page
@Composable
fun HomePage() {
    PageLayout {
        var boxState by remember { mutableStateOf(BoxState.NORMAL) }
        if (boxState != BoxState.REMOVED) {
            Box(
                BoxStyle.toModifier()
                    .onClick { boxState = BoxState.REMOVING }
                    .thenIf(boxState == BoxState.REMOVING) { Modifier.animation(FadeOut.toAnimation(<http://1000.ms|1000.ms>)) }
                    .onAnimationEnd { boxState = BoxState.REMOVED }
            )
        }
    }
}
๐Ÿ™Œ๐Ÿป 1
And here's a video of it in action (the inspector is included so you can see it removed from the DOM after the animation is finished):
Here's the slightly different but also similar transition approach (again, using Kobweb, which would need to be translated to raw Compose HTML by declaring styles in a
StyleSheet
and then using
attrs = { classes(...) }
to add them to say a Div, exercise left to the reader ;P):
Copy code
val BoxStyle by ComponentStyle.base {
    Modifier
        .size(200.px)
        .background(Colors.Magenta)
        .cursor(Cursor.Pointer)
        .transition(CSSTransition("opacity", <http://1000.ms|1000.ms>))
}

val AnimatingOutBoxVariant by BoxStyle.addVariantBase {
    Modifier.opacity(0f)
}

enum class BoxState {
    NORMAL,
    REMOVING,
    REMOVED
}

@Page
@Composable
fun HomePage() {
    PageLayout {
        var boxState by remember { mutableStateOf(BoxState.NORMAL) }
        if (boxState != BoxState.REMOVED) {
            Box(
                BoxStyle
                    .toModifier(AnimatingOutBoxVariant.takeIf { boxState == BoxState.REMOVING })
                    .onClick { boxState = BoxState.REMOVING }
                    .onTransitionEnd { boxState = BoxState.REMOVED }
            )
        }
    }
}
๐Ÿ™Œ๐Ÿป 1
m
Thanks @David Herman! I asked this question because I wanted to animate some things on my webapp, and one of my ideas was to implement like how you did (however I didn't know about onAnimationEnd! I was going to use a coroutine with a delay matching the animation length heh) So I implemented something similar to your idea... and it worked!
๐ŸŽ‰ 1
HOWEVER, everything breaks if a toast notification is removed while another toast notification is in their "I'm getting removed" animation, Compose attempts to recompose the toast notification, which stops the animation
And here's the code: The toast list
Copy code
Div(attrs = {
                                    classes("toast-list")

                                    if (globalState.activeSaveBar)
                                        classes("save-bar-active")
                                }) {
                                    for (toastWithAnimationState in globalState.activeToasts) {
                                        Div(attrs = {
                                            classes(
                                                "toast",
                                                when (toastWithAnimationState.toast.type) {
                                                    <http://Toast.Type.INFO|Toast.Type.INFO> -> "info"
                                                    Toast.Type.SUCCESS -> "success"
                                                    Toast.Type.WARN -> "warn"
                                                }
                                            )

                                            when (toastWithAnimationState.state.value) {
                                                GlobalState.ToastWithAnimationState.State.ADDED -> {
                                                    classes("added")
                                                    onAnimationEnd {
                                                        println("Finished (added) animation")
                                                        toastWithAnimationState.state.value = GlobalState.ToastWithAnimationState.State.DEFAULT
                                                    }
                                                }
                                                GlobalState.ToastWithAnimationState.State.DEFAULT -> {
                                                    // I'm just happy to be here
                                                }
                                                GlobalState.ToastWithAnimationState.State.REMOVED -> {
                                                    classes("removed")
                                                    onAnimationEnd {
                                                        println("Finished (removed) animation!")
                                                        globalState.activeToasts.remove(toastWithAnimationState)
                                                    }
                                                }
                                            }
                                        }) {
                                            Div(attrs = {
                                                classes("toast-title")
                                            }) {
                                                Text(toastWithAnimationState.toast.title)
                                            }

                                            Div {
                                                toastWithAnimationState.toast.body.invoke()
                                            }
                                        }
                                    }
                                }
The toast show
Copy code
fun showToast(toastType: Toast.Type, title: String, body: @Composable () -> (Unit) = {}) {
        val toast = Toast(
            toastType,
            title,
            body
        )
        val toastWithAnimationState = ToastWithAnimationState(toast, mutableStateOf(ToastWithAnimationState.State.ADDED))

        activeToasts.add(toastWithAnimationState)

        launch {
            delay(7.seconds)
            toastWithAnimationState.state.value = ToastWithAnimationState.State.REMOVED
        }
    }

    class ToastWithAnimationState(
        val toast: Toast,
        val state: MutableState<State>,
    ) {
        enum class State {
            ADDED,
            DEFAULT,
            REMOVED
        }
    }
...okay, I think I found a solution! When creating a toast, we assign a random ID to it
Copy code
val toastWithAnimationState = ToastWithAnimationState(toast, Random.nextLong(0, Long.MAX_VALUE), mutableStateOf(ToastWithAnimationState.State.ADDED))
Then, when rendering the toast, we
key
it based on the ID!
Copy code
Div(attrs = {
                                    classes("toast-list")

                                    if (globalState.activeSaveBar)
                                        classes("save-bar-active")
                                }) {
                                    for (toastWithAnimationState in globalState.activeToasts) {
                                        key(toastWithAnimationState.randomId) {
                                            ... the toast code ...
                                    }
                                }
d
Glad you got something working. Your site looks really dynamic!
In Kobweb's UI library Silk I have a method called
deferRender
which I use for things like popups and it will definitely power toasts when I add them. I suspect it would also solve your problem of interfering with composition. It works by delaying elements that render on top of everything until the end of the DOM. You can check it out here if curious, but you first have to call
renderWithDeferred
(method just below it) as a parent scope (preferably at the root of your site)
m
Glad you got something working. Your site looks really dynamic!
Thanks! It is a configuration dashboard for my Discord bot :3 I will look into the deferRender function later! Maybe it can also be useful for other things I'm working on ๐Ÿ™‚
d
If you ever find yourself reaching to
z-index
, then that's the time to look into
deferRender
๐Ÿ™‚