In this lab, we will use WebKit to help us build our own browser for the iPhone. It’s a scaled down browser, but it should have a navigation bar so we can enter in URLs, a reload and stop Botton, and a set of back and forward buttons for simple navigation. Here is what the app will look like when finished:
-
The 2 elements of the URL Navigation bar are Text
element for the URL label, and a TextField
element for typing in the URL. Create a new SwiftUI view called SearchBar.swift
. Create a struct like so:
struct SearchBar: View {
var body: some View { }
}
-
Inside the body
, add the two necessary elements for the search bar like so:
HStack {
Text("URL:")
TextField("URL", text: $viewModel.urlString)
}
Notice the $viewModel.urlString
. This is the binding for URL the user will type in. We will need to create a view model to keep this value consistent throughout the app.
-
Create a file called ViewModel.swift
and create a class called ViewModel
and make it an ObservableObject
like so:
class ViewModel: ObservableObject { }
-
Add an published variable for the urlString so the class now looks like this:
class ViewModel: ObservableObject {
@Published var urlString: String = ""
}
-
Then, add a reference to a ViewModel
to the SearchBar
struct.
@ObservedObject var viewModel: ViewModel
-
Back to SearchBar.swift
. To make the TextField
more user friendly for typing in URLs, here are some options you can chain to the end of it:
TextField("URL", text: $viewModel.urlString)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
-
Now to add the SearchBar
to your ContentView
. First, store an instance of the ViewModel
as a constant in your ContentView
struct. Then, add the SearchBar
inside a VStack
to the body
variable, giving it that instance of the ViewModel
:
var body: some View {
VStack {
SearchBar(viewModel: viewModel)
}
-
Now when you run your app, you should be able to see the URL navigation bar.
Now let’s create the bottom navigation option button bar.
-
Let’s create empty methods for our button actions in our ViewModel
. Create 5 empty methods for our five empty actions (you can add print statements to these methods to ensure they are working/linked correctly). These methods are:
goBack()
goForward()
share()
refresh()
stop()
-
Now, let’s link them to our buttons. First, in your BottomBar
struct, create an @ObservedObject
for your ViewModel
.
Now, in your ContentView
where you create your BottomBar
you will need to pass the instance of ViewModel
.
-
Then, add the view model methods to your button actions.
You can either add them in the action closure of the buttons like this:
Button(action: {
self.viewModel.goBack()
}) {
Image(systemName: "chevron.left")
}
Or pass in the function as a parameter like this:
Button(action: viewModel.goBack) {
Image(systemName: "chevron.left")
}
I personally prefer the latter, but it’s up to you!
-
Now these buttons still don’t do anything, but make sure they’re linked to your ViewModel by using breakpoints or print statements.
-
Create a new SwiftUI view and within it, create a struct called WebView
.
-
Make sure you import WebKit
, import Combine
, and import SwiftUI
.
-
Make this conform to UIViewRepresentable
instead of having it be an instance of View
(like the top and bottom bars were) and delete body
.
-
Create 2 methods in this WebView
struct:
func makeUIView(context: Context) -> WKWebView
func updateUIView(_ webView: WKWebView, context: Context)
Fill them out like so:
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
if let url = URL(string: "https://\(viewModel.urlString)") {
webView.load(URLRequest(url: url))
}
}
-
You will need an instance of ViewModel
for the above code. Add a constant for this just as you did for BottomBar
and SearchBar
-
Now, in your ContentView
add an instance of WebView
between your SearchBar
and your BottomBar
.
-
Try this out! You should be able to open web pages using your app, but navigation will not work yet.
-
We will come back to these methods later when we implement our navigations actions, but before that, within the WebView
struct, let’s create a class called Coordinator
, which will be an NSObject
and conform to WKNavigationDelegate
.
-
Give this class 2 variables:
var parent: WebView
var webViewOptionsSubscriber: AnyCancellable?
-
Create an init
function for this class, make the init take a WebView
object and set the parent
variable equal to this WebView
.
-
Also create a deinit
like this:
deinit {
webViewOptionsSubscriber?.cancel()
}
-
Finally, let’s create the most important method of this class:
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { }
This method is what is going to handle our navigation buttons and utilize Combine to do so.
-
Let’s go back to our pretty empty ViewModel
and create an enum (outside of the class) for our web view button action options. Enums are great and used heavily in iOS development. I would suggest getting very comfortable with enums. Read more about them here.
enum WebViewOptions {
case back
case forward
case share
case refresh
case stop
}
-
Now within the ViewModel
, let’s create a published variable for which option the user has just tapped:
@Published var webViewOptionsPublisher = PassthroughSubject<WebViewOptions, Never>()
You will need to import Combine
at the top of your ViewModel
in order to use the PassthroughSubject
.
Learn more about a PassthroughSubject
here.
-
This will allow us to respond to the user’s selections in our WebView
. But to do that, we need to send these values in our View Model’s methods. For example, our goBack()
method will look like this:
func goBack() {
webViewOptionsPublisher.send(.back)
}
Fill out the rest of the methods accordingly.
This will send the value from the publisher to the subscriber, which we will set up in the WebView
so the WebView
can respond to the user’s selection. Let’s do that next.
-
Start filling out the Coordinator.webView(_:didStartProvisionalNavigation:)
method in WebView.swift
.
This is what it should look like before you fill it out:
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
webViewOptionsSubscriber = parent.viewModel.webViewOptionsPublisher.sink(receiveValue: { webViewOption in
// create a switch statement for each of the different options
})
}
Make sure you understand what .sink()
is doing. It is receiving the value we sent using the webViewOptionsPublisher
in the ViewModel
and we can respond to it in our WebView
.
Let’s create a switch statement to respond to the different navigation options. This goes inside the .sink
closure.
switch webViewOption {
case .back: return
case .forward: return
case .share: return
case .refresh: return
case .stop: return
}
-
Right now we’re just returning, but let’s fill each option out:
switch webViewOption {
case .back:
if webView.canGoBack {
webView.goBack()
}
case .forward:
if webView.canGoForward {
webView.goForward()
}
case .share: return // we'll fill this out later
case .refresh:
webView.reload()
case .stop:
webView.stopLoading()
}
-
Now let’s make sure our WebView
respond to these actions. In your WebView
struct, add this method:
func makeCoordinator() -> Coordinator {
Coordinator(webView: self)
}
-
And update the WebView.makeUIView(context:)
function to look like this:
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
This method allows our WebView
to use the custom Coordinator
we defined.
-
Now, all buttons on your app should be working except for the share button - try them out!
-
Create a Swift file called ShareSheet.swift
:
import SwiftUI
import UIKit
struct ShareSheet: UIViewControllerRepresentable {
typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void
let activityItems: [Any]
let applicationActivities: [UIActivity]? = nil
let excludedActivityTypes: [UIActivity.ActivityType]? = nil
let callback: Callback? = nil
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
controller.excludedActivityTypes = excludedActivityTypes
controller.completionWithItemsHandler = callback
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { }
}
-
Add a new variable to the ViewModel
: @Published var shouldShowShareSheet: Bool = false
We will set this to true in WebView.Coordinator.webView(_:didStartProvisionalNavigation:)
like so:
case .share:
self.parent.viewModel.shouldShowShareSheet = true
-
To present the share sheet, add the following code to the end of the VStack
in the ContentView
.
Note: due to improvements in how SwiftUI handles if statements the code can be written more concisely in XCode 12 than it can in XCode 11. Please use the appropriate version.
XCode 12 and above
.sheet(isPresented: $viewModel.shouldShowShareSheet) {
if let url: URL = URL(string: "https://\(viewModel.urlString)") {
ShareSheet(activityItems: [url])
}
}
The ContentView.swift
should look like this now:
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
SearchBar(viewModel: viewModel)
WebView(viewModel: viewModel)
BottomBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.shouldShowShareSheet) {
if let url: URL = URL(string: "https://\(viewModel.urlString)") {
ShareSheet(activityItems: [url])
}
}
}
}
XCode 11 and below
.sheet(isPresented: $viewModel.shouldShowShareSheet) {
if URL(string: "https://\(self.viewModel.urlString)") != nil {
ShareSheet(activityItems: [URL(string: "https://\(self.viewModel.urlString)")!])
}
}
The ContentView.swift
should look like this now:
struct ContentView: View {
@ObservedObject var viewModel: ViewModel = ViewModel()
var body: some View {
VStack {
SearchBar(viewModel: viewModel)
WebView(viewModel: viewModel)
BottomBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.shouldShowShareSheet) {
if URL(string: "https://\(self.viewModel.urlString)") != nil {
ShareSheet(activityItems: [URL(string: "https://\(self.viewModel.urlString)")!])
}
}
}
}
-
And now all your actions should work! And you have your own simple browser :)