2014年2月1日土曜日

Unordered list with a custom layout algorithm

Polymer.dartを使って作ってるウェブアプリで次のようなカスタム<ul>が必要になった。

  • 通常の<ul>と同じように<li>を子要素として追加できる
  • <li>のレンダリングされたときのサイズに応じてそれぞれの表示位置を決定する

例えば1280x720ピクセルの矩形内にそれぞれのli要素がオーバーラップしないよう自動的に配置してくれるようなul、てところ。それで色々と調べたり実験した結果なんとかできたので、ここでサンプルを公開しておく。

何をつくったか

今回作ったのは、追加されたli要素が階段上に表示されるよう配置するweb component。各要素のレンダリングサイズに応じて位置を決めているのでキレイな階段になってるはず(でもAndroid版Chromeで試したら表示崩れてた…)。

要素を追加したり折り返しの幅を変えたりといったこともできる。

コードそのものはGithub上のリポジトリに置いてある。今回は更にdart2jsを試す実験も兼ねてjavascriptに変換してGithub Pagesにおいてみたので、実際に動かしてみることもできる。現状FirefoxとChromeで動作確認した。

MutationObsever

コードに関してはGithubを直接みてもらうとして、このサンプルで重要な役割を果たしたMutationObserverのあたりだけ少し解説しておく。

MutationObserverを使うと指定されたElementにDOM的な変更が加えられたときにイベントとして受け取ることができる。今回は子要素が追加・削除されたかのみを監視してるけど、設定次第では別のイベントも受け取れるらしい。

今回なぜMutationObserverを使ったかというと、li要素をレンダリングサイズに応じて配置する必要があったから。調べたかぎりレンダリングサイズが決定するのは実際にElementがDOMに挿入されたあとなので、サイズが決定した段階で適切な位置へ移動するために使ってる。

今回はulをcustom-listというweb componentでラップしてて、そのcustom-listが表示された(enteredViewが呼ばれた)タイミングで直下にいるulをMutationObserverへ登録している。

    @override
    void enteredView() {
        super.enteredView();
        _root = shadowRoot.querySelector("#root");

        // observe change of child elements for a 2 pass layout approach
        var mo = new MutationObserver(onChildMutation);
        mo.observe(_root, childList:true);
        _observer = mo;
    }

上のコードでMutationObserverのコールバックとしてonChildMutationという関数を登録してて、その中身は以下の通り。

    /// Called when a mutation occurs to the target element
    void onChildMutation(List<MutationRecord> changes, MutationObserver observer) {
        // layout children depending on its rendering size

        int itemCount = 0;
        int bounceBackDelta = bouncebacknum - 1;
        int nextBounceBack = bounceBackDelta;
        int xOffset = 0;
        int yOffset = 0;
        bool offsetRight = true;
        for (var record in changes) {
            var added = record.addedNodes;
            var removed = record.removedNodes;
            for (var a in added) {
                if (!(a.nodeType == Node.ELEMENT_NODE && a is CustomListItem)) {
                    // ignore nodes expect for the one we want
                    continue;
                }
                var elem = a as CustomListItem;
                var width = elem.clientWidth;
                var height = elem.clientHeight;
                // print("${width}x${height}");

                var unit = "px";
                if (elem.item.entered) {
                    // This item is already displayed
                    // so place the view at the same position

                    var l = "${elem.item.rect.left}${unit}";
                    var t = "${elem.item.rect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                } else {
                    // This item is a new one.
                    //  Layout the remark at a sufficient position
                    Rectangle<int> posRect;
                    if (offsetRight) {
                        posRect = new Rectangle<int>(xOffset,yOffset,width, height);
                    } else {
                        posRect = new Rectangle<int>(xOffset-width, yOffset, width, height);
                    }
                    var l = "${posRect.left}${unit}";
                    var t = "${posRect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                    elem.item.rect = posRect;
                    applyAnimation(elem, "fadein", 250);
                    elem.item.entered = true;
                }

                // update offset depending on direction
                if (offsetRight) {
                    xOffset += width;
                } else {
                    xOffset -= width;
                }

                // update bounceback state
                if (offsetRight && itemCount == nextBounceBack) {
                    offsetRight = false;
                    xOffset -= width;
                    nextBounceBack += bounceBackDelta;
                } else if (!offsetRight && itemCount == nextBounceBack) {
                    offsetRight = true;
                    xOffset += width;
                    nextBounceBack += bounceBackDelta;
                }

                yOffset += height;
                itemCount++;
            }
        }
    }

    /// Apply specified animation to the given element
    static void applyAnimation(Element element, String name, int msecDuration) {
        element.style.animationName = name;
        element.style.animationDuration = "${msecDuration}ms";
        element.style.animationTimingFunction = "linear";
        element.style.animationFillMode = "forwards";
    }

onChildMutationでやってることは大体次のこと。

  1. 追加された子要素のうち、新規に追加されたCustomListItemのサイズを取得して設定に応じて階段状に配置する。ついでにアニメーションも適用する。
  2. 既に表示されているCustomListItemが来た場合はそのまま現在の位置に表示する

注意しなきゃいけないのは既に表示されている項目と新規に追加された項目の処理をわけるところ。今回試した限りonChildMutationは新しい子要素を一つ追加したときでも全子要素がaddされた要素としてよばれてくるため、既に表示されてる項目の位置が変わらないようenteredというフラグで処理を切り替えている。

加えてなぜか空のTextElementも追加された子要素として送られてくるため、対象のElement以外は弾くようにしないといけない。空のTextElementが来る理由はよくわからない。バグ? Polymer.dartの仕様?

ちなみにcustom-listのhtmlは下の通りで、中身はほぼulをラップしているだけ。

<link rel="import" href="custom-list-item.html">
<polymer-element name="custom-list">
    <template>
        <style>
        ul#root {
            list-style-type: none;
            position: relative;
        }
        li {
            position: absolute;
        }
        @-moz-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }
        @-webkit-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }

        </style>
        <ul id="root">
            <template repeat="{{item in itemlist}}">
                <li is="custom-list-item" item="{{item}}"></li>
            </template>
        </ul>
    </template>
    <script type="application/dart" src="custom-list.dart"></script>
</polymer-element>

さきほどちらっと出てきたCustomListItem(custom-list-item)は下記のように定義されてる。これもほぼテキストを表示しているだけ。

<polymer-element name="custom-list-item" extends="li">
    <template>
        <style>
        .container {
            padding: 15px;
            background-color: ghostwhite;
            border:solid 1px #dedede;
        }
        .content {
            overflow:hidden;
            padding:5px;
            display:block;
        }
        .text {
            font-family:'Georgia', Times New Roman, Times, serif
        }

        </style>
        <div id="root" class="container">
            <div class="content text">{{item.text}}</div>
        </div>
    </template>
    <script type="application/dart" src="custom-list-item.dart"></script>
</polymer-element>

感想

当初はMutationObserverの存在を知らずにやろうとしてたため、なんか無駄に苦労してた。具体的には下のような流れで詰まってた。

  1. 新しいliを追加するにあたって、その配置を決めるためにレンダリング時のサイズが知りたい!
  2. でもサイズを知るためには一旦liを追加してみるしかない…
  3. 本番環境にliを追加するまえに、仮の環境(visibilityをhiddenにして隠してあるcustom-list)で追加してみてサイズを計ろう!
  4. 仮の環境に追加してもすぐにはDOMに反映されない(おそらくPolymer.dartがそういう動きになってる)から、本番環境に突っ込むタイミングではやっぱりサイズがわからない…

それでどうすればいいかよくわからなくなったので、Stackoverflowで質問してみたところMutationObserverなるものを教えてもらった。結果的にこのMutationObserverのおかげで色々と解決したので、似たようなことをやりたい人のためにサンプルとしてまとめておいた。

0 件のコメント:

コメントを投稿