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 + } +}