VectorDrawableの穴を塞がないようにする

  • 32
    Like
  • 0
    Comment

vector_be_winding というツールを作ったので宣伝がてら VectorDrawableのハマりやすい「穴」について書こうと思います。

VectorDrawableの「穴」

VectorDrawableというのは拡大縮小できて DPIごとに生成とかしなくて良くて便利なものです。その導入の仕方などについては VectorDrawable対応まとめ などにあるのでそちらを参考にしてください。この稿ではそこらでは扱われない「穴」について書きます。

それは論理的なものでも心理的なのでもなく、物理的な穴です。例えば図のような非常に簡単な四角形があったとします。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
    xmlns="http://www.w3.org/2000/svg"
    xmlns:xlink="http://www.w3.org/1999/xlink"
    width="40px" height="40px" viewBox="0 0 40 40"  version="1.1" >

    <path
        d="M10,10 30,10 30,30 10,30 Z M15,15 25,15 25,25 15,25 Z"
        fill="#ff0000" fill-rule="evenOdd"  />
</svg>

image

さて、この SVGを VectorAssetToolに突っ込むと、なんと穴のない四角形になってしまいます:

evenodd_in_asset.png

一つ一つ手で入れてる場合はまだいいですが、各種ツールでバッチ的に変換している場合、なんの警告も出ずにビルドされてしまうので見つかりにくいバグになります。

なぜ穴がなくなってしまうのか?

こうなってしまう理由を端的に言ってしまえば、SVGの pathと VectorDrawbleの pathに互換性がないためです。ドキュメントには"the same format as "d" attribute in the SVG's path data."って書いてあるのに!

より正確に言えば、SVG pathの描く図形は d 属性だけではなく fill-rule 属性もかかわってくるのだけど、VectorDrawableは (すくなくとも Nougatより前では) それに対応する属性がなかったため、決め打ちになってたのです。ちなみに Nougatでは android:fillType 属性をサポートしています。でも minSdkVersion = 24 はまだ壁が高いですよね。

Support libraryの VectorDrawableを使っても、Lollipop以上では platformの実装が用いられ fillTypeは無視されますし、Lollipop以前の実装でも対応していなかった気がします。

そもそも fill-ruleってなに?

ここはあまり知らなくてもいいのかもしれませんが、後述する vector_be_winding の制限を理解するためにも解説させてください。

二次元図形の内部を閉曲線 (≒パス) で定義するというのは非常に素直なアイディアです。しかし、塗りつぶしたい部分に穴があったり、パスが単純閉曲線でない場合は、どこを塗りつぶせばいいかは自明ではありません。それを決めるルールが fill-rule であり android:fillType です。

例えば、以下のような pathがあったとしましょう。はたしてこの pathで囲まれた部分のどこを塗るべきでしょうか?

image

出展: Wikipedia

evenOdd

それを決める一つのルールが "evenOdd"(奇遇性)ルールというものです。ある点から無限遠点にむかって引いた直線がパスと交わる回数が奇数なら塗り、偶数なら塗りません。結果は市松模様のようなものになります。

image

わかりやすいのですが、SVGのデフォルトではありません。また、(Nougatより前の) VectorDrawableではサポートされてません。

nonzero

もう一つのルールが nonzeroルール (もしくは windingルール) です。

まず、これは pathの向きが問題になります。その上で、平面上の点の「回転数」(winding) を定義します。直感的には、その点の周りをパスが何回回っているかです。回転数を考えるときは回転方向も考えて、例えば反時計回りが正なら時計回りは負になります。

そして、回転数が 0ではないところを塗りつぶすのが nonzeroルールです。

ちなみに回転数は、その点から無限遠点に向かって引いた直線とパスの交差する数の合計 (ただし、無限遠点に向かって左から右にパスが通り抜けるときと逆とでは符号を反転させる) と定義もできます。

image

最初の SVGにもどると..

さて、最初の SVGはよく見れば fillRuleevenOdd が指定されてます。しかし、これは VectorDrawableではサポートされてなく、nonzeroルールで解釈されます。正確には、VectorDrawableに変換するときに fillRule属性がなくなります。その結果、中心部の回転数は 2 になり、塗りつぶされることになります。

original.png

回避策は?

Styling Android によると対応してるツールをつかえとありましたが、自分が試してみたところ、対応してる様子はありませんでした。
デザイナーが絵を書く段階から線の向きを意識すればなんとなかる、というのもありますが、デザイナーとの間の殺伐とした関係を目指してる自分としては納得いきません。

そこで作ったのが vector_be_winding です。面倒くさいので、以下 vbwと略します。

vbwは nonzeroルールで解釈すると穴があかない、むだなパスがあった場合、向きを変更して無理やり穴を開けるツールです。つまり、最初の VectorDrawableはいかのようになります:
wound.png

めでたく、中心の回転数が 0になり、穴が空くことになりました。

vbwの使い方

gemなので、普通に gem install vector_be_winding でいれてください。すると vbw コマンドがインストールされるので、

% vbw app/src/main/res/drawable/box_evenodd.xml

とかで、指定した drawableが変換され、原本は .bak のついたファイルにバックアップされます。

VectorDrawableがどう変換されるかみたい

vbwのgitレポジトリ をcloneしてくると sampleAppがあります。これは、自分の持ってる VectorDrawableを一覧にして表示してくれるものです。このアプリは、nonzeroルールに従ってない VectorDrawableを検出し、vbwでどう変換されるか確認するにも役立ちます。

流れは以下のとおりです:

  1. app/src/main/drawables に、変換してみたいVectorDrawableをコピーする
  2. 以下のコマンドを実行する。 % vbw -v -i '%{d}/%{r}_orig%{e}' app/src/main/res/drawable/*.xml
  3. ビルドしてアプリケーションを立ち上げる。
  4. メニューより ”Show ones with orig” を選ぶ。
  5. すると変換された Drawableだけが変換前と一緒にみえる

vbwの制限など

SVGの a ディレクティブ (円弧) には対応してません。

単に自分の使った範囲ではなかったので対応してないだけです。気合入ったらやります。

そもそも対応できない図形があります

そもそも、evenOddルールと nonzeroルールは本質的に違うので、自分自身と交差する曲線 (例の ”&”のようなの) などは変換できません。そういうのは出てこないと思って楽観的に処理しています。VectorDrawableで扱う範囲ではではあんまりないとは思いますが、念のため変換後の結果を確認お願いします。

包含関係の判定は手抜きです

基本的に向きを変えるための基準を、包含関係で決めています。ただ、その判定は手抜きのため包含関係を間違って判断する可能性があります。

ここは、実際に問題になったら適宜精度を高めていくつもりです。

おわりに

実際のところ、VectorAssetToolなどを使って一つ一つ変換している分には、この問題はあまり大きくないのかもしれません。ただ、弊社ではバッチ式に変換しているので、vbwを導入してさらに殺伐とした運用を目指します。

まだまだ完全とはいえないツールですが、皆様も良ければ試してみてください。