Leave a rating/review
Notes: 16. Model Objects
In this episode, you’re going to put the finishing touches on your book model!
That’s going to involve a property for whether the book still needs you to read it…
…and then, for afterwards, a “micro” review (probably just a sentence fragment).
With that data available, you’ll also be finishing up with this Detail View, by the end of this part!
The bookmark in the upper-left will reflect whether you still need to read the book. To start, let’s head to the Book file, and add our properties.
You’ll want to be able to change them, so they’ll be “vars”, not “lets”. First, let’s add a “microReview” String…
let author: String
var microReview: String
init(title: String = "Title", author: String = "Author") {
…with a default empty value set in the initializer.
var microReview: String
init(
title: String = "Title",
author: String = "Author",
microReview: String = ""
) {
self.title = title
self.author = author
self.microReview = microReview
}
Then, a readMe Bool…
var microReview: String
var readMe: Bool
init(
…which should start off true, of course!
microReview: String = "",
readMe: Bool = true
) {
self.title = title
self.author = author
self.microReview = microReview
self.readMe = readMe
}
That’s all the modeling done; now, we’ll add a button in Detail View …
to let users update the readMe value, after they’ve read a book! We’ll put it right next to the Title and Author , so start by embedding in an HStack…
HStack {
TitleAndAuthorStack(...)
}
And give things just a little more breathing room, with spacing of 16 points.
HStack(spacing: 16)
And then start a new Button with an action and a label
Button {
}
We’ll leave the action empty for now, and move on to the label, which will be a symbol. (Created, like you’ve done before, with the Image-systemName initializer.)
Button {
} label: {
Image(systemName: <#T##String#>)
}
}
In the case where the book’s readMe property is true, we’ll use a filled bookmark symbol, so, bookmark.fill
Image(systemName: book.readMe ? "bookmark.fill")
And otherwise, just “bookmark” will do it. It’ll be an outline.
"bookmark.fill" : "bookmark")
In either case, use a system Font at 48 points, with light weight.
} label: {
Image(systemName: book.readMe ? "\(bookmark).fill" : bookmark)
.font(.system(size: 48, weight: .light))
}
As for the button’s action, we’d like it to toggle the readMe state of the book.
Button {
book.readMe.toggle()
} label: {
And that means we’re mutating properties of the book, so we’ll need it to be var, not let.
🟩var🟩 book: Book
But as you see, the toggling is still not possible. SwiftUI offers multiple solutions to this problem, such as using State, and Binding, which work with value types, like Book is.
But we’re going to take a different route. Although passing Book bindings through the app is a viable approach, I think it would result in harder to manage code in this app. Plus, it’ll give you a chance to practice using some of SwiftUI’s other data flow solutions.
So, what we’re going to do is turn Book into a reference type. A class, not a struct.
class Book: Hashable {
As you can see, it’s not as simple as just changing that keyword. But we’ll put in some work in this file, and everything else we write will benefit from it.
So, why are we getting this error now? You know how Swift will synthesize an initializer for structs, but not classes? This is related.
We’re saying here that we want Book to be Hashable. What’s been hidden from us until now is that the Hashable protocol requires its type to also conform to equatable.
class Book: Hashable {
And for code organization, it’s better to put that in an extension.
extension Book: Equatable {
}
We should get a helpful “fix it” option at this point, so hit that button.
self.readMe = readMe
}
}
extension Book: Equatable {
static func == (lhs: Book, rhs: Book) -> Bool {
<#code#>
}
}
This double-equals operator is how Swift determines if two things are “equal”. It gets auto-generated for structs much like the member-wise initializer does. Structs are value types, so if all of their properties are equatable and their values match, as far as Swift’s concerned, they’re equal.
But classes are reference types! They refer to a specific object. So, matching property values is not quite enough information. The easiest way to equate two class instances is to find out if they do refer to the same object. The triple-equals operator does that.
static func == (lhs: Book, rhs: Book) -> Bool {
lhs === rhs
}
Swift is open source, so we can go to GitHub, and find out what that actually does.
Search for “func ===”. It’s right near the bottom.
public func === (lhs: AnyObject?, rhs: AnyObject?) -> Bool {
It works on optionals too. When neither of the operands are nil, Swift will create instances of the ObjectIdentifier structure, using them, and equate those. ObjectIdentifier is just a lightweight wrapper around a memory address. So that’s how you figure out if two objects are really the same.
return ObjectIdentifier(l) == ObjectIdentifier(r)
We’ll make use of ObjectIdentifier, in a second.
First, let’s move this Hashable adoption to an extension, too. Because like equatable, classes need us to specify how we want it to be hashed.
class Book {
self.readMe = readMe
}
}
extension Book: Hashable {
}
extension Book: Equatable {
Start typing “hash” inside of that, and autocomplete will take care of you with a hash(into hasher) method.
extension Book: Hashable {
func hash(into hasher: inout Hasher) {
<#code#>
}
}
You don’t need to know much of anything about hashing algorithms, in order to make use of this. The typical thing to do, is to take the “hasher” you’re provided, and use its combine method with something Hashable, that identifies what you’re working with somehow.
func hash(into hasher: inout Hasher) {
hasher.combine(<#T##value: Hashable##Hashable#>)
}
ObjectIdentifiers are Hashable (which makes sense, because you can’t have two things at the same memory address), so let’s use that, with the Book instance we’re working with.
hasher.combine(ObjectIdentifier(self))
And now, Book is Hashable! (And it’s Equatable too, because Hashable things need to be Equatable.)
A book instance is now also called a “Model Object”. Because 1. The Book type is our model and 2. an instance of a class is known as an “object”.
A lovely side effect of making book into a reference type is that it also conforms to the Identifiable protocol now.
Identifiable is sort of like Hashable, in that it’s designed to identify an instance somehow. But the difference is in indirection:
An Identifiable type does not, itself, need to be Hashable. (Though it’s fine if it is). Instead, an Identifiable instance needs to provide a property that is Hashable. That property has to be named “id”.
And for reference types, Swift provides you with a default implementation for it. This extension, from the standard library, works with “AnyObject”. That’s a protocol that all classes conform to.
The “id” for a class instance, unless you provide your own custom version, is an ObjectIdentifier that wraps it.
So instead of making one of those ourselves…
hasher.combine(ObjectIdentifier(self))
…we can adopt the Identifiable protocol, instead…
extension Book: Hashable, Identifiable {
…and then, use id, which is the same thing, only pre-packaged for us!
hasher.combine(id)
Now, in our List…
…we don’t need to bother with an id anymore.
List(library.sortedBooks) { book in
If you’re using a List or a ForEach view, with a collection of Identifiable instances, you can, and should, do that!