Repository navigation
fix: make in-app survey text follow Dynamic Type [ENG-3427] #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6585517
fix: make in-app survey text follow Dynamic Type [ENG-3427]
itsjavi dc81277
docs: describe how WebKit applies the text scale to the survey layout
itsjavi b8d1f34
test: assert the Dynamic Type scale's shape, not a guessed table value
itsjavi 9c767a1
ci: hold sonarqube-scan-action at v6.0.0 again
itsjavi 6a97990
Merge remote-tracking branch 'origin/main' into claude/sweet-tesla-wp…
itsjavi 47675f6
fix: read the survey's text size from the presenting window [ENG-3427]
itsjavi 67377c3
fix: load the survey in mobile content mode so iPads follow Dynamic Type
itsjavi 8197ef1
chore: retrigger license check
itsjavi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)%" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.