X-0030 ホイールスクロールで普通のボックスの内容をスクロールする

はじめに

以前、こんな情報誰も必要としてないだろうなあと思って日記にポインタだけ書いたのですが、ニーズがあるようなので例も含めてまとめておきます。

scrollbox などの元からスクロールさせるために作られた要素ではないただの box やなんかでも、 CSS の overflow プロパティを使えば中身をスクロールできるようになります。しかし、この方法でスクロールできるようにした場合でも、マウスのホイールスクロールでは内容をスクロールできません(Webページ上でCSSを使って「疑似フレーム」にしている場合に起こる問題もこれと同じ)。以下は、この問題を自力で解決してしまおうという話です。

追記。この問題は2004/8/7付けで修正されました。以下の情報は今となってはまるっきり無駄骨ですので、暇つぶしに読む程度でヨロシク。

実装

ホイールスクロールで要素の内容をスクロールするには、要素の上でホイールが回されたことを検知し(ステップ1)、内容をホイールの回転に合わせてスクロールする(ステップ2)、という二つの処理を行う必要があります。

ステップ1:ホイールの回転の検出

Mozillaでは、ホイールスクロールが行われると、その要素についてDOMMouseScrollという名前のイベントが発生します。このイベントのevent.detailの値を見ると回転の方向を知ることができ、正の値であれば手前(下)、負の値であれば向こう(上)への回転と判別できます。

このイベントは、clickやkeypressなどの通常のイベントと違って「onDOMMouseScroll」イベントハンドラでは捕捉できませんが、イベントリスナーやバインディングでのイベントハンドラ定義を使うと捕捉できます。

JavaScript的には以下のようにやります。

function onscroll(aEvent) {
	aEvent.target.scrollContent(aEvent.detail > 0 ? 50 : -50 );
}

var box = document.getElementById('scrollbox');
box.addEventListener('DOMMouseScroll', onscroll, false);

さらっとaEvent.target.scrollContent()と書いてしまいましたが、こんなメソッドはもちろんありません。後で自力で実装することになります。

次はバインディングで実装する場合の例です。

.scrollable {
    overflow: auto;
    /*
      縦スクロールのみの時は overflow: -moz-scrollbars-vertical;
      横スクロールのみの時は overflow: -moz-scrollbars-horizontal;
    */
    -moz-binding: url('scrollableElement.xml#scrollableElement');
}

<?xml version="1.0"?>
<bindings xmlns="http://www.mozilla.org/xbl">
<binding id="scrollableElement">
  <handlers>
    <handler event="DOMMouseScroll"><![CDATA[
      this.scrollContent(event.detail > 0 ? 50 : -50 );
    ]]></handler>
  </handlers>
  <implementation>
    <method name="scrollContent">
      <parameter name="aCount"/>
      <body><![CDATA[
        // メソッドの内容
      ]]></body>
    </method>
  </implementation>
</binding>
</bindings>

バインディングを使う場合なら、メソッドの定義も一緒に書けてしまうのでわかりやすくていいですね。しかも、普通にJavaScriptでやる場合と違って、読み込んだ時点で勝手に初期化してくれます(要素の挿入の度に初期化する必要がない)。

ステップ2:内容をホイールの回転に合わせてスクロールする

後はイベントが起こった時に内容をスクロールすれば済むだけの話なのですが、一つ問題があります。実は、正攻法では要素の内容をスクロールできないのです。

スクロールさせるために作られている<scrollbox/>などのウィジェットでは内容をスクロールするためのメソッドが定義されていますが、その他の普通のXUL要素にはそんなメソッドはありません。また、<scrollbox/>においてそれらの機能を実現しているnsIScrollBoxObjectの機能を呼び出そうにも、<scrollbox/>以外のXUL要素のボックスオブジェクト(boxObjectプロパティ)からはnsIScrollBoxObjectにはアクセスできません。

ここでは発想を逆転する必要があります。内容をスクロールした結果としてスクロールバーが動くのではなく、スクロールバーを動かした結果として内容がスクロールする、と。つまり、スクロールバーにアクセスすることができれば、内容をスクロールさせられるということです。

スクロールバーへのアクセス経路を作る

新しいテーマをインストールすると、スクロールバーの表示もテーマに合わせて変わります。これはXULの<scrollbar/>要素として記述したものだけでなく、CSSのoverflowの効果として表示されるものについても同様です。ということは、つまり、CSSのoverflowの効果として表示されているのはXULの<scrollbar/>要素であるということです。

スクロール可能になっている要素の中にスクロールバーが表示されている、ということは、スクロール可能な要素は、バインディングでいう無名内容に例えれば、以下のような構造に内部的に変換されているものと推測できます。

<element>
  <xul:vbox>
    <xul:hbox>
      <xul:scrollbox>
        <children/><!-- elementの内容 -->
      </xul:scrollbox>
      <xul:scrollbar orient="vertical"/><!-- 縦スクロールバー -->
    </xul:hbox>
    <xul:scrollbar orient="horizontal"/><!-- 横スクロールバー -->
  </xul:vbox>
</element>

ところが、スクロール可能な要素の無名内容をdocument.getAnonymousNode()で取得しようとしても、このような構造は得られません。もちろんchildNodesプロパティのリストにも含まれていません。どうやら、この方法ではアクセスできない特別な無名内容として実装されているようです。

onclickなどのイベントを捕捉して調べてみると、スクロールバーを操作した時には、スクロールバーの実体である<scrollbar/>要素にevent.originalTargetからアクセスできることが分かります。更に調べてみると、この時、このスクロールバーはparentNodeプロパティに「スクロール可能な要素」が設定されていました。親要素からは勘当されたけれども、本人はまだ子要素のつもりでいる要素とでもいいましょうか。

スクリプト以外の方法でこのスクロールバーにアクセスする方法としては、CSSとバインディングの組み合わせが使えます。

.scrollable > scrollbar {
    -moz-binding: url('scrollableElement.xml#scrollbar');
}

CSSの子セレクタを使うと、問答無用でこれらのスクロールバーにスタイル指定ができます。ここでバインディングを指定し、そのバインディング内でスクリプトを使えば、普通はアクセスできないはずのこれらのスクロールバーに、スクリプトで処理を行えるわけです。

<?xml version="1.0"?>
<bindings xmlns="http://www.mozilla.org/xbl">
<binding id="scrollbar"
  extends="chrome://global/content/bindings/scrollbar.xml#scrollbar">
  <!-- ↑元のバインディングを読み込む必要がある -->
  <implementation>
    <constructor><![CDATA[
      // スクロールバー自身の初期化処理
      if (navigator.platform.indexOf('Mac') != -1)
        this.initScrollbar(); 

      // 親要素の「スクロール可能な要素」に
      // プロパティとして自分自身を登録
      if (this.orient == 'horizontal')
        this.parentNode.mHorizontalScrollbar = this;
      else
        this.parentNode.mVerticalScrollbar = this;
    ]]></constructor>
    <destructor><![CDATA[
      // 非表示になる時には
      // 親要素から自分への参照を削除
      if (this.orient == 'horizontal')
        this.parentNode.mHorizontalScrollbar = null;
      else
        this.parentNode.mVerticalScrollbar = null;
    ]]></destructor>
  </implementation>
</binding>
</bindings>

スクロールバーを操作する

ここまで来れば、あとは簡単です。最初のバインディングの例で定義しようとしていた「scrollContent」メソッドの内容を作っていきましょう。

スクロールバーの「現在のスクロール位置」はcurpos属性から取得できます。また、この属性に新たな値を設定すれば、スクロールバーはその位置までスクロールしてくれます。以下に、普段は縦スクロールバーを、縦スクロールバーが非表示の時は横スクロールバーを動かす、という場合の例を示します。

<method name="scrollContent">
  <parameter name="aCount"/>
  <body><![CDATA[
    var scrollbar = (this.mVerticalScrollbar) ? this.mVerticalScrollbar :
                                                this.mHorizontalScrollbar ;
    if (!scrollbar) return;
    var curPos = parseInt(scrollbar.getAttribute('curpos'));
    scrollbar.setAttribute('curpos', curPos + aCount);
  ]]></body>
</method>

ただ、これだと制限以上にスクロールできてしまいます(外見上は変化しませんが、内部的には影響します)から、最小値・最大値のチェックが必要です。

<method name="scrollContent">
  <parameter name="aCount"/>
  <body><![CDATA[
    var scrollbar = (this.mVerticalScrollbar) ? this.mVerticalScrollbar :
                                                this.mHorizontalScrollbar ;
    if (!scrollbar) return;
    var curPos = parseInt(scrollbar.getAttribute('curpos'));
    var maxPos = parseInt(scrollbar.getAttribute('maxpos'));
    var newPos = Math.max(0, Math.min(maxPos, curPos + aCount));
    scrollbar.setAttribute('curpos', newPos);
  ]]></body>
</method>

まとめ

以上の内容をまとめると、以下のようなコードになります。

.scrollable {
    overflow: auto;
    -moz-binding: url('scrollableElement.xml#scrollableElement');
}
.scrollable > scrollbar {
    -moz-binding: url('scrollableElement.xml#scrollbar');
}


<?xml version="1.0"?>
<bindings xmlns="http://www.mozilla.org/xbl">

<binding id="scrollableElement">
  <handlers>
    <handler event="DOMMouseScroll"><![CDATA[
      this.scrollContent(event.detail > 0 ? 50 : -50 );
    ]]></handler>
  </handlers>
  <implementation>
    <method name="scrollContent">
      <parameter name="aCount"/>
      <body><![CDATA[
      var scrollbar = (this.mVerticalScrollbar) ? this.mVerticalScrollbar :
                                                  this.mHorizontalScrollbar ;
      if (!scrollbar) return;
      var curPos = parseInt(scrollbar.getAttribute('curpos'));
      var maxPos = parseInt(scrollbar.getAttribute('maxpos'));
      var newPos = Math.max(0, Math.min(maxPos, curPos + aCount));
      scrollbar.setAttribute('curpos', newPos);
      ]]></body>
    </method>
  </implementation>
</binding>

<binding id="scrollbar"
  extends="chrome://global/content/bindings/scrollbar.xml#scrollbar">
  <implementation>
    <constructor><![CDATA[
      if (navigator.platform.indexOf('Mac') != -1)
        this.initScrollbar(); 

      if (this.orient == 'horizontal')
        this.parentNode.mHorizontalScrollbar = this;
      else
        this.parentNode.mVerticalScrollbar = this;
    ]]></constructor>
    <destructor><![CDATA[
      if (this.orient == 'horizontal')
        this.parentNode.mHorizontalScrollbar = null;
      else
        this.parentNode.mVerticalScrollbar = null;
    ]]></destructor>
  </implementation>
</binding>

</bindings>

スクロール量を変えたりスクロール方向が一方向だけに限定する場合などには、また適当に加工して下さい。