しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

Vue.js + TypescriptでSVGアイコンを操作してみる

はじめに

前回はPNG形式のアイコンを操作する方法を紹介しました。ですが、PNGやJPGの様な画像形式のアイコンだと拡大した際にギザギザが目立って見栄えに課題がありました。 今回はこれを克服するベクトル画像形式のSVG(Scalable Vector Graphics)でアイコンを作り、それを操作するWebアプリをVue+Typescriptで作ってみたいと思います。

出来上がりはこんな感じ!

いつもどおり、最初に出来上がりのイメージを紹介します。ちょっとわかりづらいかもしれませんが、以下の様に拡大してもギザギザしないアイコンの表示と操作です。
f:id:Shikataramuno:20190210111654p:plain

コードはここです。 GitHub - Shikataramuno/svg-icon-sample: VueでSVG Icon

SVGアイコンの準備

さぁ!作りましょうっと行きたいところですが、前回同様にまずはSVGアイコンを準備するところからです。これまた、前回同様に長くなるので別記事にまとめました。どうぞお立ちよりください(😉)。
SVGアイコンの作り方はこちら
techblo.shikataramuno.com

これ以降は前回の記事で作ったSVGアイコンを使って、これまた前々回の記事で作成したPNG形式アイコンのコードをベースにSVGアイコンへの対応を進めていきます。

PNG形式アイコンの実装はこちら
techblo.shikataramuno.com

今回はVueの公式ページにある解説を参考にしています。
Vue公式ページの開設
jp.vuejs.org

SVGアイコンの埋め込み方

前置きが長くなりましたが、最初にSVGアイコンを表示させてみたいと思います。
PNG形式アイコンのコードのうち、App.vueはそのまま使い、SampleIconView.vueSVGアイコン対応の変更を加えていきます。

まずは、公式ページの方法に従ってsrc/componentsフォルダにIconBase.vueを作成します。 次に、src/componentsの下にiconsフォルダを作成し、そこにInkscapeで作ったアイコンのSVG形式のデータからアイコンを示すノードを抜き出してIconSmaple.vueを作ります。

プロジェクトフォルダのツリー

src
│  App.vue
│  main.ts
│  shims-tsx.d.ts
│  shims-vue.d.ts
├─assets
└─components
    │  IconBase.vue            <--- これです。
    │  SampleIconView.vue
    └─icons
            IconSample.vue   <--- これです。

IconBase.vueはこんな感じです。

<template>
  <svg xmlns="http://www.w3.org/2000/svg"  :width="width" :height="height" viewBox="0 0 18 18"  :aria-labelledby="iconName" role="presentation">
    <title :id="iconName" lang="en">{{iconName}} icon</title>
    <g :fill="iconColor" :stroke="iconColor">
      <slot />
    </g>
  </svg>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class IconBase extends Vue {
  @Prop({default: 'box'}) iconName!: string;
  @Prop({default: 18}) width!: number | string;
  @Prop({default: 18}) height!: number | string;
  @Prop({default: 'currentColor'}) iconColor!: string;
}
</script>

15行目から18行目で親のコンポ―ネントから受け取るプロパティ(アイコン名、高さ、幅、色)を宣言しています。 4行目でアイコンを表示するviewBoxのサイズを指定し、これらのプロパティをsvgコンポーネントに渡しています。
5行目でslotタグを使って親コンポーネント側で指定するsvgコンポーネントのコンテンツをそのまま埋め込んでいます。

IconSample.vueはこんな感じ

<template>
  <g>
  <rect
     ry="1" rx="1" y="0.75" x="0.75" height="16.5" width="16.5" id="rect4136"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle
     r="6" cy="9" cx="9" id="path4139"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle
     r="3" cy="9" cx="9" id="path4141"
     style="opacity:1;fill-opacity:1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  </g>
</template>

Inkscapeで作ったアイコンのデータそのままですね。

そしてIconBase.vueIconSample.vueの各コンポーネントSampleIconView.vueのhtmlテンプレートに加えます。 こんな感じです。

<template>
  <div class="sample-icon-field">
    <div class="sample-icon-field">
      <icon-base icon-color="#ff0000" width=30 height=30 icon-name="sample"><icon-sample/></icon-base>
    <div class="message">{{message}}</div>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import IconBase from './IconBase.vue';
import IconSample from './icons/IconSample.vue';

@Component({
  components: {
    IconBase,
    IconSample,
  },
})
export default class SampleIconView extends Vue {
  name: string = 'SampleIconView';
  message: string = ' ↑ クリック^2 !';
}

12行目~20行目でIconBase.vueIconSample.vueコンポーネントを読み込んで、4行目でIconBaseIconSampleをテンプレートに加えています。

4行目のIconBaseのプロパティを使って色(icon-color)や高さ(height)、幅(width)を変えてみてください。変化に応じてアイコンの大きさや色が変わると思います。とても便利ですね

ここまでで、Inkscapeで作った自前のSVGアイコンを表示することができます。これだけ?
そうです。案外簡単なんです!

クリックベントの補足と伝搬

次にPNGアイコンと同じ様にクリックでメッセージを変えてみましょう。

まずはアイコンにクリックイベントとそのハンドラーを登録しましょう。IconSample.vuesvgノードのプロパティにクリックイベントのハンドラを登録し、scriptタグを追加してハンドラの実態を登録します。

IconSmaple.vueでイベントを補足して親コンポーネント(SampeIconView.vue)でイベントを処理する様にしてみます。

IconSample.vueはこんな感じです。

<template>
  <g>
  <rect v-on:click="clicked"
     ry="1" rx="1" y="0.75" x="0.75" height="16.5" width="16.5" id="rect4136"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle v-on:click="clicked"
     r="6" cy="9" cx="9" id="path4139"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle v-on:click="clicked"
     r="3" cy="9" cx="9" id="path4141"
     style="opacity:1;fill-opacity:1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  </g>
</template>

<script lang="ts">
import { Component, Emit, Vue } from 'vue-property-decorator';

@Component
export default class IconSample extends Vue {
  @Emit('clicked')
  clicked(): void {
    console.log('icon-sample clicked');
  }
}
</script>

3,5,9行目で各SVGのノードにv-on:click="clicked"でクリックイベントを定義し、ハンドラを登録しています。そして15~26行目でscriptタグを追加してクリックイベントのハンドラを定義し、親コンポーネントemitでイベントを伝搬させています。

コンポーネントSampleIconView.vueはこんな感じです。

<template>
  <div class="sample-icon-field">
    <icon-base icon-color="#ff0000" width=30 height=30 icon-name="sample"><icon-sample @clicked="iconClicked"/></icon-base>
    <div class="message">{{message}}</div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import IconBase from './IconBase.vue';
import IconSample from './icons/IconSample.vue';

@Component({
  components: {
    IconBase,
    IconSample,
  },
})
export default class SampleIconView extends Vue {
  name: string = 'SampleIconView';
  message: string = ' ↑ クリック^2 !';
  clickCount: number = 0;
  iconClicked(): void {
    alert('アイコンがクリックされました!');
    this.clickCount++;
    this.message = 'アイコンが ' +  this.clickCount + '回 クリックされました!';
  }
}
</script>

3行目でアイコンのクリックイベントの登録とハンドラの定義を行い、22~27行目でイベントハンドラを実装しています。 これで、SVGアイコンでクリックイベントを検知して処理できるようになりました。

クリックしても…

もうお気づきかもしれませんが、このままでは、クリックする場所によってイベントが発火したりしなかったりします。試しにアイコンの隙間(外枠の線、中の円と丸の間の領域)をクリックしてみてください。何も起こりません。

理由は簡単で、SVGノードにクリックイベントを登録しているからです。SVGノードで定義していない領域ではクリックイベントが検知されません。UX的にちょっと問題ありですね。

では改善してみましょう。っといってもいたって簡単です。viewBox全域の最前面に透明な矩形のノードを定義してそこでイベントを検知するようにすればOKです。改善後のIconSample.vueは以下のような感じになります。

<template>
  <g>
  <rect
     ry="1" rx="1" y="0.75" x="0.75" height="16.5" width="16.5" id="rect4136"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle
     r="6" cy="9" cx="9" id="path4139"
     style="opacity:1;fill:none;fill-opacity:1;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <circle
     r="3" cy="9" cx="9" id="path4141"
     style="opacity:1;fill-opacity:1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  <rect v-on:click="clicked"
     ry="1" rx="1" y="0" x="0" height="18" width="18" id="rect4145"
     style="opacity:1;fill-opacity:0;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
  </g>
</template>

12~14行目に新たに最前面にSVGノードを定義しています。形やサイズは一番上のrectノードと同じですが、fillプロパティをなくしfill-opacity:0を追加しています。これでアイコン領域の最前面に一番外枠の矩形と同じ領域で透明に塗りつぶされた矩形領域が定義できます。

後はこのノードでクリックイベントを検知するようにし、各ノードのイベント検知は削除します。これでアイコン領域のどこでもクリックイベントを処理できるようになります。

出来上がり!

いかがでしょうか?案外と簡単にSVGアイコンを表示し、操作できるといった印象ではないでしょうか? 後は如何に上手にSVGアイコンを描くか、その腕前とセンス次第といったところかな…

謝辞

今回を含め4本ほどの記事でVue +Typsescriptでアイコンを扱う方法や自前アイコンの作成方法など筆者のやり方を紹介させていただきました。 Webアプリ構築の際のお役に立てれば光栄です。最後まで読んでくださりありがとうございました。次回はまた別のテーマで記事を書きたいと思います。