Secret Staircase

Tauri製アプリでネイティブ感を出す8つのTips

先日Tauriで作ったJomaiをリリースしました。Markdownに特化したデスクトップサーチアプリです。 Tauri製のアプリはもちろんネイティブアプリなのですが、UI部分がWebViewで動作するため使用感がWebアプリっぽくなりがちです。Jomaiではなるべくネイティブアプリらしく振る舞えるように工夫したので、ここでまとめてみます。 なおJomaiは今のところmacOS専用のためこの記事もmacOSを対象としています。

環境

  • Tauri: 1.1.1
  • Platform: macOS

目次

非入力項目でのキー入力でビープ音がなる問題

<input><textarea> 以外の非入力項目にフォーカスがあるときにキー入力するとビープ音が鳴ります。macOSのWebViewで発生する問題でGitHubのissueがあり、2022年10月6日現在解決していません。 https://github.com/tauri-apps/tauri/issues/2626

keydownイベントをpreventDefault()すれば抑制できるのですが、その場合などへの入力も全部無効になってしまうため選択的に抑制する必要があります。

必要なkeydownイベントもあると思うので、listenして処理してから不要だったら捨てるようにします。Jomaiでは次のコードを使いました。

export const useKey = (callback: UseKeyCallback) => {
  const onKeydown = useCallback(
    (event: KeyboardEvent) => {
      // コールバックは処理したら true を返す
      const consumed = callback(event.code, {
        ctrl: event.ctrlKey,
        shift: event.shiftKey,
      });

      const e = event.composedPath()[0];
      if (
        !(e instanceof HTMLInputElement || e instanceof HTMLAreaElement)
        || consumed
      ) {
        // 入力項目じゃなかったら preventDefault()
        // コールバックにより処理された場合も preventDefault()
        applyBeepSoundWorkaround(event);
      }
    },
    [callback],
  );

  useEffect(() => {
    window.addEventListener('keydown', onKeydown);
    return () => {
      window.removeEventListener('keydown', onKeydown);
    };
  }, [onKeydown]);
};

イベントのソースがHTMLInputElementまたはHTMLAreaEelement以外の場合にpreventDefault()すればほぼ大丈夫なのですが、これら入力項目にフォーカスが当たっている時にキーボードショートカットを使ってフォーカスが非入力項目に移動した時(ややこしい)も音が鳴ってほしくないので少々複雑な処理をしています。

UIのテキストを選択できないようにする

Webページのテキストは選択できるのが当たり前ですがネイティブアプリは一般的にそうではありません。UIを操作しようとして意図せずテキスト選択になるを避けるために<body>などDOM要素の根元の方でuser-select: noneとしておくことでテキスト選択を防げます。

body {
  user-select: none;
}

マウスカーソルをdefaultに設定する

前項によりUI要素の操作はボタンを押すなどに限定したので、マウスカーソルでもそのように表現しましょう。

body {
  cursor: default;
}

cursor: defaultにより、たとえばテキストにホバーしたときにIビームになるのを防ぎます。ボタンなど操作可能な場合はcursor属性を明示的に指定してください。

button {
  cursor: pointer;
}

画面全体をスクロールさせない

画面のサイズよりコンテンツの方が大きい場合、多くのWebページでは画面全体がスクロールします。ネイティブアプリではUI部品を固定表示して、その一部の部品だけスクロール可能とするのが自然です。 Jomaiでは画面の上部のタブやフォームは常時表示され、下部のコンテンツ部分だけがスクロール可能です。

実際の動作をご覧ください。

これを抑制するには画面全体にoverflow: hiddenを設定し、スクロール可能な部分にoverflow: scrollheightを設定します。

.page {
  overflow: hidden;
}

.contents {
  overflow: scroll;
  height: calc(100vh - 40px)
}

画面全体のバウンススクロールを抑制する

バウンススクロール、画面の端までスクロールするとバネのような動きをするアレです。デフォルトでは画面全体がスクロール可能で、バウンススクロールになります。この動画を見てください。

バウンススクロールはスクロールが可能であることを連想させますので、画面全体がスクロールするべきコンテンツでない場合には抑制しましょう。

body {
  overflow: hidden;
}

メニューを用意する

Tauriはデフォルトで基本的なメニューを用意してくれます。

カスタマイズが必要な場合にはちょっと面倒ですが自分でこれらのメニューを構築する必要があります。tauri::Menu::os_defaultが参考になります。

キーボードショートカットを用意する

Webページでは独自のキーボードショートカットはユーザーを混乱させることがあるので使うときは注意が必要ですが、ネイティブアプリには積極的に導入しましょう。

ダークモードをサポートする

私はダークモードのファンなので常用するアプリにはダークモードをサポートしてもらいたいです。 Tauriには現在のテーマを得るappWindow.theme()と変更イベントを監視するためのappWindow.onThemeChanged()があります。これらを使えばOSのテーマ設定に合わせてアプリを変更できます。

Reactでの実装例です。

import { appWindow, Theme } from '@tauri-apps/api/window';

export const useTheme = () => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    let unlisten: UnlistenFn | undefined;

    (async () => {
      setTheme(await appWindow.theme());

      unlisten = await appWindow.onThemeChanged(({ payload: theme }) => {
        console.log(`theme changed to ${theme}`);
        setTheme(theme);
      });
    })();

    return () => {
      if (unlisten != null) {
        unlisten();
      }
    };
  }, []);

  return theme;
};

まとめ

この記事ではTauri製のアプリを、よりネイティブらしく仕上げるTipsを紹介しました。ちょっとした手間でアプリの使い勝手が向上します。参考になれば幸いです。

これらのテクニックを使って制作したJomaiもよろしくお願いします。