Oh-button with animation

hey

I’m working with a widget where I want a checkmark animation to appear when a button is pressed. I’ve created the animation, but I need some input on how to make it work as I intend. What I want to achieve is that when the button is pressed, the icon disappears, and an animation plays. After the animation runs, it should disappear, and the icon should return, allowing the button to be pressed again. Currently, I’m using actionVariableValue: "=(vars.animate === 'running') ? 'paused' : 'running'" to run the animation, but this causes the animation to play immediately when the page loads, so you have to reload the page to see the animation. Alternatively, if you’re working with it in the editor, you have to redraw it. After the animation ends, you also need to press the button again for it to return to its previous state before it was pressed.

chrome-capture (2)

Here is the code for the widget, and it can definitely be optimized. I’m just an electrician, but any input is welcome

uid: animated-loading-test-2
tags: []
props:
  parameters:
    - description: Label for Button 1
      label: Label for button 1
      name: Button_1_label
      required: true
      type: TEXT
    - description: Button 1 icon
      label: Iconify icon example; iconify:mdi:garage-variant
      name: Button_1_icon
      required: false
      type: TEXT
  parameterGroups:
    - name: Button_1
      context: action
      label: Choose action for button 1
timestamp: Aug 18, 2024, 1:54:37 PM
component: f7-card
config:
  style:
    --check-size: 80px
    --check-border-width: 5px
    --checkmark-width: calc(var(--check-size) / 2)
    --checkmark-height: calc(var(--checkmark-width) / 2)
    --checkmark-left: calc(var(--checkmark-width) / 2)
    --checkmark-top: 50%
    --checkmark-color: "#19b8a2"
    margin-left: auto
    margin-right: auto
    height: 150px
    noShadow: false
slots:
  default:
    - component: oh-button
      config:
        action: variable
        actionVariable: animate
        actionVariableValue: "=(vars.animate === 'running') ? 'paused' : 'running'"
        class:
          - elevation-5
          - elevation-hover-10
          - elevation-pressed-1
        expandable: true
        style:
          background-color: =(themeOptions.dark=="light") ? ("#FFFFFF"):("#252525")
          border-radius: var(--f7-card-expandable-border-radius)
          height: 110px
          margin-left: 5px
          margin-right: 5px
          margin-top: 21px
          max-width: 100%
          noShadow: false
          padding: 0px
          position: relative
      slots:
        default:
          - component: oh-icon
            config:
              icon: =props.Button_1_icon ? props.Button_1_icon :'iconify:wi:lightning'
              style:
                visibility: =vars.animate === 'running' ? 'hidden' :'visible'
                color: =(themeOptions.dark=="light") ? ("black"):("white")
                font-size: "=screen.viewAreaWidth <= 400 ? '26px' : (screen.viewAreaWidth <= 600
                  ? '28px' : (screen.viewAreaWidth <= 800 ? '30px'
                  :(screen.viewAreaWidth <= 1100 ? '32px' : '34px')))"
                left: 15px
                position: absolute
                top: 15px
          - component: Label
            config:
              style:
                bottom: 15px
                color: =(themeOptions.dark=="light") ? ("black"):("white")
                font-size: "=screen.viewAreaWidth <= 400 ? '12px' : (screen.viewAreaWidth <= 600
                  ? '16px' : (screen.viewAreaWidth <= 800 ? '14px'
                  :(screen.viewAreaWidth <= 1100 ? '16px' : '18px')))"
                left: 15px
                position: absolute
              text: =props.Button_1_label
          - component: div
            config:
              style:
                visibility: =vars.animate === 'running' ? 'visible' :'hidden'
                width: var(--check-size)
                height: var(--check-size)
                position: relative
                margin-left: auto
                margin-right: auto
                margin-top: 10px
            slots:
              default:
                - component: div
                  config:
                    style:
                      content: ""
                      position: absolute
                      inset: 0
                      border: var(--check-border-width) solid
                      border-color: =(themeOptions.dark=="light") ? ("white"):("black")
                      width: 100%
                      height: 100%
                      border-radius: 50%
                      display: block
                      z-index: 0
                - component: div
                  config:
                    style:
                      content: ""
                      position: absolute
                      inset: 0
                      border: var(--check-border-width) solid transparent
                      border-left-color: var(--checkmark-color)
                      width: 100%
                      height: 100%
                      border-radius: 50%
                      display: block
                      z-index: 1
                      animation: circle linear forwards .75s
                      animation-play-state: =vars.animate
                    stylesheet: |
                      @keyframes circle {
                          0% {
                              border-color: transparent;
                              border-left-color: var(--checkmark-color);
                          }
                          90% {
                              transform: rotate(-360deg);
                              border-color: transparent;
                              border-left-color: var(--checkmark-color);
                          }
                          100% {
                              transform: rotate(-360deg);
                              border-color: var(--checkmark-color);
                          }
                      }
                - component: div
                  config:
                    style:
                      height: var(--checkmark-height)
                      width: var(--checkmark-width)
                      position: absolute
                      opacity: 0
                      left: var(--checkmark-left)
                      top: var(--checkmark-top)
                      display: block
                      border-left: var(--check-border-width) solid var(--checkmark-color)
                      border-bottom: var(--check-border-width) solid var(--checkmark-color)
                      transform-origin: left top
                      transform: rotate(-45deg)
                      animation: checkmark linear both 1s
                      animation-play-state: =vars.animate
                    stylesheet: |
                      @keyframes checkmark {
                          0% {
                              height: 0;
                              width: 0;
                              opacity: 0;
                            }
                            80% {
                              height: 0;
                              width: 0;
                              opacity: 0;  
                            }
                            90% {
                              height: var(--checkmark-height);
                              width: 0;
                              opacity: 1;
                            }
                            100% {
                              height: var(--checkmark-height);
                              width: var(--checkmark-width);
                              opacity: 1;
                            }
                      }

This is a fairly non-trivial thing that you are attempting. The problem stems from the fact that oh can’t really get feedback from the css animation about when it has stopped, of course. There are a couple of different ways to get around this.

The first would be to use an item with an expire setting that match the total duration of your animation. Then when you press your button instead of setting a variable, you send a command to the item. Then all your necessary changes key off the state of the item which causes the animation to run but then after the expire time the item state gets automatically reset and everything is ready to go again. This is easy, but has some downsides: 1) if you are going to have more than one iteration of this widget you need to have different items for each one, 2) if you have different instances of the MainUI open they all would undergo the animation when the item changes, 3) if you plan to put this widget on the marketplace it would require users to create and configure that widget as well.

There’s also the possibility that you could render your animation as an image file instead of doing it with css. Again, this depends on your use case, but displaying a simple gif or if you want slightly more control an animated svg might bypass some of the issue you are having. The gif solution has some of the same problems as the first solution (namely deploying this would require providing the file and expecting users to place it in the properly accessible location). The svg could technically be rendered in-line in the widget but that adds significant complexity to the widget.

With my confirm button, I opted for a different approach. You can find it here:

The countdown bar on that widget is accomplished by css animation triggered but a button click just like your checkmark. When you look through the code of that widget you’ll see that there are a few critical differences:

  1. The animations are not applied directly to the components. They are defined in some css classes. Those classes are then added to the components or not based on the status of the triggering variable. This gives you a couple of advantages, but the primary one is that you don’t get the animation running when the page refreshes because the classes are not initially applied to any elements.

  2. The use of the key property on the root component of the widget. The key property is special to vue (the library that is rendering the pages). Whenever that property changes, for any reason, that signals to view to redraw that component (and therefore, all of it’s child components as well). When you make that property depend on the state of your triggering variable then every time the variable changes the whole widget gets redrawn/reset. (Note the use of Math.random() in the key, that’s necessary to avoid conflicts of the view property value if you have more than one of these widgets on a page.)

  3. The use of a counter variable instead of a binary toggling variable. This goes back to the initial problem that OH cannot know when the animation has ended and therefore can take no actions (like resetting the animation variable) on its own. This might be less of an issue for your use case if you want the check mark to remain in place after the animation runs, but if you want the button to fully reset after then you need to not depend on the value of the variable changing back to some value because it won’t. To make this work you have shift the changes in visibility from the actual style object to the animations so that all triggering the button does is start the animations (really apply the animation css classes to the components) and when the animations are done the final css state reverts to your “baseline” css settings. Then, with a variable that increments each time you push the button, you can trigger the system multiple times without having to refresh the page.

Thank you for an extremely detailed response, now I have something to work with when time permits.

Hi

I’ve been looking at it again and created another example with a simple animation. I’m trying to use a counter to activate the animation, but I can’t get it to activate with a simple greater-than code, only by using even and odd numbers.

This works:

=((vars.trigger_animation % 2 === 1) ? 'button-animate' :'')

But not this:

=vars.trigger_animation > 0 ? 'button-animate' : ''
uid: test_button_scaleup3
tags: []
props:
  parameters:
    - description: Button 1 icon
      label: Iconify icon example; iconify:mdi:garage-variant
      name: Button_1_icon
      required: false
      type: TEXT
  parameterGroups: []
timestamp: Oct 13, 2024, 8:09:31 PM
component: f7-card
config:
  style:
    --f7-card-bg-color: transparent
    --f7-card-header-border-color: transparent
    box-shadow: none
    font-family: sans-serif
    font-weight: bold
    height: auto
  stylesheet: |
    .button-style {
      box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.5);
    } .button-animate {
      animation: scale-up 1s forwards, scale-down 1s forwards 2s;
    }
    @keyframes scale-up {
      0% { transform: scale(1); }
      100% { transform: scale(1.1); }
    }
    @keyframes scale-down {
      0% { transform: scale(1.1); }
      100% { transform: scale(1); }
    }
slots:
  default:
    - component: oh-button
      config:
        action: variable
        actionVariable: trigger_animation
        actionVariableValue: =(vars.trigger_animation || 0) + 1  # Increment the counter by 1
        class: 
          -  =((vars.trigger_animation % 2 === 1) ? 'button-animate' :'') # Aktivér animation  
          - button-style
        expandable: true
        style:
          background-color: =(themeOptions.dark=="light") ? ("#FFFFFF"):("#252525")
          border-radius: var(--f7-card-expandable-border-radius)
          height: 110px
          margin-left: 50px
          margin-right: 5px
          margin-top: 21px
          max-width: 50%
          padding: 0px
          position: relative
      slots:
        default:
          - component: oh-icon
            config:
              icon: =props.Button_1_icon ? props.Button_1_icon :'iconify:wi:lightning'
              style:
                color: =(themeOptions.dark=="light") ? ("black"):("white")
                font-size: "=screen.viewAreaWidth <= 400 ? '26px' : (screen.viewAreaWidth <= 600
                  ? '28px' : (screen.viewAreaWidth <= 800 ? '30px'
                  :(screen.viewAreaWidth <= 1100 ? '32px' :
                  '34px')))"
                left: 15px
                position: absolute
                top: 15px
          - component: Label
            config:
              style:
                bottom: 15px
                color: =(themeOptions.dark=="light") ? ("black"):("white")
                font-size: "=screen.viewAreaWidth <= 400 ? '12px' : (screen.viewAreaWidth <= 600
                  ? '16px' : (screen.viewAreaWidth <= 800 ? '14px'
                  :(screen.viewAreaWidth <= 1100 ? '16px' :
                  '18px')))"
                left: 15px
                position: absolute
              text: ="Animation Counter:" + (vars.trigger_animation || "0")

I used ChatGPT a lot to make this, so I know the code can be much better.

"Thanks in advance for any reply.

It looks like you just missed one of the points up above:

What I perhaps should have explained in more detail is why the redraw that is triggered by the key value is required. When you use the even/odd test to determine whether or not the class is applied to the button then when the variable is even the class is removed, and when the variable is odd the class is added back in. The removal or addition of a class to an element is enough to trigger the renderer to redraw that element. When it is redrawn with the animation class, then the animation runs. If you just use the greater than test, once the variable is set to 1 then the button will always have the animation class so it doesn’t actually change when the variable increases. No change means no redraw which in turn means the animation isn’t triggered.

This is where key comes in. Whenever key changes that is enough to cause the renderer to redraw the element even if it thinks there is no other reason to do so (i.e., the classes haven’t changed). So each time key changes as long as the button has the animate class, the animation will run again.

So you just need to add a key value that is dependent on the value of your vairable:

    - component: oh-button
      config:
        key: =Math.random() + (vars.trigger_animation || 0)
        action: variable
        actionVariable: trigger_animation
        actionVariableValue: =(vars.trigger_animation || 0) + 1  # Increment the counter by 1

Code I write can also always be better, that’s the way it goes. As far as the widgets are concerned, if it works, makes sense to the user, and doesn’t weigh down the interface, then you’ve succeeded. The only thing that I would advocating modifying at the moment is the animation key frames. There really isn’t much reason to split it up the way you have it. You can get the up and down effect in one sequence with additional keyframes.

.button-animate {
      animation: scale-up 2s forwards;
    }
    @keyframes scale-up {
      0% { transform: scale(1); }
      50% { transform: scale(1.1); }
      100% { transform: scale(1); }
    }

Or, if you want a pause in between the up and down:

.button-animate {
      animation: scale-up 2s forwards;
    }
    @keyframes scale-up {
      0% { transform: scale(1); }
      40% { transform: scale(1.1); }
      60% { transform: scale(1.1); }
      100% { transform: scale(1); }
    }

Hi Justin, thanks for the response. You were right—I hadn’t understood all the points, but your solution worked, so thanks for that.

Right now, I’m trying to expand the solution I have to include an icon switch, but again, I can’t get it to work. I’m attempting to assign a new class to the icon, but it doesn’t seem to accept the code. The idea is that when the button scales up, the first icon should fade out, and the second one should become visible.

uid: test_button_scaleup_animation_checkmark
tags: []
props:
  parameters:
    - description: Button 1 icon
      label: Iconify icon example; iconify:mdi:garage-variant
      name: Button_1_icon
      required: false
      type: TEXT
timestamp: Oct 27, 2024, 9:30:53 AM
component: f7-card
config:
  style:
    --f7-card-bg-color: transparent
    --f7-card-header-border-color: transparent
    box-shadow: none
    font-family: sans-serif
    font-weight: bold
    height: auto
  stylesheet: |
    .button-style {
      box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.5);
    }
    .button-animate {
      animation: scale-up 3s forwards;
    }
    .icon-fade {
      opacity: 0;
      transition: opacity 1s;
    }
    .checkmark-visible {
      opacity: 1;
      transition: opacity 1s;
    }
    @keyframes scale-up {
      0% { transform: scale(1); }
      40% { transform: scale(1.1); }
      50% { transform: scale(1.1); }
      60% { transform: scale(1.1); }
      100% { transform: scale(1); }
    }
slots:
  default:
    - component: oh-button
      config:
        key: =Math.random() + (vars.trigger_animation || 0)
        action: variable
        actionVariable: trigger_animation
        actionVariableValue: =(vars.trigger_animation || 0) + 1
        class:
          - button-style
          - '=(vars.trigger_animation > 0) ? "button-animate" : ""'
        expandable: true
        style:
          background-color: =(themeOptions.dark=="light") ? ("#FFFFFF"):("#252525")
          border-radius: var(--f7-card-expandable-border-radius)
          height: 110px
          margin-left: 50px
          margin-right: 5px
          margin-top: 21px
          max-width: 50%
          padding: 0px
          position: relative
      slots:
        default:
          - component: oh-icon
            config:
              icon: =props.Button_1_icon ? props.Button_1_icon :'iconify:wi:lightning'
              class:
                - '=(vars.trigger_animation > 0) ? "icon-fade" : ""'
              style:
                color: =(themeOptions.dark=="light") ? "black" :"white"
                font-size: "=screen.viewAreaWidth <= 400 ? '26px' : (screen.viewAreaWidth <= 600
                  ? '28px' : (screen.viewAreaWidth <= 800 ? '30px' :
                  (screen.viewAreaWidth <= 1100 ? '32px' : '34px')))"
                left: 15px
                position: absolute
                top: 15px
          - component: oh-icon
            config:
              icon: iconify:mdi:check-bold
              class:
                - '=(vars.trigger_animation > 0) ? "checkmark-visible" : ""'
              style:
                opacity: 0
                color: green
                font-size: "=screen.viewAreaWidth <= 400 ? '26px' : (screen.viewAreaWidth <= 600
                  ? '28px' : (screen.viewAreaWidth <= 800 ? '30px' :
                  (screen.viewAreaWidth <= 1100 ? '32px' : '34px')))"
                left: 15px
                position: absolute
                top: 15px
          - component: Label
            config:
              style:
                bottom: 15px
                color: =(themeOptions.dark=="light") ? "black" :"white"
                font-size: "=screen.viewAreaWidth <= 400 ? '12px' : (screen.viewAreaWidth <= 600
                  ? '16px' : (screen.viewAreaWidth <= 800 ? '14px' :
                  (screen.viewAreaWidth <= 1100 ? '16px' : '18px')))"
                left: 15px
                position: absolute
              text: ="Animation Counter:" + (vars.trigger_animation || "0")

Am I on the right track or completely off course?

There are two issues here:

The bigger issue is that the icon are actually a little bit tricky. The way the f7 and iconify icon libraries work, is that they replace the element you create with a special, custom element containing the image. This makes it difficult to apply classes to the image for a couple of different technical reasons. The basic result however, is that if you use your browser’s page inspector on the iconify image in your card you’ll see that “icon” is actually a whole svg image and no matter what you set in the component yaml class property, the iconify svg will only ever have class="iconify iconify--wi".

The solution to this problem is one of the fundamental principles of html, in my opinion: “put it in a container”. Nearly all your code will work as intended if you just apply it to a div component, and then put the icon inside that div:

  - component: div
    config:
      class:
        - '=(vars.trigger_animation > 0) ? "icon-fade" : ""'
      style:
        color: =(themeOptions.dark=="light") ? "black" :"white"
        font-size: "=screen.viewAreaWidth <= 400 ? '26px' : (screen.viewAreaWidth <= 600
          ? '28px' : (screen.viewAreaWidth <= 800 ? '30px' :
          (screen.viewAreaWidth <= 1100 ? '32px' : '34px')))"
        left: 15px
        position: absolute
        top: 15px
    slots:
      default:
        - component: oh-icon
          config:
            icon: =props.Button_1_icon ? props.Button_1_icon :'iconify:wi:lightning'

I say “nearly all your code” because of the second small problem. You assign an opacity of 0 to the second icon in it’s style object. By the rule of cascading style sheets, that style applied at the component level will override any style set by a class. So on that second icon it doesn’t matter that the checkmark-visible class sets the opacity to 1. To make that work you either need to assign the opacity of 0 with some other class (on the list before the checkmark-visible class), or to use the !important keyword on the class opacity setting so that it overrides the element setting.