メモ帳アプリに Markdown プレビューを後付け実装中 — WKWebView の選択

| 開発記録 | メモ帳

タグ: #iOS #Swift #WKWebView #開発中

純正 Swift のメモアプリに Markdown プレビューを追加実装している進行中の話。ネイティブレンダラを書くか、WKWebView でやるか。最終的に WKWebView を選んだ理由をまとめます。次回アップデートでリリース予定です。

本記事は現在開発中の機能についての開発記録です。Markdown プレビューはまだリリースされておらず、次回のアプリアップデートで提供予定です。

なぜ Markdown プレビューなのか

「タブで切り替え可能なメモ帳」というシンプルなアプリですが、ユーザー層の中に開発者やライターが一定数いて、「Markdown で書きたい」「リスト記法を整形して表示したい」という要望が継続的にありました。

書く側は素の Markdown で十分書けます。問題は「表示」側で、# 見出し- リスト をパースして整形する仕組みが必要でした。

選択肢: ネイティブレンダラ vs WKWebView

Swift で Markdown プレビューを実装する方法は大きく 2 つあります。

  1. ネイティブレンダラ(CoreText でひとつずつ装飾)
  2. WKWebView で HTML として表示

ネイティブの方が動作が軽く、UI 統合度も高い。けれど、Markdown のフル仕様(表、コードハイライト、リンク、画像、ネスト構造)を自前で書くのは膨大な工数です。

結論として、WKWebView + 軽量 JS パーサで実装することにしました。

WKWebView アプローチの設計

  1. メモ本文(Markdown)を JS パーサ(marked.js など)で HTML に変換
  2. CSS で iOS 風のフォント・色・余白に整える
  3. WKWebView に loadHTMLString で表示
  4. リンクタップは decidePolicyFor navigationAction で intercept してネイティブブラウザに渡す

WKWebView への流し込みは loadHTMLString 一発ですが、相対パスのリソース解決のために baseURL を渡しておくと、後からローカル画像対応を入れる時に楽です。

func renderMarkdown(_ markdown: String, into webView: WKWebView) {
  let escaped = markdown
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "`", with: "\\`")
  let html = """
  <!doctype html>
  <html><head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="preview.css">
  </head>
  <body>
    <div id="out"></div>
    <script src="marked.min.js"></script>
    <script>
      document.getElementById('out').innerHTML = marked.parse(`\(escaped)`);
    </script>
  </body></html>
  """
  let base = Bundle.main.url(forResource: "preview", withExtension: "css")?
    .deletingLastPathComponent()
  webView.loadHTMLString(html, baseURL: base)
}

JavaScript エスケープシーケンスエラー

ハマったポイントとして、メモ本文に JS の特殊文字(バックスラッシュ、シングルクォート、改行)が含まれていると、HTML 生成時にエスケープエラーになりました。

初期実装では \n の扱いを誤って、改行が全部消えてしまうバグも踏みました。本文のエスケープ処理を専用関数として切り出し、ユニットテストで主要パターンを潰しました。

スクロール位置の保持

編集モードからプレビューに戻ったときに、スクロール位置がリセットされる問題がありました。WKWebView のスクロール位置を手動で保存し、再表示時に復元する処理を追加しました。

let scrollPosition = webView.scrollView.contentOffset
// ...再ロード後
webView.scrollView.setContentOffset(scrollPosition, animated: false)

iOS 26 対応

後日 iOS 26 で WKWebView の挙動が一部変わり、レンダリング後のスクロールタイミングがずれる問題が出ました。viewDidLayoutSubviews で再調整するパッチを当てて対応しています。

まとめ

  • Markdown プレビューは WKWebView + JS パーサが圧倒的に楽
  • エスケープ処理は必ずテストを書く
  • リンクは intercept してネイティブブラウザへ
  • スクロール位置の保持は地味だが重要

「後付け機能」はアーキテクチャに無理を強いがちですが、WKWebView は「Markdown 程度の表示なら」十分な選択肢でした。