Searchable SwiftUI: Part 2

Episode #157 • Aug 16, 2021 • Free Episode

We finish our search-based application by adding and controlling another MapKit API, integrating it into our application so we can annotate a map with search results, and then we’ll go the extra mile and write tests for the entire thing!

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

OK, so we’re now about halfway to implementing our search feature. We’ve got a map on the screen that we can pan and zoom around, and we’re getting real time search suggestions as we type, all powered by MapKit’s local search completer API.

The final feature we want to implement is to allow the user to tap a suggestion in the list and place a marker on the map corresponding to that location. Even better, sometimes the suggestions provided by the search completer don’t correspond to a single location, but rather a whole collection of collections. For example, if we search for “Apple Store” then the top suggestion has the subtitle “Search Nearby”, which should place a marker on every Apple Store nearby.

But, where are we going to get these search results from? As we saw a moment ago, the MKLocalSearchCompletion object has only a title and subtitle, so we don’t get an address or geographic coordinates for the location. Well, there is another API in MapKit that allows you to make a search request for points-of-interest, which means we have yet another dependency we need to control and add to our environment.

Let’s start by explore this API a little bit in a playground like we did for the search completer.

Using and controlling MKLocalSearch

MapKit comes with a class called MKLocalSearch that can be used to search for particular locations. A request can be made in a variety of ways, including just asking for all points of interests in a region:

MKLocalSearch(
  request: MKLocalPointsOfInterestRequest(coordinateRegion: <#MKCoordinateRegion#>)
)

Or constructing something known as a MKLocalSearch.Request, which allows you to search for locations using a natural language query:

MKLocalSearch(
  request: <#MKLocalSearch.Request#>
)

Interestingly, there is even an initializer of MKLocalSearch.Request that takes a MKLocalSearchCompletion as an argument, which allows us to perform a full location search using the skeletal suggestion handed to us from the completer:

MKLocalSearch.Request.init(completion: <#MKLocalSearchCompletion#>)

In order to get access to a completion, we’ll need to do so from the MKLocalSearchCompleter’s delegate callback.

func completerDidUpdateResult(_ completer: MKLocalSearchComleter) {
  print("succeeded")
  dump(completer.results)

  let search = MKLocalSearch(request: .init(completion: completer.results[0]))
}

And to kick off this request we will construct a MKLocalSearch with it and invoke the .start method:

MKLocalSearch(request: request)
  .start { <#MKLocalSearch.Response?#>, <#Error?#> in
    <#code#>
  }

There’s even an overload of .start that is powered off of Swift’s new async/await machinery. Basically any API that currently works with completion handler callbacks can be refactored to work with async/await, and it looks like this is one API that Apple has updated. Instead of the above closure-based handling of the response we can simply try to await the response:

let response = try await search.start()

🛑 ‘async’ call in a function that does not support concurrency

Although it seems that Swift playgrounds are not provided an async context at the root of the document, and so we have to provide it by spinning off a task:

Task {
  let response = try await search.start()
}

This response contains a few interesting things, including a bunch of map items, which are the things we want to render on our map:

print(response.mapItems)
// [<MKMapItem: 0x6000027c05a0> {
//     isCurrentLocation = 0;
//     name = "Apple Grand Central";
//     phoneNumber = "+1 (212) 284-1800";
//     placemark = "Apple Grand Central, 45 Grand Central Terminal, New York, NY 10017, United States @ <+40.75265500,-73.97682800> +/- 0.00m, region CLCircularRegion (identifier:'<+40.75265500,-73.97682800> radius 141.52', center:<+40.75265500,-73.97682800>, radius:141.52m)";
//     timeZone = "America/New_York (EDT) offset -14400 (Daylight)";
//     url = "http://www.apple.com/retail/grandcentral";
// },
// …

These map items contain a lot of information, but most important is where they are located:

response.mapItems[0].placemark.coordinate

This is enough information to drop a marker on the map representing each item in the mapItems array.

The response also holds onto a boundingRegion field, which describes the rectangle coordinate region that encompasses all the results held in the response:

response.boundingRegion

This is enough information to re-position the map on the screen so that all of the markers appear on the screen at once.

So it seems like we’ve got everything we need to implement the feature we have in mind. Let’s start integration this API into our application by designing a dependency that can be used in the environment. We’ll start with a basic struct wrapper like we did for the search completer client:

struct LocalSearchClient {
}

And we’ll expose an endpoint for searching. We want to run this search against one of the completions a user taps, so we can capture that in the following signature:

struct LocalSearchClient {
  var search: (MKLocalSearchCompletion) -> Effect<MKLocalSearch.Response, Error>
}

And you’ll notice that this endpoint is quite a bit simpler than the endpoints provided by LocalSearchCompleter. This really does model a basic network request, where we provide it the data it needs to fire off a request, and it then returns a response or error.

So what does a live implementation look like?

extension LocalSearchClient {
  static let live = Self(
    search: { completion in

    }
  }
}

We can start by instantiating a request for a completion and calling start on it.

search: { completion in
  MKLocalSearch(request: .init(completion: completion))
    .start()

}

We can’t simply call await on it, because we’re not in an async context, and what we want to do is return an Effect.

To construct an Effect we could use the future initializer, which takes a callback, and then move the request inside, where we can invoke the response handler version of start instead:

search: { completion in
  Effect.future { callback in
    MKLocalSearch(request: .init(completion: completion))
      .start { response, error in

      }

  }
}

And in here we can fall back to the old API that speaks completion handlers.

Effect.future { callback in
  MKLocalSearch(request: .init(completion: completion))
    .start { response, error in
      if let response = response {
        callback(.success(response))
      } else if let error = error {
        callback(.failure(error))
      } else {
        fatalError()
      }
  }
}

It’s a bummer how much more verbose this version is than the async version. Even worse, it introduces invalid states that don’t make sense. Because we are working with two optionals, we have 2 states that technically compile but I’m not sure how to handle:

  • Both response and error can be nil, which represents that fatal-erroring path.
  • Both response and error can be non-nil, and in this case we’re quietly ignoring the error.

In order to leverage the new API we could introduce an async context:

Effect.future { callback in
  Task {
    let response = try await MKLocalSearch(request: .init(completion: completion))
      .start()

  }
}

This is API can fail, so we should introduce a do block to capture any errors:

Task {
  do {
    let response = try await MKLocalSearch(request: .init(completion: completion))
      .start()
  } catch {

  }
}

And this is exactly what we want to feed to our callback:

callback(.success(response))

And in the case of failure, we can hand the error off in the catch block.

do {
  let response = try await localSearch.start()
  callback(.success(response))
} catch {
  callback(.failure(error))
}

OK, this is compiling now!

We’re wrapping this async work in a very ad hoc way right now. It seems like it would be very useful to wrap any async work in an effect. Well no such helper exists in the Composable Architecture right now, but let’s not wait on library support. We should be able to cook up this helper ourselves. To understand what the signature should be we can look at the Task initializer that is defined in the _Concurrency module:

public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)

And there is another initializer that takes a throwing closure, which creates a task that can fail:

public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success)

So we should be able to mimic this signature to initialize an Effect. We’ll create a new static constructor called task to wrap this work.

extension Effect {
  static func task(
    priority: TaskPriority? = nil,
    operation: @escaping @Sendable () async throws -> Output
  ) -> Self
  where Failure == Error {
  }
}

We’ve constrained Failure to be Error because we’re wrapping an async failure that throws.

And in here we can do the same work we were doing before, except we’ll pass along the task priority, and we’ll call the operation instead of the concrete MapKit work we were doing before:

extension Effect {
  static func task(
    priority: TaskPriority? = nil,
    operation: @escaping @Sendable () async throws -> Output
  ) -> Self
  where Failure == Error {
    .future { callback in
      Task(priority: priority) {
        do {
          callback(.success(try await operation()))
        } catch {
          callback(.failure(error))
        }
      }
    }
  }
}

This allows us to greatly simplify our dependency:

extension LocalSearchClient {
  static let live = Self { completion in
    .task {
      try await MKLocalSearch(request: .init(completion: completion))
        .start()
    }
  }
}

We now understand how the local search APIs work, we’ve written a wrapper around the dependency in order to make it mockable and testable, and we’ve even introduced a general Effect helper for executing work using Swift’s new async/await APIs, all without having to modify the Composable Architecture library.

Integrating local search

Now that we have the search client defined and created a live implementation, let’s integrate it into the application so that when we tap one of those search completions, we can fire off that local search request and display results on the map.

First, let’s add our new dependency to the environment of our application:

struct AppEnvironment {
  var localSearch: LocalSearchClient
  …
}

To kick off a search we need to pass a completion along to the search client when we tap a particular row by sending an action to the view store when a row is tapped:

enum AppAction {
  …
  case tappedCompletion(MKLocalSearchCompletion)
}
…
ForEach(viewStore.completions, id: \.id) { completion in
  Button(action: { viewStore.send(.tappedCompletion(completion)) }) {
    VStack(alignment: .leading) {
      Text(completion.title)
      Text(completion.subtitle)
        .font(.caption)
    }
  }
}

Our reducer needs to handle this case.

case let .tappedCompletion(completion):
  return environment.localSearch.search(completion)

🛑 Cannot convert return expression of type ‘Effect<MKLocalSearch.Response, Error>’ to return type ‘Effect<AppAction, Never>’

And to feed the result of this effect back into the system we need another action.

case searchResponse(Result<MKLocalSearch.Response, Error>)

So that we can catch the effect.

case let .tappedCompletion(completion):
  return environment.localSearch.search(completion)
    .catchToEffect()
    .map(AppAction.searchResponse)

And then handle the response in our reducer.

case let .searchResponse(.success(response)):

case let .searchResponse(.failure(error)):

In the case of success, there are a few items we want to pluck off the response. For instance, we want to replace our state’s region with the bounding region of the search results:

case let .searchResponse(.success(response)):
  state.region = .init(rawValue: response.boundingRegion)

We also have the map items available to us.

response.mapItems

We just need to introduce some state to our application in order to hold onto them and render them in our view.

struct AppState: Equatable {
  …
  var mapItems: [MKMapItem] = []
  …
}

When we get a successful response, we can assign the map items and update the region.

case let .searchResponse(.success(response)):
  state.mapItems = response.mapItems
  state.region = .init(rawValue: response.boundingRegion)
  return .none

And we can stub out some error handling:

case .searchResponse(.failure):
  // TODO: error handling
  return .none

To render these map items, we can hook into a couple Map view fields we’ve been ignoring:

annotationItems: <#Items#>,
annotationContent: <#(Items.Element) -> Annotation#>

These two fields are responsible for rendering annotation views over a map. This includes a collection of data with an element per annotation to render, and a view builder that can render an annotation, given one of those elements.

For items we can pass along the view store’s map items:

annotationItems: viewStore.mapItems,

🛑 Initializer ‘init(coordinateRegion:interactionModes:showsUserLocation:userTrackingMode:annotationItems:annotationContent:)’ requires that ‘MKMapItem’ conform to ‘Identifiable’

SwiftUI needs these annotation items to be identifiable, and MKMapItem is not. Ideally this means introducing our own type that can hold the map item data we care about and that we can determine an Identifiable conformance for. But to get things building we can simply add a conformance to MKMapItem.

extension MKMapItem: Identifiable {}

Because MKMapItem is an object, it gets a free conformance based on object identity, which probably isn’t what we want here. There is no guarantee that MapKit is going to return the exact same object for the same place across searches. So all the more reason to introduce a type we own. But for now, at least we can get something on the screen.

And for annotation content we can render an annotation. There are several annotation views at our disposal, including a pin, a marker, or a completely customizable MapAnnotation view. Let’s simply render a marker by passing along the map item coordinate:

annotationContent: { mapItem in
  MapMarker(coordinate: mapItem.placemark.coordinate)
}

Everything is now building except for our previews and app entrypoint, which need to be supplied a local search client.

environment: .init(
  localSearch: .live,
  localSearchCompleter: .live
)

If we run this in the preview we can type in a query, tap a row, and the map will zoom into a region and display a pin. However, by running this in the preview we are hiding a bug that unfortunately can only be seen by running in the simulator.

If we do the same in the simulator we will see we have a purple warning:

🟣 SwiftUI: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

This is because MapKit’s local search is delivering its response on a background queue. We need to redispatch this work on the main queue so that it can be rendered on the UI thread.

We can do this by tacking a .receive(on:) operation on the effect to get its output back on the main queue:

return environment.localSearch.search(completion)
  .receive(on: DispatchQueue.main)
  .catchToEffect()
  .map(AppAction.searchResponse)

However, by doing a little bit of upfront work right now we’ll make our lives much easier when it comes to testing. We are going to explicitly add a main queue dependency to our environment via the [AnyScheduler](https://pointfreeco.github.io/combine-schedulers/AnyScheduler/) type from our Combine Schedulers library:

struct AppEnvironment {
  …
  var mainQueue: AnySchedulerOf<DispatchQueue>
}

So that we can use it on the search effect:

case let .tappedCompletion(completion):
  return environment.localSearch.search(completion)
    .receive(on: environment.mainQueue)
    .catchToEffect()
    .map(AppAction.searchResponse)

This will make it possible to use immediate schedulers and test schedulers when writing tests for this feature, rather than being at the mercy of the live dispatch queue, which forces us to add explicitly waits to our tests to wait for thread hops.

Next we need to update our preview and app entry point:

environment: .init(
  localSearch: .live,
  localSearchCompleter: .live(),
  mainQueue: .main
)

Now when we build, we can search for some places and they immediately appear on the map, and the purple warning has gone away.

If we wanted to get a little fancy we could even add an animation so that the map zooms and pans to the region where the marker will be. This is quite easy thanks to the .animation() method we defined in our Combine Schedulers library, which we did a number of episodes on a few months ago:

case let .tappedCompletion(completion):
  return environment.localSearch.search(completion)
    .receive(on: environment.mainQueue.animation())
    .catchToEffect()
    .map(AppAction.searchResponse)

This is pretty cool. With just a bit of work we have now designed two dependency wrappers around MapKit APIs, MKLocalSearchCompleter and MKLocalSearch, and have implemented a decently complicated piece of logic to allow us to search for locations and display those locations on the map.

Testing the entire application

But let’s kick things up a notch.

We could keep adding features to this, but let’s turn our attention to testing. Already the logic is quite complicated, requiring us to fire off multiple effects and coordinate their responses. As we will build more and more of this places searching application we are going to add more logic to the reducer, and so ideally we should have some tests in place to make sure things are working as we expect.

As we’ve said a number of times on Point-Free, testing is one of the most important features of the Composable Architecture and it is a true super power of the library. We can test every little subtle edge case of our reducers, including how effects are executed and fed back into the system, all the while the library keeps us in check to make sure we are exhaustively asserting on everything that happens and no letting anything slip by.

We can start with a stub:

import ComposableArchitecture
import XCTest
@testable import Search

class SearchTests: XCTestCase {
  func testExample() {
  }
}

The first thing we need to do to test a feature in the Composable Architecture is to create a test store, which takes the same arguments as a normal store:

let store = TestStore(
  initialState: <#_#>,
  reducer: <#Reducer<_, _, _>#>,
  environment: <#_#>
)

We can configure it with some initial state, reducer, and environment.

let store = TestStore(
  initialState: .init(),
  reducer: appReducer,
  environment: .init(
    localSearch: <#LocalSearchClient#>,
    localSearchCompleter: <#LocalSearchCompleter#>,
    mainQueue: <#AnySchedulerOf<DispatchQueue>#>
  )
)

We don’t want to use a “live” environment here because it hits Apple’s APIs, which we can’t control, and uses a main queue, which would require us to wait for effects to be received using XCTestExpectations.

Back in a series of episodes we titled “Better Test Dependencies”, we introduced the notion of “failing dependencies”: dependencies that call XCTFail whenever an endpoint is exercised, letting us prove that the code paths we are testing do not use certain dependencies, and forcing us to address when new dependencies are exercised in a test.

In fact, the Combine Schedulers library that the Composable Architecture depends on comes with a “failing” scheduler, so we can supply that immediately:

mainQueue: .failing

And the Composable Architecture comes with a “failing” effect, which we can use in the endpoints for the search client and the completer. Let’s go ahead and create a static .failing implementation of each of our clients, just like AnyScheduler has:

extension LocalSearchClient {
  static let failing = Self(
    search: { _ in .failing("LocalSearchClient.search is unimplemented") }
  )
}

extension LocalSearchCompleter {
  static let failing = Self(
    completions: { .failing("LocalSearchCompleter.completions is unimplemented") },
    search: { _ in .failing("LocalSearchCompleter.search is unimplemented") }
  )
}

And now instantiating a store is short and sweet:

let store = TestStore(
  initialState: .init(),
  reducer: appReducer,
  environment: .init(
    localSearch: .failing,
    localSearchCompleter: .failing,
    mainQueue: .failing
  )
)

Now that we have a store, we can start sending it a script of actions and describe how we expect state to evolve over time.

The .onAppear action seems like a good one to start with.

store.send(.onAppear)

And we can run tests and already get our first failure.

🛑 LocalSearchCompleter.completions is unimplemented - A failing effect ran.

This is a good error to have! The failing effect requires us to consider this effect and explicitly handle it.

Since this is an effect that can emit multiple times we will use a passthrough subject to control it under the hood, which allows us to emit many outputs:

import Combine
import MapKit
…
let completionsSubject = PassthroughSubject<Result<[MKLocalSearchCompletion], Error>, Never>()

And we can override the environment’s endpoint to return this subject:

store.environment.localSearchCompleter.completions = {
  completionsSubject.eraseToEffect()
}

This is the first time we have updated the environment of a test store in this fashion on Point-Free episodes. Typically we create an environment up at the top of the test and pass it to the test store all at once. However, it is also possible to make updates to the environment after creating the test store, which allows you to either change a dependency’s behavior in the middle of the test. Both styles have their pros and cons, so it’s up to you and your team to decide which you prefer.

Now when we run tests, we get a different failure:

🛑 An effect returned for this action is still running. It must complete before the end of the test.

This is saying that the test store knows there is a long-living effect that is still running when the test completes. This is also a good error to have. It’s forcing us to be exhaustive in our tests. If an action fires off an effect, you should want to assert against actions it may feed back into the system, or you should want it to complete.

In the future we may want to cancel this effect using another hook like onDisappear, but for now we can get the test passing by sending a completion to the subject at the end of the test.

defer { completionsSubject.send(completion: .finished) }

And now the test is passing.

Let’s start interacting with the feature. We can simulate a user typing a query into the search field.

store.send(.queryChanged("Apple"))

And we get a couple failures:

❌ LocalSearchCompleter.search is unimplemented - A failing effect ran. ❌ State change does not match expectation: …

  AppState(
    completions: [
    ],
    mapItems: [
    ],
−   query: "",
+   query: "Apple",
    region: CoordinateRegion(
      center: LocationCoordinate2D(
        latitude: 40.7,
        longitude: -74.0
      ),
      span: CoordinateSpan(
        latitudeDelta: 0.075,
        longitudeDelta: 0.075
      )
    )
  )

(Expected: −, Actual: +)

The first failure is because queryChanged fires off another failing effect that we need to override. The second is because the test store forces us to describe any mutations made to state, and queryChanged updates the query field. We can assert against this state change by opening a trailing closure where we mutate the state to how we expect it to look.

store.send(.queryChanged("Apple")) {
  $0.query = "Apple"
}

And then, for the other failure, we can stub out an effect that feeds completions back into the system.

store.environment.localSearchCompleter.search = { _ in
  .fireAndForget {
    completionsSubject.send(.success([MKLocalSearchCompletion()]))
  }
}

When we re-run the test we get a new failure:

❌ The store received 1 unexpected action after this one: …

Unhandled actions: [ AppAction.completionsUpdated( Result<Array, Error>.success( [ <MKLocalSearchCompletion 0x6000029b6760> , ] ) ), ]

This is yet another good failure to have. Not only do we need to exhaustively describe any mutations that happen to test store state, we must also assert against any actions that are fed back into the system from effects. We can do so with the receive method on the test store:

store.receive(.completionsUpdated(.success([MKLocalSearchCompletion()])))

🛑 Instance method ‘receive(:file:line::)’ requires that ‘AppAction’ conform to ‘Equatable’

But to compare this action with the one we expect to receive AppAction must be equatable.

Unfortunately, we don’t get a synthesized conformance for free:

enum AppAction: Equatable {
  …
}

🛑 Type ‘AppAction’ does not conform to protocol ’Equatable’

The only type that is really getting in the way right now is Error, which as a protocol does not conform to Equatable. To work around this, we can take advantage of the fact that every Error can be cast to NSError, and NSError does conform to Equatable.

case completionsUpdated(Result<[MKLocalSearchCompletion], NSError>)
…
case searchResponse(Result<MKLocalSearch.Response, NSError>)

We just need to cast the errors in our reducer before returning the effects:

case .onAppear:
  return environment.localSearchCompleter.completions()
    .map { $0.mapError { $0 as NSError } }
    .map(AppAction.completionsUpdated)
    .eraseToEffect()
…
case let .tappedCompletion(completion):
  return environment.localSearch.search(completion)
    .receive(on: environment.mainQueue.animation())
    .mapError { $0 as NSError }
    .catchToEffect()
    .map(AppAction.searchResponse)

Our tests are now building and when we run them we get 2 new failures:

❌ Received unexpected action: …

  AppAction.completionsUpdated(
    Result<Array<MKLocalSearchCompletion>, NSError>.success(
      [
−       <MKLocalSearchCompletion 0x6000019405f0> ,
+       <MKLocalSearchCompletion 0x6000019406e0> ,
      ]
    )
  )

(Expected: −, Received: +)

❌ State change does not match expectation: …

  AppState(
    completions: [
+     <MKLocalSearchCompletion 0x6000019406e0> ,
    ],
    mapItems: [
    ],
    query: "Apple",
    region: CoordinateRegion(
      center: LocationCoordinate2D(
        latitude: 40.7,
        longitude: -74.0
      ),
      span: CoordinateSpan(
        latitudeDelta: 0.075,
        longitudeDelta: 0.075
      )
    )
  )

(Expected: −, Actual: +)

The first failure says that the action we said we received does not actually the match the action we did receive. They are both .completionsUpdated actions, and even both .success, but the objects inside the success case does not match:

−       <MKLocalSearchCompletion 0x6000019405f0> ,
+       <MKLocalSearchCompletion 0x6000019406e0> ,

The fact that we are seeing pointer addresses in here means we are dealing with reference type, which are notoriously tricky to define equatability on. Perhaps the MapKit framework tracks some additional identity that gets lost when we recreate the completion here in the test. Maybe we can work around it by reusing the same object:

let completion = MKLocalSearchCompletion()
store.environment.localSearchCompleter.search = { _ in
  .fireAndForget {
    completionsSubject.send(.success([completion]))
  }
}
…
store.receive(.completionsUpdated(.success([completion])))

❌ Received unexpected action: …

Expected: completionsUpdated(Swift.Result<Swift.Array<__C.MKLocalSearchCompletion>, __C.NSError>.success([<MKLocalSearchCompletion 0x600001dd3390> ]))

Received: completionsUpdated(Swift.Result<Swift.Array<__C.MKLocalSearchCompletion>, __C.NSError>.success([<MKLocalSearchCompletion 0x600001dd3390> ]))

Unfortunately not. Despite conforming to Equatable, even the same object does not considered equivalent:

let completion = MKLocalSearchCompletion()
XCTAssertEqual(completion, completion)

🛑 XCTAssertEqual failed: (”<MKLocalSearchCompletion 0x6000031f5810> “) is not equal to (”<MKLocalSearchCompletion 0x6000031f5810> “)

Well we should always be prepared to hit limits like these when working with types we don’t own, especially reference types, and luckily we are. Let’s create a wrapper type around MKLocalSearchCompletion that we have complete control over so that we can get this assertion reasonably passing, just as we did for the coordinate and region types from MapKit:

struct LocalSearchCompletion: Equatable {

}

It will hold onto the raw value from MapKit, which is needed later to initialize a local search request.

struct LocalSearchCompletion: Equatable {
  let rawValue: MKLocalSearchCompletion
}

This live value is not what we want to use for tests, we will make it optional.

struct LocalSearchCompletion: Equatable {
  let rawValue: MKLocalSearchCompletion?
}

And for our tests, we’ll also include the fields we care about.

struct LocalSearchCompletion: Equatable {
  let rawValue: MKLocalSearchCompletion?

  var subtitle: String
  var title: String
}

But knowing that we can’t depend on the synthesized equatability of that raw value, we should define a custom conformance.

struct LocalSearchCompletion: Equatable {
  …
  static func == (lhs: Self, rhs: Self) -> Bool {
    lhs.subtitle == rhs.subtitle
      && lhs.title == rhs.title
  }
}

We will also want a few specific ways to create this type. In the live dependency we’ll need to create this type from a raw MKLocalSearchCompletion, but in tests we’ll want to create this type from just the title and subtitle strings:

init(rawValue: MKLocalSearchCompletion) {
  self.rawValue = rawValue
  self.subtitle = rawValue.subtitle
  self.title = rawValue.title
}

init(subtitle: String, title: String) {
  self.rawValue = nil
  self.subtitle = subtitle
  self.title = title
}

Next we should update our dependencies to work with this type. First, the completer will not return completion results of the LocalSearchCompletion type, rather than the MKLocalSearchCompletion type:

struct LocalSearchCompleter {
  var completions: () -> Effect<Result<[LocalSearchCompletion], Error>, Never>
  …
}

And its live implementation will need to be updated to deal with the new type::

class Delegate: NSObject, MKLocalSearchCompleterDelegate {
  let subscriber: Effect<Result<[LocalSearchCompletion], Error>, Never>.Subscriber
  init(subscriber: Effect<Result<[LocalSearchCompletion], Error>, Never>.Subscriber) {
    self.subscriber = subscriber
  }
  func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    self.subscriber.send(
      .success(
        completer.results
        .map(LocalSearchCompletion.init(rawValue:)))
    )
  }
  func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
    self.subscriber.send(.failure(error))
  }
}

And then the search client:

struct LocalSearchClient {
  var search: (LocalSearchCompletion) -> Effect<Response, Error>
}

And its live implementation can reach into the completion’s rawValue to get the real MKLocalSearchCompletion, but because its optional we will force unwrap it:

extension LocalSearchClient {
  static let live = Self { completion in
    .task {
      try await MKLocalSearch(request: .init(completion: completion.rawValue!))
        .start()
    }
  }
}

We feel it is OK to force unwrap here because the rawValue should never be non-nil in production code, only in test code. In fact, we could strengthen this property by making the initializer that uses the rawValue to be the only publicly available initializer, and then the initializer that takes a title and subtitle would be made internal so that it was only available to tests.

Next, in our app domain we will hold onto an array of LocalSearchCompletion values, which should make equatable checks much better:

struct AppState: Equatable {
  var completions: [LocalSearchCompletion] = []
  …
}

Some of our app actions also update:

enum AppAction: Equatable {
  case completionsUpdated(Result<[LocalSearchCompletion], NSError>)
  …
  case tappedCompletion(LocalSearchCompletion)
}

Nothing needs to change in the reducer.

And in the view, where ForEach that iterates over the completions.

🛑 Referencing initializer ‘init(_:content:)’ on ‘ForEach’ requires that ‘LocalSearchCompletion’ conform to ’Identifiable’

We need to update the id we wrote to work on our custom type, instead. We can even make it Identifiable and drop the view’s id: parameter.

extension LocalSearchCompletion: Identifiable {
  public var id: [String] {
    [self.title, self.subtitle]
  }
}

Which means we can even drop the id parameter from ForEach.

ForEach(viewStore.completions) { completion in
  …
}

Our app is building, but our tests are not. We need to update the passthrough subject to work with our new wrapper type:

let completionsSubject = PassthroughSubject<Result<[LocalSearchCompletion], Error>, Never>()

And our completion:

let completion = LocalSearchCompletion(
  subtitle: "Search Nearby",
  title: "Apple Store"
)

Tests are compiling, and we’re down to one failure, where we need to assert against assigning the completions.

store.receive(.completionsUpdated(.success([completion]))) {
  $0.completions = [completion]
}

And tests pass!

Finally let’s test tapping a completion.

store.send(.tappedCompletion(completion))

❌ An effect returned for this action is still running. It must complete before the end of the test. ❌ DispatchQueue - A failing scheduler scheduled an action to run immediately. ❌ LocalSearchClient.search is unimplemented - A failing effect ran.

OK, 3 failures! And if we read through them we see they’re all related:

Tapping a completion fires off a failing effect, which is scheduled on a failing queue, and so that effect is still running.

We can upgrade our failing scheduler to an immediate one to fix the first two failures.

store.environment.mainQueue = .immediate

And we can override the search endpoint with some mock data.

store.environment.localSearch.search = { _ in Effect(value: <#Output#>) }

We can try to create a local search response and set some mock data on it:

let response = MKLocalSearch.Response()
response.mapItems = [MKMapItem()]
response.boundingRegion = .init(
  center: .init(latitude: 0, longitude: 0),
  span: .init(latitudeDelta: 1, longitudeDelta: 1)
)
return .init(value: response)

❌ Cannot assign to property: ‘mapItems’ is a get-only property ❌ Cannot assign to property: ‘boundingRegion’ is a get-only property

OK well it looks like we’re hitting another limitation of working directly with Apple’s types. We’ll want to wrap the response as well.

struct LocalSearchClient {
  var search: (MKLocalSearchCompletion) -> Effect<Response, Error>

  struct Response: Equatable {
    var boundingRegion = CoordinateRegion()
    var mapItems: [MKMapItem] = []
  }
}

This time we can simply wrap the raw data the reducer needs. No need to hold onto the response. The reason we needed to hold onto the MKLocalSearchCompletion in our wrapper type is because the live MKLocalSearch.Request needs access to it.

Let’s add a helper initializer that takes a raw value:

extension LocalSearchClient.Response {
  init(rawValue: MKLocalSearch.Response) {
    self.init(
      boundingRegion: .init(rawValue: rawValue.boundingRegion),
      mapItems: rawValue.mapItems
    )
  }
}

We can update the live client.

extension LocalSearchClient {
  static let live = Self { completion in
    .task {
      .init(
        rawValue: try await MKLocalSearch(
          request: .init(completion: completion.rawValue!)
        ).start()
      )
    }
  }
}

And app action:

enum AppAction: Equatable {
  …
  case searchResponse(Result<LocalSearchClient.Response, NSError>)
  …
}

There’s one compiler error in the reducer where previously we were creating a CoordinateRegion from an MKCoordinateRegion, but now we can just use the coordinate region directly:

case let .searchResponse(.success(response)):
  state.mapItems = response.mapItems
  state.region = response.boundingRegion
  return .none

The app is building again, but now we can create one of those mock values:

let response = LocalSearchClient.Response(
  boundingRegion: .init(
    center: .init(latitude: 0, longitude: 0),
    span: .init(latitudeDelta: 1, longitudeDelta: 1)
  ),
  mapItems: [MKMapItem()]
)
store.environment.localSearch.search = { _ in .init(value: response) }

❌ The store received 1 unexpected action after this one: …

Unhandled actions: [ AppAction.searchResponse( Result<Response, NSError>.success( Response( boundingRegion: CoordinateRegion( center: LocationCoordinate2D( latitude: 50.0, longitude: 50.0 ), span: CoordinateSpan( latitudeDelta: 0.5, longitudeDelta: 0.5 ) ), mapItems: [ <MKMapItem: 0x60000112cd20> { isCurrentLocation = 0; name = “Unknown Location”; placemark = “<+50.50000000,+50.50000000> +/- 0.00m, region CLCircularRegion (identifier:’<+50.50000000,+50.50000000> radius 0.00’, center:<+50.50000000,+50.50000000>, radius:0.00m)”; }, ] ) ) ), ]

One failure, where we need to handle the response action:

store.receive(.searchResponse(.success(response))) {
  $0.region = response.boundingRegion
  $0.mapItems = response.mapItems
}

And they pass!

Now that we have a test suite in place, let’s add a new feature to see how it affects our tests. Let’s make it so when you tap a completion, we repopulate the search field with the full title of the completion:

case let .tappedCompletion(completion):
  state.query = completion.title

❌ State change does not match expectation: …

  AppState(
    …
-   query: "Apple",
+   query: "Apple Store",
    …
  )

Our tests give us instant feedback as to how state changed based of our expectations, making it easy to update:

store.send(.completionTapped(completion)) {
  $0.query = "Apple Store"
}

And now it passes again!

Before we conclude, we should mention that there’s one lone type that we didn’t write a wrapper for, and that’s MKMapItem. And remember, we did conform that type to Identifiable, which is probably not a good idea for types we don’t own. Ideally this type would be wrapped, as well, but we’ll leave that as an exercise for the viewer.

Conclusion

Amazingly this is testing a pretty complex flow, and for the most part its straightforward in the Composable Architecture. We simulate a script of user actions, things like typing into the search bar and tapping on a search suggestion, and we get to assert that not only does state change how we expect, but even effects execute and feed their data back into the system as we expect. Right now we’re only testing the happy path, but there’s also the unhappy paths, which are completely testable, and could guide error handling in our application.

That concludes this series of episodes. We just wanted to give our viewers a peek into some of the cool things announced at WWDC a few months ago, and give some insight into how we might support some of those new features in the Composable Architecture.

Until next time…

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. In the episode we were able to get by without having to wrap Apple’s MKMapItem type, but let’s now go the extra mile and do so by introducing our own MapItem type. How does controlling this type affect our application and test code?

  2. Add an endpoint to LocalSearchClient that can perform a local search with a query string instead of a completion. The MKLocalSearch.Request type has a naturalLanguageQuery mutable field that does just this.

  3. WWDC introduced another search-related API that we didn’t have time to explore, and that’s the onSubmit(of:_:) view modifier, which evaluates an action closure when it detects a particular “submit trigger” is executed, which includes a “search” trigger:

    .onSubmit(of: .search) { … }
    

    Use this API to introduce the ability for a user to fire off a search by submitting the current query string to the local search endpoint from the previous exercise.

  4. Let’s clean up the LocalSearchClient dependency. There are a few things we can fix and make nicer:

    • MKLocalSearch.Request has a region field that we’ve been ignoring, but we should pass the app’s region to the dependency as input so that it can apply the region to the search request.

    • We have 2 separate endpoints for local search, but it might be nicer to unify this into a single interface to better match Apple’s APIs.

References

Craft search experiences in SwiftUI

Harry Lane • Wednesday Jun 9, 2021

A WWDC session exploring the .searchable view modifier.

Searchable modifier in SwiftUI

Sarun Wongpatcharapakorn • Wednesday Jul 7, 2021

A comprehensive article explaining the full .searchable API, including some things we did not cover in this episode, such as the .dismissSearch environment value and search completions.

SwiftUI finally got native search support in iOS 15. We can add search functionality to any navigation view with the new searchable modifier. Let’s explore its capability and limitation.

Downloads