iOS brings with it some really exciting functionality, such as indexing contents inside your app as searchable content on an iOS device. Even better, you can contribute to iOS’s public search index so that your searchable content appears on devices that don’t even have your app installed. That’s pretty cool, don’t you agree? In this chapter, we’ll have a look at all these great features.
You want the user to be able to search within the contents inside your app, from iOS’s search functionality (see Figure 10-1).
First, you will need to construct an object of type CSSearchableItemAttributeSet
. This will represent the metadata for any one item that you want to index in the search. Having the metadata, construct an instance of the CSSearchableItem
class with your metadata and expiration date, plus some other properties that you will see soon. Index an item using the CSSearchableIndex
class. You’ll get a completion block that will let you know whether or not things went well.
You have to keep quite a few things in mind when indexing items in the local device search functionality. I’ll walk you through them one by one. Always keep this index in a useful state. Don’t index stuff that you don’t need, and make sure you delete the old items. You can specify an expiration date for your content, so I suggest that you always do that.
Let’s look at an example. We will start off by including the two required frameworks that we are going to use:
import
CoreSpotlight
import
MobileCoreServices
Then we will proceed by deleting all existing indexed items using the deleteAllSearchableItems(completionHandler:)
method of the CSSearchableIndex
class. This method takes in a closure that gives you an optional error. Check this error if you want to find out whether something went wrong:
// delete the existing indexed items
CSSearchableIndex
.
default
()
.
deleteAllSearchableItems
{
err
in
if
let
err
=
err
{
(
"Error in deleting
(
err
)
"
)
}
}
Now let’s instantiate our metadata of type CSSearchableItemAttributeSet
and give it a title, description, path and URL, keywords, and a thumbnail:
let
attr
=
CSSearchableItemAttributeSet
(
itemContentType
:
kUTTypeText
as
String
)
attr
.
title
=
"My item"
attr
.
contentDescription
=
"My description"
attr
.
path
=
"http://reddit.com"
attr
.
contentURL
=
URL
(
string
:
attr
.
path
!)
!
attr
.
keywords
=
[
"reddit"
,
"subreddit"
,
"today"
,
"i"
,
"learned"
]
if
let
url
=
Bundle
(
for
:
type
(
of
:
self
))
.
url
(
forResource
:
"Icon"
,
withExtension
:
"png"
){
attr
.
thumbnailData
=
try
?
Data
(
contentsOf
:
url
)
}
Then let’s create the actual searchable item of type CSSearchableItem
and set its expiration date 20 seconds into the future:
// searchable item
let
item
=
CSSearchableItem
(
uniqueIdentifier
:
attr
.
contentURL
!.
absoluteString
,
domainIdentifier
:
nil
,
attributeSet
:
attr
)
let
cal
=
Calendar
.
current
// our content expires in 20 seconds
item
.
expirationDate
=
cal
.
date
(
from
:
cal
.
dateComponents
(
in
:
cal
.
timeZone
,
from
:
Date
().
addingTimeInterval
(
20
)))
Finally, use the indexSearchableItems(_:)
method of the CSSearchableIndex
class to index the item that we just created. You can index an array of items, but we have just one item, so let’s index that for now:
// now index the item
CSSearchableIndex
.
default
()
.
indexSearchableItems
([
item
])
{
err
in
guard
err
==
nil
else
{
(
"Error occurred
(
err
!
)
"
)
return
}
(
"We successfully indexed the item. Will expire in 20 seconds"
)
}
When the user taps your item in the results list, your app will be opened and iOS will call the application(_:continue:restorationHandler:)
method on your app delegate. In this method, you have to do a few things:
CSSearchableItemActionType
. The aforementioned method gets called under various circumstances—for example, with Handoff—so we have to make sure we are responding only to activities that concern indexed items.userInfo
property of the activity and read the value of the CSSearchableItemActivityIdentifier
key from it. This should be the identifier for your indexed item.func
application
(
_
application
:
UIApplication
,
continue
userActivity
:
NSUserActivity
,
restorationHandler
:
@
escaping
([
Any
]?)
->
Void
)
->
Bool
{
guard
userActivity
.
activityType
==
CSSearchableItemActionType
,
let
id
=
userActivity
.
userInfo
?[
CSSearchableItemActivityIdentifier
]
as
?
String
else
{
return
false
}
// now we have access to id of the activity. and that is the URL
(
id
)
return
true
}
Run your code and then send your app to the background. Open a search in your iPhone and do a search on the item that we just indexed (see Figure 10-2).
Let’s say that the user is inside your app and is editing the text inside a text field. You start a user activity and want the user to be able to search for this activity in her home screen, then continue with that activity later. Start with the UI. Drop a text field and a text view on your view controller to make it look like Figure 10-3.
The text field will allow the user to enter whatever text she wants, and we will use the text view to write log messages so that we (and the user) know what is going on under the hood of our app. Hook these up to your code. I’ve named the text field textField
and the text view status
. Also set the delegate of your text field to your view controller, because you are going to want to know when the text field becomes active and inactive. That lets you update the user activity accordingly.
Make your view controller conform to UITextFieldDelegate
and NSUserActivityDelegate
protocols and implement the user activity delegate methods:
func
userActivityWasContinued
(
_
userActivity
:
NSUserActivity
)
{
log
(
"Activity was continued"
)
}
func
userActivityWillSave
(
_
userActivity
:
NSUserActivity
)
{
log
(
"Activity will save"
)
}
Let’s also write a handy method that allows us to log messages into our text view:
func
log
(
_
t
:
String
){
DispatchQueue
.
main
.
async
{
self
.
status
.
text
=
t
+
"
"
+
self
.
status
.
text
}
}
We need another method that can read the contents of our text field and, if it’s nil
, give us an empty string:
func
textFieldText
()
->
String
{
if
let
txt
=
self
.
textField
.
text
{
return
txt
}
else
{
return
""
}
}
Then create your user activity as a lazy variable and mark it as searchable:
//
TODO:
change this ID to something relevant to your app
let
activityType
=
"se.pixolity.Making-User-Activities-Searchable.editText"
let
activityTxtKey
=
"se.pixolity.Making-User-Activities-Searchable.txt"
lazy
var
activity
:
NSUserActivity
=
{
let
a
=
NSUserActivity
(
activityType
:
self
.
activityType
)
a
.
title
=
"Text Editing"
a
.
isEligibleForHandoff
=
true
a
.
isEligibleForSearch
=
true
// do this only if it makes sense
// a.isEligibleForPublicIndexing = true
a
.
delegate
=
self
a
.
keywords
=
[
"txt"
,
"text"
,
"edit"
,
"update"
]
let
att
=
CSSearchableItemAttributeSet
(
itemContentType
:
kUTTypeText
as
String
)
att
.
title
=
a
.
title
att
.
contentDescription
=
"Editing text right in the app"
att
.
keywords
=
Array
(
a
.
keywords
)
if
let
u
=
Bundle
.
main
.
url
(
forResource
:
"Icon"
,
withExtension
:
"png"
){
att
.
thumbnailData
=
try
?
Data
(
contentsOf
:
u
)
}
a
.
contentAttributeSet
=
att
return
a
}()
Once your text field becomes active, mark the activity as the current one:
func
textFieldDidBeginEditing
(
_
textField
:
UITextField
)
{
log
(
"Activity is current"
)
userActivity
=
activity
activity
.
becomeCurrent
()
}
func
textFieldDidEndEditing
(
_
textField
:
UITextField
)
{
log
(
"Activity resigns being current"
)
activity
.
resignCurrent
()
userActivity
=
nil
}
When the text field’s content changes, mark that the user activity needs to be updated:
func
textField
(
_
textField
:
UITextField
,
shouldChangeCharactersIn
range
:
NSRange
,
replacementString
string
:
String
)
->
Bool
{
activity
.
needsSave
=
true
return
true
}
A method in your view controller named updateUserActivityState(_:)
gets called periodically when the current activity needs to be updated. Here you get the chance to update the user info dictionary of the activity:
override
func
updateUserActivityState
(
_
a
:
NSUserActivity
)
{
log
(
"We are asked to update the activity state"
)
a
.
addUserInfoEntries
(
from
:
[
self
.
activityTxtKey
:
self
.
textFieldText
()])
super
.
updateUserActivityState
(
a
)
}
That’s it, really. Now when the user starts writing text in the text field, and then sends the app to background, she will be able to search for the activity that she had started right on her home screen and then continue where she left off. I will not cover the details of handling the request to continue the user activity, because they are not new APIs.
Let’s have a look at an example. Say that you have already indexed some items (see Recipe 10.1) and you want to delete that content. The first step is to get a handle to the CSSearchableIndex
class:
let
identifiers
=
[
"com.yourcompany.etc1"
,
"com.yourcompany.etc2"
,
"com.yourcompany.etc3"
]
let
i
=
CSSearchableIndex
(
name
:
Bundle
.
main
.
bundleIdentifier
!)
Then use the fetchLastClientState(_:completionHandler:)
method on the index to get the latest application state that you had submitted to the index. After that, you can begin deleting the items inside the identifiers
array by using the beginIndexBatch()
function on the index. Then use the deleteSearchableItems(withIdentifiers:completionHandler:)
function, which returns a completion handler. This handler will return an optional error that dictates whether the deletion went OK or not. Once we are done, we end the batch updates on the index with the endBatch(withClientState:completionHandler:)
method:
i
.
fetchLastClientState
{
clientState
,
err
in
guard
err
==
nil
else
{
(
"Could not fetch last client state"
)
return
}
let
state
:
Data
if
let
s
=
clientState
{
state
=
s
}
else
{
state
=
Data
()
}
i
.
beginBatch
()
i
.
deleteSearchableItems
(
withIdentifiers
:
identifiers
)
{
err
in
if
let
e
=
err
{
(
"Error happened
(
e
)
"
)
}
else
{
(
"Successfully deleted the given identifiers"
)
}
}
i
.
endBatch
(
withClientState
:
state
,
completionHandler
:
{
err
in
guard
err
==
nil
else
{
(
"Error happened in ending batch updates =
(
err
!
)
"
)
return
}
(
"Successfully batch updated the index"
)
})
}
The content identifiers that I’ve put in the identifiers
array are just an example. I don’t know what identifiers you want to use, but make sure that you update this array before attempting to delete the existing indexed items.