WKWebView で CSS の opacity transition が動かないとき疑うこと

症状
WKWebView (macOS / iOS) や同系統のエンジン (Electron, Capacitor, Tauri) で、
- HTML / CSS のロードは成功している
- 画像 URL も取れて Network には 200 OK
<div class="layer active">のactiveクラスもちゃんと付与されている- なのに 画面に何も表示されない
CSS は典型的なフェードイン:
.layer { opacity: 0; transition: opacity 1s ease-in-out; }
.layer.active { opacity: 1; }
Web Inspector で getComputedStyle(el).opacity を見ると "0" のまま固まっている。
原因
WebKit は document.visibilityState === 'hidden' のとき CSS transition / animation を停止する 最適化を持っている。具体的には:
requestAnimationFrameを停止- CSS transition の進行も止める
setTimeout/setIntervalは throttled
普通にブラウザでタブを開いていれば visible なので問題ないが、WKWebView が OS から見て表示されてない 状態だと hidden 扱いになる。
opacity: 0 → 1 の transition は開始時刻 t=0 のフレームから始まって 1 秒かけて t=1 になるはずが、t=0 で停止してしまう。class は付いているし、CSS の最終値も解釈されているのに、画面上は opacity 0 のまま。
どこで踏むか
- macOS のスクリーンセーバ (
legacyScreenSaverプロセス内の WKWebView) - iOS / iPadOS のバックグラウンドプリロード
- Electron で
BrowserWindow({ show: false })のまま操作 - Mac Catalyst の背面ウィンドウ
- Capacitor / Tauri の初期表示前のフレーム
- Headless Chrome で screenshot を急いで撮らせている時
切り分け
Web Inspector の Console で 4 つチェック:
document.visibilityState // → "hidden" ならビンゴ
el.classList.contains('active') // → true (= class は付いている)
getComputedStyle(el).opacity // → "0" のまま固まっている
el.style.transition = 'none'; el.style.opacity = 1 // → 即座に表示される
これが揃ったら確定。
解決策 A: WKUserScript で visibility を偽装する
WKWebView 側 (Swift) で WKUserScript を仕掛ける。
let visibilityOverride = """
try {
Object.defineProperty(Document.prototype, 'hidden', {
configurable: true, get: function() { return false; }
});
Object.defineProperty(Document.prototype, 'visibilityState', {
configurable: true, get: function() { return 'visible'; }
});
} catch (e) {}
"""
config.userContentController.addUserScript(
WKUserScript(source: visibilityOverride,
injectionTime: .atDocumentStart,
forMainFrameOnly: false)
)
ポイント:
documentに直接Object.definePropertyするのではなくDocument.prototype経由 で再定義する。document自体はインスタンスプロパティが既に確定済みのことがあるconfigurable: trueを必ず付ける。再定義時の例外を防ぐためinjectionTime: .atDocumentStartで webapp の JS より早く走らせる
解決策 B: CSS で transition を殺す
visibility 偽装が効かない場合や効果が遅れる場合、CSS で直接 transition を切る:
let cssOverride = """
(function(){
var css = '.layer { transition: none !important; }' +
'.layer.active { opacity: 1 !important; }';
var apply = function(){
if (!document.head) return false;
var s = document.createElement('style');
s.textContent = css;
document.head.appendChild(s);
return true;
};
if (!apply()) document.addEventListener('DOMContentLoaded', apply);
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: cssOverride,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true)
)
A と B を両方仕掛けても害はない。
解決策 C: webapp 側に flag を渡す
URL に ?embed=1 のような印を付けて、webapp 側で transition を無効化、初期から opacity: 1 で render する。長期的にはこれが一番きれい。
<style>
.layer { opacity: 0; transition: opacity 1s; }
.layer.active { opacity: 1; }
body.embed .layer { transition: none; opacity: 1; }
</style>
if (new URLSearchParams(location.search).get('embed') === '1') {
document.body.classList.add('embed');
}
埋め込み側 (WKWebView) が webapp の CSS 詳細を知らずに済むので、知識の局在化という意味でも筋が良い。
まとめ
「class は付いているのに opacity だけ 0」のとき document.visibilityState を疑う。WKWebView では Object.defineProperty(Document.prototype, 'visibilityState', ...) で偽装可能だが、可能なら webapp 側に flag を渡して対応するのがきれい。transition: none !important の CSS 注入を併用すると確実。
開発相談をお待ちしています。