サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Vue + <script setup> + TypeScript: TodoMVC 08 要素にアニメーションを加える


Vue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくるシリーズ第8回です。実は、Vue公式サイトの作例と同じ動きは、すでにでき上がりました。今回は番外編で、作例にアニメーションを加えてみましょう。

01 要素をフェードインで登場させる

アニメーションに用いるのは<Transition>コンポーネントです。CSSでトランジションを定めます(「CSSでのトランジション」参照)。アニメーションさせたい要素(<h1>)を、以下のように<Transition>コンポーネントで包んでください。appearは、初期レンダリングのときにトランジションさせたい場合に加えるプロパティです(「出現時のトランジション」参照)。

ここでは、ヘッダのタイトル(<h1>要素)を画面の上部から、フェードインで登場させます。このアニメーションを与えるのが、CSSの「トランジションクラス」です。クラスは大きく、enterleaveをそれぞれキーワードとするふたつのトランジションに分かれます。本来のスタイルに対して、別のスタイルからトランジションするのがenterです。leaveは逆に、もともとのスタイルから別のスタイルに変わります。はじめは見えない要素を、フェードインで登場させるのはenterです。つぎのふたつのトランジションクラスを用います。

場合によっては、ひとつのコンポーネントモジュールの中に、異なるトランジションが加わるかもしれません。そのときは、<Transition>コンポーネントにnameプロパティで名前(つぎのコードでは"todo-head")をつけて分けられます(「名前付きトランジション」参照)。CSSのトランジションクラスには、vに替えてつぎのようにこの名前を接頭辞として添えてください。

src/App.vue

<template>
	<section id="app" class="todoapp">
		<header class="header">
			<Transition appear name="todo-head">
				<h1>todos</h1>
			</Transition>

		</header>

	</section>
</template>

<style scoped>
.todo-head-enter-active {
	transition: 1s ease-in;
}
.todo-head-enter-from {
	opacity: 0;
	transform: translateY(-40px);
}
</style>

これで、ヘッダのタイトルがフェードインしながら下りてきます(図001)。書き改めたルートモジュールsrc/App.vueの記述全体は、以下のコード001のとおりです。

図001■ヘッダのタイトルがフェードインしながら下りてくる

図001

コード001■ルートモジュール

src/App.vue

<script setup lang="ts">
import { computed, onMounted, ref, watchEffect } from 'vue';
import { fetch, getNewId, save } from './TodoStorage';
import TodoInput from './components/TodoInput.vue';
import TodoList from './components/TodoList.vue';
import TodoController from './components/TodoController.vue';

export interface Todo {
	id: number;
	title: string;
	completed: boolean;
}
const todos = ref(fetch());
const visibility = ref('all');
const remaining = computed(() => getActive(todos.value).length);
const allDone = computed(() => remaining.value === 0);
const filteredTodos = computed((): Todo[] => {
	switch (visibility.value) {
		case 'all':
			return todos.value;
		case 'active':
			return todos.value.filter((todo) => !todo.completed);
		case 'completed':
			return todos.value.filter((todo) => todo.completed);
		default:
			return todos.value;
	}
});
watchEffect(() => save(todos.value));
const addTodo = (todoTitle: string) => {
	if (!todoTitle) return;
	todos.value.push({
		id: getNewId(),
		title: todoTitle,
		completed: false,
	});
};
const removeTodo = (todo: Todo) => {
	todos.value = todos.value.filter((item) => item !== todo);
};
const done = (todo: Todo, completed: boolean) => {
	todo.completed = completed;
};
const getActive = (todos: Todo[]) => {
	return todos.filter((todo) => !todo.completed);
};
const onHashChange = () => {
	visibility.value = window.location.hash.replace(/#\/?/, '');
};
const removeCompleted = () => {
	todos.value = getActive(todos.value);
};
const toggleAll = (checked: boolean) => {
	todos.value.forEach((todo) => (todo.completed = checked));
};
onMounted(() => {
	window.addEventListener('hashchange', onHashChange);
});
</script>

<template>
	<section id="app" class="todoapp">
		<header class="header">
			<Transition appear name="todo-head">
				<h1>todos</h1>
			</Transition>
			<TodoInput @addTodo="addTodo" />
		</header>
		<TodoList
			:allDone="allDone"
			:filteredTodos="filteredTodos"
			:todos="todos"
			@removeTodo="removeTodo"
			@done="done"
			@toggleAll="toggleAll"
		/>
		<TodoController
			:todos="todos"
			:remaining="remaining"
			:visibility="visibility"
			@removeCompleted="removeCompleted"
		/>
	</section>
</template>

<style scoped>
.todo-head-enter-active {
	transition: 1s ease-in;
}
.todo-head-enter-from {
	opacity: 0;
	transform: translateY(-40px);
}
</style>

<style>
@import url("https://unpkg.com/todomvc-app-css@2.4.2/index.css");
</style>

ノート01■後方互換性を破るトランジションクラスの変更

トランジションクラスについて、Vue 2からVue 3で後方互換性を破るつぎのような名前の変更が行われました。-toに対して-fromが新たに定義されたことにより、CSSの記述はわかりやすくなったでしょう。けれど、後方互換性がなくなることにご注意ください。

v-enterトランジションクラスはv-enter-fromへ、そしてv-leaveトランジションクラスはv-leave-fromへと名前が変更されました(「トランジションクラスの変更」)。

02 Todoリスト項目の追加と削除でアニメーションさせる

Todoリスト項目の追加と削除もアニメーションで表示しましょう。

アニメーションさせたい要素がv-forでつくられるリストの場合には、<TransitionGroup>コンポーネントを用います(「トランジショングループ」参照)。コンポーネント<Transition>と異なるのは、ラップしてレンダリングする要素を<TransitionGroup>tagプロパティで定めることです。したがって、これまで親要素だった<ul>は置き替えるかたちで、つぎのようにtagプロパティに"ul"を与えます。v-forでつくるリストの親要素として加わります。なお、とくにトランジション/アニメーションするときは、v-forで差し込む要素にはkey特別属性を一意の不変な値にデータバインディング(:v-bind)しなければなりません(「Vue.js: v-forで項目インデックスをkey属性にしていいのか」参照)。また、トランジションを定めるCSSのクラス(todo-item)は、<template>でクラスバインディング(:class)した配列構文で要素を差し替えました(「配列構文を使う」参照)。

src/components/TodoList.vue

<template>
	<section class="main">

		<!-- <ul class="todo-list"> -->
		<TransitionGroup class="todo-list" name="todo-item" tag="ul">
			<li
				v-for="todo in filteredTodos"
				:class="[
					// 'todo',
					'todo-item',
					{ completed: todo.completed, editing: todo == editedTodo },
				]"
				:key="todo.id"
			>

		<!-- </ul> -->
		</TransitionGroup>
	</section>
</template>

<style scoped>
.todo-item {
	transition: 1s;
}
.todo-item-enter-from,
.todo-item-leave-to {
	opacity: 0;
	transform: translateX(200px);
}
.todo-item-leave-active {
	position: absolute;
}
</style>

これで、リストの項目を加えたり除いたりすると、テキストが水平に移動しつつフェードイン・フェードアウトするようになります。モジュールsrc/components/TodoList.vueの記述全体は、つぎのコード002にまとめたとおりです。

コード002■Todoリスト表示のモジュール

src/components/TodoList.vue

<script setup lang="ts">
import { ref } from 'vue';
import type { Todo } from '../App.vue';
import TodoEdit from './TodoEdit.vue';
import TodoItem from './TodoItem.vue';

interface Props {
	allDone: boolean;
	filteredTodos: Todo[];
}
interface Emits {
	(event: 'removeTodo', todo: Todo): void;
	(event: 'done', todo: Todo, completed: boolean): void;
	(event: 'toggleAll', checked: boolean): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const editedTodo = ref<Todo | null>(null);
const removeTodo = (todo: Todo) => {
	emit('removeTodo', todo);
};
const done = (todo: Todo, completed: boolean) => {
	emit('done', todo, completed);
};
const editTodo = (todo: Todo) => {
	editedTodo.value = todo;
};
const cancelEdit = () => {
	editedTodo.value = null;
};
const doneEdit = (todoTitle: string) => {
	if (!editedTodo.value) return;
	const title = todoTitle.trim();
	if (title) {
		editedTodo.value.title = title;
	} else {
		removeTodo(editedTodo.value);
	}
	editedTodo.value = null;
};
const onChange = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	emit('toggleAll', target.checked);
};
</script>

<template>
	<section class="main">
		<input
			id="toggle-all"
			type="checkbox"
			class="toggle-all"
			:checked="allDone"
			@change="onChange"
		/>
		<label for="toggle-all">Mark all as complete</label>
		<TransitionGroup class="todo-list" name="todo-item" tag="ul">
			<li
				v-for="todo in filteredTodos"
				:class="[
					'todo-item',
					{ completed: todo.completed, editing: todo == editedTodo },
				]"
				:key="todo.id"
			>
				<TodoItem
					:todo="todo"
					@removeTodo="removeTodo"
					@done="done"
					@editTodo="editTodo"
				/>
				<TodoEdit
					:editedTodo="editedTodo"
					:todo="todo"
					@cancelEdit="cancelEdit"
					@doneEdit="doneEdit"
				/>
			</li>
		</TransitionGroup>
	</section>
</template>

<style scoped>
.todo-item {
	transition: 1s;
}
.todo-item-enter-from,
.todo-item-leave-to {
	opacity: 0;
	transform: translateX(200px);
}
.todo-item-leave-active {
	position: absolute;
}
</style>

ソース01■TodoMVC 08 要素にアニメーションを加える

Vue + <script setup> + TypeScript: TodoMVCシリーズ


作成者: 野中文雄
作成日: 2023年01月10日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.