Welcome, Guest User :: Click here to login

Logo 67443

Lab 4: BookManager

Due Date: October 01

Objectives

  • introduce students to table views, tabs, forms, and other UI patterns in SwiftUI
  • learn how to pass data between sections of the app with EnvironmentObject.
  • teach students how to use the new SwiftUI Charts library to make basic visualizations

README.md

BookManager


In this lab we are going to build a slight variation of the BookManager lab from 67-272. However, at this stage, we don't have access to a database yet so we will be drawing data through an array of records we'll provide. But it will allow us to use some mobile UI elements we discussed last year, in addition to a form to add records to the array and create some charts to analyze our book records.

When we are finished, we'll have an app like this:

Again, this lab can be completed on a Mac with Xcode or on an iPad with the Playgrounds app. Unfortunately, this is a new lab and there is no Android version yet.

Part 0: Creating tab bar navigation

  1. Create a new app called BookManager that uses SwiftUI as we did last week. In the main BookManagerApp file, change ContentView() to AppView(). Create three folders -- Views, Models, and ViewModels. In the Views folder, create four new files -- AppView.swift, LibraryView.swift, NewBookView.swift, and ChartsView.swift. Copy the contents of ContentView.swift into the latter three files, adjusting the class names to match the file names. (Leave AppView untouched.) Once you done this, delete the ContentView.swift file.

  2. Inside the AppView.swift file we are going to build our tabs. The following code will get us started:

import SwiftUI

struct AppView: View {
    var body: some View {
      TabView {
        LibraryView()
        .tabItem {
          Image(systemName: "books.vertical")
          Text("Library")
        }
      }
    }
  }

struct AppView_Previews: PreviewProvider {
    static var previews: some View {
      AppView()
    }
  }
If we run this code now, we should see the following:

<img src="https://i.imgur.com/G7Aond2.png" width="30%"/>
  1. Now go ahead and add two more tabs, one with a text of "New Book" and an image of rectangle.stack.badge.plus and another tab with a text of "Charts" and an image of chart.bar.xaxis. If you want to choose different icons, you can do that; check out Apple's SF Symbols app for help with this. Rerunning and flipping through the tabs, you should see this:

Part 1: Creating models

  1. We are going to be tracking the gender of the authors of our books, so to make that easier, we'll create an enum called Gender.swift in the Models folder. Add to that file the following code:
  enum Gender: String {
    case male = "Male"
    case female = "Female"
    case other = "Other"

    static let allGenders = ["Male", "Female", "Other"]
  }
  1. Now create a Book struct inside the Models folder. The struct should have String properties for title,author, and gender, as well as a boolean property called displayed.

  2. Of course, we need an init() method for this struct, which we will do with the following:

  init(title: String, author: String, gender: String, displayed: Bool) {
    self.title = title
    self.author = author
    self.gender = gender
    self.displayed = displayed
  }
  1. Since we are going to read the data from a dictionary later, rather than a database, there is no ID for these records that allow us to uniquely identify them. In cases like this, we'd like to make this struct conform to Swift's Identifiable protocol. We will discuss this in class soon, but for now, doing this is really easy. First, edit the first line of the struct to include the protocol as follows:
  struct Book: Identifiable {

Next, add the following line to the properties list created in the prior step to associate a Universally Unique ID for each object created:

  var id = UUID()    // To conform to Identifialbe protocol
  1. The other challenge with the struct is how do we compare objects build with it? How do we sort them? If we conform to the Comparable protocol, we could solve these issues. To make that happen, add this protocol to the struct declaration as follows:
  struct Book: Identifiable, Comparable {

Now to implement this protocol, we need a method to demonstrate equality. We can do that with the following method that will declare an object identical if both the title and author strings match:

  static func ==(lhs: Book, rhs: Book) -> Bool {
    return lhs.title == rhs.title && lhs.author == rhs.author
  }

Next we need a method to determine ordering of object of type Book. The following method orders objects alphabetically based on title:

  static func <(lhs: Book, rhs: Book) -> Bool {
    return lhs.title < rhs.title
  }

Part 2: Creating a library

  1. We have a Book model which describes a book object and adds some basic protocols to extend functionality, but now we need a class (not struct) for a collection of books, which we will call Library; create that file now and make the class an ObservableObject.

  2. Within the class we will declare a published array property for books and then create an init() method that will read in 77 classic books from a dictionary and populate that books array. This code will do that:

    @Published var books: [Book] = []

    init() {
      let bookData = [
              "The Count of Monte Cristo":["Alexandre Dumas","Male"],
        "The Man in the Iron Mask":["Alexandre Dumas","Male"],
        "The Three Musketeers":["Alexandre Dumas","Male"],
        "Black Beauty":["Anna Sewell","Female"],
        "The Tenant of Wildfell Hall":["Anne Bronte","Female"],
        "The Prisoner of Zenda":["Anthony Hope","Male"],
        "Adventures of Sherlock Holmes":["Arthur Conan Doyle","Male"],
        "The Hound of the Baskervilles":["Arthur Conan Doyle","Male"],
        "Dracula":["Bram Stoker","Male"],
        "A Christmas Carol":["Charles Dickens","Male"],
        "A Tale of Two Cities":["Charles Dickens","Male"],
        "David Copperfield":["Charles Dickens","Male"],
        "Great Expectations":["Charles Dickens","Male"],
        "Hard Times":["Charles Dickens","Male"],
        "Nicholas Nickleby":["Charles Dickens","Male"],
        "Oliver Twist":["Charles Dickens","Male"],
        "The Pickwick Papers":["Charles Dickens","Male"],
        "Jane Eyre":["Charlotte Bronte","Female"],
        "Shirley":["Charlotte Bronte","Female"],
        "The Professor":["Charlotte Bronte","Female"],
        "Villette":["Charlotte Bronte","Female"],
        "The Age of Innocence":["Edith Wharton","Female"],
        "Wuthering Heights":["Emily Bronte","Female"],
        "Crime and Punishment":["Fyodor Dostoyevsky","Male"],
        "The Brothers Karamazov":["Fyodor Dostoyevsky","Male"],
        "The Phantom of the Opera":["Gaston Leroux","Male"],
        "1984":["George Orwell","Male"],
        "Animal Farm":["George Orwell","Male"],
        "To Kill A Mockingbird":["Harper Lee","Female"],
        "Uncle Tom's Cabin":["Harriet Beecher Stowe","Female"],
        "Walden":["Henry David Thoreau","Male"],
        "Last of the Mohicans":["James Fenimore Cooper","Male"],
        "The Deerslayer":["James Fenimore Cooper","Male"],
        "Emma":["Jane Austen","Female"],
        "Mansfield Park":["Jane Austen","Female"],
        "Northanger Abbey":["Jane Austen","Female"],
        "Persuasion":["Jane Austen","Female"],
        "Pride and Prejudice":["Jane Austen","Female"],
        "Sense and Sensibility":["Jane Austen","Female"],
        "Pilgrim's Progress":["John Bunyan","Male"],
        "Of Mice and Men":["John Steinbeck","Male"],
        "Gulliver's Travels":["Jonathan Swift","Male"],
        "Around the World in Eighty Days":["Jules Verne","Male"],
        "Journey to the Center of the Earth":["Jules Verne","Male"],
        "The Awakening":["Kate Chopin","Female"],
        "Anna Karenina":["Leo Tolstoy","Male"],
        "War and Peace":["Leo Tolstoy","Male"],
        "Alice's Adventures in Wonderland":["Lewis Carroll","Male"],
        "Through The Looking Glass":["Lewis Carroll","Male"],
        "Little Women":["Louisa May Alcott","Female"],
        "Frankenstein":["Mary Shelley","Female"],
        "The Scarlet Letter":["Nathaniel Hawthorne","Male"],
        "Fahrenheit 451":["Ray Bradbury","Male"],
        "Kidnapped":["Robert Louis Stevenson","Male"],
        "The Strange Case of Dr Jekyll and Mr Hyde":["Robert Louis Stevenson","Male"],
        "Treasure Island":["Robert Louis Stevenson","Male"],
        "The Red Badge of Courage":["Stephen Crane","Male"],
        "Rights of Man":["Thomas Paine","Male"],
        "Les Miserables":["Victor Hugo","Male"],
        "A Midsummer Night's Dream":["William Shakespeare","Male"],
        "Hamlet":["William Shakespeare","Male"],
        "Henry V":["William Shakespeare","Male"],
        "Julius Caesar":["William Shakespeare","Male"],
        "King Lear":["William Shakespeare","Male"],
        "Macbeth":["William Shakespeare","Male"],
        "Much Ado About Nothing":["William Shakespeare","Male"],
        "Othello":["William Shakespeare","Male"],
        "Romeo and Juliet":["William Shakespeare","Male"],
        "The Comedy of Errors":["William Shakespeare","Male"],
        "The Merchant of Venice":["William Shakespeare","Male"],
        "The Taming of the Shrew":["William Shakespeare","Male"],
        "The Tempest":["William Shakespeare","Male"],
        "Twelfth Night":["William Shakespeare","Male"],
        "The Hobbit or There and Back Again":["J.R.R. Tolkien","Male"],
        "The Fellowship of the Ring":["J.R.R. Tolkien","Male"],
        "The Two Towers":["J.R.R. Tolkien","Male"],
        "The Return of the King":["J.R.R. Tolkien","Male"]
      ]
  
      for (title, authData) in bookData {
        self.books.append(Book(title: title, author: authData[0], gender: authData[1], displayed: true))
        self.books = self.books.sorted()
      }
    }

The nice thing is that since books are Comparable, we can run the Swift method sorted() and get our books in alphabetical order by title, rather than some random order.

  1. We want this class to have a method to add a new book to the library. The following method would accomplish this:
  // MARK: Adding books
  func addBookToLibrary(title: String, author: String, gender: String, displayed: Bool) {
    self.books.append(Book(title: title, author: author, gender: gender, displayed: displayed))
  }
  1. We also need some methods to extract subsets of our library. We will cover functional programming soon, but as a preview, Swift has a method called filter that behaves like select in Ruby. We can use it here to write a method to get all the books for a particular author:
  // MARK: Parsing methods
  func getBooksFor(_ author: String) -> [Book] {
    return self.books.filter { $0.author == author }
  }

Now write two more methods, one called getFemaleAuthoredBooks() which will return a list of books for all the books in the library written by women. After that, write a similar method called getMaleAuthoredBooks(). We will need all three of these methods later in our charting section.

  1. The thing is that this is much more like a model than a controller, yet we'd like to create an instance of this class and pass it between our various tabs. Let's move this file to the ViewModels folder so it's properly recognized for what it is. Second, open AppView and before we declare the body variable, let's add the line var library = Library(). Now we'd like to add to the TabView an environmentObject and put the library object there that all the tabs can share:
      }   // THIS BRACE CLOSES OFF THE TAB VIEW 
      .environmentObject(library)
    }
  }

Part 3: Building out the library view

  1. Let's build out the table view in LibraryView first. Before the body variable in that file, we want to add the environment object with the line @EnvironmentObject var library: Library.

  2. To make a simple list, we can just use the List object as follows:

   List(library.books) { book in
     Text(book.title)
       .fontWeight(.bold)
       .font(.body)
    }

If we add this code inside the body and then run it, we should get the following:

This is okay, but what if we wanted more information about the book (like author and such)? We need a little more than a simple list -- as we saw last week with RailsCards, it'd be nice to navigate to a details view and then pop back to the list later.

  1. To make this desire happen, let's first create a simple BookDetailView in the Views folder with the following code:
  import SwiftUI

  struct BookDetailsView: View {
    var book: Book
    var body: some View {
      Text("Qapla'")
     }
  }

We will clean this up later, but we just need something for now so the compiler won't gripe.

  1. Now we want to go back to LibraryView and replace body with the following:
  var body: some View {
    NavigationView {
      List(library.books) { book in
        NavigationLink(
          destination: BookDetailsView(book: book),
          label: {
            Text(book.title)
              .fontWeight(.bold)
              .font(.body)
          })
      }.navigationBarTitle("Library")
    }
  }

Run this code and see that it works and you can navigate to a details view (which just says "Qapla'" right now for every book).

  1. This works, but is the code easy to read? Look at the code below and see if it's not just a little easier to understand.
    NavigationView {
      List(library.books) { book in
        BookRowView(book: book)
      }.navigationBarTitle("Library")
    }

The library view is just making a list of book rows and if we want to learn more about book rows, we can go look there. This is much like the partials we discussed again and again in 67-272.

  1. Of course, we need a BookRowView, but this is just a view with the code we deleted above added to body:
      NavigationLink(
        destination: BookDetailsView(book: book),
        label: {
          Text(book.title)
            .fontWeight(.bold)
            .font(.body)
       })

The only gotcha is that we can't forget to add var book: Book just prior to body. (Don't worry -- if you forget, the compiler will let you know...)

  1. Now go back and create a book details view which has the book title, book author, and author's gender listed. The view should use different fonts and/or colors to display this information. Also, do NOT center it (as it would be default), but push this information to the top of the screen.

  2. One last thing is that we'd like to swipe to the left of the table row and remove a book (it's a gesture we are used to in the mobile space). As Paul Hudson explains in this article, Swift makes this easy with an onDelete() method, but we will have to rewrite the List to use ForEach. The following code would work:

    NavigationView {
      List{
        ForEach(library.books) { book in
          BookRowView(book: book)
        }.onDelete(perform: removeRows)
      }.navigationBarTitle("Library")
    }

Of course, you will need Paul's removeRows() method provided in the article. His method will work if you replace his numbers with our equivalent here; instead of numbers, we have a list of library books (hint, hint, wink, wink.)

Part 4: Building a new book form

  1. In our NewBookView start by adding @EnvironmentObject var library: Library as we did in the prior section.

  2. Add the following properties:

  @State private var title = ""
  @State private var author = ""
  @State private var gender = "Male"
  @State private var displayed = false
  1. Replace the contents inside body with:
  VStack {
    Text("New Book")
      .font(.title)
      .fontWeight(.bold)
  }    
  1. The next element in our VStack is going to be a Form. We'll start with just the two text fields for title and author:
  Form {
    TextField("Title", text: $title)
    TextField("Author", text: $author)
  }

Running this, you should see:

  1. Within Form we need to add a Picker that will allow us to choose the author gender:
    Picker(selection: $gender,
           label: Text("Author Gender")) {
      ForEach(Gender.allGenders, id: \.self) { gender in
           Text(gender).tag(gender)
      }
    }

Running this, you should see:

  1. Within Form we need to add a Toogle that will allow us to choose whether to display. (In truth, this field only exists right now for us to do a toggle control and we don't use it.)
    Toggle(isOn: $displayed,
           label: {
             Text("Display book in library")
    })

Running this, you should see:

When we add in Anne Frank's "Diary of a Young Girl", we have a problem -- everything is there, but we can't add it. Oh dear.

  1. Luckily this is easily overcome by adding a Button that uses the addBookToLibrary() method written earlier:
    Button("Add Book") {
      library.addBookToLibrary(title: title, author: author, gender: gender, displayed: displayed)
    }

Running this, you should see that you can add Anne Frank's book now:

  1. Note that the book was added to the end of the list -- we could correct this. Likewise, we want the button to only be visible if the minimum is present (title and author). Finally, we'd like to clear out the form fields after submission and give a notice that it was a success. This lab is already long, so this is left as an exercise on your own and not required to get credit.

Part 5: Building out some simple charts

SwiftUI has the ability to make some basic charts and it would be good to have some familiarity with that. The Charts library is more recent (summer 2022), so the documentation on this is still not fully developed. Hopefully this brief exercise will help.

  1. In the ChartsView file, let's add in our @EnvironmentObject as we did in the previous views. We also need to import Charts in addition to SwiftUI at the top of the file.

  2. Our first chart will be a simple bar chart counting the books by author gender. We'll use a VStack to first create a title ("Books by Author Gender"). We'll use those two methods you wrote earlier to build the chart. Here's some code that would work if added to the VStack:

      Chart {
      BarMark(
        x: .value("Mount", "Male"),
        y: .value("Value", library.getMaleAuthoredBooks().count)
      )
      BarMark(
        x: .value("Mount", "Female"),
        y: .value("Value", library.getFemaleAuthoredBooks().count)
      )
    }
    .frame(height: 250)
    .padding(20)
    
    Spacer()  // To push the chart to the top instead of centering

Running this should give you the following:

  1. We are going to add the counts for some of the most popular authors. We will hand-pick these now (we could write methods to dynamically generate the list). Take what you learned in the graph above and pick 5 authors (two must be William Shakespeare and Jane Austen, but you can choose any others for the remaining 3; I did J.R.R. Tolkein, Charles Dickens, and Charlotte Bronte as my others, but do as you wish). Use the getBooksFor(_ author: String) function we wrote in Library to get these authors' counts. Also, change the color to green by adding at the end .foregroundColor(Color.green) after the padding. You should see something like this:

I know we've covered a lot of ground, but hopefully this gets you familiar with some basic UI elements and prepares you for future labs. In our next lab, we will draw on data from an external API to power our application, which should be fun. Qapla'