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.

3 replies on “StockX Widget”

If the AvailableStocksIntent does not resolve properly, resulting in the project failing to build, go to the “IntentConfigurationFile” click on the intent and open the Inspector in the top right corner. Click here on the “Identity Inspector” (4th Icon). In the Section “Custom Class” you see the Class name for the intent. Click on the arrow on the right of the Textfield. You have now navigated to the AvailableStocksIntent. If you rebuild the project again, it will work from now on, resolving properly.

Aside from that the tutorial went very smooth.

Also, if you can not change the selected Stock in the Widget, delete it manually from the simulator and run the program again.
This should solve the issue of the intent configuration not coming up when wanting to and it getting stuck in the “placeholder” function

Leave a Reply

Your email address will not be published. Required fields are marked *