Skip to content
14 changes: 12 additions & 2 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand All @@ -42,6 +48,10 @@ private extension FormbricksViewModel {

<head>
<title>Formbricks WebView Survey</title>
<style>
/* Follows Dynamic Type; see SurveyTextScale. */
html { -webkit-text-size-adjust: {{TEXT_SIZE_ADJUST}}; }
</style>
</head>

<body style="overflow: hidden; height: 100vh; margin: 0; background: transparent;">
Comment thread
itsjavi marked this conversation as resolved.
Resolved
Expand Down
45 changes: 45 additions & 0 deletions Sources/FormbricksSDK/WebView/SurveyTextScale.swift
Original file line number Diff line number Diff line change
@@ -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)%"
}
}
4 changes: 4 additions & 0 deletions Sources/FormbricksSDK/WebView/SurveyWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions Tests/FormbricksSDKTests/SurveyTextScaleTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading