Skip to content

Commit d170e0d

Browse files
authored
fix(feedback): user feedback widget in SwiftUI (#5223)
1 parent 7908e84 commit d170e0d

File tree

5 files changed

+66
-4
lines changed

5 files changed

+66
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- Set handled to false for fatal app hangs (#5514)
8+
- User feedback widget automatically injects into SwiftUI apps correctly (#5223)
89
- Fix crash when SentryFileManger is nil (#5535)
910

1011
### Improvements
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import XCTest
2+
3+
final class FeedbackUITests: XCTestCase {
4+
func testWidgetDisplayInSwiftUIApp() throws {
5+
let app = XCUIApplication()
6+
app.launchArguments.append(contentsOf: [
7+
"--io.sentry.feedback.all-defaults"
8+
])
9+
app.launch()
10+
11+
// ensure the widget button is displayed
12+
XCTAssert(app.otherElements["Report a Bug"].exists)
13+
14+
// ensure tapping the widget displays the form
15+
app.otherElements["io.sentry.feedback.widget"].tap()
16+
XCTAssert(app.staticTexts["Report a Bug"].exists)
17+
18+
// ensure cancelling the flow hides the form and redisplays the widget
19+
app.buttons["io.sentry.feedback.form.cancel"].tap()
20+
XCTAssert(app.otherElements["Report a Bug"].exists)
21+
}
22+
}

Samples/iOS-SwiftUI/iOS-SwiftUI/SwiftUIApp.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Sentry
23
import SentrySampleShared
34
import SwiftUI
45

@@ -34,6 +35,7 @@ class MySceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
3435
func sceneDidBecomeActive(_ scene: UIScene) {
3536
guard !initializedSentry else { return }
3637
SampleAppDebugMenu.shared.display()
38+
SentrySDK.feedback.showWidget()
3739
initializedSentry = true
3840
}
3941
}

Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ import UIKit
4242
} else if let widgetConfigBuilder = configuration.configureWidget {
4343
widgetConfigBuilder(configuration.widgetConfig)
4444
validate(configuration.widgetConfig)
45+
46+
/*
47+
* We cannot currently automatically inject a widget into a SwiftUI application, because at the recommended time to start the Sentry SDK (SwiftUIApp.init) there is nowhere to put a UIWindow overlay. SwiftUI apps must currently declare a UIApplicationDelegateAdaptor that returns a UISceneConfiguration, which we can then extract a connected UIScene from into which we can inject a UIWindow.
48+
*
49+
* At the time this integration is being installed, if there is no UIApplicationDelegate and no connected UIScene, it is very likely we are in a SwiftUI app, but it's possible we could instead be in a UIKit app that has some nonstandard launch procedure or doesn't call SentrySDK.start in a place we expect/recommend, in which case they will need to manually display the widget when they're ready by calling SentrySDK.feedback.showWidget.
50+
*/
51+
if UIApplication.shared.connectedScenes.isEmpty && UIApplication.shared.delegate == nil {
52+
return
53+
}
54+
4555
if configuration.widgetConfig.autoInject {
4656
widget = SentryUserFeedbackWidget(config: configuration, delegate: self)
4757
}
@@ -57,9 +67,9 @@ import UIKit
5767
@objc public func showWidget() {
5868
if widget == nil {
5969
widget = SentryUserFeedbackWidget(config: configuration, delegate: self)
60-
} else {
61-
widget?.rootVC.setWidget(visible: true, animated: configuration.animations)
6270
}
71+
72+
widget?.rootVC.setWidget(visible: true, animated: configuration.animations)
6373
}
6474

6575
@objc public func hideWidget() {

Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,53 @@ final class SentryUserFeedbackWidget {
1616
let button = SentryUserFeedbackWidgetButtonView(config: config, target: self, selector: #selector(showForm))
1717
return button
1818
}()
19+
1920
lazy var rootVC = RootViewController(config: config, button: button)
20-
private lazy var window = SentryUserFeedbackWidget.Window(config: config)
21+
22+
private var window: Window?
23+
2124
let config: SentryUserFeedbackConfiguration
2225
weak var delegate: (any SentryUserFeedbackWidgetDelegate)?
2326

2427
init(config: SentryUserFeedbackConfiguration, delegate: any SentryUserFeedbackWidgetDelegate) {
2528
self.config = config
2629
self.delegate = delegate
30+
31+
/*
32+
* We must have a UIScene in order to display an overlaying UIWindow in a SwiftUI app, which is currently how we display the widget. SentryUserFeedbackIntegrationDriver won't try to initialize this class if there are no connected UIScenes _and_ there is no UIApplicationDelegate at the time the integration is being installed.
33+
*
34+
* Both UIKit and SwiftUI apps can have connected UIScenes. Here's how we then try to tell the difference:
35+
* - If there is no connected UIScene but there is already a UIApplicationDelegate by the time this integration is being installed, then we are either in a UIKit app, or inside a SwiftUI app that for whatever reason delays the call to SentrySDK.start until there is a connected scene. In either case, we'll just grab the first connected UIScene and proceed.
36+
* - Otherwise, we're either in a SwiftUI app that _does_ call SentrySDK.start at the recommended time (SwiftUIApp.init), or there is a more complicated initialization procedure in a UIKit app that we can't automatically detect, and the app will need to call SentrySDK.feedback.showWidget() at the appropriate time, the same as how SwiftUI apps must currently do once they've connected a UIScene to their UIApplicationDelegateAdaptor.
37+
*/
38+
let window: Window
39+
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
40+
window = SentryUserFeedbackWidget.Window(config: config, windowScene: scene)
41+
} else {
42+
window = SentryUserFeedbackWidget.Window(config: config)
43+
}
2744
window.rootViewController = rootVC
2845
window.isHidden = false
46+
self.window = window
2947
}
3048

3149
@objc func showForm() {
3250
self.delegate?.showForm()
3351
}
3452

3553
final class Window: UIWindow {
54+
private func _init(config: SentryUserFeedbackConfiguration) {
55+
windowLevel = config.widgetConfig.windowLevel
56+
}
57+
58+
init(config: SentryUserFeedbackConfiguration, windowScene: UIWindowScene) {
59+
super.init(windowScene: windowScene)
60+
_init(config: config)
61+
}
62+
3663
init(config: SentryUserFeedbackConfiguration) {
3764
super.init(frame: UIScreen.main.bounds)
38-
windowLevel = config.widgetConfig.windowLevel
65+
_init(config: config)
3966
}
4067

4168
required init?(coder: NSCoder) {

0 commit comments

Comments
 (0)