diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
index 3510a6c..78fad7d 100644
--- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
+++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
@@ -11,14 +11,19 @@ final class FormbricksViewModel: ObservableObject {
/// reach the open survey through `evaluateJavaScript` instead.
let initialAppearance: String
- /// Where `.system` reads the app's theme: the host key window (no overlay) or the presenting
- /// view controller (overlay). Not the WebView, whose own window ignores the host's override.
+ /// Where the survey reads the app's traits — its theme for `.system`, and its Dynamic Type size:
+ /// the host key window (no overlay) or the presenting view controller (overlay). Not the WebView,
+ /// whose own window ignores the host's overrides.
private(set) weak var traitSource: UITraitEnvironment?
init(workspaceResponse: WorkspaceResponse, surveyId: String, traitSource: UITraitEnvironment? = nil) {
self.surveyId = surveyId
self.traitSource = traitSource
self.initialAppearance = AppearanceState.resolved(traits: traitSource?.traitCollection)
+ // Read once, at presentation; a Dynamic Type change while the survey is open applies to the
+ // next one. Without a trait source the page keeps the default size (see `SurveyTextScale`).
+ let textSizeAdjust = SurveyTextScale.textSizeAdjust(
+ for: traitSource?.traitCollection.preferredContentSizeCategory ?? .unspecified)
if let webviewDataJson = WebViewData(workspaceResponse: workspaceResponse, surveyId: surveyId, appearance: initialAppearance).getJsonString(),
let surveyScriptUrl = FormbricksWorkspace.surveyScriptUrlString {
// Base64-encode the payload before injecting it into the HTML. Base64 output is
@@ -28,6 +33,7 @@ final class FormbricksViewModel: ObservableObject {
let webviewDataBase64 = Data(webviewDataJson.utf8).base64EncodedString()
htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataBase64)
.replacingOccurrences(of: "{{SURVEY_SCRIPT_URL}}", with: surveyScriptUrl)
+ .replacingOccurrences(of: "{{TEXT_SIZE_ADJUST}}", with: textSizeAdjust)
}
}
}
@@ -42,6 +48,10 @@ private extension FormbricksViewModel {
Formbricks WebView Survey
+
diff --git a/Sources/FormbricksSDK/WebView/SurveyTextScale.swift b/Sources/FormbricksSDK/WebView/SurveyTextScale.swift
new file mode 100644
index 0000000..f7ee5f3
--- /dev/null
+++ b/Sources/FormbricksSDK/WebView/SurveyTextScale.swift
@@ -0,0 +1,45 @@
+import UIKit
+
+/// Makes the survey's text follow the user's Dynamic Type setting.
+///
+/// WKWebView never applies Dynamic Type to web content, and the survey renderer sizes its text in CSS
+/// pixels, so without this every respondent sees the default size whatever they chose in Settings.
+/// Android's WebView already scales page text by the system font scale; this is the iOS counterpart.
+///
+/// The factor reaches the page as `-webkit-text-size-adjust` on the root element (see
+/// `FormbricksViewModel.htmlTemplate`). It is inherited, and the renderer never sets it on the survey.
+/// WebKit multiplies it into every computed font size, and `em` lengths resolve against that adjusted
+/// size. The renderer compiles its spacing to `em`, so padding and gaps grow with the text while
+/// pixel-sized parts (borders, the theme's fixed paddings) keep their size. Android's text zoom grows
+/// the text alone.
+///
+/// WebKit honours the percentage only in mobile content mode, so the survey WebView asks for it (see
+/// `SurveyWebView.makeUIView`); larger iPads would otherwise load it in desktop-class mode.
+enum SurveyTextScale {
+ /// Body text at the default ("Large") size: the reference the factor is measured against.
+ private static let defaultBodyPointSize: CGFloat = 17
+
+ /// The largest factor applied — Android's largest font scale, and WCAG 1.4.4's 200%. Choice, rating,
+ /// NPS and text questions still fit a phone-width card at 2×. iOS's accessibility sizes go up to
+ /// about 3×, where the NPS labels clip and the scroll button covers the question, so those sizes
+ /// get the same 2× Android users get.
+ static let maximumFactor: CGFloat = 2
+
+ /// How much larger than default the user's setting makes body text, capped at `maximumFactor`.
+ /// Sizes below the default shrink the text, as they do in native apps.
+ static func factor(for category: UIContentSizeCategory) -> CGFloat {
+ // What a trait collection reports when it carries no Dynamic Type setting at all.
+ guard category != .unspecified else { return 1 }
+ let traits = UITraitCollection(preferredContentSizeCategory: category)
+ let scaled = UIFontMetrics(forTextStyle: .body)
+ .scaledValue(for: defaultBodyPointSize, compatibleWith: traits)
+ return min(scaled / defaultBodyPointSize, maximumFactor)
+ }
+
+ /// The `-webkit-text-size-adjust` value for `category`: a whole percentage, or `auto` — the
+ /// property's initial value — at the default size, so the page renders exactly as it did before.
+ static func textSizeAdjust(for category: UIContentSizeCategory) -> String {
+ let percent = Int((factor(for: category) * 100).rounded())
+ return percent == 100 ? "auto" : "\(percent)%"
+ }
+}
diff --git a/Sources/FormbricksSDK/WebView/SurveyWebView.swift b/Sources/FormbricksSDK/WebView/SurveyWebView.swift
index 8e2146e..6898a18 100644
--- a/Sources/FormbricksSDK/WebView/SurveyWebView.swift
+++ b/Sources/FormbricksSDK/WebView/SurveyWebView.swift
@@ -27,6 +27,10 @@ struct SurveyWebView: UIViewRepresentable {
let webViewConfig = WKWebViewConfiguration()
webViewConfig.userContentController = userContentController
+ // Full-size iPads would otherwise load the survey in desktop-class mode, where WebKit ignores
+ // the page's `-webkit-text-size-adjust` and the text stops following Dynamic Type
+ // (see `SurveyTextScale`). iPhones and the iPad mini are in mobile mode already.
+ webViewConfig.defaultWebpagePreferences.preferredContentMode = .mobile
let webView = SurveyWKWebView(frame: .zero, configuration: webViewConfig)
webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = true
diff --git a/Tests/FormbricksSDKTests/SurveyTextScaleTests.swift b/Tests/FormbricksSDKTests/SurveyTextScaleTests.swift
new file mode 100644
index 0000000..59ddf2e
--- /dev/null
+++ b/Tests/FormbricksSDKTests/SurveyTextScaleTests.swift
@@ -0,0 +1,94 @@
+import UIKit
+import WebKit
+import XCTest
+@testable import FormbricksSDK
+
+/// WKWebView ignores Dynamic Type, so the SDK hands the user's setting to the survey page itself
+/// (ENG-3427). These pin what a respondent can see: the default size renders exactly as before, the
+/// text follows the setting in both directions, and the accessibility sizes stop at the cap the
+/// survey layout is known to fit.
+final class SurveyTextScaleTests: XCTestCase {
+
+ /// Every Dynamic Type setting, smallest to largest.
+ private let categories: [UIContentSizeCategory] = [
+ .extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge, .extraExtraExtraLarge,
+ .accessibilityMedium, .accessibilityLarge, .accessibilityExtraLarge,
+ .accessibilityExtraExtraLarge, .accessibilityExtraExtraExtraLarge,
+ ]
+
+ func testTheDefaultSizeLeavesThePageUntouched() {
+ XCTAssertEqual(SurveyTextScale.factor(for: .large), 1)
+ XCTAssertEqual(SurveyTextScale.textSizeAdjust(for: .large), "auto")
+ XCTAssertEqual(SurveyTextScale.textSizeAdjust(for: .unspecified), "auto",
+ "A trait collection with no setting must read as the default size")
+ }
+
+ func testTheTextFollowsTheSettingInBothDirections() {
+ let factors = categories.map(SurveyTextScale.factor(for:))
+ XCTAssertEqual(factors, factors.sorted(), "A larger setting must never make the text smaller")
+ XCTAssertLessThan(SurveyTextScale.factor(for: .extraSmall), 1)
+ let largestStandardSize = SurveyTextScale.factor(for: .extraExtraExtraLarge)
+ XCTAssertTrue(largestStandardSize > 1 && largestStandardSize < SurveyTextScale.maximumFactor,
+ "The largest non-accessibility size must grow the text without reaching the cap")
+ }
+
+ func testTheAccessibilitySizesStopAtTheCap() {
+ XCTAssertTrue(categories.allSatisfy { SurveyTextScale.factor(for: $0) <= SurveyTextScale.maximumFactor })
+ XCTAssertEqual(SurveyTextScale.textSizeAdjust(for: .accessibilityExtraExtraExtraLarge), "200%")
+ }
+
+ /// The setting has to reach the page's root element, or WebKit never applies it. It comes from the
+ /// presenting window's traits, so a host app that overrides Dynamic Type there sizes the survey too.
+ func testTheSurveyPageCarriesThePresentersSetting() throws {
+ guard #available(iOS 17.0, *) else { throw XCTSkip("traitOverrides needs iOS 17") }
+ let workspace = try setUpWorkspace()
+
+ func html(presentedFrom traitSource: UITraitEnvironment?) throws -> String {
+ try XCTUnwrap(FormbricksViewModel(
+ workspaceResponse: workspace, surveyId: surveyId, traitSource: traitSource
+ ).htmlString)
+ }
+ // Both directions, so the assertions hold whatever the simulator's own setting is.
+ let largest = UIWindow(frame: .zero)
+ largest.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
+ let smallest = UIWindow(frame: .zero)
+ smallest.traitOverrides.preferredContentSizeCategory = .extraSmall
+
+ XCTAssertTrue(try html(presentedFrom: largest).contains("-webkit-text-size-adjust: 200%"))
+ XCTAssertTrue(try html(presentedFrom: smallest).contains(
+ "-webkit-text-size-adjust: \(SurveyTextScale.textSizeAdjust(for: .extraSmall))"))
+ XCTAssertTrue(try html(presentedFrom: nil).contains("-webkit-text-size-adjust: auto"),
+ "Without a presenter the page must not depend on the device's setting")
+ }
+
+ /// WebKit ignores the percentage in desktop-class content mode, which full-size iPads would
+ /// otherwise load the survey in.
+ func testTheSurveyWebViewAsksForMobileContent() throws {
+ let workspace = try setUpWorkspace()
+ let window = PassthroughWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
+ let manager = PresentSurveyManager()
+ manager.installPassthroughWindow(window, workspaceResponse: workspace, id: surveyId)
+ addTeardownBlock { manager.dismissView() }
+ window.layoutIfNeeded()
+
+ let webView = try XCTUnwrap(firstWebView(in: window))
+ XCTAssertEqual(webView.configuration.defaultWebpagePreferences.preferredContentMode, .mobile)
+ }
+
+ private let surveyId = "cm6ovw6j7000gsf0kduf4oo4i"
+
+ /// Sets the SDK up for building a survey page, and tears it down after the test.
+ private func setUpWorkspace() throws -> WorkspaceResponse {
+ Formbricks.cleanup()
+ addTeardownBlock { Formbricks.cleanup() }
+ Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "https://example.com", workspaceId: "workspaceId")
+ .service(MockFormbricksService())
+ .build())
+ let fixtureUrl = try XCTUnwrap(Bundle.module.url(forResource: "Environment", withExtension: "json"))
+ return try JSONDecoder.iso8601Full.decode(WorkspaceResponse.self, from: Data(contentsOf: fixtureUrl))
+ }
+
+ private func firstWebView(in view: UIView) -> WKWebView? {
+ (view as? WKWebView) ?? view.subviews.lazy.compactMap(firstWebView(in:)).first
+ }
+}