async0x4c

本文へスキップ
async0x4c

自作ツールの UI を自慢したい — PrismDesk のデバイスプレビュー

PrismDesk の Android アプリの設定画面にある「端末にどう映るか」のプレビュー。Canvas 1 枚で、実機の角丸とカメラの切り欠きまで描いて回している。その作りと、周りの実装の話。

約 6 分
目次

はじめに

8月ごろから本格的な開発を始めた PrismDesk というツールについて、Android版の設定UIが気に入っているので自慢したい。

PrismDesk というのは Windows の画面を Android 端末で拡張したり複製したりすることができるツールで、この記事を書いている時点ではクローズテストを行っている。

この記事では、設定UIの中でも特に自信のある デバイスプレビュー を中心に、実装やその意図について書いていく。

デバイスプレビューとは

PrismDesk の Android アプリでは、画面の向き・拡張か複製か・解像度・リフレッシュレート・fps を、繋ぐ前に設定できる。ただ、いずれも数字や選択肢でしかなく、変えた結果どう映るのかは繋いでみるまで分からない。「向きを縦に固定したのに、横長の解像度を選んでいた」という食い違いが起こると、嫌な気持ちになる。

そこで、いまの設定を この端末に当てはめるとどう見えるか を、設定画面に 1 枚の絵で出すことにした。これがデバイスプレビューというもの。

タブレットの設定画面。左にデバイスプレビュー、右に解像度やリフレッシュレートの設定が並ぶ
タブレット (横持ち)
スマートフォンの設定画面。左にデバイスプレビュー、右に設定が並ぶ
スマートフォン (横持ち)
1 / 2

描いているのは次の通り。

  • 端末の外形。角の丸みとカメラの切り欠きは OS から実際の値を取るので、機種ごとに形が変わる
  • 選んだ解像度の画面。端末の画面と同じ縮尺で収める
  • 右上に fps とリフレッシュレート。fps の方が大きいと赤くなる
  • 左上に拡張/複製の記号

切り欠きは DisplayCutout.cutoutPath が取れればその形をそのまま描き、取れなければ boundingRects から推測する (ほぼ正方形ならパンチホールとみなして円で描く)。切り欠きの無い端末では、上下のベゼルを太くしてそこにカメラの穴を描いている。どれも Compose の Canvas 1 枚で描いていて、子の Composable や独自の View は使っていない。

なお、手元にある Android 16 のタブレットは切り欠きを取得できなかったので、案外形は外れるのかもしれない。

端末は回っても、中身は水平のまま

この部分が個人的には気に入っている。画面の向きの設定を変えると、端末の絵が 0.5 秒で回る。そして中に映っている画面は回らずに水平のまま、端末の画面に収まり直す。

画面の向きを切り替えると、端末の絵は回転し、中の画面は水平のまま収まり直す

縦持ちのタブレットに横長の画面を映すと実際にこう見える、というのをそのまま絵にしたかった。やっていることは、単純に回転を入れ子にしているだけ。

DevicePreview.kt (簡略化)kotlin
rotate(angle) {                                  // 端末を回す
    drawPath(outlinePath)                        // 外形
    clipPath(displayPath) {                      // 画面の形で切り抜く
        clipPath(cutoutPath, ClipOp.Difference) { // 切り欠きをくり抜く
            rotate(-angle) {                     // 中身だけ回転を戻す
                scale(contentScale) {
                    drawRect(/* 選んだ解像度の画面 */)
                }
            }
        }
    }
}

端末を回したあとで中身だけ逆に回すので、中身は水平のまま、切り抜く形だけが端末と一緒に回る。余白 (実際に繋ぐと黒帯になる部分) も特に計算していない。中身の倍率を min(画面の幅 / 解像度の幅, 画面の高さ / 解像度の高さ) で決めて収めれば、余ったところが自然に余白になる。

回転・倍率・記号の位置はどれも tween(500, EaseOutExpo) で動かしていて、最初に勢いよく動いてすっと止まる。EaseOutExpo が好きなのでこの動作がしっくりくる。

角丸に文字がかからないように

小ネタ。左上の記号と右上の fps は画面の隅に置いているが、最近の端末は画面の角が大きく丸まっているので、ただ隅に寄せると角丸に欠けてしまう。かといって角の半径 r だけ離すと、今度は隅から離れすぎて間が抜ける。

そこで、角から 45° の方向に、角丸の弧まで の距離だけ離している。角丸の中心は角から (r, r) の位置にあり、45° 方向の弧の上の点は中心から各軸 r/√2 だけ角に寄った所にある。なので角から弧までは、各軸で r − r/√2 = r(2 − √2)/2 ≈ 0.29r になる。

val leftTopMargin = r_tr * (2f - sqrt(2f)) * 0.5f

r には OS から取った右上の角の半径をそのまま使っている。

画面の右上の角を拡大した図。半径 r の角丸の中心から角へ 45° の線を引くと、弧と交わる点は角から縦横それぞれ約 0.29r の位置にあり、文字の右上をその点に合わせている

回したら壊れた

実機での確認項目を全部通したあとで、プレビューまわりの不具合がいくつか見つかったことがある。原因は単純で、確認のあいだ端末を 1 度も回していなかった。

1 つ目は、端末を回してもカメラの切り欠きが前の向きの位置に残る不具合。原因が 2 つ重なっていて、片方だけ直しても直らなかった。

  • 切り欠きは ViewCompat.getRootWindowInsets から読んでいたが、これはその場で値を取るだけで、変化を購読していない。回転しても描き直すきっかけにならない
  • 切り欠きの形を組む remember に key が無く、最初の 1 回しか組まれていなかった

Compose 側の WindowInsets.displayCutout を購読のためだけに 1 つ読み、回転や窓の大きさを remember の key に入れて直した。

2 つ目は、縦に回しても LocalConfiguration の screenHeightDp が横向きのときの値のまま残り、プレビューが半分以下の大きさで描かれる不具合。こちらは根本の原因を特定できていない。LocalConfiguration を読むのをやめて、LocalWindowInfo.current.containerSize から窓の大きさを取るようにした。

grep で守る

どちらも実機で回したときにしか出ない不具合で、テストで再現するのは難しい。Compose の画面は JVM のテストでは組み立てられず、壊れ方は「回したときだけ前の向きの絵が出る」というもの。

そこで、テストはソースコードを読むだけにした。DevicePreview.kt と SettingsScreen.kt からコメントを取り除いたうえで、LocalConfiguration が出てきたら落ちる。containerSize を読んでいなくても落ちる (禁止だけにすると、何も読まなくても通ってしまう)。

雑に見えるが、守りたいのは「この API を使わない」という約束そのものなので、これが一番確実で安い。

設定画面の分け方もプレビューから決めた

設定画面は「分類の一覧 → 分類ごとの画面」の 2 段になっている。どの項目を同じ画面に置くかは、プレビューが読む値から決めた。向き・モード・解像度・リフレッシュレート・fps の 5 つは必ず同じ画面に置き、プレビューもその画面にだけ出す。絵と、絵を変える設定が別の画面にあると、変えた結果がその場で見えなくなるからだ。

コードの上でも「この分類はプレビューを出す」と手で印を付けるのではなく、5 つを全部含む分類がプレビューを持つ、という形で導いている。

プレビューの置き場所は、実機で 2 回やり直した。

  • 分類の一覧の下に置いた → どの分類を選んでも絵が変わらないので、ただの飾りに見えた
  • 右側の上半分に置いた → 横持ちのスマホは高さが 360dp ほどしかなく、設定の方が隠れた

いまは、横長の画面ではプレビューと設定を左右に半分ずつ、縦長の画面ではプレビューを上に置いて高さの 4 割までにしている。

横長の画面。左半分にプレビュー、右半分に設定
横長: 左右に半分ずつ
縦長の画面。上にプレビュー、その下に設定
縦長: 上に高さの 4 割まで
1 / 2

ブラウザでも触れるようにした

PrismDesk の公式サイトのトップには、このプレビューをブラウザで触れるデモを置いている。プレビューが Canvas 1 枚で描かれていたおかげで、同じ式・同じ寸法のまま SVG と素の JavaScript に移すことができた (Compose の Path は SVG の d に、入れ子の回転もそのまま)。

アプリより少し高機能で、隣に PC のモニタを並べ、ウィンドウを PC から端末へドラッグできるようにしてある。端末の絵だけだと、拡張と複製の違いが伝わりにくかったからだ。

prismdesk.app のデモ。左に PC のモニタ、右に端末の絵があり、ウィンドウが PC から端末へまたがっている

おわりに

デバイスプレビューは見た目こそ単純な絵だが、数字の設定を目で見える形にするために、幾何・アニメーション・状態の購読・画面の構成・テストまで手を入れることになった。だからこそこうして自慢したくなった。

PrismDesk は 2026 年 10 月 14 日に公開予定で、無料版でも端末 1 台をサブモニタとして使える。HDR や複数台の同時接続が使える Pro 版は US$22 の買い切り。作った側から見た中身の話は ツールページ に、製品の紹介は公式サイトにまとめてある。デバイスプレビューもサイトのデモで触れるので、ぜひ回してみてほしい。

PrismDesk — Android タブレットを Windows のワイヤレスなサブモニタに手元の Android タブレットを、Windows 11 のワイヤレスなサブモニタに。設定作業なし、切れても自動で復帰、HDR とペン入力に対応。性能は実測値で公開しています。無料版あり・アカウント不要。PrismDesk

不具合の報告や要望は GitHub で受け付けている。

GitHub - Asynchronous-0x4C/prismdesk-app: Documentation, issue tracker and releases for PrismDesk - a wireless second monitor for Windows, using an Android tablet.Documentation, issue tracker and releases for PrismDesk - a wireless second monitor for Windows, using an Android tablet. - Asynchronous-0x4C/prismdesk-appGitHub