Day 80
Day 80 êŽë š
Project 24, part one
In todayâs technique project weâre going to zoom in on one specific detail of Swift programming: strings. Steve Jobs famously said âthis is what customers pay us for: to sweat all these details so it's easy and pleasant for them to use our computers,â and strings are a good example of one of those details â they look like they ought to be simple, but if you want to get them right you need to spend some time working at it.
As youâll see, thereâs more to strings than just being a sequence of characters. In fact, Swiftâs string handling is the most advanced Iâve seen in any language, and as a result if you play by Swiftâs rules youâll find your apps work great in every conceivable language â something that most developers for other languages need to work hard at.
Today youâre also going to learn an important skill that might cause you to go back and look through earlier projects for places you can use it: how to add formatting to your strings. This is done using a class called NSAttributedString
, and although weâre only using it at an introductory level here as your skills progress youâll learn how it can add images to strings â itâs really powerful!
Today you have four topics to work through, and youâll learn about string subscripts, contains(where:)
, NSAttributedString
, and more.
Setting up
Setting up
After a long project 23, itâs time to ease off and look at something that you might think would be much easier: strings. Yes, those pieces of text we take for granted in almost every program we write â they ought to be fairly straightforward things, but it turns out that they are complicated little beasts and take a little thinking.
In this technique project weâre going to look at why strings often confuse newcomers to Swift, weâll try out a variety of properties and methods that are useful, and weâre also going to look at how to add formatting to strings â bold, italics, color, and more â using a separate class called NSAttributedString
. Supported for attributed strings is baked into most of UIKit, so you can use it with UILabel
, UITextView
, and more.
In Xcode, go to the File menu and choose New > Playground. Name it Project24, make sure iOS is selected as the platform, then choose Next and save it somewhere you can find later.
Strings are not arrays
Strings are not arrays
One of the things that confuses learners is that Swiftâs strings look like arrays of letters, but thatâs not really true.
Sure, we can loop over them like this:
let name = "Taylor"
for letter in name {
print("Give me a \(letter)!"
}
However, we canât read individual letters from the string. So, this kind of code wonât work:
print(name[3])
The reason for this is that letters in a string arenât just a series of alphabetical characters â they can contain accents such as ĂĄ, Ă©, Ă, Ăł, or Ăș, they can contain combining marks that generate wholly new characters by building up symbols, or they can even be emoji.
Because of this, if you want to read the fourth character of name
you need to start at the beginning and count through each letter until you come to the one youâre looking for:
let letter = name[name.index(name.startIndex, offsetBy: 3)]
Apple could change this easily enough by adding a rather complex extension like this:
extension String {
subscript(i: Int) -> String {
return String(self[index(startIndex, offsetBy: i)])
}
}
With that in place, we can now read name[3]
just fine. However, it creates the possibility that someone might write code that loops over a string reading individual letters, which they might not realize creates a loop within a loop and has the potential to be slow.
Similarly, reading name.count
isnât a quick operation: Swift literally needs to go over every letter counting up however many there are, before returning that. As a result, itâs always better to use someString.isEmpty
rather than someString.count == 0
if youâre looking for an empty string.
Working with strings in Swift
Working with strings in Swift
Weâve used strings in lots of the projects so far, and Iâve tried to introduce you to a handful of important properties and methods as we go. Here, though, Iâm going to run through some of those, plus a few more, while also looking at how we can write extensions to make strings a little more useful.
First, there are methods for checking whether a string starts with or ends with a substring: hasPrefix()
and hasSuffix()
. They look like this:
let password = "12345"
password.hasPrefix("123")
password.hasSuffix("345")
We can add extension methods to String
to extend the way prefixing and suffixing works:
extension String {
// remove a prefix if it exists
func deletingPrefix(_ prefix: String) -> String {
guard self.hasPrefix(prefix) else { return self }
return String(self.dropFirst(prefix.count))
}
// remove a suffix if it exists
func deletingSuffix(_ suffix: String) -> String {
guard self.hasSuffix(suffix) else { return self }
return String(self.dropLast(suffix.count))
}
}
That uses the dropFirst()
and dropLast()
method of String
, which removes a certain number of letters from either end of the string.
Weâve used lowercased()
and uppercased()
in previous projects, but thereâs also the capitalized
property that gives the first letter of each word a capital letter. For example:
let weather = "it's going to rain"
print(weather.capitalized)
That will print âItâs Going To Rainâ.
We could add our own specialized capitalization that uppercases only the first letter in our string:
extension String {
var capitalizedFirst: String {
guard let firstLetter = self.first else { return "" }
return firstLetter.uppercased() + self.dropFirst()
}
}
One thing you canât see in that is an interesting subtlety of working with strings: individual letters in strings arenât instances of String
, but are instead instances of Character
â a dedicated type for holding single-letters of a string.
So, that uppercased()
method is actually a method on Character rather than String. However, where things get really interesting is that Character.uppercased()
actually returns a string, not an uppercased Character
. The reason is simple: language is complicated, and although many languages have one-to-one mappings between lowercase and uppercase characters, some do not.
For example, in English âaâ maps to âAâ, âbâ to âBâ, and so on, but in German âĂâ becomes âSSâ when uppercased. âSSâ is clearly two separate letters, so uppercased()
has no choice but to return a string.
One last useful method of strings is contains()
, which returns true if it contains another string. So, this will return true:
let input = "Swift is like Objective-C without the C"
input.contains("Swift")
So, contains()
takes a string parameter and returns true or false depending on whether that parameter exists in the string. Keep that in your head for a moment.
Now look at this code:
let languages = ["Python", "Ruby", "Swift"]
languages.contains("Swift")
That will also return true, because arrays have a contains()
method that returns true or false depending on whether they contain the element you were looking for.
Now for the part that confuses people â brace yourself!
We have an array of strings (["Python", "Ruby", "Swift"]
) and we have an input string ("Swift is like Objective-C without the C"
). How can we check whether any string in our array exists in our input string?
Well, we might start writing an extension on String
like this:
extension String {
func containsAny(of array: [String]) -> Bool {
for item in array {
if self.contains(item) {
return true
}
}
return false
}
}
We can now run our check like this:
input.containsAny(of: languages)
That certainly works, but itâs not elegant â and Swift has a better solution built right in.
You see, arrays have a second contains()
method called contains(where:)
. This lets us provide a closure that accepts an element from the array as its only parameter and returns true or false depending on whatever condition we decide we want. This closure gets run on all the items in the array until one returns true, at which point it stops.
Now letâs put together the pieces:
- When used with an array of strings, the
contains(where:)
method wants to call a closure that accepts a string and returns true or false. - The
contains()
method ofString
accepts a string as its parameter and returns true or false. - Swift massively blurs the lines between functions, methods, closures, and more.
So, what we can actually do is pass one function directly into the other, like this:
languages.contains(where: input.contains)
Donât feel bad if you need to read that single line of code several times â itâs not easy! Letâs break it down.
contains(where:)
will call its closure once for every element in the languages
array until it finds one that returns true, at which point it stops.
In that code weâre passing input.contains
as the closure that contains(where:)
should run. This means Swift will call input.contains("Python")
and get back false, then it will call input.contains("Ruby")
and get back false again, and finally call input.contains("Swift")
and get back true â then stop there.
So, because the contains()
method of strings has the exact same signature that contains(where:)
expects (take a string and return a Boolean), this works perfectly â do you see what I mean about how Swift blurs the lines between these things?
Formatting strings with NSAttributedString
Formatting strings with NSAttributedString
Swiftâs strings are plain text, which works fine in the vast majority of cases we work with text. But sometimes we want more â we want to be able to add formatting like bold or italics, select from different fonts, or add some color, and for those jobs we have a new class called NSAttributedString
.
Attributed strings are made up of two parts: a plain Swift string, plus a dictionary containing a series of attributes that describe how various segments of the string are formatted. In its most basic form you might want to create one set of attributes that affect the whole string, like this:
let string = "This is a test string"
let attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.white,
.backgroundColor: UIColor.red,
.font: UIFont.boldSystemFont(ofSize: 36)
]
let attributedString = NSAttributedString(string: string, attributes: attributes)
Itâs common to use an explicit type annotation when making attributed strings, because inside the dictionary we can just write things like .foregroundColor
for the key rather than NSAttributedString.Key.foregroundColor
.
The values of the attributes dictionary are of type Any
, because NSAttributedString
attributes can be all sorts of things: numbers, colors, fonts, paragraph styles, and more.
If you look in the output pane of your playground, you should be able to click on the box next to where it says âThis is a test stringâ to get a live preview of how our string looks â you should see large, white text with a red background.
Of course, we could get the same effect with a regular string placed inside a UILabel
: change the font and colors, and it would look the same. But what labels canât do is add formatting to different parts of the string.
To demonstrate this weâre going to use NSMutableAttributedString
, which is an attributed string that you can modify:
let attributedString = NSMutableAttributedString(string: string)
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 8), range: NSRange(location: 0, length: 4))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 16), range: NSRange(location: 5, length: 2))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 24), range: NSRange(location: 8, length: 1))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 32), range: NSRange(location: 10, length: 4))
attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 40), range: NSRange(location: 15, length: 6))
When you preview that youâll see the font size get larger with each word â something a regular Swift string certainly canât do even with help from UILabel
.
There are lots of formatting options for attributed strings, including:
- Set
.underlineStyle
to a value fromNSUnderlineStyle
to strike out characters. - Set
.strikethroughStyle
to a value fromNSUnderlineStyle
(no, thatâs not a typo) to strike out characters. - Set
.paragraphStyle
to an instance ofNSMutableParagraphStyle
to control text alignment and spacing. - Set
.link
to be aURL
to make clickable links in your strings.
And thatâs just a subset of what you can do.
You might be wondering how useful all this knowledge is, but hereâs the important part: UILabel
, UITextField
, UITextView
, UIButton
, UINavigationBar
, and more all support attributed strings just as well as regular strings. So, for a label you would just use attributedText
rather than text
, and UIKit takes care of the rest.