Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 118 additions & 36 deletions Sources/FormbricksSDK/Manager/PresentSurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ final class PresentSurveyManager {
/// The view controller that will present the survey window.
private weak var viewController: UIViewController?

/// Held strongly: a window with no other owner is released and the survey disappears. Only used
/// for the no-overlay path, where the survey cannot be a presented view controller.
private var passthroughWindow: PassthroughWindow?

/// Walks the active presentation/navigation/tab hierarchy and returns the leaf VC.
/// Mirrors UIKit's own `presentedViewController` traversal so a single walker is enough.
private func topMostViewController(from viewController: UIViewController) -> UIViewController {
Expand All @@ -33,53 +37,131 @@ final class PresentSurveyManager {
return viewController
}

/// Present the webview as a page sheet over the current top-most view controller.
/// Shows the survey.
///
/// A `light` or `dark` overlay is presented as a modal over the top-most view controller: the
/// backdrop is meant to block the host app, so a full-screen barrier is correct. `overlay: none`
/// cannot work that way — the survey is a corner card over a page the user is still using, and a
/// presented view controller answers the hit test for the whole screen even when its content
/// declines the touch. That case gets its own window instead, which can decline a touch and let
/// UIKit carry on to the host app's window underneath.
func present(
workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)? = nil
workspaceResponse: WorkspaceResponse, id: String, overlay: SurveyOverlay = .none,
completion: ((Bool) -> Void)? = nil
) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

guard let window = UIApplication.safeKeyWindow,
let rootVC = window.rootViewController
else {
Formbricks.logger?.error(
"Survey present aborted: no key window or root view controller available.")
completion?(false)
return
if overlay == .none {
self.presentPassthrough(
workspaceResponse: workspaceResponse, id: id, completion: completion)
} else {
self.presentModal(
workspaceResponse: workspaceResponse, id: id, completion: completion)
}
}
}

let presenter = self.topMostViewController(from: rootVC)

// UIAlertController/action-sheets/popovers cannot host a modal sheet — presenting on them either
// crops the survey to the alert frame or is rejected by UIKit. Bail with a clear log so the host
// app can dismiss the alert before triggering the survey.
if presenter is UIAlertController {
Formbricks.logger?.warning(
"Survey present aborted: top-most VC is a UIAlertController. Dismiss it before triggering the survey."
)
completion?(false)
return
}
/// The no-overlay path: its own window, masked to the card's rect.
private func presentPassthrough(
workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)?
) {
guard let scene = UIApplication.safeKeyWindow?.windowScene else {
Formbricks.logger?.error(
"Survey present aborted: no window scene available.")
completion?(false)
return
}

let relay = SurveyLayoutRelay()
let view = FormbricksView(
viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id),
layoutRelay: relay)
let hosting = UIHostingController(rootView: view)
hosting.view.backgroundColor = .clear

let window = PassthroughWindow(windowScene: scene)
window.rootViewController = hosting
window.backgroundColor = .clear
window.isOpaque = false
// Above the app's own windows but below system UI like alerts and the status bar.
window.windowLevel = .normal + 1

let view = FormbricksView(
viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id))
let vc = UIHostingController(rootView: view)
vc.modalPresentationStyle = .overFullScreen
vc.modalTransitionStyle = .crossDissolve
vc.view.backgroundColor = .clear
self.viewController = vc
presenter.present(
vc, animated: true,
completion: {
completion?(true)
})
// The reported rect is relative to the WebView's viewport. The WebView fills the hosting
// controller, which fills this window, which covers the screen — and `ignoresSafeArea()`
// means no inset shifts the origin — so viewport points and window points are the same
// coordinate space and the rect needs no translation.
relay.onCardRectChange = { [weak window] rect in
window?.touchRegion = SurveyTouchRegion.forReported(rect: rect)
}

// Deliberately `isHidden`, not `makeKeyAndVisible()`. Taking key status would pull the
// caret out of whatever the host app has focused — a survey appearing mid-form must not do
// that, which is the entire complaint this fixes. UIKit promotes this window to key on its
// own once the user actually taps into the survey, so text input still works.
window.isHidden = false

self.passthroughWindow = window
self.viewController = hosting
completion?(true)
}

/// Dismiss the webview
/// The overlay path, unchanged: a modal over the top-most view controller.
private func presentModal(
workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)?
) {
guard let window = UIApplication.safeKeyWindow,
let rootVC = window.rootViewController
else {
Formbricks.logger?.error(
"Survey present aborted: no key window or root view controller available.")
completion?(false)
return
}

let presenter = self.topMostViewController(from: rootVC)

// UIAlertController/action-sheets/popovers cannot host a modal sheet — presenting on them either
// crops the survey to the alert frame or is rejected by UIKit. Bail with a clear log so the host
// app can dismiss the alert before triggering the survey.
if presenter is UIAlertController {
Formbricks.logger?.warning(
"Survey present aborted: top-most VC is a UIAlertController. Dismiss it before triggering the survey."
)
completion?(false)
return
}

let view = FormbricksView(
viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id))
let vc = UIHostingController(rootView: view)
vc.modalPresentationStyle = .overFullScreen
vc.modalTransitionStyle = .crossDissolve
vc.view.backgroundColor = .clear
self.viewController = vc
presenter.present(
vc, animated: true,
completion: {
completion?(true)
})
}

/// Dismiss the webview, whichever way it was shown.
func dismissView() {
viewController?.dismiss(animated: true)
let tearDown = { [weak self] in
guard let self = self else { return }
self.viewController?.dismiss(animated: true)
// Drop the window as well, or a no-overlay survey leaves an invisible one over the app.
// Clearing `rootViewController` first releases the hosting controller and the WebView.
self.passthroughWindow?.isHidden = true
self.passthroughWindow?.rootViewController = nil
self.passthroughWindow = nil
}

if Thread.isMainThread {
tearDown()
} else {
DispatchQueue.main.async(execute: tearDown)
}
}

deinit {
Expand Down
22 changes: 20 additions & 2 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ final class SurveyManager {
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
guard let self = self else { return }
if let workspaceResponse = self.workspaceResponse {
self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: survey.id) { success in
self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: survey.id, overlay: self.resolveOverlay(for: survey)) { success in
if !success {
self.isShowingSurvey = false
}
Expand Down Expand Up @@ -208,7 +208,9 @@ private extension SurveyManager {
/// The view controller is presented over the current context.
func showSurvey(withId id: String) {
if let workspaceResponse = workspaceResponse {
presentSurveyManager.present(workspaceResponse: workspaceResponse, id: id)
let survey = workspaceResponse.data.data.surveys?.first(where: { $0.id == id })
presentSurveyManager.present(
workspaceResponse: workspaceResponse, id: id, overlay: resolveOverlay(for: survey))
}
}

Expand Down Expand Up @@ -385,6 +387,22 @@ extension SurveyManager {
return entry.language.code
}

/// The overlay this survey will actually render with.
///
/// Deliberately the same precedence as the WebView payload builds
/// (`FormbricksViewModel.WebViewData`): survey override, then workspace setting, then `none`.
/// The two have to agree — the payload decides what the renderer paints, this decides whether
/// the native side blocks touches, and a mismatch means either a backdrop you can tap through
/// or a corner card that freezes the app.
///
/// Note `none` is the default, so most workspaces take the pass-through path.
func resolveOverlay(for survey: Survey?) -> SurveyOverlay {
if let surveyOverlay = survey?.projectOverwrites?.overlay {
return surveyOverlay
}
return workspaceResponse?.data.data.settings.overlay ?? .none
}

/// Filters the surveys based on the user's segments.
func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] {
return surveys.filter { survey in
Expand Down
29 changes: 29 additions & 0 deletions Sources/FormbricksSDK/Model/Javascript/CardRectMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CoreGraphics
import Foundation

/// Where the survey card is, as the shared renderer measures it.
///
/// A `WKWebView` hit-tests its whole rectangle and ignores the `pointer-events: none` the renderer
/// puts outside the card, so a full-screen WebView swallows every touch even when nothing is
/// painted. To let touches through, the native side has to mask them itself — and only the web
/// layer knows where the card is, because CSS decides that.
///
/// Values are CSS pixels relative to the viewport. The WebView's viewport is pinned at
/// `initial-scale=1.0, maximum-scale=1.0` (see `FormbricksViewModel.htmlTemplate`), so one CSS
/// pixel is one point and the rect needs no conversion.
struct CardRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double

var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}

/// `onCardRectChange` payload. `rect` is absent or null when no card is on screen — while it
/// animates out, or before the first paint.
struct CardRectMessage: Codable {
let rect: CardRect?
}
3 changes: 3 additions & 0 deletions Sources/FormbricksSDK/Model/Javascript/EventType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ enum EventType: String, Codable {
case onFinished = "onFinished"
case onOpenExternalURL = "onOpenExternalURL"
case onSurveyLibraryLoadError = "onSurveyLibraryLoadError"
/// The survey card moved or resized. Carries the card's rect so the native side can let
/// touches outside it reach the host app — see `CardRect`.
case onCardRectChange = "onCardRectChange"
}
7 changes: 5 additions & 2 deletions Sources/FormbricksSDK/WebView/FormbricksView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import SwiftUI
/// SwiftUI view for the Formbricks survey webview.
struct FormbricksView: View {
@ObservedObject var viewModel: FormbricksViewModel

/// Present only for a no-overlay survey, which needs the card's rect to decide which touches
/// reach the host app. Nil for an overlaid survey, and then nothing is measured or reported.
var layoutRelay: SurveyLayoutRelay?

var body: some View {
if let htmlString = viewModel.htmlString {
SurveyWebView(surveyId: viewModel.surveyId, htmlString: htmlString)
SurveyWebView(surveyId: viewModel.surveyId, htmlString: htmlString, layoutRelay: layoutRelay)
.ignoresSafeArea()
}
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ private extension FormbricksViewModel {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onOpenExternalURL", onOpenExternalURLParams: { url: url } }));
};

// Where the survey card is. The native side cannot work this out for itself — CSS
// decides it inside the page — and it needs it to pass touches outside the card
// through to the host app. `rect` is null when no card is on screen.
//
// Only the renderer shipped with Formbricks 6.0+ calls this. Against an older
// self-hosted server it simply never fires, and the native side keeps its previous
// behaviour of taking every touch.
function onCardRectChange(rect) {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onCardRectChange", rect: rect }));
};

let setResponseFinished = null;
function getSetIsResponseSendingFinished(callback) {
setResponseFinished = callback;
Expand All @@ -81,6 +92,7 @@ private extension FormbricksViewModel {
onFinished,
onClose,
onOpenExternalURL,
onCardRectChange,
};
window.formbricksSurveys.renderSurvey(surveyProps);
}
Expand Down
76 changes: 76 additions & 0 deletions Sources/FormbricksSDK/WebView/SurveyTouchRegion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import UIKit

/// Which touches over the survey's full-screen WebView belong to the survey, and which should fall
/// through to the host app underneath.
///
/// A `WKWebView` hit-tests its entire rectangle. The shared renderer already sets
/// `pointer-events: none` outside the card, but that is a *web* hit test — UIKit never sees it, so a
/// transparent full-screen WebView still swallows every touch and the host app appears frozen.
enum SurveyTouchRegion: Equatable {
/// Every touch belongs to the survey.
///
/// Correct for a `light` or `dark` overlay, where a visible backdrop is *supposed* to block the
/// host app. Also the starting state for a no-overlay survey, and it stays that way if the
/// renderer never reports a rect — an older self-hosted server serves a bundle without
/// `onCardRectChange`, and behaving exactly as the SDK always did is the safe answer there.
case everything

/// Only touches inside this rect belong to the survey; everything else reaches the host app.
/// The rect is in window points, which the reported CSS-pixel rect maps onto 1:1 (see `CardRect`).
case card(CGRect)

/// Nothing belongs to the survey, because no card is on screen.
///
/// The renderer reports this while the card animates out, and the card is hidden for a full
/// second before `onClose` arrives. Without this state the SDK leaves a dead patch over a host
/// app that looks perfectly usable.
case nothing

/// Whether a touch at `point` (in window coordinates) belongs to the survey.
func accepts(_ point: CGPoint) -> Bool {
switch self {
case .everything:
return true
case .card(let rect):
return rect.contains(point)
case .nothing:
return false
}
}

/// Maps a rect reported by the renderer onto a region. A missing rect means the card is not on
/// screen — deliberately *not* "block everything", which is the trap the Flutter SDK fell into
/// when its DOM probe stopped matching: a null rect there meant the survey itself became
/// untappable. Absence of a card and absence of the feature are different things, and only the
/// latter keeps `everything`.
static func forReported(rect: CardRect?) -> SurveyTouchRegion {
guard let rect = rect else { return .nothing }
return .card(rect.cgRect)
}
}

/// Hosts a no-overlay survey in its own window so touches outside the card reach the host app.
///
/// Returning `nil` from `hitTest` makes UIKit continue to the next window down, which is the host
/// app's. A presented view controller cannot do this reliably: UIKit's own transition container
/// answers the hit test for the whole screen even when the content declines it.
final class PassthroughWindow: UIWindow {
/// Starts at `.everything`, so the SDK blocks touches exactly as it used to until the renderer
/// tells us where the card is.
var touchRegion: SurveyTouchRegion = .everything

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard touchRegion.accepts(point) else { return nil }
return super.hitTest(point, with: event)
}
}

/// Carries card rects from the JS bridge to whoever is doing the hit testing.
///
/// A plain box rather than an `ObservableObject`: nothing here drives SwiftUI, and re-rendering the
/// WebView on every frame of the card's open animation is the opposite of what we want.
final class SurveyLayoutRelay {
/// Called on the main thread each time the renderer reports the card's rect, `nil` when no card
/// is on screen.
var onCardRectChange: ((CardRect?) -> Void)?
}
Loading
Loading