ブログ一覧

navigator.share の判定だけでは PC でも共有シートが出る

更新: Web開発者向け

ブログの記事下にシェアボタンを置いたところ、スマホから押すとログインを求められました。

同じ端末に X のアプリも LINE のアプリも入っていて、どちらもログイン済みです。それでも聞かれます。シェアリンクを踏んで開くのはモバイルブラウザで、ブラウザとアプリではセッションが別物だからです。X や LINE のアプリ内ブラウザから記事を開いた場合はさらに悪く、Cookie の保存場所ごと分かれているので確実に止まります。

これはボタンの実装の問題ではありません。公式のウィジェットを使っても同じで、静的なリンクである限り避けられません。

解決するのは navigator.share()(Web Share API)です。OS の共有シートが開き、渡す先がネイティブアプリになります。アプリは既にログイン済みなので、ログイン画面が出ません。

そこでスマホだけ共有シートに切り替えるようにしました。PC でも切り替わりました。

まず実測

判定は、よく紹介されている形で書きました。

if (typeof navigator.share === "function") {
  // 共有シートのボタンを出す
}

デスクトップの Chrome で開いたところ、リンクの行が消えて共有シートのボタンだけになりました。caniuse で対応状況を確認すると、理由はすぐ分かります。

  • Chrome デスクトップ … 128 から対応
  • Safari macOS … 12.1 から対応
  • Edge … 95 から対応
  • Firefox デスクトップ … 未対応

対応していないのは Firefox だけで、グローバルの対応率は 91.7% です。「Web Share API はスマホの機能」という理解が間違っていました。

上のコードは、調べて出てきたものをそのまま書いた

書く前に判定方法を調べていて、「UA 判定ではなく navigator.share があるかどうかで分岐する」という形を見て、それをそのまま使いました。navigator.canShare を使う書き方も見ましたが、判定としては同じです。UA 判定よりこちらのほうが良いという話も併せて読んでいたので、疑いませんでした。

読んだ中に「Mac の Chrome は非対応」という但し書きがあったのを覚えています。数年前のものでした。当時はデスクトップで対応しているブラウザが実質なかったので、「API があるか」を見れば「スマホか」も分かったのだと思います。私はそこを読み飛ばして、コードだけ持ってきました。

Chrome デスクトップが対応した今、この二つは別のものです。機能検出は間違っていないのですが、私が分けたかったものとは一致しなくなっていました。

困っていたのはスマホだけだった

ここで一度手を止めました。そもそも何のために分けようとしていたのか。

ログイン画面に阻まれたのは、ブラウザのセッションとアプリのログイン状態が別だからです。PC の自分の環境にはこの分断がありません。 ブラウザでログインしたままなので、X のリンクを踏めばそのまま投稿画面が出ます。

つまり PC では何も困っていませんでした。困っていない場所のリンクを、押し慣れていない OS のメニューに差し替えていたことになります。

分けたかったのは「API が使えるか」ではなく「スマホか」でした。

条件に (pointer: coarse) を足す

if (
  typeof navigator !== "undefined" &&
  typeof navigator.share === "function" &&
  window.matchMedia("(pointer: coarse)").matches
) {
  document.documentElement.dataset.shareSheet = "true";
}

(pointer: coarse) が true になるかどうかは、端末ごとにこう分かれます。

  • iPhone / Android のスマホ … coarse
  • iPad … coarseMagic Keyboard のトラックパッドを繋いでいても `coarse` のまま(WebKit の既知の挙動)
  • マウスやトラックパッドの PC … fine
  • タッチスクリーン付きのノート PC … `fine`。 タッチできるかどうかではなく、主に使う入力がどちらかで決まる
  • ペンタブレットやスタイラス … fine(精度が高いため)
  • ポインティングデバイスが無い(音声操作、キーボードのみ)… none

つまり pointer が表しているのは「タッチできるか」ではなく「主たるポインティングデバイスの精度」です。この「主たる」の一語で、タッチスクリーン付きのノート PC が fine 側に落ちます。どれか一つでも粗いポインタがあるかを知りたいなら any-pointer のほうです。2018 年 12 月から広く使える状態にあります。

iPad が coarse のままなのは今回は都合が良い方に働きました。iPad にも共有シートはあり、アプリもログイン済みなので、シェアボタンが出て正解です。

残り二つ、気をつけた点があります。

  • 値は動的に変わりうる。 タブレットにマウスを繋げば変化します。今回はページの読み込み時に一度読むだけなので、繋ぎ替えてもリロードするまで反映されません
  • 画面サイズとは無関係。 PC のウィンドウを細くしても fine のままです。これも意図どおりで、分けたいのは画面の幅ではなく端末の種類だからです。幅で分けると、細くしたデスクトップで共有シートに切り替わってしまいます

判定は JS、出し分けは CSS

実装で一つ決めたことがあります。JS がやるのは <html> に印を付けるかどうかだけで、ボタンを作ったり消したりはしません。両方のボタンを最初から HTML に入れておき、CSS でどちらかを隠します。

.share-item--sheet { display: none; }
html[data-share-sheet="true"] .share-item--sheet { display: block; }
html[data-share-sheet="true"] .share-item--link  { display: none; }

理由は三つあります。

  • チラつかない。 JS で DOM を組み替えると、ブラウザはまずリンクの行を描画してから差し替えるので、一瞬見えてカクッとします。判定用のスクリプトを行より前に置いておけば、行がパースされる時点でルールが効いているので、最初から正しい方しか描画されません
  • サーバーが返す HTML が全員同じになる。 ここをサーバー側で出し分けようとすると User-Agent を見ることになり、エッジキャッシュを端末ごとに分ける必要が出てきます
  • 失敗したときに壊れない。 JS が動かなければ印が付かず、リンクの行がそのまま残ります。フォールバックが既定の状態なので、復旧処理が要りません

隠れている側のマークアップが数百バイト分 HTML に乗りますが、display: none の要素はスクリーンリーダーからも読まれないので支障はありません。

Astro の is:inline は中身を式として解釈しない

Astro で書いていて踏んだ罠も残しておきます。判定用のスクリプトを <script is:inline> で置き、中身を式で囲みました。

<script is:inline>
  {`
  if (typeof navigator.share === "function") { ... }
  `}
</script>

is:inline の中身はそのまま出力されるので、この囲みが文字としてページに出ます。

<script>
  {`
  if (typeof navigator.share === "function") { ... }
  `}
</script>

構文エラーになり、スクリプトは一度も実行されません。素のスクリプトとして書けば済む話でした。

厄介なのは、ビルドも型チェックも通ることです。 エラーはブラウザのコンソールにしか出ません。

確認は五通りやる

片方だけ見ても正しくなったか分からないので、Playwright で navigator.share をスタブした状態とそうでない状態を作り、端末のエミュレーションと組み合わせて確認しました。

desktop,     share あり  coarse=false → はてブ / ポスト / リンクをコピー
desktop,     share なし  coarse=false → はてブ / ポスト / リンクをコピー
iPhone 相当, share あり  coarse=true  → シェア
iPhone 相当, share なし  coarse=true  → はてブ / ポスト / リンクをコピー
iPad 相当,   share あり  coarse=true  → シェア

navigator.share のスタブは、context を作るときに仕込みます。

await context.addInitScript(() => {
  Object.defineProperty(navigator, "share", {
    configurable: true,
    value: () => Promise.resolve(),
  });
});

上の is:inline の罠は、この五通りを機械的に確認していて見つかりました。PC だけ見ていたら「直った」で終わっていて、スマホでもボタンが出なくなっていることに気づかないまま出していたと思います。

計測はできない

共有シートを開いたあと、結果は返ってきません。実際にシェアされたか、どこに送られたかは分かりません。取れるのは「シートを開いた」ところまでです。

もっとも、静的なリンクのほうも別ドメインへ出て行くだけなので同じです。計測したいなら、リンクにクリック計測を挟む形になります。

この構成で気をつけること

  • Web Share API はスマホ専用ではない。 Chrome・Safari・Edge はデスクトップでも対応している
  • `navigator.share` の有無だけでは「スマホか」は分からない。 (pointer: coarse) と組み合わせる
  • `pointer` は「主たる」入力の話。 タッチ対応のノート PC は fine
  • 画面幅で分けない。細くしたデスクトップで切り替わってしまう
  • 判定は JS、出し分けは CSS。リンクの行を既定にして、シェアボタンが現れる向きにする
  • `is:inline` の中身は式として解釈されない。 構文エラーになってもビルドは通るので、ブラウザで確かめる

拾ってきたコードが動かなかったのではなく、動いた結果が自分の欲しかったものと違っていた、というのが今回でした。何を検出するかより先に、何で分けたいのかを決めておくべきでした。

関連

この記事をシェア