Go to Contents Go to Java Page

実験室

 
 

透明フレーム

 
 

壁紙アプリケーションを作りたい

 
 

壁紙アプリという言葉があるかどうか分かりませんが、付箋とかカレンダーとか時計などタイトルバーがなくて Window システムのベースウィンドウに直接貼りついているようなソフトがあります。それを Java で作りたいのです。

単に付箋のようなものであれば、java.awt.Window クラスもしくは javax.swing.JWindow クラスを使用すればタイトルバーのないフレームが作れるので、たぶんできると思います。

でも、本当に作りたいのは透明なウィンドウを持つアプリケーションなんです。透明カレンダーのようなものです。

実をいうと他にも作りたいものがあります。それをまとめると

  1. 透明ウィンドウ
  2. 非四角ウィンドウ
  3. xeye や XXXXX のようにマウスカーソルの位置によって動作を変えるアプリケーション
  4. Windows Media Player のようにタイトルバーが出たり消えたりするアプリケーション
  5. スキンに対応したアプリケーション

透明ウィンドウができれば 2 や 5 は擬似的に作ることができそうです。あくまでも擬似的ですが...

3 は Java の制約からして無理かもしれません。Java の MouseEvent/MouseMotionEvent は Java のウィンドウ以外の部分では拾えないようになっているためです。

4 は java.awt.Frame#setUndecorated メソッドを使えばできるような気がします。しかし、実際は一度フレームを非表示にしないとタイトルバーの切り替えができません。このため、単純につくるととてもちらついてしまうのです。

まぁ、1 以外はおいおい考えていくことにしましょう。

ということで今回は透明ウィンドウを作ってみようです。

 

 
 

どうやって作ろう

 
 

透明ウィンドウを作るときの一番の問題は、Java では透明ウィンドウはサポートしていないということです。

うーん、困った。どうしよう。

考えた抜いた末にたどり着いた結論は、「透明にならないなら透明に見せかけてやろう」です。どういうことかというと、ウィンドウの下にある部分と同じものをウィンドウの背景として描画してしまうということです。

図 1 を見ていただければだいたい分かると思いますが、ウィンドウの下の部分をキャプチャして、それをウィンドウの背景として書きなおすのです。そうすれば、あたかもウィンドウが透明のように見えるはずです。

擬似透明ウィンドウの原理
図 1 擬似透明ウィンドウの原理

さて、手法は決まったのですが、問題なのは画面キャプチャをどうやるかですが、それは悩む必要はありません。なぜなら、Java で画面キャプチャを行うクラスが用意されているからです。

そのクラスは java.awt.Robot クラスです。

Robot クラスは GUI のテスト用に作られたクラスで、J2SE 1.3 から使用できます。

GUI のテストって面倒くさいですよね。アプリケーションのウィンドウにあるすべてのボタンやテキストフィールド、メニューなどすべてテストしなくてはいけないのですから。それを人が全部やるとしたら大変です。そのために、テストを自動化することが考えられます。

Robot クラスはそんな用途に作られており、マウスカーソルを移動させたりクリックしたり、入力を行ったりすることができます。画面のキャプチャはマウスをクリックしたりしたときに正しい動作が行われているかを検証するために、使われます。とりあえず、画像を取っておいて、後で人が確認できるようにするためです。

そんな Robot クラスですが、別にテスト以外の用途に使っても構わないはずです。今回はこれを使って透明ウィンドウを作ってしまいましょう。

ただ、ちょっとした問題もあります。Robot クラスはマウスカーソルなどのローカルリソースを扱います。このため Applet や Java Web Start など Sandbox で実行されるアプリケーションではセキュリティ的に使うことができません。この点は目をつぶりましょう。

 

 
 

画面キャプチャをしてみる

 
 

まず、画面キャプチャのテストをしてみましょう。

ソース CaptureTest1.java

画面キャプチャを行うメソッドは Robot#createScreenCapture です。引数は java.awt.Rectangle オブジェクトでキャプチャする範囲を指定します。

CaptureTest1 クラスはまず Robot オブジェクトを生成し、その後 createScreenCapture メソッドを使用してキャプチャを行っています。キャプチャする範囲は 100, 100 の点から幅 400、高さ 400 の四角形です。

createScreenCapture メソッドの戻り値は BufferedImage オブジェクトなので、それを ImageIcon クラスを使用してフレームに描画します。

    private Robot robot; 
 
    public CaptureTest1() {
        try {
            robot = new Robot();
        } catch (AWTException ex) {
            ex.printStackTrace();
            return;
        }
 
        // 範囲を指定してキャプチャ
        Rectangle bounds = new Rectangle(100, 100, 400, 400);
        BufferedImage image = robot.createScreenCapture(bounds);
 
        JFrame frame = new JFrame("Capture Test");
        frame.setBounds(bounds);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
 
        JLabel label = new JLabel(new ImageIcon(image));
        frame.getContentPane().add(label);
 
        frame.setVisible(true);
    }

さて、実行してみましょう。ちゃんとキャプチャできましたか ?

図 2 に実行した例を示しましたが、キャプチャした画像が背景とずれています。このずれはタイトルバーとフレームの外枠のためです。最後の frame.setVisible(true); の前に frame.setUndecorated(true); を挿入すれば、タイトルバーは描画されないのでずれないはずです。でも、これだとフレームがどこにあるのか全然分からなくなってしまいますけど。

CaptureTest1 の実行例
図 2 CaptureTest1 の実行例

 

 
 

透明フレームに至るまでには

 
 

ここまでできれば後は簡単にと思われるかもしれません。ところが、ここからが大変なのです。

まず、フレームの背景に画像を描画することから考えてみましょう。

CaptureTest1 クラスでは単に JLabel を貼ってしまいましたが、これだと他のコンポーネントが貼られてしまったり、レイアウトマネージャが異なっている場合に正しく背景として描画できません。

それでは、JFrame クラスを派生させたクラスを作って、paintComponent メソッドの中で画像を描画してみましょう。ちなみに AWT のコンポーネントでは描画を行うメソッドは paint ですが、Swing では paintComponent になります。

ソース CaptureTest2.java

paintComponent メソッド内で、イメージを描画するには Graphics#drawImage メソッドを使用します。これだけだとうまくいったかどうかよく分からないので、JButton をフレームに貼っています。

import java.awt.AWTException;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.image.BufferedImage;
import javax.swing.JButton;
import javax.swing.JFrame;
 
public class CaptureTest2 extends JFrame {
    private Robot robot; 
    private BufferedImage image;
 
    public CaptureTest2() {
        super("Capture Test");
 
        try {
            robot = new Robot();
        } catch (AWTException ex) {
            ex.printStackTrace();
            return;
        }
 
        // 範囲を指定してキャプチャ
        Rectangle bounds = new Rectangle(100, 100, 400, 400);
        image = robot.createScreenCapture(bounds);
 
        setBounds(bounds);
    }
 
    public void paintComponent(Graphics g) {
        g.drawImage(image, 0, 0, this);
    }
 
    public static void main(String[] args) {
        JFrame frame = new CaptureTest2();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().setLayout(new FlowLayout());
        frame.getContentPane().add(new JButton("Button"));
        frame.setVisible(true);
    }
}

さあ、実効です。

あれ、なにもボタン以外何も表示されません。グレーのままです。

なにが悪かったのでしょう ? ヒントは Frame の時はコンポーネントを貼るときに単に add していたのが、JFrame クラスだと getContentPane().add(comp) のように書かなくてはいけなくなったことです。

答えは JFrame クラスは複数の面 (Pane) から構成されているからなのです。JFrame クラスは一番下に RootPane があり、その上に LayeredPane があって、そのレイヤとしてメニューバーと ContentPane があります。そして、一番上に GlassPane が覆っています。

通常使用するのは ContentPane であり、JFrame 自体の paintComponent メソッドはコールもされません。

それじゃ、ContentPane に描いてしまいましょう。JFrame クラスには setContentPane というメソッドがあります。このメソッドを使用して、イメージを描画するコンポーネントを ContentPane にしてしまうのです。

ソース CaptureTest3.java

CaptureTest3 クラスには内部クラスとして ImageComponent クラスを持っています。これを ContentPane としています。ImageComponent クラスは paintComponent メソッドだけを持っていて、イメージを描画しています。

import java.awt.AWTException;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.image.BufferedImage;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
 
public class CaptureTest3 extends JFrame {
    private Robot robot; 
    private BufferedImage image;
 
    public CaptureTest3(String title) {
        supre(title);
 
        try {
            robot = new Robot();
        } catch (AWTException ex) {
            ex.printStackTrace();
            return;
        }
 
        Rectangle bounds = new Rectangle(100, 100, 400, 400);
        image = robot.createScreenCapture(bounds);
        setBounds(bounds);
 
        setContentPane(new ImageComponent());
    }
 
    class ImageComponent extends JComponent {
        public void paintComponent(Graphics g) {
            g.drawImage(image, 0, 0, this);
        }
    }
 
    public static void main(String[] args) {
        JFrame frame = new CaptureTest3("Capture Test");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().setLayout(new FlowLayout());
        frame.getContentPane().add(new JButton("Button"));
        frame.setVisible(true);
    }
}

これを実行したら、ちゃんとキャプチャされたイメージの上にボタンが表示されました。まだ、ずれてますけど。

しかし、実際にこのままだとちょっと問題があります。というのも setContentPane メソッドは public なので、勝手にContentPane を変更されてしまう可能性があるのです。

これを防ぐには setContentPane メソッドをオーバーロードして private メソッドにしてしまえば...... と思いますが、それはできません。親クラスが private なのを public にするなど制限をゆるくするのはできるのですが、制限を厳しくすることはできないのです。

というわけで、JavaDoc を見ていたら setRootPane が protected なのを発見してしまいました。次からは ContentPane ではなくて、RootPane を使うことにしましょう。

 

 
 

フレームにしたてる

 
 

それでは、これをフレームにしたてましょう。

まずは JFrame にしたてために JFrame で定義されているコンストラクタをすべて定義します。また、RootPane に描画を行うコンポーネントをセットしましょう。描画位置のずれも一緒に直してしまいましょう。

ソース SimpleTransFrame1.java

位置のずれは描画領域以外の部分がどの程度の大きさなのか知らなくてはいけません。この情報は Insets クラスを使用します。

    class ImageRootPane extends JRootPane {
        public void paintComponent(Graphics g) {
            Insets insets = SimpleTransFrame1.this.getInsets();
            g.drawImage(image, -insets.left, -insets.top, this);
        }
    }

次はフレームの大きさを自由に変更できるようにしましょう。

ソース SimpleTransFrame2.java

今まではフレームの位置とサイズは固定だったので、いつでもキャプチャできたのですが、可変にするとそうはいきません。

一般に GUI コンポーネントのサイズが決まるのは addNotify メソッドがコールされるときなので、ここでもそうしています。

    public void addNotify() {
        super.addNotify();
 
        Rectangle bounds = getBounds();
        Insets insets = getInsets();
 
        bounds = new Rectangle(bounds.x + insets.left,
                               bounds.y + insets.top,
                               bounds.width - insets.left - insets.right,
                               bounds.height - insets.top - insets.bottom);
        image = robot.createScreenCapture(bounds);
    }

ただ、これだと setVisble(true) メソッドをコールする前にサイズや位置を変えるのであればいいのですが、一度フレームを表示してしまってからサイズを変更しても反映されません。というのも addNotify メソッドは表示されるときに 1 度しかコールされないからです。

フレームが他のアプリケーションに隠された後にまた表示したり、サイズを変更すると、OS から再描画するようにアプリケーションに通知されます。このとき、repaint メソッドがコールされるかなと思っていたのですが、どうやらコールされないようです。repaint メソッドはあくまでもアプリケーション内部で再描画を行うときに使われるためだと思います。

何かないかなと JavaDoc を探していたら、なにやらにおうメソッドが... そのメソッドは getIgnoreRepaint メソッドです。getIgnoreRepaint メソッドは J2SE 1.4 から導入されたメソッドで、OS からの再描画を無視するかどうかを設定するためのメソッドです。

試してみると、OS から再描画があったときには毎回このメソッドがコールされているようです。そこで、このメソッドに

    public boolean getIgnoreRepaint() {

        Rectangle bounds = getBounds();
        Insets insets = getInsets();
 
        bounds = new Rectangle(bounds.x + insets.left,
                               bounds.y + insets.top,
                               bounds.width - insets.left - insets.right,
                               bounds.height - insets.top - insets.bottom);
        image = robot.createScreenCapture(bounds);
     
        return super.getIgnoreRepaint();
    }

とやってみたのですが、うまくキャプチャできません。というかキャプチャしているんだけど、違うものをキャプチャしているのです。

ようするに自分自身をキャプチャしています。フレームが表示されているのですから当たり前です。

そこで、一度フレームを非表示にして、キャプチャ、表示ということしてみましょう。

    public boolean getIgnoreRepaint() {

        Rectangle bounds = getBounds();
        Insets insets = getInsets();
 
        bounds = new Rectangle(bounds.x + insets.left,
                               bounds.y + insets.top,
                               bounds.width - insets.left - insets.right,
                               bounds.height - insets.top - insets.bottom);
     
        hide();
        image = robot.createScreenCapture(bounds);
        show();
     
        return super.getIgnoreRepaint();
    }

これを実行すると.... うんともすんともいいません。デッドロックに陥ってしまっているようです。

まぁ、当たり前といえば当たり前ですが ^^;; 再描画の部分で hide していて、その上、再表示までしているのですから。

それじゃあ、ということでこの処理は別スレッドで行いましょう。

こういう処理を行うには wait - notifyAll を使うのが常套手段です。

ソース SimpleTransFrame3.java

通常は wait で眠っており、誰かが notifyAll メソッドで眠っているスレッドを起こしたらキャプチャを行うようにします。notifyAll メソッド以外に notify メソッドもありますが、それよりは notifyAllメソッドを使ったほうが確実です。

wait メソッド、notifyAll メソッドをコールするには synchronized をする必要があります。

    class Watcher extends Thread {
        public synchronized void wakeup() {
            notifyAll();
        }
 
        public void run() {
            try {
                while(true) {
                    synchronized (this) {
                        wait();
                    }
                    
                    copyScreen();
                }
            } catch (InterruptedException ex) {
                return;
            }
        }
    }

copyScreen メソッドが実際にキャプチャをするメソッドです。

    public void copyScreen() {
 
        Rectangle bounds = getBounds();
        Insets insets = getInsets();
  
        bounds = new Rectangle(bounds.x + insets.left,
                               bounds.y + insets.top,
                               bounds.width - insets.left - insets.right,
                               bounds.height - insets.top - insets.bottom);
     
        hide();
        image = robot.createScreenCapture(bounds);
        show();
    }

getIgnoreRepaint メソッドと addNotify メソッドはこうなります。

    public boolean getIgnoreRepaint() {
        watcher.wakeup();
        return super.getIgnoreRepaint();
    }
 
    public void addNotify() {
        watcher.wakeup();
        super.addNotify();
    }

watcher が Watcher クラスのオブジェクトです。

これで、サイズが可変になり、再描画でも再キャプチャできるようになりました。

 

 
 

移動

 
 

残った問題は移動です。

なぜ移動が問題になるのか。ComponentEvent で componentMoved の時に再キャプチャすればいいように思います。

私もはじめはそう思って単純に作ってみました。Windows で動作させるとちゃんと動くので、これでおしまい、としていたのです。

ところが、あるとき Linux で動かしてみたらちゃんと動かないのです。

なぜだ????????????????????

理由はすぐ分かりました。ComponentEvent の発生の仕方が異なるためです。ようするに、Windows の場合移動が終了すると 1 回だけ ComponentEvent が発生します。しかし、Linux の場合移動中ずっと ComponentEvent が発生し続けるのです。

これだと、移動中に何度もキャプチャを行うため、ろくに移動ができません。最後の ComponentEvent が分かれば、そのときだけキャプチャするようにしなくてはだめなようです。

さて、どうしましょう?

苦肉の策なんですが、移動監視スレッドを作ることにしました。

ComponentEvent が発生すると、フラグを立てるようにします。監視スレッドは定期的にこのフラグを監視します。もし、フラグが立っていたら、それを 1 つ前の状態として保持し、フラグはおろします。

もし、移動が終了すれば、フラグは降りたままになっているはずなので、これをチェックします。ただ、単にチェックすると移動していないときもふくまれてしまいます。そこで、1 つ前の状態がフラグが立っており、現在の状態でフラグが降りたよう場合を移動の終了とみなします。

ソース SimpleTransFrame4.java

このような処理を Timer で行うようにしています。init メソッドに次の処理を加えました。

        enableEvents(AWTEvent.COMPONENT_EVENT_MASK);
 
        timer = new Timer();
        TimerTask task = new TimerTask() {
                public void run() {
                    if (moveFlag) {
                        moveFlagOld = moveFlag;
                        moveFlag = false;
                    } else if (moveFlagOld) {
                        watcher.wakeup();
                        moveFlagOld = moveFlag;
                    }
                }
            };
        timer.schedule(task, 0, 1000L);

普通、イベントはリスナを使って処理するのが一般的ですが、派生クラスの場合 processXXXXEvent メソッドを使ったほうがいいのです。なぜかというと addXXXXListener で登録したリスナは removeXXXXListener で登録解除されてしまいます。それも public メソッドなので、誰かが登録解除してしまう可能性は取り除けません。そのために processXXXXEvent メソッドを使用します。

processXXXXEvent メソッドを使用するには 1 行目の enableEvents メソッドで扱うイベントの種類を指定する必要があります。processComponentEvent メソッドは次のようになります。

    protected void processComponentEvent(ComponentEvent e) {
        if (e.getID() == ComponentEvent.COMPONENT_MOVED) {
            moveFlag = true;
        }
 
        super.processComponentEvent(e);
    }

これで、実行してみましょう。

うまくいくことはいくのですが、なんども再キャプチャを繰り返しているようです。これを防ぐためにフラグを導入して完成させたのが、JTransFrame です。キャプチャの部分は別クラス (ScreenCapture クラス) にしました。

パッケージは jp.gr.java_cons.skrb.gui.swing.transframe です (長い...)。

ソース

JTransFrame.java
ScreenCapture.java
Translucentable.java

JAR transframe.jar

 

 
 

遊んでみよう

 
 

せっかくなのでサンプルを作ってみましょう。今回は 2 つのサンプルを作ってみました。時計とフォトフレームです。

TransClock

デジタル時計です。

ソース

TransClock.java
ClockTask.java
FontChooser.java
ARGBChooserPanel.java

JAR transclock.jar

使い方は

C:\temp>java -cp transframe.jar;transclock.jar jp.gr.java_conf.skrb.util.transclock.TransClock

とても単純です。単に透明フレームの上に時刻を表示する JLabel を貼っているだけです。時刻の表示も DateFormat クラスのデフォルトを使っているだけです。本来なら、ロケールにあわせることや、設定できるようにしたほうがいいかもしれません。

その代わり、色とフォントを設定できるようにしました。

色の選択には JColorChooser を使用しています。ただし、JFontChooser はアルファを設定できないというとんでもない仕様になっているので、アルファも設定できるようにしました。

フォントの選択は JFontChooser を使用していません。何でないんだろう。とりあえず、自前で FontChooser を作ってしまいました。フォントのファミリー、スタイル、サイズを指定して選ぶことができます。JComboBox を 3 つつなげただけですが....

後、目新しいことといえば、Preferences API を使って、場所と色とフォントを覚えさせています。設定ファイルもいらないので、非常にとりまわしが楽になっています。

左クリック + ドラッグで移動、右クリックで色、フォントの設定ができます。終了も右クリックで選択してください。

移動の時には枠は表示されないのですが (Java ではできない...)、マウスカーソルのところまで移動します。

TransClock
図 3 TransClock

 

PhotoFrame

イメージを表示するだけのアプリケーションですが、せっかくなのでイメージを半透明にできるようにしてみました。

ソース PhotoFrame.java
JAR phaotoframe.jar

使い方は

C:\temp>java -cp transframe.jar;transclock.jar jp.gr.java_conf.skrb.util.photoframe.PhotoFrame [画像ファイル]

画像ファイルは Image I/O を使っているので、JPG、GIF、PNG が使えます。

イメージを半透明にするには Graphics#drawImage メソッドをコールする前に Graphics2D#setComposite メソッドで AlphaComposite オブジェクトを設定するようにします。こんな感じです。

        public void paintComponent(Graphics g) {
            Graphics2D g2d = (Graphics2D)g;
            
            AlphaComposite ac =
                AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f);
            
            g2d.setComposite(ac);
            g2d.drawImage(image, 0, 0, this);
        }

これだとアルファが 0,5 になります。

このアプリケーションで小さいイメージを表示するのならいいのですが、大きいイメージだと再描画の時にうざいです ^^;;

 

 
 

おわりに

 
 

JTransFrame クラスは JFrame とまったく同じ使い方ができます。

これを使って、おもしろいアプリケーションを作ってみてください。

 

ソース/クラスファイル TransFrame.zip
JAR

transframe.jar
transclock.jar
photoframe.jar

(Oct. 2003)

 
 
Go to Contents Go to Java Page