2

I'm working on a SwiftUI app using SwiftData, and I'm very new to both frameworks, and swift in general to be honest. I'm trying to create a webview that embeds a monaco editor that will then send the change events to swift to track the changes to the initial content in SwiftData.

This is what that struct looks like:

import SwiftData
import SwiftUI
import WebKit

enum CodeEditorTheme: String, Codable, CaseIterable {
    case materialLight, solarizedLight, githubLight, aura, tokyoNightDay,
        dracula, tokyoNight, materialDark, tokyoNightStorm, githubDark,
        solarizedDark, xcodeDark, xcodeLight
}

func getConfig() -> WKWebViewConfiguration {
    let config = WKWebViewConfiguration()
    config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
    config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
    return config
}

struct ResponsiveEditorWebView: UIViewRepresentable {

    let url: URL
    @State private var webView: WKWebView = WKWebView(
        frame: .zero,
        configuration: getConfig()
    )
    @Environment(\.openURL) var openURL
    @Environment(\.modelContext) var modelContext

    @Binding var theme: WebViewTheme
    @Binding var editorTheme: CodeEditorTheme
    @Binding var editingNote: NoteModel?
    @State var haveSetInitialContent: Bool = false
    @Environment(\.colorScheme) var colorScheme {
        didSet {
            applyWebViewColorScheme()
        }
    }

    func makeUIView(context: Context) -> WKWebView {
        self.webView.navigationDelegate = context.coordinator
        self.webView.scrollView.isScrollEnabled = false
        self.webView.scrollView.zoomScale = 1
        self.webView.scrollView.minimumZoomScale = 1
        self.webView.scrollView.maximumZoomScale = 1
        self.webView.allowsBackForwardNavigationGestures = false
        self.webView.configuration.userContentController.add(
            context.coordinator,
            name: "editor-update"
        )
        if #available(iOS 16.4, macOS 13.3, *) {
            self.webView.isInspectable = true  // Enable inspection
        }

        // now load the local url
        self.webView.loadFileURL(url, allowingReadAccessTo: url)
//        emitEditorThemeEvent(theme: editorTheme)
//        applyWebViewColorScheme()
        return self.webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        if !haveSetInitialContent {
// Even with these commented off the issue persists.
//            applyWebViewColorScheme()
//            emitEditorThemeEvent(theme: editorTheme)
//            setInitialContent()
        }
        uiView.loadFileURL(url, allowingReadAccessTo: url)
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    func setInitialContent() {
        if !haveSetInitialContent {
            print("Setting initial content")
            let body = editingNote?.markdown.body.replacingOccurrences(
                of: "`",
                with: "\\`"
            )
            self.webView.evaluateJavaScript(
                """
                window.localStorage.setItem("editor-initial-value", `\(body ?? "")`)
                """
            ) { (result, error) in
                if error != nil {
                    print("set initial value error: ", error)
                } else {
                    print("Set initial value result: ", result)
                    haveSetInitialContent = true
                }
            }
        }
    }
    func emitEditorThemeEvent(theme: CodeEditorTheme) {
        print("Changing editor theme event")
        self.webView.evaluateJavaScript(
            """
            window.localStorage.setItem("editor-theme", "\(theme.rawValue)")
            """
        ) { (result, error) in
            if error != nil {
                print("Error: ", error)
            } else {
                print("Result: ", result)
            }
        }
    }
    func applyWebViewColorScheme() {
        print("Applying webview color scheme")
        self.webView.evaluateJavaScript(
            """
            window.localStorage.setItem("darkMode", "\(colorScheme == .dark ? "true" : "false")")
            """
        ) { (result, error) in
            if error != nil {
                print("Error: ", error)
            } else {
                print("Result: ", result)
            }
        }
    }
    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        var parent: ResponsiveEditorWebView
        init(_ parent: ResponsiveEditorWebView) {
            self.parent = parent
        }

        // Delegate method to decide policy for navigation
        func webView(
            _ webView: WKWebView,
            decidePolicyFor navigationAction: WKNavigationAction,
            decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
        ) {
            if let url = navigationAction.request.url {
                // Check if the link should open in the default browser (e.g., an external link)
                // You can add logic here to only open specific URLs externally

                // Example logic: if it's not a link to your internal website, open externally
                // if url.host != "www.myinternalwebsite.com" {

                if navigationAction.navigationType == .linkActivated
                    && url.host != webView.url?.host
                {
                    // Open the URL using the environment's openURL action
                    parent.openURL(url)

                    // Cancel the navigation within the web view
                    decisionHandler(.cancel)
                    return
                }
            }

            // Allow the navigation within the web view for other links
            decisionHandler(.allow)
        }
        func userContentController(
            _ userContentController: WKUserContentController,
            didReceive message: WKScriptMessage
        ) {
            if message.name == "editor-update" {
                if parent.editingNote != nil {
                    print("Message: \(message.body)")
                    parent.editingNote!.markdown.body = message.body as! String
                }
            }
        }
    }

}

The problem is that when the editor changes the content and sends the messages to swift, an update occurs some seconds later (what I believe is SwiftData reading from the store?) causing the entire webview to re-render. This causes the initial events to be sent again, which isn't the end of the world, apart from that it overwrites the changes in the editor leaving the editor in this cycle of making small changes of a sentence or two before swiftdata tries to sync and overwrites those changes with the initial content leaving the user back where they started.

Any help is greatly appreciated. Like I said, I'm brand new to swift, SwiftUI and SwiftData, so if the answer is obvious, please forgive me... but this is incredibly frustrating.

3
  • Ok, please enlighten me... like I said, I'm very new to swift. I know there's a swiftui webview if that's what you're getting at, but I was having issues with the overscroll and this is the first way I found to resolve it. Maybe not the best idea, but for somebody that couldn't write a line of swift a month ago it's a sacrifice I'm willing to make. Commented yesterday
  • makeUIView must init the webview as a local and return it. Fix that first then see. Commented yesterday
  • Then how can I access it outside of the makeUIView function? I know in the SwiftUI webview view you can just pass the webview as a binding, but I wasn't able to find any examples that use UIViewRepresentable and still access the webview outside of that init function? Commented yesterday

1 Answer 1

2

Your WebView is stored in @State, which means SwiftUI is free to recreate it whenever the view updates. On top of that, updateUIView calls loadFileURL again, which completely reloads the page on every render pass. So any SwiftData update triggers a normal SwiftUI redraw → updateUIView runs → the file is reloaded → the WebView resets back to the initial content → the editor overwrites the user’s changes.

The correct approach is to move the WKWebView into a separate class so it becomes a stable reference type, and then pass that instance into the Representable. This prevents SwiftUI from recreating it. Also, only load the HTML inside makeUIView, not in updateUIView. And set your initial content once, after the page finishes loading, inside didFinish of the WKNavigationDelegate.

Once you do this, the WebView will stop resetting itself, and the user’s changes will no longer get overwritten.

Container for WKWebView:

final class EditorWebViewContainer {
    let webView: WKWebView = {
        let view = WKWebView(frame: .zero, configuration: getConfig())
        view.scrollView.isScrollEnabled = false
        view.scrollView.minimumZoomScale = 1
        view.scrollView.maximumZoomScale = 1
        view.allowsBackForwardNavigationGestures = false
        return view
    }()
}

The rewritten UIViewRepresentable:

struct ResponsiveEditorWebView: UIViewRepresentable {
    let url: URL
    let container: EditorWebViewContainer        // stable WebView

    @Environment(\.openURL) var openURL
    @Environment(\.modelContext) var modelContext

    @Binding var theme: WebViewTheme
    @Binding var editorTheme: CodeEditorTheme
    @Binding var editingNote: NoteModel?

    @Environment(\.colorScheme) var colorScheme

    func makeUIView(context: Context) -> WKWebView {
        let webView = container.webView

        webView.navigationDelegate = context.coordinator
        webView.configuration.userContentController.add(context.coordinator, name: "editor-update")

        // Loading the page only once
        webView.loadFileURL(url, allowingReadAccessTo: url)

        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // No reboots. No initial values.
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}

Initializing initial content:

extension ResponsiveEditorWebView {
    final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        var parent: ResponsiveEditorWebView
        private var didSetInitialContent = false

        init(_ parent: ResponsiveEditorWebView) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            guard !didSetInitialContent else { return }
            didSetInitialContent = true

            let body = parent.editingNote?.markdown.body
                .replacingOccurrences(of: "`", with: "\\`") ?? ""

            webView.evaluateJavaScript("""
                window.localStorage.setItem("editor-initial-value", `\(body)`);
            """)
        }

        func userContentController(
            _ userContentController: WKUserContentController,
            didReceive message: WKScriptMessage
        ) {
            if message.name == "editor-update",
               let str = message.body as? String {
                parent.editingNote?.markdown.body = str
            }
        }
    }
}

Ready-made instructions:

struct EditorScreen: View {
    @State private var container = EditorWebViewContainer()

    var body: some View {
        ResponsiveEditorWebView(
            url: Bundle.main.url(forResource:"editor", withExtension:"html")!,
            container: container,
            theme: $theme,
            editorTheme: $editorTheme,
            editingNote: $editingNote
        )
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

What's the correct approach to getting the @StateObject macro to work? I appreciate the detailed response, and I imagine you're probably correct, but as far as I'm aware to get the EditorWebViewContainer class to conform to ObservableObject a property would need to be published? Is that the best way to get rid of that error, or would that defeat what we're trying to accomplish?
I just created some arbitrary property to mark it as published to get it to conform since there was an error in the example you provided, but it works after that! I'm almost sure there's a cleaner way, but I'm still in the 'hobbling things together' phase of my Swift journey.
@StateObject isn’t needed there at all. The container doesn’t need to be an ObservableObject, and it doesn’t need any @Published properties. It’s just a simple holder for the WKWebView so it doesn’t get recreated. A plain @State is enough to keep the instance alive between renders. @StateObject is unnecessary in this case. Sorry that code was taken from the real project, my bad. I’ve corrected it now to show how it should actually be in the end.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.