diff --git a/Examples/Examples/ContentView.swift b/Examples/Examples/ContentView.swift index adfc7b7..09aadbe 100644 --- a/Examples/Examples/ContentView.swift +++ b/Examples/Examples/ContentView.swift @@ -34,6 +34,13 @@ struct ContentView: View { Label("Reload", systemImage: "arrow.clockwise") .labelStyle(.iconOnly) } + Button { + proxy.clearAll() + proxy.load(request: viewState.request) + } label: { + Label("Clear", systemImage: "clear") + .labelStyle(.iconOnly) + } } .padding(.vertical, 8) diff --git a/Examples/ExamplesUITests/ExamplesUITests.swift b/Examples/ExamplesUITests/ExamplesUITests.swift index c405f32..98d73b3 100644 --- a/Examples/ExamplesUITests/ExamplesUITests.swift +++ b/Examples/ExamplesUITests/ExamplesUITests.swift @@ -51,6 +51,12 @@ final class ExamplesUITests: XCTestCase { XCTAssertTrue(app.webViews.staticTexts["0"].waitForExistence(timeout: 3)) } + XCTContext.runActivity(named: "WebViewProxy.clearAll()") { _ in + app.buttons["Clear"].tap() + XCTAssertFalse(app.buttons["Go Back"].isEnabled) + XCTAssertFalse(app.buttons["Go Forward"].isEnabled) + } + XCTContext.runActivity(named: "WebView.uiDelegate(_:)") { _ in app.webViews.buttons["Confirm"].tap() XCTAssertTrue(app.alerts.staticTexts["Confirm Test"].waitForExistence(timeout: 3)) diff --git a/Sources/WebUI/Remakeable.swift b/Sources/WebUI/Remakeable.swift new file mode 100644 index 0000000..fb4a2e7 --- /dev/null +++ b/Sources/WebUI/Remakeable.swift @@ -0,0 +1,48 @@ +#if os(iOS) +import UIKit +typealias OSView = UIView +#elseif os(macOS) +import AppKit +typealias OSView = NSView +#endif + +final class Remakeable: OSView { + private(set) var wrappedValue: Content { + didSet { + action?(wrappedValue) + } + } + private let content: () -> Content + private var action: ((Content) -> Void)? + + init(content: @escaping () -> Content) { + self.content = content + wrappedValue = content() + super.init(frame: .zero) + addSubview(wrappedValue) + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func remake() { + wrappedValue.removeFromSuperview() + wrappedValue = content() + addSubview(wrappedValue) + setConstraints() + } + + func onRemake(perform action: @escaping (Content) -> Void) { + self.action = action + } + + private func setConstraints() { + wrappedValue.translatesAutoresizingMaskIntoConstraints = false + wrappedValue.topAnchor.constraint(equalTo: topAnchor).isActive = true + wrappedValue.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + wrappedValue.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + wrappedValue.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + } +} diff --git a/Sources/WebUI/SetUpWebViewProxyAction.swift b/Sources/WebUI/SetUpWebViewProxyAction.swift index d279a0d..3d76591 100644 --- a/Sources/WebUI/SetUpWebViewProxyAction.swift +++ b/Sources/WebUI/SetUpWebViewProxyAction.swift @@ -2,10 +2,10 @@ import SwiftUI import WebKit struct SetUpWebViewProxyAction { - let action: @MainActor @Sendable (WKWebView) -> Void + let action: @MainActor @Sendable (Remakeable) -> Void @MainActor - func callAsFunction(_ webView: WKWebView) { + func callAsFunction(_ webView: Remakeable) { action(webView) } } @@ -20,4 +20,3 @@ extension EnvironmentValues { set { self[SetUpWebViewProxyActionKey.self] = newValue } } } - diff --git a/Sources/WebUI/WebView+Extension.swift b/Sources/WebUI/WebView+Extension.swift index 0221025..dc23a96 100644 --- a/Sources/WebUI/WebView+Extension.swift +++ b/Sources/WebUI/WebView+Extension.swift @@ -18,34 +18,36 @@ extension WebView: View { let parent: WebView @MainActor - private func makeEnhancedWKWebView() -> EnhancedWKWebView { - let webView = EnhancedWKWebView(frame: .zero, configuration: parent.configuration) + private func makeView() -> Remakeable { + let webView = Remakeable { + EnhancedWKWebView(frame: .zero, configuration: parent.configuration) + } setUpWebViewProxy(webView) - parent.applyModifiers(to: webView) - parent.loadInitialRequest(in: webView) + parent.applyModifiers(to: webView.wrappedValue) + parent.loadInitialRequest(in: webView.wrappedValue) return webView } @MainActor - private func updateEnhancedWKWebView(_ webView: EnhancedWKWebView) { - parent.applyModifiers(to: webView) + private func updateView(_ view: Remakeable) { + parent.applyModifiers(to: view.wrappedValue) } #if os(iOS) - func makeUIView(context: Context) -> EnhancedWKWebView { - makeEnhancedWKWebView() + func makeUIView(context: Context) -> Remakeable { + makeView() } - func updateUIView(_ webView: EnhancedWKWebView, context: Context) { - updateEnhancedWKWebView(webView) + func updateUIView(_ view: Remakeable, context: Context) { + updateView(view) } #elseif os(macOS) - func makeNSView(context: Context) -> EnhancedWKWebView { - makeEnhancedWKWebView() + func makeNSView(context: Context) -> Remakeable { + makeView() } - func updateNSView(_ webView: EnhancedWKWebView, context: Context) { - updateEnhancedWKWebView(webView) + func updateNSView(_ view: Remakeable, context: Context) { + updateView(view) } #endif } diff --git a/Sources/WebUI/WebViewProxy.swift b/Sources/WebUI/WebViewProxy.swift index 8d50668..71c1e4b 100644 --- a/Sources/WebUI/WebViewProxy.swift +++ b/Sources/WebUI/WebViewProxy.swift @@ -9,7 +9,7 @@ import WebKit @available(iOS 16.4, macOS 13.3, *) @MainActor public final class WebViewProxy: ObservableObject { - weak var webView: WKWebView? + private(set) weak var webView: Remakeable? /// The page title. @Published public private(set) var title: String? @@ -37,9 +37,18 @@ public final class WebViewProxy: ObservableObject { task?.cancel() } - func setUp(_ webView: WKWebView) { + func setUp(_ webView: Remakeable) { self.webView = webView + observe(webView.wrappedValue) + webView.onRemake { [weak self] in + guard let self else { return } + observe($0) + } + } + + private func observe(_ webView: WKWebView) { + task?.cancel() task = Task { await withTaskGroup(of: Void.self) { group in group.addTask { @MainActor in @@ -85,22 +94,22 @@ public final class WebViewProxy: ObservableObject { /// - Parameters: /// - request: The request specifying the URL to which to navigate. public func load(request: URLRequest) { - webView?.load(request) + webView?.wrappedValue.load(request) } /// Reloads the current webpage. public func reload() { - webView?.reload() + webView?.wrappedValue.reload() } /// Navigates to the back item in the back-forward list. public func goBack() { - webView?.goBack() + webView?.wrappedValue.goBack() } /// Navigates to the forward item in the back-forward list. public func goForward() { - webView?.goForward() + webView?.wrappedValue.goForward() } /// Evaluates the specified JavaScript string. @@ -130,7 +139,7 @@ public final class WebViewProxy: ObservableObject { public func evaluateJavaScript(_ javaScriptString: String) async throws -> Any? { guard let webView else { return nil } return try await withCheckedThrowingContinuation { continuation in - webView.evaluateJavaScript(javaScriptString) { result, error in + webView.wrappedValue.evaluateJavaScript(javaScriptString) { result, error in if let error { continuation.resume(throwing: error) } else { @@ -139,4 +148,11 @@ public final class WebViewProxy: ObservableObject { } } } + + /// Clears all properties managed by `WKWebView`. + /// + /// As a side effect, the WKWebView instance will be remade. + public func clearAll() { + webView?.remake() + } } diff --git a/Tests/WebUITests/Mock.swift b/Tests/WebUITests/Mock.swift index 76ee404..c7ba285 100644 --- a/Tests/WebUITests/Mock.swift +++ b/Tests/WebUITests/Mock.swift @@ -1,7 +1,7 @@ @testable import WebUI import WebKit -final class WKWebViewMock: EnhancedWKWebView { +final class EnhancedWKWebViewMock: EnhancedWKWebView { private(set) var loadedRequest: URLRequest? private(set) var reloadCalled = false private(set) var goBackCalled = false diff --git a/Tests/WebUITests/WebViewProxyTests.swift b/Tests/WebUITests/WebViewProxyTests.swift index 596fa91..6735e79 100644 --- a/Tests/WebUITests/WebViewProxyTests.swift +++ b/Tests/WebUITests/WebViewProxyTests.swift @@ -5,48 +5,74 @@ final class WebViewProxyTests: XCTestCase { @MainActor func test_load_the_specified_URLRequest() { let sut = WebViewProxy() - let webViewMock = WKWebViewMock() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } sut.setUp(webViewMock) let request = URLRequest(url: URL(string: "https://www.example.com")!) sut.load(request: request) - XCTAssertEqual(webViewMock.loadedRequest, request) + XCTAssertEqual((webViewMock.wrappedValue as! EnhancedWKWebViewMock).loadedRequest, request) } @MainActor func test_reload() { let sut = WebViewProxy() - let webViewMock = WKWebViewMock() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } sut.setUp(webViewMock) sut.reload() - XCTAssertTrue(webViewMock.reloadCalled) + XCTAssertTrue((webViewMock.wrappedValue as! EnhancedWKWebViewMock).reloadCalled) } @MainActor func test_go_back() { let sut = WebViewProxy() - let webViewMock = WKWebViewMock() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } sut.setUp(webViewMock) sut.goBack() - XCTAssertTrue(webViewMock.goBackCalled) + XCTAssertTrue((webViewMock.wrappedValue as! EnhancedWKWebViewMock).goBackCalled) } @MainActor func test_go_forward() { let sut = WebViewProxy() - let webViewMock = WKWebViewMock() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } sut.setUp(webViewMock) sut.goForward() - XCTAssertTrue(webViewMock.goForwardCalled) + XCTAssertTrue((webViewMock.wrappedValue as! EnhancedWKWebViewMock).goForwardCalled) } @MainActor func test_evaluate_JavaScript() async throws { let sut = WebViewProxy() - let webViewMock = WKWebViewMock() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } sut.setUp(webViewMock) let actual = try await sut.evaluateJavaScript("test") - XCTAssertEqual(webViewMock.javaScriptString, "test") + XCTAssertEqual((webViewMock.wrappedValue as! EnhancedWKWebViewMock).javaScriptString, "test") let result = try XCTUnwrap(actual as? Bool) XCTAssertTrue(result) } + + @MainActor + func test_clear_all() async { + let sut = WebViewProxy() + let webViewMock = Remakeable { + EnhancedWKWebViewMock() as EnhancedWKWebView + } + sut.setUp(webViewMock) + let oldInstance = sut.webView?.wrappedValue + + sut.clearAll() + + let newInstance = sut.webView?.wrappedValue + + XCTAssertNotEqual(oldInstance, newInstance) + } } diff --git a/Tests/WebUITests/WebViewTests.swift b/Tests/WebUITests/WebViewTests.swift index 66bd185..75d788f 100644 --- a/Tests/WebUITests/WebViewTests.swift +++ b/Tests/WebUITests/WebViewTests.swift @@ -6,7 +6,7 @@ final class WebViewTests: XCTestCase { func test_applyModifiers_uiDelegate() { let uiDelegateMock = UIDelegateMock() let sut = WebView().uiDelegate(uiDelegateMock) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(uiDelegateMock === webViewMock.uiDelegate) } @@ -15,7 +15,7 @@ final class WebViewTests: XCTestCase { func test_applyModifiers_navigationDelegate() { let navigationDelegateMock = NavigationDelegateMock() let sut = WebView().navigationDelegate(navigationDelegateMock) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(navigationDelegateMock === webViewMock.navigationDelegateProxy.delegate) } @@ -23,7 +23,7 @@ final class WebViewTests: XCTestCase { @MainActor func test_applyModifiers_isInspectable() { let sut = WebView().allowsInspectable(true) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(webViewMock.isInspectable) } @@ -31,7 +31,7 @@ final class WebViewTests: XCTestCase { @MainActor func test_applyModifiers_allowsBackForwardNavigationGestures() { let sut = WebView().allowsBackForwardNavigationGestures(true) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(webViewMock.allowsBackForwardNavigationGestures) } @@ -39,7 +39,7 @@ final class WebViewTests: XCTestCase { @MainActor func test_applyModifiers_allowsLinkPreview() { let sut = WebView().allowsLinkPreview(true) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(webViewMock.allowsLinkPreview) } @@ -47,7 +47,7 @@ final class WebViewTests: XCTestCase { @MainActor func test_applyModifiers_isRefreshable() { let sut = WebView().refreshable() - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.applyModifiers(to: webViewMock) XCTAssertTrue(webViewMock.isRefreshable) } @@ -55,7 +55,7 @@ final class WebViewTests: XCTestCase { @MainActor func test_loadInitialRequest_do_not_load_URL_request_if_request_is_not_specified_in_init() { let sut = WebView() - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.loadInitialRequest(in: webViewMock) XCTAssertNil(webViewMock.loadedRequest) } @@ -64,7 +64,7 @@ final class WebViewTests: XCTestCase { func test_loadInitialRequest_load_URL_request_if_request_is_specified_in_init() { let request = URLRequest(url: URL(string: "https://www.example.com")!) let sut = WebView(request: request) - let webViewMock = WKWebViewMock() + let webViewMock = EnhancedWKWebViewMock() sut.loadInitialRequest(in: webViewMock) XCTAssertEqual(webViewMock.loadedRequest, request) }