Categories
Uncategorized

Directing Focus in SwiftUI

Moving the User’s attention using Focus 👀

Finally, we would like to address a new SwiftUI feature that was introduced for the first time in iOS 15 and is called “Focus” management. Focus is used to detect which control the user is currently interacting with. For example, we can detect that the user is editing a certain TextField. On the other hand, we can also control the Focus. For example, we can direct the user to a certain TextField and automatically open the keyboard to motivate the user to edit this particular TextField.

For example, after the user edited the “First Name” TextField in our OrderForm and taps the “Return” button on the keyboard, we can direct the Focus to the “Last Name” TextField and thus start the editing process of the “Last Name” TextField. This would look like this:

To achieve this we need to add a new property wrapper to our OrderForm: @FocusState. Since also no control can be focused at all, the FocusState can also be nil. Therefore it is necessary that the FocusState is an Optional.

@FocusState var focusedField: AddressField?

Finally, we need to declare the appropriate “AddressField” type. For this, we add the following enum to our Helper.swift file

enum AddressField: Int, Hashable {
    case firstName
    case lastName
    case street
    case city
    case zip
}

To connect the different TextFields to our FocusState, we use the .focused modifier for each.

TextField("First name", text: $firstName)
    .focused($focusedField, equals: .firstName)
TextField("Last name", text: $lastName)
    .focused($focusedField, equals: .lastName)
TextField("Street", text: $street)
    .focused($focusedField, equals: .street)
TextField("City", text: $city)
    .focused($focusedField, equals: .city)
TextField("ZIP code", text: $zip)
    .focused($focusedField, equals: .zip)

So as soon as a new TextField is edited, the FocusState changes to the corresponding case of our AddressField enum. 

If we now append the .onSubmit modifier to our “First Name” TextField, we can determine that the Focus is moved to the “Last Name” TextField. The code inside the .onSubmit modifier is triggered as soon as the user taps the return key on the keyboard.

TextField("First name", text: $firstName)
    .focused($focusedField, equals: .firstName)
    .onSubmit {
        focusedField = .lastName
     }

We repeat these steps for the remaining TextFields. However, after the user fills in the “ZIP” TextField and taps the return key, we want to dismiss the keyboard. We do this by assigning nil to our FocusState. 

TextField("First name", text: $firstName)
    .focused($focusedField, equals: .firstName)
    .onSubmit {
        focusedField = .lastName
     }
TextField("Last name", text: $lastName)
    .focused($focusedField, equals: .lastName)
    .onSubmit {
        focusedField = .street
    }
TextField("Street", text: $street)
    .focused($focusedField, equals: .street)
    .onSubmit {
        focusedField = .city
    }
TextField("City", text: $city)
    .focused($focusedField, equals: .city)
    .onSubmit {
        focusedField = .zip
    }
TextField("ZIP code", text: $zip)
    .focused($focusedField, equals: .zip)
    .onSubmit {
        focusedField = nil
    }

Let’s see if this works.

Great! You can see how we can control the editing Focus by linking our FocusState to the TextFields and moving the Focus accordingly.

Customizing our Keyboard using ToolbarItems ⌨️

Finally, we would like to enable the user to comfortably switch between the address TextFields while the keyboard is open. For this, we can add two Buttons to the keyboard to move up and down.

To do this, we simply append a .toolbar with a ToolBarItemGroup to the NavigationView.

NavigationView {
    //...
}
    .toolbar(content: {
        ToolbarItemGroup(placement: .keyboard, content: {

        })
    })
    //...
}

Now we can add two corresponding Buttons to this ToolBarItemGroup. However, we disable them when the user has reached the first or last TextField.

.toolbar(content: {
    ToolbarItemGroup(placement: .keyboard, content: {
        Button(action: {selectPreviousField()}) {
            Label("Previous", systemImage: "chevron.up")
        }
            .disabled(focusedField == .firstName ? true : false)
        Button(action: {selectNextField()}) {
            Label("Next", systemImage: "chevron.down")
               .disabled(focusedField == .zip ? true : false)
        }
    })
})

Now we only need the selectPreviousField and selectNextField function to change the FocusState and switch between the address fields accordingly.

    func selectNextField() {
        if focusedField == .firstName {
            focusedField = .lastName
        } else if focusedField == .lastName {
            focusedField = .street
        } else if focusedField == .street {
            focusedField = .city
        } else if focusedField == .city {
            focusedField = .zip
        } else {
            focusedField = nil
        }
    }

    func selectPreviousField() {
        if focusedField == .zip {
            focusedField = .city
        } else if focusedField == .city {
            focusedField = .street
        } else if focusedField == .street {
            focusedField = .lastName
        } else if focusedField == .lastName {
            focusedField = .firstName
        } else {
            focusedField = nil
        }
    }

Let’s run our app again to check if everything works:

Conclusion 🎊

We finished extending our Food Delivery App!

We learned how to work with Forms in SwiftUI. While doing this, we also how to use different control such as Sliders, Steppers, and Toggles. We also discovered system icons and used them for our app. 

We presented our new OrderForm view as a modal view that can be dismissed by swiping down or by using the cancel navigation bar item. You also learned how to control the user’s attention using Focus.

You already learned how to present new views by using a navigation view hierarchy or by overlaying them with modal views. But until now, we didn’t talk about navigating to new views independently. Let’s do this in the next chapter!

Categories
Uncategorized

Adding a Search Bar

In this section, we add another useful feature to our To-Do app: a search bar! The search bar allows the user to quickly find the right task. This is especially useful if the user has already added many to-do’s. 

Creating a custom Search Bar view 🔦

Let’s start by building the interface of our search bar. 

In the end, we want our search bar will look like this:

The background of our tab bar should be a lighter (or darker Gray in dark mode) gray. To do this, we quickly create a custom color set in our Assets folder and name it “LightGray”.

Now, let’s create a new File-New-SwiftUI view file and name it “SearchBar”.

The corresponding SeachBar_Previews should always be exactly the same size as our SearchBar view is. For this, we simply use the .previewLayout modifier and select the .sizeThatFits option. 

struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar()
             .previewLayout(.sizeThatFits)
     }
 }

Our SearchBar_Previews struct now always minimizes our preview to the size of the initialized SearchBar instance. At the moment this is the frame of the pre-generated Text view.

Basically, our SearchBar should simply consist of a gray background on which we then place an Image view for the “magnifying glass” icon and a TextField. 

For this purpose, we use a ZStack with the .leading alignment mode and set its height to three-quarters of the rowHeight as defined in our TasksView

For the background, we simply use a gray Rectangle.

struct SearchBar: View {
     var body: some View {
         ZStack {
             Rectangle()
                 .foregroundColor(Color("LightGray"))
         }
             .frame(height: rowHeight*0.75)
     }
 }

We now place an HStack on top of the Rectangle. This HStack consists of an Image view with the “magnifyingglass” icon from the SFSymbols app. 

ZStack {
     Rectangle()
         .foregroundColor(Color("LightGray"))
     HStack {
         Image(systemName: "magnifyingglass")
     }
 }
     .frame(height: rowHeight*0.75)

Next to it, we want to place our TextField. As already discussed, we require a property to be bound to the TextField. We will initialize this property later in our TasksView. For our SearchBar_Previews struct, we can simply use a constant value.

import SwiftUI
 

struct SearchBar: View {
     
     @Binding var searchText: String
     
     var body: some View {
         //...
     }
 }
 

 struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar(searchText: .constant(""))
             .previewLayout(.sizeThatFits)
     }
 }

Now we can initialize our TextField as usual. We also use a .padding to increase the distance between the Image and the TextField and change their .foregroundColor.

HStack {
     Image(systemName: "magnifyingglass")
     TextField("Search task ..", text: $searchText)
 }
     .foregroundColor(.gray)
     .padding(.leading, 13)

Finally, we round off the corners of the entire ZStack and add some .padding to all sides again.

ZStack {
     //...
 }
     .frame(height: rowHeight*0.75)
     .cornerRadius(13)
     .padding()

And this is what our finished SearchBar looks like:

We can now add the SearchBar to our TasksView. But first, we need a corresponding State for the TextField of our SearchBar.

@State var searchText = ""

To make sure the SearchBar is right on top of the existing List, we wrap the List in a VStack.

VStack {
     List {
         //
     }
         .listStyle(GroupedListStyle())
         .navigationTitle("To-Do")
 }

Above the List, we can now insert our SearchBar and bind it to our searchText State:

VStack {
     SearchBar(searchText: $searchText)
     List {
         //...
     }
         .listStyle(GroupedListStyle())
         .navigationTitle("To-Do")
 }

Our finished TasksView now looks like this:

Changing our interface while searching 👨‍💻

Once the user taps on the TextField to start searching, we want to adjust our interface. During the search, we want to change the navigation bar title and show a List of filtered tasks instead of the current List containing all pending tasks.

To do this, we need to be aware of when the user starts the search. For this purpose, we add a corresponding State to our TasksView.

@State var searching = false

We pass this on to our SearchBar as a Binding accordingly. For the SearchBar_Previews we again use a constant value.

import SwiftUI
 

 struct SearchBar: View {
     
     @Binding var searchText: String
     
     @Binding var searching: Bool
     
     var body: some View {
         //...
     }
 }
 

 struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar(searchText: .constant(""), searching: .constant(false))
             .previewLayout(.sizeThatFits)
     }
 }

Again, we initialize this accordingly in our TasksView.

SearchBar(searchText: $searchText, searching: $searching)

The user starts his search process by tapping the TextField in the SearchBar. We can easily detect this by adding the “onEditingChanged” closure to the TextField. This will be executed as soon as the user starts editing the TextField. As soon as this happens, we change the searching property to true.

TextField("Search task ..", text: $searchText) { startedEditing in
     if startedEditing {
         withAnimation {
             searching = true
         }
     }
 }

Consequently, we want to set searching to false as soon as the user taps the return key of the keyboard. To do this, we append the “onCommit” closure to our TextField:

TextField("Search task ..", text: $searchText) { startedEditing in
     if startedEditing {
         withAnimation {
             searching = true
         }
     }
 } onCommit: {
     withAnimation {
         searching = false
     }
 }

Back to our TasksView. Once the search has started, we want to change the existing .navigationTitle

.navigationTitle(searching ? "Searching" : "To-Do")

We would also like to provide a “Cancel” button during the search process. For this, we use the .toolbar modifier. 

.navigationTitle(searching ? "Searching" : "To-Do")
     .toolbar {
         if searching {
             Button("Cancel") {
                 searchText = ""
                 withAnimation {
                     searching = false
                 }
             }
         }
    }

Let’s see if this works:

As you can see, the keyboard disappears when we tap on the return key (because then the “onCommit” closure of our TextField gets executed). However, the keyboard doesn’t disappear when we tap on the “Cancel” Button in our NavigationBar. 

Resigning the keyboard ⌨️

To fix this, we need to find a way to hide the keyboard manually. There is no native SwiftUI feature for this, so again we have to rely on the UIKit.

Just add the following extension to your TasksView.swift file.

extension UIApplication {
     func dismissKeyboard() {
         sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
     }
 }

The code is quite complicated. Basically, it says that the “control” that currently commands the keyboard should stop commanding it. See this post if you are interested in the topic further.

We can now add the following line to our “Cancel” Button.

Button("Cancel") {
     searchText = ""
     withAnimation {
         //...
         UIApplication.shared.dismissKeyboard()
     }
 }

If we now tap on the “Cancel” Button, the keyboard disappears!

To provide a better user experience, we also want to hide the keyboard while the user is scrolling through the search results.

To do this, we add a .gesture to the List in our TasksView. There are many different gestures available in SwiftUI, for example, the TapGesture or the onLongPressGesture. Here we use a DragGesture.

SearchBar(searchText: $searchText, searching: $searching)
 List {
     //...
 }
     //...
     .gesture(DragGesture()
          
     )

Once the user performs a drag gesture across the List, we want to dismiss the keyboard. For this purpose, we use the .onChanged modifier:

.gesture(DragGesture()
             .onChanged({ _ in
                 UIApplication.shared.dismissKeyboard()
             })
 )

When we run our app now, the keyboard will also be hidden when swiping over the list while performing a search.

Filtering our List’s results 🕵️‍♀️

Okay, we’re almost there. All we need to do now is implement the actual search functionality.

While the user is searching we want to display the search results instead of all tasks pending. For this, we wrap the whole current content of our List into an if-else statement like this:

List {
     if searching {
         
     } else {
         ForEach(fetchedItems, id: \.self) { item in
             //...
         }
             .frame(height: rowHeight)
         HStack {
             //...
         }
             .frame(height: rowHeight)
         NavigationLink(destination: TasksDoneView()) {
             //...
         }
     }
 }

If searching is true, we want to use an alternative ForEach loop that displays only those ToDoItems in our fetchedItems that match the searchText.

if searching {
     ForEach(fetchedItems.filter({ (item: ToDoItem) -> Bool in
         return item.taskTitle!.hasPrefix(searchText) || searchText == ""
     }), id:\.self) { searchResult in
         HStack {
             Text(searchResult.taskTitle ?? "Empty")
             Spacer()
             Button(action: {
                 markTaskAsDone(at: fetchedItems.firstIndex(of: searchResult)!)
             }){
                 Image(systemName: "circle")
                     .imageScale(.large)
                     .foregroundColor(.gray)
             }
         }
     }
         .frame(height: rowHeight)
 }

Using the filter function we cycle through each ToDoItem in the fetchedItems. Only if the particular item has the same initial letters as the searchText (or if there is no searchText at all), we use it for our alternative ForEach loop.

We can now run our app and see if our search bar works!

Conclusion 🎊

Wow, we’ve learned a lot so far!

We discovered how the CoreData framework interacts with SwiftUI and how we can fetch and save objects using our own data model.

We learned what the viewContext is, how it gets initialized by the context property of the PersistenceController, and how we can access it using the @Environment property wrapper and .environment modifier. We also learned how to filter to-do’s by using the “predicate” argument of the @FetchRequest property wrapper.

We saw how easy it is to delete to-do items using the delete method of the viewContext combined with the .onDelete modifier of the ForEach loop.

Finally, we learned how to let the user filter his tasks by providing a search bar.

You are now able to store data persistently by using the CoreData framework within your SwiftUI projects!

Categories
Uncategorized

To-Do App Widget

Next, we will look at how to use CoreData in Widgets. For this, we will use our already completed To-Do app.

Our Widget should, depending on its size, end up looking like this:

Open the corresponding Xcode project and add a Widget Target by selecting File-New-Target in the Xcode toolbar and searching for “Widget Extension”. For example, name the widget “OpenTasksWidget”. Again, make sure you don’t check “Use Configuration Intent” as we will be using a StaticConfiguration for this Widget. When asked, activate the Widget Scheme. 

We will use both the PersistenceController and the CoreData model of our To-Do app for the Widget. Therefore, we add the corresponding files to the Widget Target:

Composing views depending on the Widget family 🎨

We start by designing the OpenTasksEntryView for our Widget. To work with some sample To-Do’s we temporarily add a “sampleTaskTitle” array to our SimpleEntry struct. We will replace this later with a set of respective CoreData instances.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let sampleTaskTitles = ["Learning SwiftUI", "Hitting the gym"]
}

As discussed earlier, a Widget can have three different sizes: .systemSmall, .systemMedium, and .systemLarge. We can simulate these different sizes simultaneously by initializing the OpenTasksWidgetEntryView multiple times in our OpenTasksWidget_Previews using a Group. For each instance we use a different .family:

struct OpenTasksWidget_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemSmall))
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemMedium))
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemLarge))
        }
    }
}

The Preview simulator should now look like this:

We now have the option to make the OpenTasksWidgetEntryView dependent on the Widget family.

To know which size the user has chosen, we can use the .widgetFamily @Environment property wrapper:

struct OpenTasksWidgetEntryView : View {
     
     @Environment(\.widgetFamily) var family
     
     var entry: Provider.Entry
 
     var body: some View {
        Text(entry.date, style: .time)
    }
}

If we now use an appropriate switch statement, we can easily design the view for our widget depending on its size.

struct OpenTasksWidgetEntryView : View {
    
    @Environment(\.widgetFamily) var family
    
    var entry: Provider.Entry
    
    var body: some View {
        switch family {
        case .systemExtraLarge:
            Text("View for extra large widget")
        case .systemLarge:
            Text("View for large widget")
        case .systemMedium:
            Text("View for medium-sized widget")
        case .systemSmall:
            Text("View for small widget")
        }
    }
}

Let’s start with the .systemLarge family. In this case, we build a view that displays both the number of open tasks and the title of those tasks, where we want to display at most the seven most recent tasks. For example, we can achieve this like this:

case .systemLarge:
    VStack(alignment: .leading) {
        Text("\(entry.sampleTaskTitles.count) open tasks")
            .foregroundColor(.blue)
            .font(.title)
        Divider()
        ForEach(entry.sampleTaskTitles.prefix(7), id: \.self) { item in
            Text(item ?? "")
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 7 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
        .padding()

The corresponding preview should now be updated:

We copy the VStack and paste it to the .systemExtraLarge case to reuse it:

case .systemExtraLarge:
    VStack(alignment: .leading) {
        Text("\(entry.sampleTaskTitles.count) open tasks")
            .foregroundColor(.blue)
            .font(.title)
        Divider()
        ForEach(entry.sampleTaskTitles.prefix(7), id: \.self) { item in
            Text(item)
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 7 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
        .padding()

Since the Widget in the .systemMedium family already has a little less space available, we want to display at most the three most recent open tasks. Also, we change the arrangement a bit, since the widget will be aligned “horizontally” in this case:

case .systemMedium:
    HStack {
        VStack(alignment: .leading) {
            Text("\(entry.sampleTaskTitles.count)")
            Text("open")
            Text("tasks")
    }
       .font(.title)
       .foregroundColor(.blue)
    Divider()
    VStack(alignment: .leading) {
        ForEach(entry.sampleTaskTitles.prefix(3), id: \.self) { item in
            Text(item)
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 3 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
    Spacer()
}
    .padding()

This results in the following preview:

For the .systemSmall family, we use a much scaled-down version of the .systemLarge family and again limit the maximum tasks displayed to three. 

case .systemSmall:
    VStack(alignment: .leading) {
         Text("\(entry.sampleTaskTitles.count) open tasks")
             .foregroundColor(.blue)
             .font(.headline)
             .padding(.top, 2)
         Divider()
         ForEach(entry.sampleTaskTitles.prefix(3), id: \.self) { item in
             Text(item ?? "")
                 .font(.caption)
                 .padding(.bottom, 2)
                 .lineLimit(1)
         }
         if entry.sampleTaskTitles.count > 3 {
             Text("More ...")
                 .font(.caption)
                 .padding(.top, 2)
                 .foregroundColor(.blue)
         }
         Spacer()
     }
         .padding()

The corresponding Widget will look like this:

Great, we are now done designing our OpenTasksWidgetEntryView!

Accessing CoreData using an App Group 🤝

However, the Widget cannot yet use the CoreData database that is created and managed by the To-Do app. In order for extensions like our OpenTasksWidget to get access to it, we need to create a so-called App Group.

To do this, go to the Apple Developer Account (the one you also use for Xcode Project Signing) and click on “Certificates, Identifies & Profiles”.  

Then click on the plus button under “Identifiers”. Next, select “App Groups” and click “Continue”. 

Now you can pick any description you like. But as the identifier, you should use the following structure “group.*YOUR DEVELOPER NAME*.*YOUR PROJECT NAME“. For example:

After you have registered the identifier, quit Xcode by hitting CMD-Q, and reopen your project. To add the corresponding App Group to the project, you have to go to the Signing & Capabilities tab of your app. Drag and drop the “App Group” capability from the capability library below the “Signing” section. After some time, the identifier you just created should appear. Then set a checkmark to register the App Group. Otherwise, you can just click on the plus button to enter the identifier manually.

You should end up with an entitlements file added to your project. This is a plist file containing the app group identifier you just created:

To share this App Group with the Widget, we repeat the process again for the OpenTasksWidget target itself. After that, you should end up with two entitlement files in total.

Okay, we are almost finished. As a final preparation, we just need to update the Persistence.swift file so that the CoreData database is also captured by the App Group.

To do this, we need to modify the init method of the PersistenceController. So, except for setting the container, delete the current content of this method:

init(inMemory: Bool = false) {
     
}

Next, we define the path where the database should be stored. Make sure you use your App Group identifier for this!

init(inMemory: Bool = false) {
     container = NSPersistentContainer(name: "Shared")
     
     //USE YOUR APP GROUP IDENTIFIER FOR THE "forSecurityApplicationGroupIdentifier" parameter
     guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.BLCKBIRDS.To-Do") else {
         fatalError("Shared file container could not be created.")
     }
     
     let storageURL = fileContainer.appendingPathComponent("ToDoAppDatabase.sqlite")
     
     
 }

Next, we set the description for the database using our storageURL. After this, we load the persistent storage.

init(inMemory: Bool = false) {
     //...
     
     
     let description = NSPersistentStoreDescription(url: storageURL)
     container.persistentStoreDescriptions = [description]
     
     container.loadPersistentStores(completionHandler: { (storeDescription, error) in
         if let error = error as NSError? {
             fatalError("Unresolved error \(error), \(error.userInfo)")
         }
     })
 }

Finally, we are ready to access the CoreData database of our To-Do app through the Widget!

Updating our Provider 🔄

Next, we will update our OpenTasksWidget.swift file. The first thing we need to do is to import the CoreData framework.

import CoreData

We can now replace the sampleTaskTitles property of our SimpleEntry model with an array of ToDoItem instances.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let toDoItems: [ToDoItem]
}

Accordingly, we now need to update our OpenTasksWidgetEntry view …

struct OpenTasksWidgetEntryView : View {
     
     @Environment(\.widgetFamily) var family
     
     var entry: Provider.Entry
 

     var body: some View {
         switch family {
         case .systemLarge:
             VStack(alignment: .leading) {
                 Text("\(entry.toDoItems.count) open tasks")
                     //...
                 Divider()
                 ForEach(entry.toDoItems.prefix(7), id: \.self) { item in
                     Text(item.taskTitle ?? "")
                         //...
                 }
                 if entry.toDoItems.count > 7 {
                     //...
                 }
                 Spacer()
             }
                 .padding()
         case .systemMedium:
             HStack {
                 VStack(alignment: .leading) {
                     Text("\(entry.toDoItems.count)")
                     //...
                 }
                     //...
                 Divider()
                 VStack(alignment: .leading) {
                     ForEach(entry.toDoItems.prefix(3), id: \.self) { item in
                         Text(item.taskTitle ?? "")
                             //...
                     }
                     if entry.toDoItems.count > 3 {
                         Text("More ...")
                             //...
                     }
                     Spacer()
                 }
                 Spacer()
             }
                 .padding()
         case .systemSmall:
             VStack(alignment: .leading) {
                 Text("\(entry.toDoItems.count) open tasks")
                     //...
                 Divider()
                 ForEach(entry.toDoItems.prefix(3), id: \.self) { item in
                     Text(item.taskTitle ?? "")
                         //...
                 }
                 if entry.toDoItems.count > 3 {
                     //...
                 }
                 Spacer()
             }
                 .padding()
         }
     }
 }
 

… our OpenTasksWidget_Previews struct…

struct OpenTasksWidget_Previews: PreviewProvider {
     static var previews: some View {
         Group {
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemSmall))
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemMedium))
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemLarge))
         }
     }
 }

… and the placeholder function of our Provider:

func placeholder(in context: Context) -> SimpleEntry {
     SimpleEntry(date: Date(), toDoItems: [ToDoItem()])
 }

Both our getSnapshot and getTimeline functions require access to the CoreData database. Let’s start with the getSnapshot function. Since we can’t use the @FetchRequest property wrapper in Widgets, we need to implement the corresponding functionality manually.

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
     
     let viewContext = PersistenceController.shared.container.viewContext
     let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
     request.predicate = NSPredicate(format: "taskDone = %d")
     
 }

We can fetch the ToDoItems as usual and generate a corresponding SimpleEntry:

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
     
    let viewContext = PersistenceController.shared.container.viewContext
    let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
    //Filter out tasks marked as done
    request.predicate = NSPredicate(format: "taskDone = %d")
     
    do {
        let fetchedItems = try viewContext.fetch(request)
        let entry = SimpleEntry(date: Date(), toDoItems: fetchedItems)
        completion(entry)
    } catch {
        fatalError("Failed to fetch tasks: \(error)")
    }
}

Also for the timeline, we fetch the ToDoItems from our CoreData database. Then, we create a single SimpleEntry instance from the corresponding array and add it to the timeline.

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
     
    let viewContext = PersistenceController.shared.container.viewContext
    let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
    request.predicate = NSPredicate(format: "taskDone = %d")
     
    do {
        let fetchedItems = try viewContext.fetch(request)
        print(fetchedItems)
        var entries: [SimpleEntry] = []
        entries.append(SimpleEntry(date: Date(), toDoItems: fetchedItems))
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    } catch {
        fatalError("Failed to fetch tasks: \(error)")
    }
}

If we now run our OpenTasksWidgetExtension in the standard simulator, the widget should successfully display the open To-Do’s (assuming you already added some in the To-Do app before).

Globally Reloading Widgets using the WidgetCenter 🌎

You probably noticed that we had set the refresh policy to .never. However, to refresh the widget, we don’t want to choose a time interval, so neither the .atEnd nor the .after policy is suitable.

Instead, we want to refresh the widget every time a new task is added, deleted or marked as done.

Widgets can easily be refreshed manually, and that too from the app itself! To do this, we simply use the so-called Widget Center.

We add the following statement to the saveTask and markTaskAsDone functions of our TasksView:

func saveTask() {
    //...
     
    do {
        //...
        WidgetCenter.shared.reloadAllTimelines()
    } catch {
        //...
    }
     
    //...
}
 

func markTaskAsDone(at index: Int) {
    //...
    do {
        //...
        WidgetCenter.shared.reloadAllTimelines()
    } catch {
        //...
    }
}

At the same time, we need to make sure that the corresponding file also contains the WidgetKit framework.

import WidgetKit

We repeat the same steps for the removeItems method in our TasksDoneView.

import SwiftUI
import WidgetKit
 

struct TasksDoneView: View {
     
    //...
     
    var body: some View {
        //...
    }
     
    private func removeItems(at offsets: IndexSet) {
        //...
        do {
            //...
            WidgetCenter.shared.reloadAllTimelines()
        } catch {
            //...
        }
    }
}

Now every time we create a new task, mark it as done, or delete it, the getTimeline method of the widget Provider is called manually, and the Widget gets refreshed!

Conclusion 🎊

That’s it! We learned how to use CoreData databases in Widgets using App Groups and how to manually refresh Widgets using the WidgetCenter. You also learned how to adjust the design of a Widget to its size using widget families. 

If you have any questions, feel free to leave a comment below!

Categories
Uncategorized

Download: MyDemoWidget, StockX Widget, To-Do Widget

Categories
Uncategorized

StockX Widget

Let’s get started with our first practical Widget. For this, we will build a Widget extension of our StockX app. The Widget will show the latest chart of a certain stock picked by the user in appropriate intervals.

Our Widget will look like this:

Preparations 🔧

To get started, open the finished StockX project. As already learned, we add a Widget to an app by selecting File – New – Target in the Xcode menubar and adding a Widget Extension. For example, name the extension “StockXWidget”. Again, we don’t need to check the “Include Configuration Intent” box, since we will add the corresponding functionality manually later. Activate the Extension Scheme when you are asked for it.

Our Widget will utilize the Chart view of our StockX app. We also need access to the DataEntry struct, the sampleData array, the TimeSeriesJSON struct and to the “Helper” files. Therefore, we need to add the corresponding files to our StockXWidget target. For this, we use the “Target Membership” section in the Identity Inspector of these files:

Setting up the SimpleEntry model, the widget view, and the Provider

Each SimpleEntry represented by the corresponding StockXWidgetEntryView must contain a Date, the corresponding stockSymbol, and a DataEntry array to display the chart later. For this, we extend the SimpleEntry struct inside the generated StockXWidget.swift file by two properties:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let stockSymbol: String
    let stockData: [DataEntry]
}

Now we have to adjust the placeholder, snapshot and getTimeline function of the Provider. For now, we use the sampleData array from our DataEntry.swift file and the “AAPL” stock symbol.

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let dummyEntry = SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
        entries.append(dummyEntry)

        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

Also, the StockXWidget preview must be supplied with a sample SimpleEntry.

struct StockXWidget_Previews: PreviewProvider {
    static var previews: some View {
        StockXWidgetEntryView(entry: SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

As said, we want the Widget to display the latest chart of the corresponding stock. For this, we can simply reuse the Chart view of the StockX app and use the stockData property of the provided entry.

struct StockXWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Chart(dataSet: entry.stockData)
    }
}

The corresponding preview looks like this:

In addition, the StockXWidgetEntryView should display the symbol of the represented stock. We can achieve this – for example – like this:

struct StockXWidgetEntryView : View {
    
    var entry: Provider.Entry
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            Chart(dataSet: entry.stockData)
            Rectangle()
                .foregroundColor(.clear)
                .background(LinearGradient(gradient: Gradient(colors: [.black, .clear]), startPoint: .top, endPoint: .center))
                .opacity(0.4)
            Text(entry.stockSymbol)
                .bold()
                .foregroundColor(.white)
                .padding(8)
        }
    }
}

The preview now looks like this:

Adding a WidgetDownloadManager ⬇️

To download the data of the respective stock, we use a download manager again. However, we don’t reuse the DownloadManager.swift file of our StockXApp but create a new, slightly modified one.

Thus, create a new File-New-File and select “Swift File”. Name it “WidgetDownloadManager”. Make sure that “MyWidgetExtension” is selected as the target.

Create a class with the same name.

import Foundation
 
class WidgetDownloadManager {
      
}

Our DownloadManager needs a method that uses the given stockSymbol to download the necessary information using the Alphavantage API and generate a DataEntry array.

For this, we use a slightly modified version of the downloadJSON function of our DownloadManager

class WidgetDownloadManager {
    
    
    enum FetchError: Error {
        case badURL
        case badEntries
    }
    
    func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

        var dataEntries = [DataEntry]()
            
        guard let url = URL(string: generateRequestURL(stockSymbol: stockSymbol)) else { throw FetchError.badURL}
        let request = URLRequest(url: url)
        let (data, _) = try await URLSession.shared.data(for: request)

        let parsedJSON = try JSONDecoder().decode(TimeSeriesJSON.self, from: data)
        
        for timeSeries in parsedJSON.timeSeries {
            dataEntries.append(DataEntry(date: Date(timeSeries.key, dateFormat: "yyyy-MM-dd HH:mm:ss"), close: (timeSeries.value.close as NSString).doubleValue))
        }
        
        if dataEntries.count == parsedJSON.timeSeries.count {
            dataEntries.sort(by: {$0.date.compare($1.date) == .orderedAscending})
        }
        
        guard let lastDateOfData = dataEntries.last?.date else { throw FetchError.badEntries }
        
        var filteredEntries = [DataEntry]()
        for entry in dataEntries {
            if Calendar.current.isDate(entry.date, equalTo: lastDateOfData, toGranularity: .day) {
                filteredEntries.append(entry)
            }
        }
        
        dataEntries = filteredEntries
        
        return dataEntries

    }
}

Updating the GetTimeline method

Let’s use our new download manager to generate the entries for our widget’s timeline.

To do this, we initialize a WidgetDownloadManager instance in the Provider struct.

struct Provider: TimelineProvider {
     
    let widgetDownloadManager = WidgetDownloadManager()
     
    //...
}

Inside the getTimeline function, we can now use the downloadJSON method of the widgetDownloadManager to download the latest stock data.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: "AAPL")
    }
}

Once this process is finished, we use the stockData to initialize a SimpleEntry. Then, we add it to the timeline.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: "AAPL")
        let entries = [SimpleEntry(date: Date(), stockSymbol: "AAPL" , stockData: stockData)]
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

We want the Widget to update itself 10 minutes at the earliest. Therefore, we change the timeline policy from .never to .after as follows:

let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 10, to: Date())!))

Now when we run the Widget Extension in the simulator, the Provider calls the getTimeline method. This uses the downloadJSON method of the widgetDownloadManager to retrieve the data of the “AAPL” stock via the Alphavantage API. Once this process is complete, a corresponding SimplyEntry is initialized and added to the timeline. The StockXWidgetEntryView uses this to display the corresponding chart.

Allowing user customization using IntentConfiguration 🤘

Until now, the user is always shown the latest “Apple” chart.

However, we want to allow the user to select the stock represented in the Widget. To offer the user individualization options, we use a so-called IntentConfiguration. As we saw earlier, you can check the corresponding option when adding the Widget Extension to the app.

To add an IntentConfiguration manually, we go to File-New-File and search for “SiriKit Intent Definition File”. Name the file “IntentConfiguration”. Again, make sure that the Widget Extension is selected as the target.

To add a new configuration option, open the IntentConfiguration file and click “Plus”-icon on the bottom and select “New Intent”. Double click on the resulting Intent to rename it to “AvailableStocks”.

Now we configure our AvailableStocks intent. Select “View” as the “Category” and use “AvailableStocks” as the “Title”. It is important that you now check “Intent is eligible for widgets”! However, we do not want the “Shortcuts” and “Siri Suggestions” functionality.

We only need one parameter for our Intent. To add it, we click on the plus icon under the “Parameter” section and name it “selectedStock”. For this parameter, we now create a corresponding enum. This can be done easily by selecting “Add enum” as the “Type”.

We name the generated enum “SelectedStock”. Now, we can easily create different cases with the respective stock symbol and also define under which display name each is shown to the user. For example:

We’re finished preparing our IntentConfiguration!

Updating our StockXWidget.swift file to a IntentConfiguration

Next, we need to modify our StockXWidget.swift file accordingly. Let’s start with the Provider struct. 

Instead of the TimelineProvider protocol, the Provider struct needs to conform to the IntentTimelineProvider protocol:

struct Provider: IntentTimelineProvider {
     
     //...

}

Accordingly, we need to make some adjustments to our Provider.

First, our getSnapshot function needs another parameter named “for configuration”:

func getSnapshot(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    //...
}

Also, the snapshot should depend on the stock picked by the user. To find know this stock, we add the following function to our Provider struct:

private func getStockSymbol(for configuration: AvailableStocksIntent) -> String {
    switch configuration.selectedStock {
    case .aAPL:
        return "AAPL"
    case .nFLX:
        return "NFLX"
    case .tSLA:
        return "TSLA"
    case .unknown:
        return "AAPL"
    }
}

Instead of the fixed “AAPL” stockSymbol, we can now use this function for the SimplyEntry in our getSnapshot method:

func getSnapshot(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date(), stockSymbol: getStockSymbol(for: configuration), stockData: sampleData)
    completion(entry)
}

We repeat the same steps for the getTimeline method:

func getTimeline(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: getStockSymbol(for: configuration))
        let entries = [SimpleEntry(date: Date(), stockSymbol: getStockSymbol(for: configuration), stockData: stockData)]
        let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 10, to: Date())!))
        completion(timeline)
    }
}

Our Provider now conforms to the IntentTimelineProvider protocol. Finally, we just need to use an IntentConfiguration instead of a StaticConfiguration in our StockXWidget struct:

@main
struct StockXWidget: Widget {
    let kind: String = "StockXWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: AvailableStocksIntent.self,provider: Provider()) { entry in
            StockXWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

If the user now long-presses on the widget, he has the possibility to select his preferred stock.

Recap 🎊

We have already learned how Widgets basically work, how to provide them with data and how to update them at the appropriate time. We also learned how to provide customization options to the user using an IntentConfiguration.

Next, learn how to integrate CoreData functionality into Widgets, how to adjust the appearance to the size of the Widget, and how to manually refresh Widgets.

Categories
Uncategorized

Chapter 15: Mastering Widgets

What you’ll learn in this chapter:

  • How to extend your app’s functionality using Widgets
  • The theory behind Widgets: Providing and refreshing Widgets
  • How to offer customization options using IntentConfigurations
  • CoreData Integration and Networking Requests

Here is a preview of the Widgets we’re going to create:

The basics: How widgets work 💡

One of the major innovations of iOS 14 was the introduction of Widgets. What has been available on Android for years is now also possible on Apple devices. Widgets can be used to individualize the home screen.

Widgets are available in three different sizes: .systemSmall, .systemMedium and .systemLarge. You can see an example of this when you open the Widget Gallery and open the Widgets for the pre-installed “Clock” app, for example. Depending on the selected size of the Widget, a different view will be presented.

It is important to note that widgets are not mini-apps, i.e. they cannot be interacted with (with the exception that tapping on the Widget opens the app). Rather, they act as mere views that display information from the associated app that is relevant for the user. 

But how does the Widget exactly know when to display which information? We will go into this in detail in a moment, but here is a rough outline: When a Widget is added to the home screen, the system first asks our app for one or more entries, which are arranged along a so-called timeline. 

For example, imagine a Widget that always counts up from 0 to 40 in steps of ten, then start again from 0 (to be honest, a quite impractical example). In this example, we give the system five entries (the values from 0, 10, 20, 30, and 40) and align them on the timeline at an interval of ten seconds each. 

Note: In case you’re wondering why we don’t just update the Widget every second, Apple doesn’t allow us to refresh the Widget too often. In my experience, a 10-second interval has proven to be a reasonable minimum.

Look at the following illustration for a better understanding:

But what happens now when all given entries have been used? That is our decision: For example, we could not provide any new entries, but then the Widget would not be updated anymore (i.e., in our example, it would permanently show 40).

But we could also, for example, start over and provide five new entries again for the next 50 seconds on the timeline and so on.

Finally, we can specify a completely different time when new entries are played out, which would result in a “gap” on the timeline.

This all sounds very abstract now, but once we actually implement a Widget, it should be easier to imagine.

One more thing: By default, Widgets have a so-called StaticConfiguration, where we as developers configure all settings for our Widget in advance. But there are also so-called IntentConfigurations possible, with which we can allow the user to set their own preferences for the Widget to customize the Widget accordingly. But more about this more advanced topic later.

Creating our first Widget 🔨

Let’s start with creating our own Widget. Each widget is part of a SwiftUI app. For this, we create a new Xcode project, select „iOS App“, and name the project, e.g. “MyDemo”. 

We can leave our MyDemo app as it is. To add a Widget, we go to File – New – Target in the Xcode menubar and search for “Widget Extension”. Click on “Next” and name the widget “MyDemoWidget”, for example. Also, make sure you select a “Team” (regularly that’s your Apple Developer account). We won’t check the “Include Configuration Intent” box since we’re just using a StaticConfiguration for this example.

After you click on “Finish”, a window should pop up asking you if you want to activate the Extension Scheme. Click on “Activate”. 

In your Xcode project, there is now a new group that contains the file MyDemoWidget.swift.

Let’s take a closer look at this one.

How the Provider works

Basically, our MyDemoWidget is structured quite similarly to the corresponding MyDemoApp. In the MyDemoWidget.swift file, you will find a @main MyDemoWidget struct. 

@main
struct MyDemoWidget: Widget {
    let kind: String = "MyDemoWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyDemoWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

This initializes the Widget when the user adds it to the home screen. Here we can specify, among other things, whether it is a static or intent configuration and initialize the Provider. More about this in a moment.

As you can see, the MyDemoWidget struct initializes the MyDemoWidgetEntryView. We can find the corresponding one above.

struct MyDemoWidgetEntryView: View {
     
    var entry: Provider.Entry
     
    var body: some View {
        Text(entry.date, style: .time)
    }
}

As already explained, there are one or more entries on the so-called timeline. The MyDemoWidgetEntryView takes the date contained in the respective entry and represents it in a Text view.

Our Widget gets the entries from the so-called Provider, whose implementation we see above.

struct Provider: TimelineProvider {
    //...
}

The Provider contains three different functions:

  1. func placeholder
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

This placeholder function provides our view with a sample SimpleEntry instance. The iOS device then displays the resulting instance in a much-reduced version. This is needed when the Widget has not yet finished loading.

You’ll see how this looks like in a moment.

2. func getSnapshot:

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date())
    completion(entry)
}

Similar to the placeholder function, the getSnapshot function also returns a sample SimpleEntry. The resulting view is used, for example, when the Widget is selected in the Widget Gallery of the device.

3. func getTimeline:

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries: [SimpleEntry] = []
     
    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate)
        entries.append(entry)
    }
     
     
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

This function is the heart of every Widget. As shown above, it provides the timeline with a set of SimpleEntry instances, which are then played out on the respective date.

We will look into this in more detail in a moment.

Let’s now take another look at the preview simulator. This is generated by the MyDemoWidget_Previews struct and represents a simple SimpleEntry instance.

You can see how your placeholder looks like by applying the .redacted modifier to the MyDemoWidgetEntry view in the PreviewProvider:

Setting up our widget 🧑🏽‍💻

Let’s talk about the getTimeline function of the Provider in more detail. As said before, it is responsible for providing the given entries at the right time.

Let’s delete the pre-generated code.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
     
}

As discussed in our example above, we want our Widget to display a new view every ten seconds showing the current elapsed time. For this purpose, the corresponding SimpleEntry instances must have a property to know at what time they are used and a property to hold the elapsed seconds. Our SimpleEntry already contains a corresponding date property, so we only need to add a “secondsElapsed” property:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let secondsElapsed: Int
}

Next, we need to update our placeholder and getSnapshot function accordingly. 

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), secondsElapsed: 10)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), secondsElapsed: 10)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
    }
}

We also need to update the PreviewProvider struct:

struct MyDemoWidget_Previews: PreviewProvider {
    static var previews: some View {
        MyDemoWidgetEntryView(entry: SimpleEntry(date: Date(), secondsElapsed: 10))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

When our Widget requests the timeline (the first time this happens is when the Widget is added to the home screen), the getTimeline function is called. When this happens, we create an empty array first:

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries = [SimpleEntry]()
     
}

Starting from the current Date, we now create five different entries covering the next five 10-second intervals and add them to the entries array:

var entries = [SimpleEntry]()
let currentDate = Date()
for secondOffset in 0 ..< 5 {
    let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
    let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
    entries.append(entry)
}

From the entries array, we can now create a Timeline and then provide it to the Widget.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries = [SimpleEntry]()
    let currentDate = Date()
    for secondOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
        let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
        entries.append(entry)
    }
     
    let timeline = Timeline(entries: entries, policy: .never)
    completion(timeline)
}

The policy parameter determines after which rule a new timeline should be asked for, i.e. the getTimeline function should be called the next time. We have the choice between .never (self-explanatory), .atEnd (i.e. immediately when the last SimpleEntry has been played on the timeline) or .after (here we specify an individual Date when the getTimeline function should get called again).

To display the elapsed seconds, we update our MyWidgetEntry view accordingly.

struct MyDemoWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text("\(entry.secondsElapsed) seconds elapsed" )
    }
}

To test our Widget we need to run the “MyWidgetDemoExtension” scheme in the regular simulator.

After the Widget has been installed and added to the simulator’s home screen, we call the getTimeline function for the first time and add the five different entries to the timeline at intervals of 10 seconds each. Accordingly, the MyDemoWidgetEntryView should refresh every ten seconds. 

Note: Even if the date of a SimplyEntry instance is reached on the timeline, this is no guarantee that the Widget will be refreshed at exactly this time. We are just setting the earliest time for the Widget to be refreshed. For our example, this is not so important, but you should definitely keep it in mind!

Since we have currently chosen .never as the policy, after playing the last SimplyEntry instance of the timeline with a secondsElapsed value of 40, the Widget is not updated anymore. If we change the policy to .atEnd, the getTimeline method is called again immediately and five new entries are added to the timeline and so on.

let timeline = Timeline(entries: entries, policy: .atEnd)

We can create a “gap” in the timeline by using the .after policy to select a specific time when the getTimeline function should be called again.

let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .second, value: 90, to: currentDate)!))

Now that we have familiarized ourselves with the theoretical functionality of Widgets, we will deepen our knowledge with practical examples. For this purpose, we will create Widgets for our already existing StockX and To-Do app.

Categories
Uncategorized

Chapter 16: Where to go from here

Congratulations, you finished this book! 🥳

You really learned a lot about developing apps with SwiftUI. For example, we talked about the basic functionality of SwiftUI, how to compose complex interfaces, how to interact with user input, how to save data, how to communicate with web services, how to draw in SwiftUI, how to works with maps and so much more. We gave you the tools you need. Now, it’s time to create your own, custom apps.

However, More chapters are currently being planned. For example:

  • How to create Augmented Reality apps with SwiftUI and ARKit
  • Sign in with Apple
  • And more!

We will notify you about every update by writing you a mail, so stay tuned and make sure you check out your spam folder as well. If you have any wishes or suggestions on what you would like to learn, make sure you send us a mail or leave a DM on Instagram.

For inspiration, more content about iOS development and tips for iOS developers, follow us on Instagram and Twitter.

Categories
Uncategorized

Download: StockX

Categories
Uncategorized

Finishing our app

Selecting different time frames ⏱

Last but not least, let’s add a time frame bar into our ContentView that we can use to switch between a weekly and daily time frame for our Chart.

To do this, add a State property to your ContentView (where 0 represents daily and 1 represents weekly).

@State var timeFrameChoice = 0

Next, insert the following struct below your ContentView struct …

struct TimeFrameBar: View {
    
    @Binding var timeFrameChoice: Int
    
    var body: some View {
        HStack {
            Text("Day")
                .font(.custom("Avenir", size: 18))
                .fontWeight(timeFrameChoice == 0 ? .medium : .none)
                .foregroundColor(timeFrameChoice == 0 ? .blue : .gray)
                .onTapGesture(perform: {self.timeFrameChoice = 0})
            Text("Week")
                .font(.custom("Avenir", size: 18))
                .fontWeight(timeFrameChoice == 1 ? .medium : .none)
                .foregroundColor(timeFrameChoice == 1 ? .blue : .gray)
                .onTapGesture(perform: {self.timeFrameChoice = 1})
            Spacer()
        }
            .padding()
    }
}

… and initialize the TimeFrameBar view inside your ContentView right above the Header and Chart view:

VStack {
    TimeFrameBar(timeFrameChoice: $timeFrameChoice)
    //...
}

Your ContentView preview should now look like this:

Depending on the selected time frame, we want our Chart and Header to use a different array containing only the specific entries. For this purpose, we need to add two more arrays to our DownloadManager.

var weeklyEntries = [DataEntry]()
var dailyEntries = [DataEntry]()

Thus, when fetching the stock data, we need to return weekly and daily entries as well.

private func downloadJSON(stockSymbol: String) async throws -> (dataEntries: [DataEntry], dailyEntries: [DataEntry], weeklyEntries: [DataEntry]) {

    dataEntries = [DataEntry]()
    dailyEntries = [DataEntry]()
    weeklyEntries = [DataEntry]()
            
    //...
        
    return (dataEntries, dailyEntries, weeklyEntries)

}

Let’s change the Task in our fetchData function correspondingly.

Task {
    (dataEntries, dailyEntries, weeklyEntries) = try await downloadJSON(stockSymbol: stockSymbol)
     DispatchQueue.main.async {
         withAnimation {
             self.dataFetched = true
         }
    }
}

Next, we need to adjust how we filter the data in our downloadJSON function. Delete the filteredEntries variable and append the data filtered using the isInLastNDays method to the declared weeklyEntries array.

private func downloadJSON(stockSymbol: String) async throws -> (dataEntries: [DataEntry], dailyEntries: [DataEntry], weeklyEntries: [DataEntry]) {

    //...
        
   guard let lastDateOfData = dataEntries.last?.date else { throw FetchError.badEntries }
        
    for entry in dataEntries {
        if entry.date.isInLastNDays(lastDate: lastDateOfData, dayRange: 7) {
            weeklyEntries.append(entry)
        }
    }
        
    return (dataEntries, dailyEntries, weeklyEntries)

}

While cycling through the dataEntries we append all entries from the same day by adding the following conditional block to the for-in loop:

for entry in dataEntries {
    if Calendar.current.isDate(entry.date, equalTo: lastDateOfData, toGranularity: .day) {
        dailyEntries.append(entry)
    }
    //...
}

Perfect! Back in our ContentView, we can now either use the weeklyEntries or the dailyEntries array when initializing the Chart and Header depending on the timeFrameChoice

if downloadManager.dataFetched {
    Header(stockData: timeFrameChoice == 0 ? downloadManager.dailyEntries : downloadManager.weeklyEntries)
    Chart(dataSet: timeFrameChoice == 0 ? downloadManager.dailyEntries : downloadManager.weeklyEntries)
        .frame(height: 300)
}

Finally, we need to tell our StockListRow view to use the dailyEntries array of its downloadManager to calculate the percentage change.

if downloadManager.dataFetched {
    VStack(alignment: .trailing) {
        Text(String(format: "%.2f", getPercentageChange(stockData: downloadManager.dailyEntries)) + "%")
        //...
       .foregroundColor(getPercentageChange(stockData: downloadManager.dailyEntries) < 0 ? .red : .green)
        Text("$" + String(format: "%.2f", downloadManager.dailyEntries.last?.close ?? 0))
        //...
    }
}

Let’s run our app to see if that works. Awesome! We’re now able to switch between a daily and weekly time frame by using the related bar.

Finishing our app ✅

Let’s finish our app by adding a dynamic navigation bar title to each ContentView indicating the currently represented stock. To do this, add a new property to your ContentView called “stockSymbol”.

let stockSymbol: String

Next, use the String assigned to the stockSymbol for the .navigationTitle modifier.

VStack {
    //...
}
    .navigationTitle(stockSymbol)

In our ContentView_Previews struct, we add the corresponding stockSymbol argument and enter a sample String.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(stockSymbol: "AAPL", downloadManager: DownloadManager(stockSymbol: "AAPL"))
    }
}

We also need to add the stockSymbol parameter when initializing the ContentView in our StockListRow. For this purpose, declare a corresponding property in the StockListRow struct as well. While doing this, also add a property named “stockName”.

let stockSymbol: String
let stockName: String

Now we can use the stockSymbol when initializing the ContentView inside the StockListRow. Additionally, we use the stockSymbol and stockName instead of current placeholder Text views.

NavigationLink(destination: ContentView(stockSymbol: stockSymbol, downloadManager: downloadManager)) {
    VStack(alignment: .leading) {
        Text(stockSymbol)
            .font(.custom("Avenir", size: 20))
            .fontWeight(.medium)
        Text(stockName)
            .font(.custom("Avenir", size: 16))
    }
        //...
})

Finally, we provide our StockListRow instance inside our StockList view with the stock’s name and symbol.

List {
    StockListRow(stockSymbol: "AAPL", stockName: "Apple, Inc.", downloadManager: DownloadManager(stockSymbol: "AAPL"))
}

If we run the app now and navigate to the ContentView, you see that the navigation bar displays the provided stock symbol.

That’s it! If you want, you can also add some other StockListRow instances into the StockList by providing each one with the stockName and specific stockSymbol (Just google “Stock Symbol *Stock Name* stock symbol to find the right one.)!

Conclusion 🎊

Great, we finished our StockX app!

You have learned a lot in this chapter. First, we have dealt with how to draw our own shapes in SwiftUI. Then, we used this knowledge to prepare data visually in charts.

Finally, we used the Alpha Vantage API to provide our app with real-life stock data. 

As a suggestion, you can try to add more time frame options to the app or display date information on the charts’ x-axis.

Categories
Uncategorized

Fetching stock data using the AlphaVantage API

Accessing the Alpha Vantage API

For gathering real stock data, we’ll be using the free Alpha Vantage API. For fetching data over this API, you need to register for a free API key, as we did in chapter 13. 

Note: The free API plan allows you to make up to 5 API requests per minute and 500 requests per day. 

Visit www.alphavantage.co and click on “Get your free API key today”. Then, fill out the contact form and click on “Get Free API Key”.

A text shows up, showing you your personal API key. Let’s save this key by creating a new Swift file in our “Helper” group and calling it “DownloadHelper.swift”.

Inside this file, declare a constant called “apiKey” and assign the key you just generated to it as a String.

let apiKey = "*your API key*"

Similar to the Flickr API, the Alpha Vantage API allows us to request information about specific stocks by sending a request in the form of a URL. We then receive the requested data in JSON format.

Visit the Alpha Vantage API documentation to learn what types of requests are possible. For now, we want to gather financial data about a specific stock on a daily basis. According to the section “Daily” in the documentation, we can use several URL parameters for specifying the request.

A typical URL would look like this:

In our DownloadHelper.swift file, let’s create a function that generates the URL for us. Since the only dynamic value should be the stock symbol, the function is as follows:

func generateRequestURL(stockSymbol: String) -> String {
    return "https://www.alphavantage.co/query?function=TIME_SERIES_INTRADAY&symbol=\(stockSymbol)&interval=15min&outputsize=full&apikey=\(apiKey)"
}

Paste the returned String to your preferred browser and replace the stock symbol with, for instance, “AAPL” for Apple, Inc. and your personal API Key. If you open this URL, you’ll see what the JSON structure looks like.

Let’s incorporate the resulting JSON structure into an appropriate data model we’ll use when fetching the JSON later on. We already discussed this topic in chapters 11 and 13, so make sure you check them out if you’re not sure what’s going on. Again, you can use the quicktype.io tool to accomplish this as we did in the last chapter.

Now, let’s create a new Swift file named “JSONModel.swift”, place it into the “Model” group and insert the following struct (note that TimeSeries is a substruct of TimeSeriesJSON).

struct TimeSeriesJSON: Decodable {
    let timeSeries: [String: TimeSeries]
    
    private enum CodingKeys : String, CodingKey {
        case timeSeries = "Time Series (15min)"
    }
    
    struct TimeSeries: Decodable {
        let open, close, high, low: String
        
        private enum CodingKeys : String, CodingKey {
            case open = "1. open"
            case high = "2. high"
            case low = "3. low"
            case close = "4. close"
        }
    }
}

Setting up the Download Manager

We need to create a download manager to fetch and parse the JSON data and to notify observing views once we are finished. For this purpose, create a new Swift file named “DownloadManager.swift”, place it into the “Model” group as well and import the SwiftUI framework. In this file, create a class also named “DownloadManager” that conforms to the ObservableObject protocol.

import SwiftUI

class DownloadManager: ObservableObject {
    
}

In our DownloadManager class, we need to declare a property that holds an array containing all the fetched and parsed financial information as DataEntry instances.

class DownloadManager: ObservableObject {
    
    var dataEntries = [DataEntry]()
    
}

As said, once we finished fetching the data, we need to notify all observing views. For this purpose, let’s add a corresponding @Published property to our DownloadManager.

@Published var dataFetched = false 

In our ContentView, replace the stockData property with an instance of the DownloadManager as an @ObservedObject. Now we can use the dataEntries in the downloadManager for passing it down to the Header and Chart:

struct ContentView: View {
    
    @ObservedObject var downloadManager: DownloadManager
    
    var body: some View {
        VStack {
            Header(stockData: downloadManager.dataEntries)
            Chart(dataSet: downloadManager.dataEntries)
                .frame(height: 300)
            //...
        }
            .navigationTitle("StockX")
    }
}

Make sure you adjust the ContentView_Previews struct as well.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(downloadManager: DownloadManager())
    }
}

In our StockListRow struct, we also need a DownloadManager to pass it down to the ContentView and display the pricing information.

struct StockListRow: View {
    
    @ObservedObject var downloadManager: DownloadManager
    
    var body: some View {
        HStack {
            NavigationLink(destination: ContentView(downloadManager: downloadManager)) {
                VStack(alignment: .leading) {
                    //...
                }
                Spacer()
                VStack(alignment: .trailing) {
                    Text(String(format: "%.2f", getPercentageChange(stockData: downloadManager.dataEntries)) + "%")
                        //...
                    Text("$" + String(format: "%.2f", downloadManager.dataEntries.last?.close ?? 0))
                        //...
                }
            }
        }
    }
}

In our StockList, we create the actual DownloadManager instance we pass all the way down to the ContentView when initializing the StockListRow.

List {
    StockListRow(downloadManager: DownloadManager())
}

Okay, let’s run our app to see if that works. Oops! After we tap on the StockListRow to navigate to the ContentView our app crashes because of an “Index out of range” error in our Chart view! This is because that GraphGraphGradient, and PriceLegend try to utilize elements in a dataSet that is currently empty. Therefore, we want to initialize these shapes only under the condition that the dataSet isn’t empty:

struct Chart: View {
    
    let dataSet: [DataEntry]
    
    var body: some View {
        ZStack(alignment: .trailing) {
            if !dataSet.isEmpty {
                //...
            }
        }
    }
}

If you re-run the app, you see that everything works fine now, and only the Grid gets displayed when navigating to the ContentView.

Great! Now we’re finally ready to fetch real stock data by using the Alpha Vantage API.

Fetching real-world stock data ⬇️ 

In our DownloadManager class, let’s create a function to fetch the stock data using the Alpha Vantage API.

private func fetchData(stockSymbol: String) {
              
}

Before starting the download, we need to make sure that our dataFetched property is false.

private func fetchData(stockSymbol: String) {
        
    dataFetched = false
        
}

Below the fetchData function, we declare another function to actually download and parse the JSON data.

private func downloadJSON(stockSymbol: String) -> [DataEntry] {

        
}

As we did in the last chapter, we mark this function with the async throws keywords to tell SwiftUI that we want this function to be performed asynchronous and that it can throw errors.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

        
}

Accordingly, we need to define an enum conforming to the Error protocol and insert it into our DownloadManager. Two errors are conceivable here: First, that we cannot generate a valid URL or second, that we cannot generate usable DataEntry instances.

enum FetchError: Error {
    case badURL
    case badEntries
}

Let’s call the downloadJSON function and assign its return to the dataEntries, weeklyEntries and dailyEntries properties when calling the fetchData function by writing:

private func fetchData(stockSymbol: String) {
        
    dataFetched = false
        
    dataEntries = try await downloadJSON(stockSymbol: stockSymbol)
        
}

As we have learned in the last chapter, asynchronous functions cannot be directly embedded in a synchronous context like our fetchData function. So to make this possible we have to use a Task again.

Task {
    dataEntries = try await downloadJSON(stockSymbol: stockSymbol)
}

We start the JSON download process by initializing an empty DataEntry array.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    dataEntries = [DataEntry]()
        
}

Next, we try to create an URL by using the generateRequestURL function we created earlier. If this fails, we throw the .badURL FetchError.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    dataEntries = [DataEntry]()
        
    guard let url = URL(string: generateRequestURL(stockSymbol: stockSymbol)) else { throw FetchError.badURL}

}

Then, we create a request out of our url and start an asynchronous URLSession.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    dataEntries = [DataEntry]()
        
    guard let url = URL(string: generateRequestURL(stockSymbol: stockSymbol)) else { throw FetchError.badURL}
    let request = URLRequest(url: url)
    let (data, _) = try await URLSession.shared.data(for: request)

}

After we received the data, we parse the JSON using a JSONDecoder instance and our data model.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    dataEntries = [DataEntry]()
        
    guard let url = URL(string: generateRequestURL(stockSymbol: stockSymbol)) else { throw FetchError.badURL}
    let request = URLRequest(url: url)
    let (data, _) = try await URLSession.shared.data(for: request)

    let parsedJSON = try JSONDecoder().decode(TimeSeriesJSON.self, from: data)

}

After we successfully parsed the JSON, we want to cycle through every timeSeries instance in it to create a DataEntry out of it and append it to the dataEntries array.

But before we can do this, we need to make sure that we’re able to create Date objects out of the Strings contained in the parsedJSON. To do this, we need a Date extension that lets us format such a String into a Date.

For this purpose, create a new Swift file called “Extensions.swift” and place it into the “Helper” group. Inside this file, add the following Date extension.

extension Date {
    init(_ dateString:String, dateFormat: String) {
        let dateStringFormatter = DateFormatter()
        dateStringFormatter.dateFormat = dateFormat
        dateStringFormatter.locale = Locale.init(identifier: "en_US_POSIX")
        let date = dateStringFormatter.date(from: dateString)!
        self.init(timeInterval:0, since:date)
    }
}

Back in our downloadJSON function, we can now create a DataEntry out of every timeSeries element inside the parsedJSON.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    for timeSeries in parsedJSON.timeSeries {
        dataEntries.append(DataEntry(date: Date(timeSeries.key, dateFormat: "yyyy-MM-dd HH:mm:ss"), close: (timeSeries.value.close as NSString).doubleValue))
    }

}

We can check if we cycled through every instance by comparing the number of our DataEntries to the number of timeSeries in the parsedJSON. If these are equal, we know that we’re finished with the downloading and parsing process.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    for timeSeries in parsedJSON.timeSeries {
        dataEntries.append(DataEntry(date: Date(timeSeries.key, dateFormat: "yyyy-MM-dd HH:mm:ss"), close: (timeSeries.value.close as NSString).doubleValue))
    }

    if dataEntries.count == parsedJSON.timeSeries.count {
        
    }

}

We need to make sure that the elements in our DataEntries array follow the correct order depending on their Date properties.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    for timeSeries in parsedJSON.timeSeries {
        dataEntries.append(DataEntry(date: Date(timeSeries.key, dateFormat: "yyyy-MM-dd HH:mm:ss"), close: (timeSeries.value.close as NSString).doubleValue))
    }

    if dataEntries.count == parsedJSON.timeSeries.count {
        dataEntries.sort(by: {$0.date.compare($1.date) == .orderedAscending})
    }

}

Filtering the data entries

Right now, our dataEntries array contains every time series we extracted from the parsed JSON. However, we only want to display the stock information for the last seven days. Therefore, we need to filter the dataEntries before turning the dataFetched property to true.

For this purpose, we need to add some more lines of codes to our downloadJSON function before returning the dataEntries.

But how do we know which time series in our DataEntries array are from within the last seven days? It’s simple, we just need to refer to the last element in our dataEntries array and compare each remaining element to it.

We need to make sure that there is actually a last element we can refer to. To do this, we use a guard statement in our filterDataEntries function. After that, we create a new array that will only contain the filtered entries.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    guard let lastDateOfData = dataEntries.last?.date else { throw FetchError.badEntries }

}

To compare each entry to the lastDateOfData we need to create another Date extension. So, let’s open the Extensions.swift file and add another function to it.

extension Date {
    //...
    
    func isInLastNDays(lastDate: Date, dayRange n: Int) -> Bool {
        let startDate = Calendar.current.date(byAdding: .day, value: -n, to: Date())!
        return (min(startDate, lastDate) ...  max(startDate, lastDate)).contains(self)
    }
}

Using this function, we can check if a specific Date is within the last n-days of another given Date. 

Back in our downloadJSON function: We create a new filteredEntries array, cycle through every element in the dataEntries array, compare its date to the lastDateOfDate and only add those entries to the filteredEntries that are in the specified time range of 7 days. Then we assign the content of the filteredEntries to the dataEntries.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    guard let lastDateOfData = dataEntries.last?.date else { throw FetchError.badEntries }

    var filteredEntries = [DataEntry]()

    for entry in dataEntries {
        if entry.date.isInLastNDays(lastDate: lastDateOfData, dayRange: 7) {
            filteredEntries.append(entry)
        }
    }

    dataEntries = filteredEntries

}

Finally, we can return the – now filtered – dataEntries.

private func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

    //...

    return dataEntries
}

Awesome, we finished our downloadJSON function!

Now back to the fetchData function that calls the downloadJSON function: after we waited for the downloadJSON to finish, we notify all observing views by turning the dataFetched property to true. We do this asynchronously as well. For this purpose, we use the DispatchQueue.main.async wrapper.

Task {
    dataEntries = try await downloadJSON(stockSymbol: stockSymbol)
    DispatchQueue.main.async {
        withAnimation {
            self.dataFetched = true
        }
    }
}

We want to start fetching the stock data once the DownloadManager gets initialized. To do this, add an init function to the DownloadManager class that calls the fetchData function by using a provided stock symbol.

init(stockSymbol: String) {
    fetchData(stockSymbol: stockSymbol)
}

Awesome! We’re finished preparing our DownloadManager for downloading, parsing, and filtering the JSON that contains the relevant financial information.

Next, we need to adjust our interface to reflect the relevant data once it’s available.

Displaying fetched data in our UI 👁

Now, we need to adjust our views to display the fetched data. To do this, provide the StockListRow instance in your StockList with the stockSymbol that the downloadManager should use to call the fetchData function.

List {
    StockListRow(downloadManager: DownloadManager(stockSymbol: "AAPL"))
}

Next, we need to provide the ContentView_Previews struct with such a stockSymbol as well.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(downloadManager: DownloadManager(stockSymbol: "AAPL"))
    }
}

Finally, we want our ContentView to display the Chart only once the data got fetched. To do this, we utilize the dataFetched property like this:

VStack {
    if downloadManager.dataFetched {
        Header(stockData: downloadManager.dataEntries)
        Chart(dataSet: downloadManager.dataEntries)
            .frame(height: 300)
    }
    //...
}

Accordingly, we only want to show the pricing information in our StockListRow once the data got fetched too. 

HStack {
    NavigationLink(destination: ContentView(downloadManager: downloadManager)) {
        //...
        if downloadManager.dataFetched {
            VStack(alignment: .trailing) {
                Text(String(format: "%.2f", getPercentageChange(stockData: downloadManager.dataEntries)) + "%")
                    .font(.custom("Avenir", size: 14))
                    .fontWeight(.medium)
                    .foregroundColor(.green)
                Text("$" + String(format: "%.2f", downloadManager.dataEntries.last?.close ?? 0))
                    .font(.custom("Avenir", size: 26))
            }
        }
    }
}

Okay, the moment has come: Let’s run our app to see if everything works fine.

Awesome! Just a few moments after we launch the app, our DownloadManager fetches the pricing information about the specified stock by accessing the Alpha Vantage API. Once the process is finished, our StockList and ContentView get notified, and they eventually show us the relevant pricing information and the corresponding chart graph!

Recap

Wow, that was a lot of work. But we are almost finished with our StockX app.

So far we’ve learned how to use the Alpha Vantage API to download relevant financial information and parse the corresponding JSON for our purposes.

We now use actual stock information to display the corresponding charts on a weekly basis.

Finally, we will add a way to display the chart on a daily basis as well.