Vueアニメーション

次レベルの

Sarah Drasner

Microsoft

どうして
アニメーションが必要?

“人間は進化の過程で、より継ぎ目無く、シームレスに流れるような動きを行うよう最適化されてきた。”

 

"人間とコンピューター間のインタラクションでも、新しく登場した概念に対応できるようにはなっていない

「感覚記憶」: あなたの後頭葉(記憶領域)は100msしかバーストしない。

-Tammy Everts

タイミング

ユーザーへの見え方を考慮しよう

理解しやすく

空間的に表現するかどうか

トランジションをなくす…

Paul Bakaus

モーフィング

詳しくはCSSトリックの記事(拙筆)

この Codepen.

ではどうやって?

  • <transition />を見直す
  • ウォッチャー/リアクティビティ
  • カスタムディレクティブ
  • ページトランジション
  • *ボーナス* vue data vis
<div id="app">
  <h3>Type here:</h3>
  <textarea v-model="message" rows="5" maxlength="75"/>
  <p>{{ message }}</p>
</div>

Codepenはこちら.

<Transition />

クイックレビュー

  <app-modal v-if="isShowing">
    ...
  </app-modal>

Codepenはこちら.

😳

<transition name="fade">
  <app-modal v-if="isShowing">
    ...
  </app-modal>
</transition>

  <app-modal v-if="isShowing">
    ...
  </app-modal>

Transitionコンポーネント

変化を宣言的に記述できる

Vueのエレガントな部分

通常のv-プレフィクスかname="foo"を使用

使い方:

.v-enter-active {
  transition: opacity 1s ease;
}
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.25s ease-out;
}

.fade-enter, .fade-leave-to {
  opacity: 0;
}

他のコンポーネントに再利用できる

Codepenはこちら

素敵!

でも...

<div :class="[isShowing ? blurClass : '', bkClass]">
  <h3>Let's trigger this here modal!</h3>
  <button @click="toggleShow">
    <span v-if="isShowing">Hide child</span>
    <span v-else>Show child</span>
  </button>
</div>
.bk {
  transition: all 0.05s ease-out;
}

.blur {
  filter: blur(2px);
  opacity: 0.4;
}

Codepenはこちら

Styleバインディングの補完- Codepen

インスタンス:

new Vue({
  el: '#app',
  data() {
    return {
      x: 0, 
      y: 0
    }
  },
  methods: {
    coords(e) {
      this.x = e.clientX / 10;
      this.y = e.clientY / 10;
    },
  }
})

テンプレート:

<div id="contain" :style="{ perspectiveOrigin: `${x}% ${y}%` }">

トランジションモード

🏆

🏆

Codepenはこちら

新しい要素がトランジション終わるまで、今の要素はトランジションを待機。

 

 

 

 

今の要素が外に出たあと、新しい要素が入ってくる。

In-out

Out-in

<transition name="flip" mode="out-in">
  <slot v-if="!isShowing"></slot>
  <img v-else src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/28963/cartoonvideo14.jpeg" />
</transition>

HTML

CSSアニメーション

enter-active-class="toasty"
leave-active-class="bounceOut"

これも<transition />コンポーネント、でも…

  <transition
    enter-active-class="bouncein"
    leave-active-class="rollout">
    <div v-if="isShowing">
      <app-child class="child"></app-child>
    </div>
  </transition>

ボールのバウンス

@mixin ballb($yaxis: 0) {
  transform: translate3d(0, $yaxis, 0);
}

@keyframes bouncein { 
  1% { @include ballb(-400px); }
  20%, 40%, 60%, 80%, 95%, 99%, 100% { @include ballb() }
  30% { @include ballb(-80px); }
  50% { @include ballb(-40px); }
  70% { @include ballb(-30px); }
  90% { @include ballb(-15px); }
  97% { @include ballb(-10px); }
}

.bouncein { 
  animation: bouncein 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}

.ballmove-enter {
  @include ballb(-400px);
}

DRY(Don't Repeat Yourself)の原則

JavaScriptフック

<transition 
  @before-enter="beforeEnter"
  @enter="enter"
  @after-enter="afterEnter"
  @enter-cancelled="enterCancelled"

  @before-leave="beforeLeave"
  @leave="leave"
  @after-leave="afterLeave"
  @leave-cancelled="leaveCancelled"
  :css="false">
 
 </transition>

名前付けのカスタマイズ

<transition 
  @enter="enterEl"
  @leave="leaveEl"
  :css="false">
  <!-- put element here-->
 </transition>

一番簡単な例

methods: {
   enterEl(el, done) {
     //entrance animation
     done();
  },
  leaveEl(el, done) {
    //exit animation
    done();
  },
}

一番簡単な例

<transition @before-enter="beforeEnter" @enter="enter" :css="false">
  <p class="booktext" v-if="load">
    {{ message }}
  </p>
</transition>
new Vue({
  ...
  methods: {
    beforeEnter(el) {
      TweenMax.set(el, {
        transformPerspective: 600,
        perspective: 300,
        transformStyle: "preserve-3d",
        autoAlpha: 1
      });
    },
    enter(el, done) {
      tl.add("drop");
      for (var i = 0; i < wordCount; i++) {
        tl.from(split.words[i], 1.5, {
          z: Math.floor(Math.random() * (1 + 150 - -150) + -150),
          ease: Bounce.easeOut
        }, "drop+=0." + (i/ 0.5));
       ...
    }
  }
});

<TransitionGroup />

重くならないためのFLIP!

FLIP は First(最初), Last(最後), Invert(逆転), Play(再生)

Rosario氏の記事

 <transition-group name="cell" tag="div" class="container">
    <div v-for="cell in cells" :key="cell.id">
      {{ cell.number }}
    </div>
  </transition-group>

ガイドより

Snipcart's post

 <input 
    class="slider" 
    id="pricerange" 
    type="range"
    :value="pricerange"  
    :min="min" 
    :max="max" 
    step="0.1" 
    @input="$emit('update:pricerange', $event.target.value)"
  />

AppSidebar.vue内:

pages/index.vue内:

<transition-group name="items" tag="section" class="content">
    <app-item
      v-for="(item, index) in products"
      :key="item"
      :item="item"
      :index="index"
    />
</transition-group>
/* --- items animation --- */

.items-leave-active {
  transition: all 0.2s ease-in;
}

.items-move {
  transition: all 0.2s ease-in-out;
}

.items-enter-active {
  transition: all 0.2s ease-out;
}

.items-enter,
.items-leave-to {
  opacity: 0;
  transform: scale(0.9);
  transform-origin: 50% 50%;
}

<transition-group>で使っているCSS

トランジションには
リアクティブな仕組みを活用しよう

ウォッチャー

&Vueのリアクティブシステム

リアクティブとは?

リアクティブプログラミングとは、非同期なデータストリームを活用した開発手法。

ストリームとは時系列に流れる連続的なイベントで、それをウォッチ&検出する仕組みを持っている

アプリケーション開発にリアクティブの概念を取り入れると、イベントに対してステートの更新が非常に簡単になる。

リアクティブとは?

 

  • Angular 1.xはDirty checking方式。
  • Cycle.jsやAngular 2はXStreamやRx.jsといったリアクティブシステムを採用。
  • Vue.js、MobXやRactive.jsはgetters/setters方式を使用。

参考資料:

名前に反し、Reactはリアクティブではない - 「プル」型のアプローチを採用(プッシュ型ではない)

Vueインスタンスで宣言されたdata
プロパティはウォッチ可能

State変更でアニメーションを作成できる

数学的なモデルであるSVGとの相性が良い

SVGアニメーション

Vue.js

=

🔥

+

SVG!

数学的な計算

<!--xaxis -->
<g targetVal="targetVal"  class="xaxis">
  <line x1="0" y1="1" x2="350" y2="1"/>
  <g v-for="(select, index) in targetVal">
    <line y1="0" y2="7" v-bind="{ 'x1':index*10, 'x2':index*10 }"/>
    <text v-if="index % 5 === 0" v-bind="{ 'x':index*10, 'y':20 }">{{ index }}</text>
  </g>
</g>
watch: {
    selected: function(newValue, oldValue) {

      var tweenedData = {}      

      var update = function() {
        let obj = Object.values(tweenedData);
        obj.pop();
        this.targetVal = obj;
      }

      var tweenSourceData = { onUpdate: update, onUpdateScope: this}

      for (let i = 0; i < oldValue.length; i++) {
        let key = i.toString()
        tweenedData[key] = oldValue[i]
        tweenSourceData[key] = newValue[i]
      }

      TweenMax.to(tweenedData, 1, tweenSourceData)
    }
  }
 watch: {
    checked() { 
      let period = this.timeVal.slice(-2),
          hr = this.timeVal.slice(0, this.timeVal.indexOf(":"));
      
      const dayhr = 12,
            rpos  = 115,
            rneg  = -118;
      
      if ((period === 'AM' && hr != 12) || (period === 'PM' && hr == 12)) {
        this.spin(`${rneg - (rneg/dayhr) * hr}`)
        this.animTime(1-hr/dayhr, period)
      } else {
        this.spin(`${(rpos/dayhr) * hr}`)
        this.animTime(hr/dayhr, period)
      }
      
    }
  },
methods: {
    // this formats the hour info without a library
    getCurrentHour(zone) {
      let newhr = new Date().toLocaleTimeString('en', {
        hour: '2-digit', 
        minute: '2-digit', 
        hour12: true, 
        timeZone: zone
      })
      return newhr
    },
  ...
}

おまけ: 巨大ライブラリは不要

イベントに反応し、ストリーム中にアニメーションを変化させる

bounceBall() {   
  this.vy += this.g; // gravity increases the vertical speed
  this.x1 += this.vx; // horizontal speed inccrements horizontal position 
  this.y1 += this.vy; // vertical speed increases vertical position

  if (this.y1 > this.total - this.radius) { // if ball hits the ground
    this.y1 = this.total - this.radius; // reposition it at the ground
    this.vy *= -0.8; // then reverse and reduce its speed
  }
},
animateBall() {
  ...
  function step(timestamp) {
    if (!start) start = timestamp;
    var progress = timestamp - start;
    if (progress < 13000) {
      vueThis.bounceBall();
      vueThis.req = window.requestAnimationFrame(step);
    } else {
      vueThis.x1 = this.radius;
      vueThis.y1 = this.radius;
      vueThis.running = false;
    }
  }
  this.req = window.requestAnimationFrame(step);
},
cancelAnimate() {
  cancelAnimationFrame(this.req);
  this.running = false;
  ...
}

感情は大脳辺縁系が担うため、記憶に残りやすい

<div id="app" @mousemove="coordinates">
coordinates(e) {
  const audio = new Audio('https://s3-us-west-2.amazonaws.com/s.cdpn.io/28963/Whoa.mp3'),
    walleBox = document.getElementById('walle').getBoundingClientRect(),
    walleCoords = walleBox.width / 2 + walleBox.left;
    ...

    TweenMax.set("#eyes", {
      scaleX: 1 + (1 - e.clientX / walleCoords) / 5
    });
    TweenMax.set("#walle", {
      x: ((e.clientX / walleCoords) * 50) - 40
    });

    this.startArms.progress(1 - (e.clientX / walleCoords)).pause();
  }
},

<template>内

Vueインスタンス

トランジションを
コーディネート

端から端まで

変化しているものをカプセル化 - レポジトリ

ステート駆動アニメーション

変化しているものをカプセル化:

export const store = new Vuex.Store({
  state: {
    showWeather: false,
    template: 0
  },
  mutations: {
    toggle: state => state.showWeather = !state.showWeather,
    updateTemplate: (state) => {
      state.showWeather = !state.showWeather;
      state.template = (state.template + 1) % 4;
    }
  }
});

Vuex

<transition @leave="leaveDialog" :css="false">
  <app-dialog v-if="showWeather"></app-dialog>
</transition>
<transition @leave="leaveDroparea" :css="false">
  <g v-if="showWeather">
    <app-droparea v-if="template == 1"></app-droparea>
    <app-windarea v-else-if="template == 2"></app-windarea>
    <app-rainbowarea v-else-if="template == 3"></app-rainbowarea>
    <app-tornadoarea v-else></app-tornadoarea>
  </g>
</transition>
export default {
  computed: {
    template() {
      return this.$store.state.template;
    }
  },
  methods: {
    toggle() {
      this.$store.commit('toggle');
    }
  },
  mounted() {
    //enter weather
    const tl = new TimelineMax();
    tl.add("enter");
    tl.fromTo("#dialog", 2, {
      opacity: 0
    }, {
      opacity: 1
    }, "enter");
    tl.fromTo("#dialog", 2, {
      rotation: -4
    }, {
      rotation: 0,
      transformOrigin: "50% 100%",
      ease: Elastic.easeOut
    }, "enter");
  }
}

ライフサイクルをフック

const Child = {
  beforeCreate() {
    console.log("beforeCreate!");
  }, 
  ...
};

カスタムディレクティブ

Vue.directive('tack', {
 bind(el, binding, vnode) {
    el.style.position = 'fixed'
  }
});
<p v-tack>I will now be tacked onto the page</p>

😳

Vue.directive('tack', {
  bind(el, binding, vnode) {
    el.style.position = 'fixed'
    el.style.top = binding.value + 'px'
  }
});
<div id="app">
  <p>Scroll down the page</p>
  <p v-tack="70">Stick me 70px from the top of the page</p>
</div>

🙂

Vue.directive('tack', {
  bind(el, binding, vnode) {
    el.style.position = 'fixed';
    el.style.top = binding.value.top + 'px';
    el.style.left = binding.value.left + 'px';
  }
}); 
<p v-tack="{ top: '40', left: '100' }">Stick me 40px from the top of the
page and 100px from the left of the page</p>

😃

引数を渡す

アニメーションに適用しよう

Vue.directive('scroll', {
  inserted: function(el, binding) {
    let f = function(evt) {
      if (binding.value(evt, el)) {
        window.removeEventListener('scroll', f);
      }
    };
    window.addEventListener('scroll', f);
  },
});

// main app
new Vue({
  el: '#app',
  methods: {
   handleScroll: function(evt, el) {
    if (window.scrollY > 50) {
      TweenMax.to(el, 1.5, {
        y: -10,
        opacity: 1,
        ease: Sine.easeOut
      })
    }
    return window.scrollY > 100;
    }
  }
});
<div class="box" v-scroll="handleScroll">
  <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. A atque amet harum aut ab veritatis earum porro praesentium ut corporis. Quasi provident dolorem officia iure fugiat, eius mollitia sequi quisquam.</p>
</div>

🔥

カスタムディレクティブ + D3

export default {
    methods: {
      totalImpact: function(evt, el) {
        if (window.scrollY > 1100) {
          TweenMax.to(el, 0.75, {
            opacity: 0
          })
          let circ =  d3.selectAll("circle")
                      .attr("cx", function(d) {
                        let lat = d["Longitude (Deg)"];
                        if (lat.includes("E")) {
                          return midX - parseInt(lat) * incByW;
                        } else {
                          return midX + (parseInt(lat) * incByW);
                        }
                      })
                      ...
                      .attr("r", 5)
                      .attr("fill", "url(#radgrad)")
        }
        return window.scrollY > 1300;
      },

円形の座標を更新する

<div class="box accelerate impact" v-dscroll="totalImpact">
  <h3>Total Impact</h3>
  <p>Most alksdjflkjasd laksdjfl;kasjdf laksd falksdjf lsdj f</p>
</div>

Nuxtルーティング & ページトランジション

pagesディレクトリ内のテンプレート

<nuxt-link to="/product">Product</nuxt link>

トランジションのフックを利用できる

name="page"
.page-enter-active, .page-leave-active {
  transition: all .25s ease-out;
}
.page-enter, .page-leave-active {
  opacity: 0;
  transform: scale(0.95);
  transform-origin: 50% 50%;
}

アニメーションも

.page-enter-active {
  animation: acrossIn .45s ease-out both;
} 
 
.page-leave-active {
  animation: acrossOut .65s ease-in both;
} 

JSフック

export default {
  transition: {
    mode: 'out-in',
    css: false,
    enter (el, done) {

      let tl = new TimelineMax({ onComplete: done }),
          spt = new SplitText('h1', {type: 'chars' }), 
          chars = spt.chars;

      TweenMax.set(chars, {
        transformPerspective: 600,
        perspective: 300,
        transformStyle: 'preserve-3d'
      })

      tl.add('start')
      tl.from(el, 0.8, {
        scale: 0.9,
        transformOrigin: '50% 50%',
        ease: Sine.easeOut
      }, 'start')
      ...
      tl.timeScale(1.5)
    }
  ...

ネイティブアプリ風の
ページトランジション

import Vuex from 'vuex'

const createStore = () => {
  return new Vuex.Store({
    state: {
      page: 'index'
    },
    mutations: {
      updatePage(state, pageName) {
        state.page = pageName
      }
    }
  })
}

export default createStore

store/index.js内

export default function(context) {
  // go tell the store to update the page
  context.store.commit('updatePage', context.route.name)
}
module.exports = {
  ...
  router: {
    middleware: 'pages'
  },
  ...
}

nuxt.config.jsにミドルウェアの登録が必要:

middleware/pages.js内:

<template>
  <div>
    <app-navigation />
    <nuxt/>
  </div>
</template>

layouts/default.vue内:

AppNavTransition.vue内:

<h2 key="profile-name" class="profile-name">
  <span v-if="page === 'group'" class="user-trip">{{ selectedUser.trips[0] }}</span>
  <span v-else>{{ selectedUser.name }}</span>
</h2>
//animations
.place {
  .follow {
    transform: translate3d(-215px, -80px, 0);
  }
  .profile-photo {
    transform: translate3d(-20px, -100px, 0) scale(0.75);
  }
  .profile-name {
    transform: translate3d(140px, -125px, 0) scale(0.75);
    color: white;
  }
  .side-icon {
    transform: translate3d(0, -40px, 0);
    background: rgba(255, 255, 255, 0.9);
  }
  .calendar {
    opacity: 1;
  }
}
.items,
.list-move {
  transition: all 0.4s ease;
}

.active {
  .rect {
    transform: translate3d(0, 30px, 0);
  }
  ...
}

スタイル:

<transition-group name="layout" tag="g">
  <rect class="items rect" ref="rect" key="rect" width="171" height="171"/>
  ...
</transition-group>

トランジショングループがラッピング...

Hooray!

Vue、データのビジュアル化

そしてサーバーレス

サーバーレス

Functions as a Service

FaaS

は一番ミスリーディングな名前だけれども非常に面白い

Azureでサーバーレス

getGeo(makeIterator(content), (updatedContent, err) => {
  if (!err) {
    // we need to base64 encode the JSON to embed it into the PUT (dear god, why)
    let updatedContentB64 = new Buffer(
      JSON.stringify(updatedContent, null, 2)
    ).toString('base64');
    let pushData = {
      path: GH_FILE,
      message: 'Looked up locations, beep boop.',
      content: updatedContentB64,
      sha: data.sha
    };
    ...
};

元データにある各要素の位置情報を取得する

function getGeo(itr, cb) {
 let curr = itr.next();
 if (curr.done) {
   // All done processing- pass the (now-populated) entries to the next callback
   cb(curr.data);
   return;
 }

 let location = curr.value.Location;

単一エレメント

Three.js

<div id="container"></div>

マウント時に呼び出し

mounted() {
  //we have to load the texture when it's mounted and pass it in
  let earthmap = THREE.ImageUtils.loadTexture('/world7.jpg');
  this.initGlobe(earthmap);
}
//from
const geometry = new THREE.SphereGeometry(200, 40, 30);
//to 
const geometry = new THREE.IcosahedronGeometry(200, 0);

Vue

を使うと、複雑で美しいインタラクションをとても簡単に作成できる上、ユーザーにとってもシームレスな体験を提供できる。

アプリ内の変化に対して、状態をつなげ、認知させる労力を減らすことが簡単にできる。

疲れ果てるのではなく、

楽しもう。

ありがとう!

These slides:

slides.com/sdrasner/animating-vue-e17