Leave a rating/review
Some of your books, you’ve read. Some, you have not! A useful organizational feature of your app would be to section those off from each other.
And you’re in luck, because sectioning is a feature of Lists. For example, the Contacts app has a section for every letter that an existing contact starts with.
The Settings app has sections for different kinds of settings. Some sections have headers or footers; some don’t!
For our app, we’ll use a section for each of the two “read me” options for a book. And each one will have a header: an old school, skeuomorphic header, to match up with the physically-based theme of the app!
We’ll start by updating our Library data to give us a representation of the sections we want, and all of our books sorted into those sections. To represent the sections in code, let’s add an enumeration in the Library file.
enum Section {
case readMe
case finished
}
class Library: ObservableObject {
We’re going to want to be able to iterate through all of the sections, later, so make this CaseIterable, too.
enum Section🟩: CaseIterable {
One of the ways you’ll be using theses cases is as dictionary keys. This booksCache below represents all of our books. But they’re not grouped by Section.
Let’s update this sortedBooks array, and make it a dictionary property with a Section for a key, and a Book array for the value.
var sortedBooks: [Section: [Book]] = [:]
/// Adds a new book at the start of the library's manually-sorted books.
func addNewBook(_ book: Book, image: UIImage?) {
Then, instead of just forwarding along the booksCache, we’ll take all of the books in there and split them up based on their readme properties
There’s actually a really handy Dictionary initializer that can do that for us. Dictionary(grouping by)
func sortBooks() {
let groupedBooks = Dictionary(grouping: booksCache, by: \.readMe)
}
This groups the books based on their readMe property! A Book’s “readMe” property is a Bool, so that line of code creates a dictionary with Bool keys. If the key is true, then the value is an array of all the “ReadMe” books! The value for the false key is all of the finished books.
But, as the error tells us, we’re looking for Sections as keys, not Bools.
We can use another Dictionary initializer to take the grouped books and effectively transform their keys into the type we want. This one takes in an array of tuples that will represent key/value pairs.
We can turn our groupedBooks into that tuple array with map! If the existing key is true, we want to use .readMe, and if it’s false, we want to use finished. And there are no changes to the value
let groupedBooks = Dictionary(grouping: booksCache, by: \.readMe)
🟩return Dictionary(
uniqueKeysWithValues: groupedBooks.map {
(($0.key ? .readMe : .finished), $0.value)
}
)
So now, all books with readMe set to true will be assigned to the readMe Section, and the rest will be assigned to the value for the finished Section.
With our library data all sorted into sections, we can move on to creating Section UI. We’re going to using some images for section header backgrounds, so, we need to import those.
Open up the asset catalog, and let’s drag the BookTexture.imageset from the Resources folder for this episode to import it.
That will give us a version for light and dark appearances! Now, near the bottom of ContentView.swift…
Add a “SectionView” and start a body property.
🟩struct SectionView: View {
var body: some View {
}
}
struct ContentView_Previews: PreviewProvider {
This view will only be used in this file, so you can make it private.
private struct SectionView: View {
That’s just a way for you to mark the code that has no reason to be used outside of the file you wrote it in. And that’s true of BookRow as well.
private struct BookRow: View {
private is a good organizational tactic. Generally, it’s good practice to keep your code private, until you need other files to use it.
But you can’t do this with the previews struct. For undocumented reasons, private previews just don’t work.
Moving on, a section view is going to require a Section.
private struct SectionView: View {
let section: Section
var body: some View {
And, like most everything else in your project, it will need a library environment mobject too.
let section: Section
@EnvironmentObject var library: Library
var body: some View {
We’ll only render the section if the library has any sorted books for it.
var body: some View {
if let books = library.sortedBooks[section] {
}
}
When there are books, we’ll use SwiftUI’s Section view. Any because we’ve got our own “Section” type, you’ll need to disambiguate using the framework name.
if let books = library.sortedBooks[section] {
SwiftUI.Section(content: <#T##() -> _#>, header: <#T##_#>)
}
for the content, instantiate a BookRow for each book.
SwiftUI.Section {
🟩ForEach(books) { book in
BookRow(book: book)
}
} header: {
The header is going to be based on the “BookTexture” image set you just imported.
} header: {
🟩Image("BookTexture")
}
We’ll want a ForEach view, for the sections, too, so head up to the ForEach in the ContentView struct.
ForEach(library.sortedBooks) { book in
BookRow(book: book)
}
- And get rid of it.
content: NewBookView.init
)
}
.navigationBarTitle("My Library")
The ForEach we want instead, will be based on all Section cases. This is why we made Section caseIterable earlier!
)
ForEach(Section.allCases, id: <#T##KeyPath<_.Element, _>#>, content: <#T##(_.Element) -> _#>)
}
CaseIterable elements can be identified using self…
ForEach(Section.allCases, id: \.self, content: <#T##(_.Element) -> _#>)
And the view we need for each case is a SectionView.
ForEach(Section.allCases, id: \.self) {
SectionView(section: $0)
}
There’s a header! But let’s make it look a little nicer. Back down in the Section View… Resize it to fit…
SwiftUI.Section(
header:
Image("BookTexture")
.resizable()
.scaledToFit()
) {
And to get it to fill up the whole width of the screen, you’ll need the listRowInsets modifier.
.scaledToFit()
.listRowInsets(/*@START_MENU_TOKEN@*/.none/*@END_MENU_TOKEN@*/)
) {
By default, that’s nil. You need a value, instead, but it can use whatever the default initializer provides.
.listRowInsets(.init())
The last thing we’ll need for the SectionView is a text view.
To set up the title for it, create a computed String property.
@EnvironmentObject var library: Library
var title: String {
}
var body: some View {
Switch on section…
var title: String {
switch section {
}
}
…and let the Fix-it fill in the cases.
var title: String {
switch section {
case .readMe:
<#code#>
case .finished:
<#code#>
}
}
Then return whatever you’d like to see for each section. I like exclamation points.
case .readMe:
return "Read Me!"
case .finished:
return "Finished!"
}
Now, embed the the image in a ZStack.
header:
ZStack {
Image("BookTexture")
.resizable()
.scaledToFit()
.listRowInsets(.init())
}
) {
But move the insets down, for them to keep taking effect.
.scaledToFit()
}
.listRowInsets(.init())
) {
ForEach(books) {
This does look a little wrong, in the preview, but it will correct itself when we run the app.
A ZStack is a way for you to put views in front of, or behind each other. To put your title in front, put it in a Text, at the bottom of your ZStack.
.scaledToFit()
Text(title)
}
To go with the look of the image, let’s use the “American Typewriter” custom font…
Text(title)
.font(.custom("American Typewriter", size: <#T##CGFloat#>))
}
…at 24 points.
size: 24))
and give it a primary foreground color
.foregroundColor(.primary)
And that’s all, for SectionView!
Trouble is, though, the books all stay in the Read Me section, even after un-bookmarking them.
That’s because, with Book being a reference type, the Library doesn’t consider a change to one of its properties to be a change to the book itself. So nothing notifies SwiftUI that it should update the ContentView.
There is a way we can manually tell SwiftUI that the library is “different”, and it should update views accordingly. Head over to Library.swift
And we’ll write a short method just above addNewBook, called sortBooks
func sortBooks() {
}
And in the body, say “objectWillChange.send()”
objectWillChange.send()
This is actually how Observable Objects work behind the scenes! Any property you wrap with “Published” invisibly “sends”, from an observable object’s objectWillChange publisher, when the property is about to change. So, we’re just doing that manually, here.
One more little thing! When we call this, we’ll have updated at book’s properties, and thus the sortedBooks computed property.
It would be a good idea, at this point, to also update the ordering in booksCache. Start with assigning to sortedBooks, at the top of the method
booksCache = sortedBooks
But the cache is an array, not a dictionary. So we need to take the books for both sections, and combine them. Which is easy, by flatmapping the values in the dictionary.
booksCache = sortedBooks🟩.flatMap { $0.value }
And just for organizational purposes, let’s sort the dictionary first, so that all of the finished books come after the “read me” books.
sortedBooks
🟩.sorted { $1.key == .finished }
.flatMap { $0.value }
Now we can use this method over in DetailView.swift
You might think a good place to do that kind of thing is in this bookmark button! So after readMe is toggled, we’ll call sortBooks()
Button {
book.readMe.toggle()
library.sortBooks()
} label: {
But if you do this from inside the button, every time the readMe value is toggled, the source of data that this detail view is based on will be invalidated, and you’ll be immediately bumped back to the List view. So, that’s probably not what we want.
Button {
book.readMe.toggle()
❌library.sortBooks()
Instead, we can use a modifier on this entire VStack called onDisappear and use sortBooks from there
.padding()
🟩.onDisappear {
library.sortBooks()
}
This way, you can make multiple changes to a book, and it will be updated when you leave the detail screen. You can make it extra fancy by wrapping the method call in a withAnimation function
.onDisappear {
🟩withAnimation {
library.sortBooks()
}
}
That will animate any view updates resulting from changes within this closure.
So now back in content view. Live preview and pick a book. Tap that bookmark button and then when you go back to the list. You should see that change animate right away!