TECH PLAY

Swift

イベント

マガジン

技術ブログ

こんにちは、 スタメンでEMをしている あさしん です 最近、Claudeのプランを上げて色々作ったり、自作キーボードの沼に徐々に沈み始めています。 はじめに スタメンが提供する「TUNAG 受付アプリ」は、2022年7月にリリースした来客受付アプリです。来訪者がiPad(当時はAndroidタブレット)を操作し、担当グループ・担当者を選んで呼び出すと、担当者へ通知が届く仕組みになっています。TUNAGをご利用いただいているお客様にご提供しているサービスです。 当初の通知先はTUNAGアプリ内のチャット機能でしたが、今回の改修によって「TUNAG チャット」にも通知できるようになりました。TUNAG チャットは、TUNAGアプリの中の1機能として提供されていたチャットを独立させたアプリで、2025年3月にリリースされました。音声通話やスレッド機能など、コミュニケーション機能をより強化した形で提供しています。サービスの成長に合わせてアプリが分化していく中で、受付アプリ側も新しい通知先に対応した形になります。 リリース当時の技術的な経緯は 2022年のテックブログ記事 にまとめています。かんたんに振り返ると、 Android開発を主担当するエンジニアが、将来のiOS拡張を見越してKotlin Multiplatform Mobile(KMM、現KMP)を採用した のが出発点でした。 当時の判断を引用すると: 「今後iOSアプリへの拡張性を考慮」してデータ・ドメイン層にKMMを導入しました。 この「将来の伏線」をついに回収しました。KMPで積み上げたロジック資産をそのまま活用し、 SwiftUIのUI層を追加するだけでiPad対応を実現した 話をします。 なぜ最小コストで済んだのか 最初にポイントを整理します。 Android版リリース時点で、ビジネスロジックはすでにKMPの shared モジュールにまとまっていました。具体的には: 認証フロー(パスワード認証・SSO判定) グループ・ユーザー一覧のフェッチ ViewModel の UI State 管理 APIクライアント(Ktor) クッキー永続化 これらはすべて commonMain に書かれており、Kotlinのまま動きます。iOS対応でやるべきことは、 SwiftUIの画面を足すだけ でした。ビジネスロジックを書き直す必要はありません。 ここで重要なのは、現在のチームにはiOSメンバーが増えており、ネイティブで0から作り直す選択肢も技術的には十分あった点です。それでもKMPの上に載せる判断をしたのは、 動いている資産を捨てるコストに見合うリターンがないから です。既存のロジックを再実装しても機能は増えません。エンジニアのリソースを「新しいビジネス価値を生む開発」に使える方が、今回の私たちの状況においては、ビジネス判断として合理的でした。 プロジェクト構成 tunag-reception/ ├── androidApp/ # Jetpack Compose による Android UI層 ├── iosApp/ # SwiftUI による iOS(iPad)UI層 └── shared/ # KMP 共有モジュール └── src/ ├── commonMain/ # 共通ロジック(Android/iOS 両方で動く) ├── androidMain/ # Android 固有実装 └── iosMain/ # iOS 固有実装 shared モジュールのレイヤリングはこうなっています。 shared/commonMain/ ├── domain/ │ ├── entity/ # データクラスのみ。ビジネスロジックなし │ └── repository/ # インターフェース定義のみ ├── application/ │ ├── service/ # ユースケース │ ├── dto/ # データ転送オブジェクト │ └── AppContainer.kt # DIコンテナ(唯一の組み立て口) ├── infrastructure/ │ ├── api/ # Ktor クライアント、Cookie管理 │ └── repository_impl/ ├── presentation/ # ViewModel └── stub/ # テスト・Preview 用スタブ 最重要ルールは「ViewModel・Repository・Entity は必ず shared に収める」 こと。iOS/Android のモジュールには View レイヤのみを配置します。この制約を守ることで、ロジックのダブルメンテが構造的に発生しません。 expect/actual パターン:プラットフォーム差を局所化する KMPでプラットフォーム固有の実装が必要な箇所は expect/actual で分岐します。代表的な例として ApiClient を紹介します。 ApiClient HTTPクライアントはプラットフォームごとにエンジンが異なります。 commonMain では expect だけを宣言します。 // commonMain — 共通のシグネチャ定義(各プラットフォームで実装を切り替える枠組み) expect class ApiClient(apiHost: ApiHost) { val apiHost: ApiHost val client: HttpClient suspend fun clearCookies() suspend fun addCookieToStorage(name: String , value: String ) fun close() } iOS側の actual はDarwinエンジン(NSURLSessionベース)を使います。 // iosMain — Darwin エンジンで実装 actual class ApiClient actual constructor ( actual val apiHost: ApiHost) { actual val client: HttpClient = HttpClient(Darwin) { engine { // Ktor の HttpCookies プラグインでクッキーを管理するため // NSURLSession のクッキーストレージを無効化して二重付与を防ぐ configureSession { setHTTPCookieStorage( null ) } } install(HttpCookies) { storage = PersistentCookieStorage(apiHost) } install(UserAgent) { agent = "Tunag-iOS/5.8.0.0 Reception" } } } Android側はOkHttpエンジンが入ります。呼び出し側は ApiClient を使うだけで、プラットフォームを意識しません。 なお、現在のKMPではクラス全体を expect class にするより interface + DI で注入するアプローチが推奨されることが多いです。今回は2022年当時の設計資産をそのまま活かす方針のため、当時の expect class をそのまま使い続けています。 クッキー永続化:iOS/Android の保存先の違いを吸収する アプリ再起動後もログイン状態を維持するため、セッションクッキーを永続化しています。保存先がプラットフォームで異なるため、 Multiplatform Settings ライブラリの Settings クラスで抽象化し、 commonMain に PersistentCookieStorage を実装しました。プラットフォーム固有の保存先は Settings の裏側に隠れるため、共通コードはストレージを意識しません。 // commonMain — プラットフォームを意識しない CookiesStorage 実装 internal class PersistentCookieStorage(apiHost: ApiHost) : CookiesStorage { private val settings = createCookieSettings() // expect/actual でプラットフォームごとの保存先に切り替わる private val storageKey = "ktor_cookies_ ${ apiHost.raw } " override suspend fun get (requestUrl: Url): List <Cookie> = mutex.withLock { ensureLoaded() val host = requestUrl.host val now = currentEpochMillis() cookieMap.entries .filter { (domain, _) -> host == domain || host.endsWith( ". $domain " ) } .flatMap { it.value } .filter { it.expiresAt == null || it.expiresAt > now } .map { it.toCookie() } } } 実装中にひとつ罠がありました。Ktor 3へのアップグレード後、ログアウト直後に再ログインするとクッキーが消えるバグが発生しました。原因はKtor 3での挙動変更で、 max-age=0 が「未設定」扱いから「即時削除」に変わっていました。RFC 6265に立ち返り maxAge <= 0 を正しく削除処理することで解決しましたが、地味な落とし穴でした。 StateFlow を SwiftUI から消費する:Wrapper パターンと SKIE KMPのViewModelはKotlinの StateFlow でUI状態を公開します。 // shared/commonMain — Kotlin ViewModel class SystemViewModel(...) : ViewModel() { val uiState: StateFlow<SystemUiState> = viewModelState .map { it.toUiState() } .stateIn(viewModelScope, SharingStarted.Eagerly, ...) } Swiftから StateFlow を直接 @Published として扱うことはできません。当初はKotlin側にコールバックを受け取るラッパーを用意していましたが( *ViewModelIos.kt )、 SKIE の導入でそれが不要になりました。 SKIEは、 Kotlin Multiplatformで作られたコードをSwiftから利用しやすくするためのコンパイラプラグインです。 Kotlinの Flow をSwiftの AsyncSequence に変換し、 sealed interface をSwiftのパターンマッチで扱えるようにしてくれます。 現在はSwift側に薄いWrapperクラスを置くだけです。 // iosApp — Swift Wrapper @MainActor final class SystemViewModelWrapper : ObservableObject { @Published private ( set ) var uiState : SystemUiState private var observationTask : Task < Void , Never > ? init (container : AppContainer ) { let vm = SystemViewModel( ... ) self .uiState = vm.uiState.value // StateFlow を for-await-in で購読し、@Published を更新する observationTask = Task { [ weak self ] in for await state in vm.uiState { guard let self else { return } self .uiState = state } } } deinit { observationTask?.cancel() } } sealed interface のパターンマッチも自然に書けます。 // SKIE の onEnum(of:) で Kotlin sealed interface を Swift で分岐する var currentBaseUrl : String { switch onEnum(of : uiState ) { case .signIn( let s ) : return "https:// \( s.currentHost ) " case .enterPassword( let e ) : return "https:// \( e.currentHost ) " case .admin( let a ) : return "https:// \( a.currentHost ) " } } SKIEなしではKotlin側にiOS専用のラッパー関数を用意する必要がありました。その手間がなくなり、Android向けに書いたKotlinコードをSwift側から直接使える体験が格段に改善されています。 ※コードは弊社環境のSKIEバージョン(0.10.11)に基づいています。SKIEの比較的新しいバージョンでは onEnum(of:) を使わずネイティブの switch で直接分岐できる場合もあります。 CI/CDを2本立てで構築する AndroidとiOSでCI/CDのパイプラインを分けています。 Android:CircleCI → Google Play 元のリリース時から変わらず、CircleCIを使っています。 # .circleci/config.yml(抜粋) deploy_production_to_playstore : docker : - image : cimg/android:2024.09 steps : - checkout - run : name : Place keystore file command : echo $STORE_FILE_BASE64 | base64 --decode > ./release.keystore - run : name : Build bundle & upload to Play Store command : ./gradlew publishProductionReleaseBundle リリースタグを打つと自動でPlay Storeの内部テスト版にデプロイされます。 iOS:Xcode Cloud → App Store iOS対応を機に Xcode Cloud を採用しました。GitHub連携でpushを検知し、アーカイブ・App Storeへの提出を自動化しています。 CircleCIに統一する案も検討しましたが、採用しませんでした。CircleCIでiOS/iPadOS向けのビルド環境を構築するには、App Store Connectとの連携に必要な認証トークンの発行・管理が必要です。さらにAndroidと同様に「ストアへのアップロード」と「社内テスト版の自動配信」を実現しようとすると、Xcode Cloudで構築するより圧倒的にコストがかかります。Xcode CloudはApple公式サービスだけあって、App Storeとの連携がそのまま動きます。 また、このアプリのテストはUnitTestレベルまでで、ほとんどのテストコードは shared モジュールに閉じています。CircleCIの既存のテストジョブで共通ロジックのテストはカバーできているため、iOS側のCIをCircleCIに統合する必然性がありませんでした。 Xcode CloudはApple公式のCI/CDサービスで、macOS環境が整っている点がメリットです。ただし、KMPの shared フレームワークをビルドするには JDK 17が必要 で、Xcode Cloudの環境にはデフォルトでJDKが含まれていません。 この問題を iosApp/ci_scripts/ci_post_clone.sh で解決しました。Xcode Cloudでは .xcodeproj と同階層の ci_scripts/ にスクリプトを置くことでビルドフックとして自動実行されます。クローン後に実行されるこのスクリプトで、AdoptiumのAPIからJDK 17を取得してセットアップします。 #!/bin/sh # Xcode Cloud: KMP shared framework のビルドに必要な JDK 17 をセットアップする JDK_INSTALL_DIR = " /Volumes/workspace/DerivedData/JDK " JDK_HOME = " $JDK_INSTALL_DIR /Home " # Apple Silicon / Intel を自動判定して対応アーキテクチャのJDKを取得する RAW_ARCH = $( uname -m ) ARCH = $( [ " $RAW_ARCH " = "arm64" ] && echo " aarch64 " || echo " x64 " ) mkdir -p " $JDK_INSTALL_DIR " curl -fsSL --retry 3 \ " https://api.adoptium.net/v3/binary/latest/17/ga/mac/ ${ARCH} /jdk/hotspot/normal/eclipse " \ -o /tmp/jdk.tar.gz # 展開前にトップディレクトリ名を取得してパスを決定的にする TOP_DIR = $( tar -tzf /tmp/jdk.tar.gz | head -1 | cut -d ' / ' -f1 ) tar -xzf /tmp/jdk.tar.gz -C " $JDK_INSTALL_DIR " rm /tmp/jdk.tar.gz # シンボリックリンクでパスを固定する ln -sfn " $JDK_INSTALL_DIR / $TOP_DIR /Contents/Home " " $JDK_HOME " Xcode CloudのワークフローにはJAVA_HOME環境変数として /Volumes/workspace/DerivedData/JDK/Home を設定し、Gradleが参照できるようにしています。Apple Silicon / Intelを uname -m で自動判定することで、Xcodeのインフラが変わっても動き続けます。 まとめ 3年前の設計判断が、今回の意思決定を楽にしました。 KMPで共通化したロジックはそのまま動いた 。認証・グループフェッチ・ViewModel・クッキー管理、すべて再実装ゼロ。 SwiftUIのUI層を追加するだけ でiPad対応が完了した。 SKIEでSwift連携が劇的に改善 した。Kotlin側にiOS専用のコードを書かずに済む。 Xcode CloudでiOSのCI/CDも整備 した。JDKをスクリプトで用意するひと工夫が必要だったが、以降は自動化できている。 「将来iOS対応できるようにKMMで作ります」というAndroidエンジニアの判断が、数年後に「動いている資産を捨てずに最大のリターンを得る」という意思決定の根拠になりました。技術選定は当時の最適解を選ぶだけでなく、将来の選択肢を広げるという意味でも重要です。 「体制が整ったから0から作り直す」という選択肢も魅力的に映ることがありますが、動いている資産を捨てるコストと得られる価値を冷静に天秤にかけた結果、今回はKMPの継続利用がベストだと判断しました。同じような場面で迷っている方の参考になれば嬉しいです。 TUNAG 受付アプリは Google Play 、 App Store で公開中です。 関連記事: 2022年リリース時のテックブログ
2026 年 6 月 8 日週、 AWS IoT Device SDK for Swift が一般公開されました。 Swift Server Workgroup (SSWG) のメンバーである私は、これに注目しています。この SDK は、macOS、iOS、tvOS、Linux の Swift 開発者に本番環境で利用できる MQTT 5 接続、デバイスシャドウ、ジョブ、フリートプロビジョニングを提供します。 皆さんなら、この SDK を使用して何を構築しますか? サーバー上の Swift はここ数年で成熟し、現在では IoT デバイスにも適用されています。これは、Swift をエッジで実行するという幅広いトレンドにつながっています。例えば、 WendyOS は物理 AI 向けのオープンソースのオペレーティングシステムで、NVIDIA Jetson や Raspberry Pi ハードウェアにアプリケーションをデプロイするためのファーストクラスの Swift サポートを提供しています。サーバーサイドの Swift、IoT、エッジコンピューティング間で、数年前にはほとんどの人が驚いたであろう複数の場所にこの言語が登場しています。 それでは、2026 年 6 月 8 日週の AWS ニュースを見ていきましょう。 見出し Amazon RDS for SQL Server が Bring Your Own Media をサポート – オンプレミス環境から SQL Server アプリケーションを移行するお客様は、Amazon RDS 上の Microsoft の License Mobility プログラムを通じて、Software Assurance を含む既存の Microsoft SQL Server ライセンスを再利用できるようになりました。BYOM は AWS License Manager と統合されており、ライセンスの使用状況とコンプライアンスを追跡できます。 さらに表示 Amazon Cognito がマルチリージョンレプリケーションのサポートを開始 – 認証情報、ユーザープール設定、フェデレーション設定などのユーザーとマシンの ID データを、スタンバイリージョンのセカンダリユーザープールにほぼリアルタイムで同期できるようになりました。プライマリリージョンで障害が発生しても、サインインしたユーザーは再認証することなく引き続きアプリケーションにアクセスでき、登録ユーザーは既存の認証情報でサインインできます。マルチリージョンレプリケーションは、16 のリージョンにわたる Essentials または Plus 機能階層のユーザープールのアドオンとして利用できます。  さらに表示 OpenAI の GPT-5.5、GPT-5.4、Codex の Amazon Bedrock での一般提供を開始 – Amazon Bedrock の本番ワークロードで GPT-5.5 と GPT-5.4 を使用できるようになりました。また、AWS 全体で既に使用しているのと同じセキュリティ、ガバナンス、運用制御を使用して、AI を活用したソフトウェア開発でコーデックスを構築できます。GPT-5.5 は OpenAI の最も高性能なモデルで、エージェンティックコーディング、データ分析、多段階の自律タスクに長けています。Codex は、Codex App、Codex CLI、および Visual Studio Code、JetBrains、Xcode との IDE 統合を通じて利用できます。価格は OpenAI のファーストパーティー料金に一致し、使用回数は既存の AWS コミットメントに反映されます。  さらに表示 2026 年 6 月 1 日週のリリース 2026 年 6 月 1 日週のリリースのうち、私が注目したリリースをいくつかご紹介します。 Amazon Bedrock に OpenAI および Anthropic互換 API 用の CloudWatch メトリクスを追加 – CloudWatch メトリクスを使用して、bedrock-mantle エンドポイントへの推論トラフィックをモニタリングできるようになりました。これには、推論カウント、入出力トークンの合計、アカウント、プロジェクト、モデル、プロジェクトおよびモデルの細分性におけるクライアントエラー数が含まれます。 Amazon Bedrock が OpenAI と Anthropic 互換の API 向けに最適化された再設計されたコンソールを発表 – モデルカタログ、並列比較、プロジェクトベースの編成、コードスニペットがあらかじめ入力されたプロジェクト対応ドキュメントなどを使用して、コンソールワークフローを一新しました。 Amazon Bedrock AgentCore Identity が AWS Secrets Manager による独自のシークレットの持ち込みのサポートを開始 – お客様は AgentCore ID 認証情報プロバイダの既存の AWS Secrets Manager シークレット ARN を参照できるようになり、カスタム CMK、タグ付け戦略、自動ローテーションを含むシークレットガバナンスの完全な所有権が実現しました。 AWS Step Functions に AgentCore を利用したエージェント推論ステップを追加 – Amazon Bedrock AgentCore のマネージドハーネスとの統合により、Step Functions ワークフローに AI エージェント推論ステップを追加できるようになりました。複数のエージェントを並行して実行したり、順番に実行したり、人間による承認を追加したり、すべてのエージェントの決定を追跡したりできます。 Amazon EKS と Amazon EKS Distro が Kubernetes バージョン 1.36 のサポートを開始 – Kubernetes 1.36 では、ユーザー名前空間が GA に昇格され、変更されたアドミッションポリシー、インプレースポッドレベルのリソースの垂直スケーリング、リソースヘルスステータスレポートが導入されました。EKS が利用可能なすべての地域で利用できます。 Amazon ECS マネージドインスタンスが AWS Trainium と AWS Inferentia のサポートを開始 – Inferentia2、Trainium1、Trainium2 の各インスタンスタイプで ECS マネージドインスタンスのキャパシティプロバイダーを作成し、Amazon ECS にすべてのアクセラレーターリソースをワークロードに自動的に割り当てることができるようになりました。 Amazon Quick が MCP 接続の VPC 接続のサポートを開始 – 企業のお客様は、プライベートにホストされているモデルコンテキストプロトコル (MCP) サーバーを VPC 経由で Amazon Quick に接続できるようになりました。これにより、インターネットに公開されることなく、独自のアプリケーションや内部ツールに安全にアクセスできるようになります。 AWS Cost and Usage Report 2.0 が Athena と Redshift の統合のサポートを開始 – CUR 2.0 のエクスポートは、サポート対象のインフラストラクチャテンプレート、テーブル定義、データロード手順とともに、選択したクエリエンジンに最適な形式で自動的に配信されます。 Amazon Location Service が公共交通機関とインターモーダルルーティングを発表 – Routes API では、交通手段とインターモーダルという 2 つの新しい移動モードがサポートされるようになりました。これにより、13 の地域にわたって、公共交通機関と徒歩、車、タクシー、レンタルのセグメントを組み合わせて旅行を計画できます。 AWS のお知らせに関する詳しいリストについては、「 AWS の最新情報 」ページをご覧ください。 今後の AWS イベント AWS の詳細について学び、今後予定されている AWS 主催の対面イベントやバーチャルイベント 、 スタートアップイベント 、 開発者向けイベント 、 AWS Summits や AWS Community Days を閲覧して、ご参加ください。 AWS Builder Center に参加して、ビルダーとつながり、ソリューションを共有し、開発をサポートするコンテンツにアクセスしましょう。 2026 年 6 月 8 日週のニュースは以上です。2026 年 6 月 15 日週に再びアクセスして、新たな Weekly Roundup をぜひお読みください! – seb 原文は こちら です。
はじめに こんにちは、スタメンでプロダクトエンジニアをしている おしん ( @38Punkd ) です。 5月9日、ウインクあいちで開催された フロントエンドカンファレンス名古屋 2026 にLT枠で登壇させていただきました。 その発表内容をブログ形式でご紹介できればと思います。 WebViewの文字サイズ、固定されていませんか? この問題は、ネイティブとWebの境界に起因する「実装責務の曖昧さ」から生じがちです。 モバイルアプリ開発において、ユーザーのアクセシビリティへの配慮は不可欠です。特に、スマートフォンの設定で文字サイズを大きくしているユーザーにとって、アプリ内のテキストがその設定に追従するかどうかは、使いやすさに直結します。 私たちのアプリ TUNAG は、モバイルアプリではアプリネイティブとWebView(モバイルアプリでHTMLを表示できる機能)を併用しています。ネイティブUIはOSの文字サイズ設定に追従する一方で、アプリ内のWebViewだけが標準サイズのままでした。文字のリサイズが画面ごとに適用されたりされなかったりすると、ユーザーにとって読みにくいコンテンツになってしまうため、WebViewへの適用を本格的に開始しました。 本記事では、iOSとAndroidそれぞれのWebViewにおいて、OSの文字サイズ設定を適切に反映させるための具体的な実装方法と、その際の注意点について解説します。 文字サイズ設定は「特別対応」ではない 文字サイズ調整は、多くのユーザーが日常的に利用する標準機能です。ある調査によると、 モバイルユーザーの約33%が文字サイズ調整を有効化している というデータもあります *1 。iOSのDynamic Type *2 やAndroid 14以降の200%フォントスケーリング *3 など、OSもこの機能を重視しています。 結論としては、 ネイティブアプリ側のごくわずかな修正と、Web開発時のただ1点のポイントをおさえるだけ で、WebViewでもiOS, Android共に文字サイズ設定が反映されるようになります。 対応工数に対して UX改善のインパクトが大きい領域 であると言えそうです。 iOS WebView:JavaScript注入による動的なフォントサイズ反映 iOSのWebViewでDynamic Typeを反映させる最も柔軟で推奨される方法は、ネイティブ側でOSのフォントサイズを取得し、JavaScriptを介してWebViewに注入するアプローチです。 ① Swift:Dynamic Type 変更を検知してフォントサイズを JS で注入 NotificationCenter. default .addObserver( self , selector : #selector(dynamicTypeDidChange), name : UIContentSizeCategory.didChangeNotification , object : nil ) @objc private func dynamicTypeDidChange () { applyFontSize() } func webView (_ webView : WKWebView , didFinish navigation : WKNavigation! ) { applyFontSize() // ページロード完了時にも適用 } private func applyFontSize () { let size = UIFont.preferredFont(forTextStyle : .body).pointSize webView?.evaluateJavaScript( "document.documentElement.style.fontSize = ' \( size ) px'" , completionHandler : nil ) } UIContentSizeCategory.didChangeNotification を受信したら、Swift側でフォントサイズを取得し、 evaluateJavaScript でWebViewの :root 要素の font-size プロパティに直接書き込みます。この方式は リロードが不要で即時反映され、独自のスケールにも柔軟に対応できる 点が利点です。 ② CSS: font-size は rem 単位で指定 Swift側で :root の font-size を動的に書き換えるため、Webコンテンツ側では rem 単位でフォントサイズを指定することで、すべてのテキスト要素が連動してスケールするようになります。 : root { font-size : 17px ; /* フォールバック。Swift が evaluateJavaScript で上書きする */ } .title { font-size : 1.143rem ; } /* ✅ :root 基準でスケールする */ .description { font-size : 0.857rem ; } /* ✅ :root 基準でスケールする */ 参考:よりシンプルな代替案( -apple-system-body ) CSSに :root, body { font: -apple-system-body; } を指定し、Dynamic Typeの変更検知時に webView.reload() を呼ぶ方法もあります。この方法は手軽ですが、 WebViewのリロードが発生する 点と、 Apple提供のスケールに固定される 点がデメリットです。 Android WebView:「対応できている風」に注意 AndroidのWebViewでは、iOSとは異なる挙動と注意点があります。特に「文字サイズ設定に対応できているように見えるが、実は問題がある」ケースに注意が必要です。 見た目 実際に起きていること 文字が大きく見える Activity再生成によりWebViewが作り直される 全体が拡大される 文字だけでなく画像・余白もズームされる場合がある 設定変更後に戻る スクロール位置・入力中状態が失われる可能性がある android:configChanges に fontScale が含まれていない場合、OSのフォントスケール変更時にActivityが再生成され、WebView全体がズームされます。これは文字以外の要素も拡大し、レイアウト崩れや状態喪失の原因となります。 文字だけを適切に反映できているか を見極める必要があります。 AndroidはOS値をJSで橋渡しする Androidで文字サイズ設定をWebViewに適切に反映させるには、ネイティブ側でOSのフォントスケール値を取得し、JavaScriptを介してWebViewに伝えるアプローチが有効です。 ① AndroidManifest: fontScale を configChanges に追加 <activity android : configChanges = "fontScale|uiMode|density" ... /> AndroidManifest.xml の <activity> タグに android:configChanges="fontScale" を追加し、フォントスケール変更時のActivity再生成を防ぎます。 ② Kotlin:フォントスケール変化を検知して WebSettings.setTextZoom(int) で反映 // 起動時の初期化 applyFontScale(resources.configuration.fontScale) // フォントスケール変化を検知(iOS の didChangeNotification に相当) override fun onConfigurationChanged(newConfig: Configuration) { super .onConfigurationChanged(newConfig) applyFontScale(newConfig.fontScale) } private fun applyFontScale(fontScale: Float ) { // fontScale 1.0 → 100(標準), 2.0 → 200(200%) // px・em 問わず WebView 内のテキスト全体をスケールする webView.settings.textZoom = (fontScale * 100 ).roundToInt() } onConfigurationChanged で newConfig.fontScale を取得し、 WebSettings.setTextZoom(int) でWebViewのテキストズームレベルを設定します。 ③ CSS:テキストは単位を問わずスケールされる WebSettings.setTextZoom(int) はレンダリングエンジンレベルで適用されるため、HTML/JS の変更は不要です。 px 、 em 、 rem のいずれの単位で指定されたテキストもスケールされます。ただし、非テキスト要素はスケールされないため、Web側で柔軟な設計が必要です。 共通の考慮事項:拡大しても壊れないレイアウトにする OSの文字サイズ設定をWebViewに反映させるだけでなく、Webコンテンツ側で、文字が拡大されてもレイアウトが崩れないような柔軟な設計が求められます。 避けたい実装 推奨する実装 固定高さ 内容量に応じて伸びる高さ 1行前提 折り返し・複数行を許容 アイコンと文字の密結合 gap・flex-wrap・min-widthで逃がす 文字サイズ対応の本質は、値の反映だけでなく「拡大を許容するUI」を構築すること にあります。 まとめ 本記事では、iOSおよびAndroidのWebViewにおいて、OSの文字サイズ設定を適切に反映させるための具体的な実装方法と、その際の注意点について解説しました。 iOSでは、Swift側でDynamic Typeのフォントサイズを取得し、JavaScriptでWebViewの :root 要素の font-size を動的に書き換えるアプローチが最も柔軟です。これにより、リロードなしで即時反映が可能となります。Androidでは、 AndroidManifest で fontScale の変更を検知し、 WebSettings.setTextZoom(int) でWebView全体のテキストズームレベルを設定します。 そして、iOSとAndroidの両方でWebView内のテキストをOSの文字サイズ設定に追従させるための共通の鍵となるのが、 CSSでの rem 単位の活用 です。 デフォルトの文字サイズ 拡大した文字サイズ OSではJavaScriptによる :root 操作と連動させるために rem 指定が 必須 となります。一方で、Androidの setTextZoom は単位を問わず追従してくれます。つまり、Web側の実装をiOSに合わせて rem に統一しておけば、Android側でも一切の不都合なく自然に拡大縮小が行われ、両OSに矛盾なく対応できるのです。 スマートフォンのOS設定を尊重し、より多くのユーザーに読みやすいWebコンテンツを届けることは、ユーザーの満足度向上に繋がりやすく重要です。本記事が参考になりましたら幸いです。 サンプルリポジトリ 本記事で紹介した実装の詳細は、以下のサンプルリポジトリでご確認いただけます。 iOSアプリ : GitHub - iOS WebView Dynamic Type Sample Androidアプリ : GitHub - Android WebView Font Scale Sample herp.careers *1 : Ian Savchenko, “Designing for Accessibility: How Text Resizing Works in Different Web Browsers,” PayPal Technology Blog. https://medium.com/paypal-tech/designing-for-accessibility-how-text-resizing-works-in-different-web-browsers-bed9e424e071 *2 : Apple Developer Documentation, “Scaling fonts automatically.” https://developer.apple.com/documentation/uikit/scaling-fonts-automatically *3 : Android Developers, “Features and APIs Overview — Non-linear font scaling to 200%.” https://developer.android.com/about/versions/14/features

動画

該当するコンテンツが見つかりませんでした

書籍