Microsoft
“人間は進化の過程で、より継ぎ目無く、シームレスに流れるような動きを行うよう最適化されてきた。”
"人間とコンピューター間のインタラクションでも、新しく登場した概念に対応できるようにはなっていない”
「感覚記憶」: あなたの後頭葉(記憶領域)は100msしかバーストしない。
-Tammy Everts
ユーザーへの見え方を考慮しよう
空間的に表現するかどうか
Paul Bakaus
詳しくはCSSトリックの記事(拙筆)
この Codepen.
<div id="app">
<h3>Type here:</h3>
<textarea v-model="message" rows="5" maxlength="75"/>
<p>{{ message }}</p>
</div>
Codepenはこちら.
クイックレビュー
<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>
変化を宣言的に記述できる
通常の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はこちら
新しい要素がトランジション終わるまで、今の要素はトランジションを待機。
今の要素が外に出たあと、新しい要素が入ってくる。
<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
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)の原則
<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));
...
}
}
});
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のリアクティブシステム
リアクティブプログラミングとは、非同期なデータストリームを活用した開発手法。
ストリームとは時系列に流れる連続的なイベントで、それをウォッチ&検出する仕組みを持っている
アプリケーション開発にリアクティブの概念を取り入れると、イベントに対してステートの更新が非常に簡単になる。
参考資料:
名前に反し、Reactはリアクティブではない - 「プル」型のアプローチを採用(プッシュ型ではない)
Vueインスタンスで宣言されたdata
プロパティはウォッチ可能
数学的なモデルであるSVGとの相性が良い
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;
}
}
});
<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>
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;
}
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!
Functions as a Service
は一番ミスリーディングな名前だけれども非常に面白い
シリーズもの: サーバーレスとVueでデータを操る
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;
単一エレメント
<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);
を使うと、複雑で美しいインタラクションをとても簡単に作成できる上、ユーザーにとってもシームレスな体験を提供できる。
アプリ内の変化に対して、状態をつなげ、認知させる労力を減らすことが簡単にできる。
These slides:
slides.com/sdrasner/animating-vue-e17