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

本記事は[https://blog.physalis.net/2022/how-to-make-tauri-app-look-and-feel-native](https://blog.physalis.net/2022/how-to-make-tauri-app-look-and-feel-native)からの転載です。

先日Tauriで作った[Jomai](https://jomai.app/ja/)をリリースしました。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](https://github.com/tauri-apps/tauri/issues/2626)

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

必要な`keydown`イベントもあると思うので、`listen`して処理してから不要だったら捨てるようにします。Jomaiでは次のコードを使いました。
```typescript
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`としておくことでテキスト選択を防げます。
```css
body {
  user-select: none;
}
```

## マウスカーソルを`default`に設定する
前項によりUI要素の操作はボタンを押すなどに限定したので、マウスカーソルでもそのように表現しましょう。
```css
body {
  cursor: default;
}
```
`cursor: default`により、たとえばテキストにホバーしたときにIビームになるのを防ぎます。ボタンなど操作可能な場合は`cursor`属性を明示的に指定してください。
```css
button {
  cursor: pointer;
}
```

## 画面全体をスクロールさせない
画面のサイズよりコンテンツの方が大きい場合、多くのWebページでは画面全体がスクロールします。ネイティブアプリではUI部品を固定表示して、その一部の部品だけスクロール可能とするのが自然です。
Jomaiでは画面の上部のタブやフォームは常時表示され、下部のコンテンツ部分だけがスクロール可能です。
![scrollable-area.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859/59a2d927-c2c6-bbe5-16e7-858d454893f2.png)
実際の動作をご覧ください。
![scrollable-area.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859/3183e452-c5bb-9c0f-ac2b-1f524f06ed1f.gif)

これを抑制するには画面全体に`overflow: hidden`を設定し、スクロール可能な部分に`overflow: scroll`と`height`を設定します。
```css
.page {
  overflow: hidden;
}

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

## 画面全体のバウンススクロールを抑制する
バウンススクロール、画面の端までスクロールするとバネのような動きをするアレです。デフォルトでは画面全体がスクロール可能で、バウンススクロールになります。この動画を見てください。
![bounce-scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859/97434b19-a167-807c-01dc-d6cd9e5a0236.gif)


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

## メニューを用意する
Tauriはデフォルトで基本的なメニューを用意してくれます。
![menu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859/963e9f14-14e8-a987-32f7-24db9e37a235.png)


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

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

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

Reactでの実装例です。
```typescript
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](https://jomai.app/ja/)もよろしくお願いします。