Aim
To display a web view inside a SwiftUI view
Reload the contents of the web view when a button (SwiftUI) is pressed.
My Attempt
I have made an attempt to do the above.
Pass a Publisher (CurrentValueSubject) to the UIViewRepresentable
Observe for any new values from the publisher
If the value is true refresh the web view
Question:
Is there a better way to do this? or is my approach reasonable?
Code:
WebView
import SwiftUI
import WebKit
import Combine
struct WebView: UIViewRepresentable {
let url: URL
let shouldRefresh: CurrentValueSubject<Bool, Never>
func makeUIView(context: Context) -> some UIView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
let request = URLRequest(url: url)
context.coordinator.navigation = webView.load(request)
context.coordinator.webView = webView
context.coordinator.observeChanges(for: shouldRefresh)
return webView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
print("update view called")
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
extension WebView {
class Coordinator {
var navigation: WKNavigation?
var webView: WKWebView?
private var cancellable: AnyCancellable?
#MainActor
func observeChanges(for shouldRefresh: CurrentValueSubject<Bool, Never>) {
cancellable = shouldRefresh
.filter { $0 == true }
.sink { [weak self] newValue in
self?.navigation = self?.webView?.reload()
}
}
}
}
ContentView
import SwiftUI
import Combine
struct ContentView: View {
#StateObject private var model = Model()
var body: some View {
VStack {
Button("Reload") {
print("reload pressed")
reload()
}
WebView(url: URL(string: "https://www.apple.com")!,
shouldRefresh: model.shouldRefresh)
}
}
func reload() {
model.shouldRefresh.send(true)
}
}
extension ContentView {
class Model: ObservableObject {
var shouldRefresh = CurrentValueSubject<Bool, Never>(false)
}
}
Related
I am pretty new to SwiftUI. I have a very simple view. It's just a root view that contains a WKWebView wrapped in a UIViewRepresentable. My problem is, that the init method of the UIViewRepresentable is called 6 times when the view is opened. Which means the WKWebView is initialised 6 times and all my initialisation code (setting JS callbacks, ...) is called 6 times. I added print statements to the init functions of the root view MyWebView and the subview WebView (the UIViewRepresentable). The root view init is only called once, but the subview's init is called 6 times. Is this normal? Or am I doing something wrong?
struct MyWebView: View {
#ObservedObject private var viewModel = WebViewModel()
init() {
print("root init")
}
var body: some View {
VStack(alignment: .leading, spacing: 0, content: {
WebView(viewModel: viewModel)
})
.navigationBarTitle("\(viewModel.title)", displayMode: .inline)
.navigationBarBackButtonHidden(true)
} }
struct WebView: UIViewRepresentable {
var wkWebView: WKWebView!
init(viewModel: WebViewModel) {
print("webview init")
doWebViewInitialization(viewModel: viewModel)
}
func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
let request = URLRequest(url: URL(string: "https://www.google.com")!, cachePolicy: .returnCacheDataElseLoad)
wkWebView.load(request)
return wkWebView
}
}
I'm not getting your issue of multiple calls to the init method of the UIViewRepresentable.
I modified slightly your WebView, and this is how I tested my answer:
import SwiftUI
import Foundation
import WebKit
#main
struct TestSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MyWebView()
}.navigationViewStyle(StackNavigationViewStyle())
}
}
// for testing
class WebViewModel: ObservableObject {
#Published var title = ""
}
struct WebView: UIViewRepresentable {
let wkWebView = WKWebView()
init(viewModel: WebViewModel) {
print("\n-----> webview init")
// doWebViewInitialization(viewModel: viewModel)
}
func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
if let url = URL(string: "https://www.google.com") {
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
wkWebView.load(request)
}
return wkWebView
}
func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext<WebView>) { }
}
struct MyWebView: View {
#ObservedObject private var viewModel = WebViewModel()
init() {
print("\n-----> root init")
}
var body: some View {
VStack(alignment: .leading, spacing: 0, content: {
WebView(viewModel: viewModel)
})
.navigationBarTitle("\(viewModel.title)", displayMode: .inline)
.navigationBarBackButtonHidden(true)
}
}
This leaves "doWebViewInitialization" with a possible problem spot.
You have to write your code assuming that the initializer of the View in SwiftUI will be called many times.
You write the initialization process in makeUIView(context:) in this case.
See:
https://developer.apple.com/documentation/swiftui/uiviewrepresentable/makeuiview(context:)
For example, I wrote the following code based on this answer. I added a toggle height button to this referenced code.
the -----> makeUIView log is only output once,
but the -----> webview init logs are output every time the toggle button is pressed.
import SwiftUI
import WebKit
struct ContentView: View {
var body: some View {
MyWebView()
}
}
class WebViewModel: ObservableObject {
#Published var title = ""
}
struct WebView: UIViewRepresentable {
let wkWebView = WKWebView()
init(viewModel: WebViewModel) {
print("\n-----> webview init")
}
func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
print("\n-----> makeUIView")
if let url = URL(string: "https://www.google.com") {
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
wkWebView.load(request)
}
return wkWebView
}
func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext<WebView>) { }
}
struct MyWebView: View {
#State private var toggleHight = false
#ObservedObject private var viewModel = WebViewModel()
init() {
print("\n-----> root init")
}
var body: some View {
VStack {
WebView(
viewModel: viewModel
)
.frame(
height: { toggleHight ? 600 : 300 }()
)
Button(
"toggle",
action: {
toggleHight.toggle()
}
)
}
}
}
Furthermore, I realized after I wrote example code that WebView: UIViewRepresentable should not have an instance variable of wkWebView.
Please do it all(create instance and configuration) in makeUIView(context:), as shown below.
This is because instance variables are recreated every time the initializer is called.
import SwiftUI
import WebKit
struct ContentView: View {
var body: some View {
MyWebView()
}
}
class WebViewModel: ObservableObject {
#Published var title = ""
}
struct WebView: UIViewRepresentable {
init(viewModel: WebViewModel) {
print("\n-----> webview init")
}
func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
print("\n-----> makeUIView")
let wkWebView = WKWebView()
if let url = URL(string: "https://www.google.com") {
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
wkWebView.load(request)
}
return wkWebView
}
func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext<WebView>) { }
}
struct MyWebView: View {
#State private var toggleHight = false
#ObservedObject private var viewModel = WebViewModel()
init() {
print("\n-----> root init")
}
var body: some View {
VStack {
WebView(
viewModel: viewModel
)
.frame(
height: { toggleHight ? 600 : 300 }()
)
Button(
"toggle",
action: {
toggleHight.toggle()
}
)
}
}
}
I struggled with this tight constraint when I was developing with UIViewControllerRepresentable. With the help of my colleagues, I managed to finish the code.
Your code has been called 6 times, so there may be some problem. but I cannot tell what the problem is from the code you provided.
It is common for init to be called multiple times in SwiftUI. We need to write code to deal with this. If your init is being called too often, you may want to look for the root cause. The code I referred to and the code I wrote are only once at startup.
I'm trying to make a very barebones mobile browser to practice swiftui and wkwebview (WKwebview is wrapped in a UIViewRepresentable). However, when trying to implement multiple tabs for the browser I hit an odd error. The default webview works but when pressing the button to add new tabs, I notice that the new webview never actually has it's makeuiview() function run. The new tab just displays the last page that was loaded by the first webview but remains static and can't receive navigation. I'm not sure what would cause a uiviewrepresentable to not run it's makeuiview method. There's more code to this but I posted what I thought mattered most.
Content View
(Where the logic for adding the new tab resides) Refer to Tablist.newTab() function
import SwiftUI
import CoreData
class TabList: ObservableObject {
#Published var tabs: [BrowserPage] = [BrowserPage()]
func newTab(){
tabs.append(BrowserPage())
}
}
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#StateObject var tablist: TabList = TabList()
#State var selection = 0
#State var showTabs: Bool = false
var body: some View {
VStack {
tablist.tabs[selection]
Button("next") {
withAnimation {
selection = (selection + 1) % tablist.tabs.count
}
}
Divider()
Button("add") {
withAnimation {
tablist.newTab()
selection = tablist.tabs.count - 1
}
}
Divider()
Text("\(selection)")
Text("\(tablist.tabs.count)")
}
.frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.sheet(isPresented: $showTabs, content: {
})
}
}
WebviewModel Class
import Foundation
import Combine
class WebViewModel: ObservableObject{
var webViewNavigationPublisher = PassthroughSubject<WebViewNavigation, Never>()
var showWebTitle = PassthroughSubject<String, Never>()
var showLoader = PassthroughSubject<Bool, Never>()
var valuePublisher = PassthroughSubject<String, Never>()
var url: String = "https://www.google.com"
#Published var urltoEdit: String = "https://www.google.com"
}
enum WebViewNavigation {
case backward, forward, reload, load
}
WebsiteView (UIViewRepresentable for WKWebview)
import Foundation
import UIKit
import SwiftUI
import Combine
import WebKit
protocol WebViewHandlerDelegate {
func receivedJsonValueFromWebView(value: [String: Any?])
func receivedStringValueFromWebView(value: String)
}
struct WebView: UIViewRepresentable, WebViewHandlerDelegate {
func receivedJsonValueFromWebView(value: [String : Any?]) {
print("JSON value received from web is: \(value)")
}
func receivedStringValueFromWebView(value: String) {
print("String value received from web is: \(value)")
}
#ObservedObject var viewModel: WebViewModel
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> WKWebView {
let preferences = WKPreferences()
let configuration = WKWebViewConfiguration()
configuration.userContentController.add(self.makeCoordinator(), name: "iOSNative")
configuration.preferences = preferences
let webView = WKWebView(frame: CGRect.zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
//remove after debugging...
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.isScrollEnabled = true
webView.load(URLRequest(url: URL(string: viewModel.url)!))
print("UUUUUUUUUUUUUUUUUUUUU " + viewModel.url)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
}
class Coordinator : NSObject, WKNavigationDelegate {
var parent: WebView
var valueSubscriber: AnyCancellable? = nil
var webViewNavigationSubscriber: AnyCancellable? = nil
var delegate: WebViewHandlerDelegate?
init(_ uiWebView: WebView) {
self.parent = uiWebView
}
deinit {
valueSubscriber?.cancel()
webViewNavigationSubscriber?.cancel()
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Get the title of loaded webcontent
webView.evaluateJavaScript("document.title") { (response, error) in
if let error = error {
print("Error getting title")
print(error.localizedDescription)
}
guard let title = response as? String else {
return
}
self.parent.viewModel.showWebTitle.send(title)
}
valueSubscriber = parent.viewModel.valuePublisher.receive(on: RunLoop.main).sink(receiveValue: { value in
let javascriptFunction = "valueGotFromIOS(\(value));"
webView.evaluateJavaScript(javascriptFunction) { (response, error) in
if let error = error {
print("Error calling javascript:valueGotFromIOS()")
print(error.localizedDescription)
} else {
print("Called javascript:valueGotFromIOS()")
}
}
})
// Page loaded so no need to show loader anymore
self.parent.viewModel.showLoader.send(false)
}
/* Here I implemented most of the WKWebView's delegate functions so that you can know them and
can use them in different necessary purposes */
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// Hides loader
parent.viewModel.showLoader.send(false)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
// Hides loader
parent.viewModel.showLoader.send(false)
}
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
self.parent.viewModel.urltoEdit = webView.url!.absoluteString
// Shows loader
parent.viewModel.showLoader.send(true)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// Shows loader
parent.viewModel.showLoader.send(true)
self.webViewNavigationSubscriber = self.parent.viewModel.webViewNavigationPublisher.receive(on: RunLoop.main).sink(receiveValue: { navigation in
switch navigation {
case .backward:
if webView.canGoBack {
webView.goBack()
}
case .forward:
if webView.canGoForward {
webView.goForward()
}
case .reload:
webView.reload()
case .load:
webView.load(URLRequest(url: URL(string: self.parent.viewModel.url)!))
print("IIIIIIIIIIIIIIIIIIIII " + self.parent.viewModel.url)
}
})
}
// This function is essential for intercepting every navigation in the webview
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: #escaping (WKNavigationActionPolicy) -> Void) {
// Suppose you don't want your user to go a restricted site
// Here you can get many information about new url from 'navigationAction.request.description'
decisionHandler(.allow)
}
}
}
// MARK: - Extensions
extension WebView.Coordinator: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// Make sure that your passed delegate is called
if message.name == "iOSNative" {
if let body = message.body as? [String: Any?] {
delegate?.receivedJsonValueFromWebView(value: body)
} else if let body = message.body as? String {
delegate?.receivedStringValueFromWebView(value: body)
}
}
}
}
BrowserPage View
import SwiftUI
struct BrowserPage: View, Identifiable{
let id: UUID = UUID()
#ObservedObject var viewModel = WebViewModel()
var body: some View {
VStack{
URLBarView(viewModel: viewModel)
HStack{
WebView(viewModel: viewModel)
}
BottomNavBarView(viewModel: viewModel)
.padding(.bottom, 25)
}
}
}
I am trying to use a UIViewController representable in a swiftUi project. Specifically I am trying to press one button (assetOne) that allows the EU to select a video and then press another button (assetTwo) and it allows the user to select another video. Then the user will have the option to merge the videos (with a third button). I assumed that I would need to use a Coordinator to accomplish this but after seeing a SO solution without it I tried to do it without one. But when I run my project the build is successful but when I click on any of the buttons from the content view I get the error message below. What am I doing wrong? Do I need a Coordinator and how do I incorporate it with my current configuration?
Warning: Attempt to present <UIImagePickerController: 0x7fa05f827600>
on <TempTest.MergeVideoViewController: 0x7fa05ed088c0> whose view is
not in the window hierarchy!
Content View:
import SwiftUI
struct ContentView: View {
let someView = ImagePicker()
var body: some View {
VStack {
Button(action: {
print("SwiftUI: assetOne button tapped")
// Call func in SomeView()
self.someView.assetOne()
}) {
Text("Asset One").foregroundColor(Color.black)
}
.background(Color.blue)
.padding(10)
.clipShape(Capsule())
}
//...
ImagePicker: UIViewControllerRepresentable
struct ImagePicker: UIViewControllerRepresentable{
let someView = MergeVideoViewController()
func makeUIViewController(context: Context) -> MergeVideoViewController {
someView
}
func updateUIViewController(_ uiViewController: MergeVideoViewController, context: Context) {}
func assetOne() {
someView.loadAssetOne()
}
//...
}
My UIViewController class:
class MergeVideoViewController: UIViewController {
var firstAsset: AVAsset?
var secondAsset: AVAsset?
var audioAsset: AVAsset?
var loadingAssetOne = false
var activityMonitor: UIActivityIndicatorView!
func exportDidFinish(_ session: AVAssetExportSession) {
// Cleanup assets
activityMonitor.stopAnimating()
firstAsset = nil
secondAsset = nil
audioAsset = nil
//...
func loadAssetOne() {
// func loadAssetOne(_ sender: AnyObject) {
if savedPhotosAvailable() {
loadingAssetOne = true
VideoHelper.startMediaBrowser(delegate: self, sourceType: .savedPhotosAlbum)
}
}
//...
The ImagePicker is-a View, it should be somewhere in body.
Here is possible approach - the idea is to get controller reference back in SwiftUI and call its actions directly when needed.
struct ImagePicker: UIViewControllerRepresentable{
let configure: (MergeVideoViewController) -> ()
func makeUIViewController(context: Context) -> MergeVideoViewController {
let someView = MergeVideoViewController()
configure(someView)
return someView
}
func updateUIViewController(_ uiViewController: MergeVideoViewController, context: Context) {}
}
struct ContentView: View {
#State private var controller: MergeVideoViewController?
var body: some View {
VStack {
ImagePicker {
self.controller = $0
}
Button(action: {
print("SwiftUI: assetOne button tapped")
self.controller?.loadAssetOne()
}) {
Text("Asset One").foregroundColor(Color.black)
}
.background(Color.blue)
.padding(10)
.clipShape(Capsule())
}
}
}
I try to load a url in my WebView, it works well with "https://www.google.com", but it doesn't work with "https://www.sl.se".
It shows "Not Found, HTTP Error 404: The requested resource is not found."
The weird thing is that, if I google "sl.se" in Google page, click the sl.se, the the "www.sl.se" can be load the WebView. Does anyone know the reason?
ContentView.swift
// ContentView.swift
struct ContentView: View {
#State private var shouldRefresh = false
var body: some View {
VStack{
Button(action: {
self.shouldRefresh.toggle()
}){
Text("Reload")
}
WebView(url: nil, reload: $shouldRefresh)
}
}
}
WebView.Swift
// WebView.swift
import WebKit
import SwiftUI
struct WebView: UIViewRepresentable{
var url: URL? // optional, if absent, one of below search servers used
#Binding var reload: Bool
private let urls = [URL(string: "https://google.com/")!, URL(string: "https://www.sl.se")!]
private let webview = WKWebView()
fileprivate func loadRequest(in webView: WKWebView) {
if let url = url {
webView.load(URLRequest(url: url))
} else {
let index = Int(Date().timeIntervalSince1970) % 2
print("load: \(urls[index].absoluteString)")
webView.load(URLRequest(url: urls[index]))
}
}
func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
loadRequest(in: webview)
return webview
}
func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<WebView>) {
if reload {
loadRequest(in: uiView)
DispatchQueue.main.async {
self.reload = false // must be async
}
}
}
}
There is nothing wrong with your code. You just need to remove the www from your URL. try https://sl.se and It'll work.
When creating the WKWebView I pass the web page URL via #Binding webPageURL.
I also update the displayed website via webPageURL.
The problem here is that when I call a NavigationLink and navigate back from the NavigationLink, the updateUIView method is called and the WKWebView reloads the web page.
How do I prevent the WKWebView from calling the method updateUIView when I navigate back from the NavigationLink?
struct MainView:View {
#State private var isActive = false
#Binding var stateWebPageURL:String
var body: some View {
return VStack {
MyWKWebView(webPageURL:$stateWebPageURL)
NavigationLink(destination: SecoundDestination(),isActive: $isActive) {
Text("Do Something")
}
}
}
}
struct MyWKWebView: UIViewRepresentable {
#Binding var webPageURL:String
func updateUIView(_ uiView: WKWebView, context: Context) {
let pageURL = URL(string:webPageURL)
let urlRequest = URLRequest(url: pageURL!)
uiView.load(urlRequest)
}
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView(frame: .zero,configuration: webConfiguration)
let pageURL = URL(string:webPageURL)
let urlRequest = URLRequest(url: pageURL!)
webView.load(urlRequest)
return webView
}
func makeCoordinator() -> ContentController {
ContentController()
}
}
Provided code is not testable, so just by code reading, the idea is to make explicitly MyWKWebView equatable, as below
var body: some View {
return VStack {
MyWKWebView(webPageURL:$stateWebPageURL).equatable() // << here !!
and
struct MyWKWebView: UIViewRepresentable, Equatable {
static func == (lhs: MyWKWebView, rhs: MyWKWebView) -> Bool {
lhs.webPageURL == rhs.webPageURL
}
// ... other your code
SwiftUI should not update equal views, so should work.