In Part 1 of this tutorial, we set up the API and the Lasso building blocks for our SwiftUI app. If you didn't read the first article in this series, be sure to check it out here.
Now comes the next step: implementing SwiftUI View
s with Lasso. In this part, we'll implement the Home screen to illustrate key Lasso concepts when using SwiftUI. To see the implementation of the Detail and Map screens, be sure to review the source code.
If you're unfamiliar with Lasso, see the documentation and WW Tech articles that introduce Lasso concepts.
- Lasso: Introducing a new architectural framework for iOS
- Lasso: An introduction to Flows
- Lasso documentation
This article assumes some knowledge of SwiftUI. As a result, this article excludes parts of the code and does not explain SwiftUI concepts; instead, this focuses on Lasso concepts and changes.
As we go through this tutorial, be sure to follow along with the original project source code to add the necessary code blocks and files.
If you're familiar with SwiftUI, you know that a View
can be as small as an icon and as large as an infinitely scrolling list. As a result, Lasso defines a Screen
as a View
that is not the child of a parent View
—for example, a home screen, a profile screen, a modal sheet, etc.
In this sample application, we have three Screen
s: Home, Detail, and Map. Each has child View
s, but each is not a child of any higher-level View
.
We'll start by creating the Home screen. This will serve as the entry point of our app where users can search by zip code or select a recently searched city. To configure this screen, we'll conform WeatherHomeView
to LassoView
.
// WeatherHomeView.swift
struct WeatherHomeView: View, LassoView {
@ObservedObject private(set) var store: HomeScreen.ViewStore
var body: some View {
...
}
}
Let's take a look at the LassoView
protocol.
// Screen.swift
public protocol LassoView {
associatedtype ViewState
associatedtype ViewAction
typealias ViewStore = AnyViewStore<ViewState, ViewAction>
var store: ViewStore { get }
}
You'll notice there is one requirement: A LassoView
must have a ViewStore
property called store
. The high-level views must conform to LassoView
to leverage static func createScreen(with store: ConcreteStore) -> Screen
when using a ScreenModule
. This function wraps a SwiftUI View
in a UIHostingController
under the hood.
The
store
property automatically conforms toObservableObject
. This conformance allows us to use the@ObservedObject
property wrapper, which automatically re-renders a SwiftUIView
when state changes occur.
Next, we'll begin implementing the components of our Home screen using SwiftUI. Before we do that, let's take a look at the Home screen and remind ourselves of the major screen components.
The screenshot shows roughly three major components to our Home screen: a navigation bar title, a search bar, and a recent searches list. To further modularize this screen, we'll extract the content of the list as a recent search row.
We'll start by writing the list row component to display a recent search. To do this, we'll create a View
that accepts a RecentSearch
data model instance. Using this data model, we'll design a UI component with native SwiftUI components, such as VStack
and Text
, to display the city name and zip code from the search instance.
// RecentSearchPreview.swift
struct RecentSearchPreview: View {
let recentSearch: RecentSearch
var body: some View {
VStack(alignment: .leading) {
Text(recentSearch.cityName)
.font(.headline)
Text(recentSearch.zipCode)
.font(.subheadline)
}
}
}
You can see
RecentSearchPreview
doesn't conform toLassoView
because thisView
is not considered aScreen
and does not have anyObservedObject
s.
Later, we'll wrap this UI component with a NavigationRow
, a custom UI component that accepts the view's ViewStore
and an Action
value.
// WeatherHomeView.swift
private func recentSearchRow(for recentSearch: RecentSearch) -> some View {
NavigationRow(store, action: .didTapRecentSearch(recentSearch)) {
RecentSearchPreview(recentSearch: recentSearch)
}
}
Leveraging Xcode's Preview Canvas, we can preview our UI implementation in Xcode—without running the simulator—using mock data. Here is a screenshot of our current implementation.
Next we'll build on our recent search row component by implementing the recent searches list. To accomplish this, we'll use the following SwiftUI components:
List
: The foundational UI component that arranges our data and rows.Section
: This will group our recent search rows and a header title.ForEach
: This iterates over anIdentifiable
collection and returns aView
.onDelete
: AViewModifier
that executes ourViewStore
's logic when the user swipes to delete a row.listStyle
: A nativeViewModifier
that customizes ourList
's appearance.
// WeatherHomeView.swift
List {
Section(header: Text("Recent Searches")) {
ForEach(state.recentSearches, content: recentSearchRow)
.onDelete(store) { .didDeleteRecentSearch(indexSet: $0) }
}
}
.listStyle(InsetGroupedListStyle())
You can see how only a few lines of code are required to build a list in SwiftUI in comparison to UIKit; more importantly, note how easily Lasso fits into building these common components.
Using Xcode's Preview Canvas, let's take a look at the newly implemented List
. To do this, we can override the State
of our HomeScreenStore
with mock RecentSearch
values.
When implementing our home screen's search bar, we'll need to use a SwiftUI Binding
to observe changes to the search bar text. If you've used SwiftUI before, you're probably used to the $
prefix syntax when creating a Binding
. This allows for easy read-and-write access on state values. For example, you might have a SwiftUI View
that looks like this:
// Traditional SwiftUI Binding
@ObservedObject var vm: SearchViewModel
var body: some View {
SearchBar(placeholder: "Search Here!", text: $vm.searchText)
}
At this point, you can't leverage that syntax when using Lasso. Lasso's read-only restriction on State
prevents this behavior. As a result, you'll have to set up your Binding
with a bit more code when creating the search bar. For example, you might set up your search bar Binding
like this:
// Lasso + SwiftUI Binding
@ObservedObject private(set) var store: HomeScreen.ViewStore
// 1
private func searchBarBindingAction(newZipCode: String) -> WeatherHomeViewModule.ViewAction {
.didEditZipCode(newZipCode)
}
private var locationSearchBar: some View {
// 2
let searchBinding = store.binding(\.searchZipCode, action: searchBarBindingAction)
// 3
return SearchBar(placeholder: "Search Here!", text: searchBinding)
}
Let's take a look at each of the steps involved.
- First, we'll create a function that accepts a parameter matching the
Binding
's wrapped type. This function will return anAction
fromstore
that will update the appropriateState
properties in theLassoStore
by callingupdate
. - Next, we'll create a
Binding
using thebinding
method. This uses aWritableKeyPath
on theState
to determine which property to observe. Then we'll pass in our function from step 1, which matches the input and output of theaction
parameter. - Last, we'll continue implementing SwiftUI components as usual. The only difference is that we exclude the
$
prefix because we created aBinding
directly.
You must implement the
update
logic in yourLassoStore
with the correspondingAction
to update values appropriately. Otherwise, yourState
property will remain constant.
Consistent with the screenshot of our app, we've implemented nearly every component of our home screen. The last step that remains is to put them all together and add our navigation bar title to the Screen
. Realizing that our components align vertically, we'll use a VStack
to build the Screen
.
// WeatherHomeView.swift
var body: some View {
VStack(spacing: 0) {
// 2. Search Bar
locationSearchBar
// 3. Recent Searches List
List {
Section(header: Text("Recent Searches")) {
// 4. Recent Search Row
ForEach(state.recentSearches, content: recentSearchRow)
.onDelete(store) { .didDeleteRecentSearch(indexSet: $0) }
}
}
.listStyle(InsetGroupedListStyle())
}
.navigationBarTitle("Weather") // 1. Navigation Bar Title
.alert(item: searchErrorBinding, content: searchErrorAlert)
.onTapGesture(perform: KeyboardManager.hideKeyboard)
.overlay(loadingView)
}
Looking at this implementation, let's highlight a few important facts.
- Navigation You'll see right away that we're defining the navigation bar title without embedding the root view in a
NavigationView
. That's because our LassoFlow
is handling the navigation stack for us. You must omitNavigationView
too. - Bindings If you're familiar with SwiftUI, you've probably used
Binding
s to handle changes to state properties. With Lasso,State
properties are read-only. This prevents the traditional$
syntax that is normally used with native SwiftUI components, such asAlert
andTextField
. As a work-around, Lasso provides abinding
method on thestore
property. - View methods Many SwiftUI components have methods such as
onTapGesture
,onAppear
, etc. In our case, the list elements are using theonDelete
modifier. Many of these methods allow you to execute logic when appropriate. Looking at our project’s source code, you'll see that Lasso extends many of these methods to accept the view'sViewStore
and anAction
value—keeping the business logic in theLassoStore
.
Using Xcode's Preview Canvas one final time, we can see that our Home screen is complete.
Our navigation bar title doesn't appear in the preview because our preview instance is not embedded by a
NavigationView
.
At this point, we’ve implemented the Home screen of our app. We’ve built the user interface using small reusable components. Additionally, we’ve maintained separation of concerns by isolating the business logic in a LassoStore
. Most importantly, we’ve demonstrated the ability to build a scalable app with Lasso and SwiftUI.
When implementing the Detail screen, we can follow a process similar to the one we followed for the Home screen. When implementing the Map screen, the project follows a slightly different process by using UIViewRepresentable
as a wrapper around a UIKit MKMapView
component. To learn more about UIViewRepresentable
, check out Apple’s documentation here and see this project’s implementation in the file named MKMapView+SwiftUI.swift
. Regardless, be sure to check out the project’s source code to learn more about the remaining implementation details.
In the rest of this article, we’ll focus on some of the features that Lasso introduces to support SwiftUI, such as wrappers around native SwiftUI components and View
methods. Additionally, we’ll consider the need for a Lasso StoreModule
in a SwiftUI application and how we might implement one in an app.
Lasso extends a lot of SwiftUI components, such as Button
, Alert
, TextField
, etc. Let's look at Button
, for example. Traditionally, you might define one like this:
// SwiftUI Button - Traditional Usage
@ObservedObject private(set) var store: HomeScreen.ViewStore
var buttonView: some View {
Button("Search") {
store.dispatchAction(.didTapSearch)
}
}
Lasso makes interfacing with SwiftUI components a bit easier by adding convenience initializers where possible. Looking at a similar example, here's how you might now define a Button
with the same behavior.
// SwiftUI Button - Lasso Version
@ObservedObject private(set) var store: HomeScreen.ViewStore
var buttonView: some View {
Button("Search", target: store, action: .didTapSearch)
}
The two examples look very similar. The latter intends to add some convenience, while also maintaining a slightly more Swifty approach.
Lasso extends some other SwiftUI components for convenience purposes. Here are a couple of examples to illustrate possible use cases.
TextField("Username", boundTo: store, text: \.username, action: didEditUsernameBinding)
Alert.Button.default(label: "Accept", for: store, action: .didTapAccept)
You'll notice that we're not using NavigationLink
in this sample app and the Lasso source code.
Currently, NavigationLink
doesn't interface well with Lasso because NavigationLink
is tightly coupled with navigation logic and instantiates a View
. Lasso is opinionated in that this logic should live within a LassoStore
and Flow
. When using Lasso, it's recommended that you use components like Button
instead of NavigationLink
to handle navigation behavior.
Check out
NavigationRow.swift
to see how the behavior ofNavigationLink
is maintained while abstracting the navigation and view instantiation logic.
Similar to the View
initializers, there are several convenience wrappers around native View
methods. For example, let's look at onAppear(perform:)
. Traditionally, you might use this as follows:
// OnAppear Traditional Usage
@ObservedObject private(set) var store: HomeScreen.ViewStore
var body: some View {
Text("Hello, World!)
.onAppear { store.dispatchAction(.onAppear) }
}
Using the Lasso version, you can now use onAppear
like this:
// OnAppear Lasso Extension
@ObservedObject private(set) var store: HomeScreen.ViewStore
var body: some View {
Text("Hello, World!)
.onAppear(store, action: .onAppear)
}
While the two examples maintain the same behavior, you might find the second keeps the call site a bit more Swifty.
Lasso further extends some SwiftUI View
methods for convenience purposes. Here are a couple of other examples to illustrate possible use cases.
Text("Hello, World!)
.onDisappear(store, action: .onDisappear)
Text("Hello, World!)
.onTapGesture(store, action: .onDidTap)
Text("Hello, World!)
.onLongPressGesture(store, action: .onLongPress)
In this sample app, we created our Home Screen
using a ScreenModule
. However, we might encounter future scenarios where a StoreModule
is the more appropriate implementation option, such as when state and logic are shared across multiple Screen
s. Let's take a closer look at how we might have implemented that functionality.
static func createHomeController(using store: Store) -> UIViewController {
let stateMap = { (state: State) -> WeatherHomeViewModule.ViewState in
WeatherHomeViewModule.ViewState(
isSearching: state.isSearching,
searchError: state.searchError,
...
)
}
let actionMap = { (viewAction: WeatherHomeViewModule.ViewAction) -> Action in
switch viewAction {
case .didEditZipCode(let zipCode):
return .didEditZipCode(zipCode)
case .didTapSearch:
return .didTapSearch
...
}
}
let viewStore = store.asViewStore(stateMap: stateMap, actionMap: actionMap)
let homeView = WeatherHomeView(store: viewStore)
return UIHostingController(rootView: homeView)
}
The example shows that this implementation remains nearly the same as in UIKit. There are only two differences.
- We instantiate the
View
with theViewStore
. - For UIKit interoperability, we wrap the
View
instance withUIHostingController
and return theUIHostingController
.
This sample app is just the beginning of using Lasso and SwiftUI together. Be on the lookout for more WW Tech articles and expect more updates to Lasso as SwiftUI matures and becomes increasingly common in production code.
If you're interested in contributing to Lasso, we encourage you to fork the GitHub repository (found here) and open a pull request.
Author: Charles Pisciotta, iOS Engineering Intern
Special thanks to Steven Grosmark, author of the Lasso framework, for his help on this tutorial project.