Skip to content

Latest commit

 

History

History
385 lines (284 loc) · 17.3 KB

File metadata and controls

385 lines (284 loc) · 17.3 KB

Create widgets in iOS

In the WWDC20, Apple announced a disruptive new home screen experience in iOS14, one of them is the ability to add widgets on the iPhone/iPad home screen.

iOS already supported some kind of widgets using the Today extension, so if you already have that extension in your app we can reuse some pieces of code like using a shared group container to store and read the data, however in terms of UI the new widget extension for is purely SwiftUI, and this is a interesting feature to start doing the first views using SwiftUI even if your app is developed with UIKit. I think of that Apple added this extension purely in SwiftUI to allow developers doing the first steps with SwiftUI in a productive app.

Docs

First of all, I strongly recommend start reading and bookmarking Apple developer documentation about WidgetKit, the Human Interface Guidelines for Widgets and if you have any question take a look Apple forum using Widget topic .

ProTip👨🏽‍💻: Eventhough there are a bunch of blog post like this, I recommend to read Apple docs first.

Requisites

  • Xcode 12.0 or above
  • Basic understanding of SwiftUI

Let's get rolling

  1. Open the Xcode project (or create a new one) and choose File > New > Target.
  2. Select Widget Extension, and click next.
  3. Enter the product name of this new target.
  4. Select the Include Configuration Intent checkbox to allow user customized config in the widget, otherwise if you do not want to allowing the user to configure the widget unselect
  5. Click Finish

Widget extension template walkthrough

Xcode automatically creates a Swift file with five structs, to have the target group clean I recommend to extract each struct in different groups and files, for instance, I share an example here

  • Main

    • HeadlinesWidget : Let's start reviewing the starting point of the widget, implementing Widget protocol we return in the body a WidgetConfiguration specifying the provider, view, kind and depending of the type of widget can be a Static or Intent configuration. Let's continue with the data model of the widget
  • Model

    • SimpleEntry : This struct that implements TimelineEntry protocol is the type that specifies the date and to display a widget and data needed in the content block of the configuration returned in HeadlinesWidget. The responsible of creating and returning this type is the Provider
  • ViewMode/Repository

    • Provider : This struct that implements TimelineProvider protocol, if you uses a IntentConfiguration the provider has to implement IntentTimelineProvider protocol , in the case of StaticConfiguration TimelineProvider protocol . In this example we will conform the IntentTimelineProvider to support user-customized values.
  • View

    • HeadlinesWidgetEntryView : Finally we have a default SwiftUI View as part of the content block of the WidgetConfiguration defined at the beginning,

      • HeadlinesWidget_Previews: The view also includes a preview, to see the content in Xcode.

ProTip⚡️: I recommend to create a Group of preview for each WidgetFamily that we want to support.

      Group {
                  HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
                      .previewContext(WidgetPreviewContext(family: .systemSmall))
                  HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
                      .previewContext(WidgetPreviewContext(family: .systemMedium))
                  HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
                      .previewContext(WidgetPreviewContext(family: .systemLarge))
              }

Create the widget with the Latest News

Data model and API request task

Let's add a new struct to decode the latest stories using NewsAPI.org https://newsapi.org/v2/top-headlines?country=us&apiKey= . In our case we are only interested on the title and the thumbnail url of the news, so we'll create a new file TopHeadlines.swift

import Foundation

// MARK: - TopHeadlines
struct TopHeadlines: Codable {
    let articles: [Article]
}

// MARK: - Article
struct Article: Codable {
    let title: String
    let url: String
    let urlToImage: String

    enum CodingKeys: String, CodingKey {
        case title
        case url, urlToImage
    }
}

// MARK: - Helper functions for creating encoders and decoders

func newJSONDecoder() -> JSONDecoder {
    let decoder = JSONDecoder()
    if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
        decoder.dateDecodingStrategy = .iso8601
    }
    return decoder
}

func newJSONEncoder() -> JSONEncoder {
    let encoder = JSONEncoder()
    if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
        encoder.dateEncodingStrategy = .iso8601
    }
    return encoder
}

// MARK: - URLSession response handlers
extension URLSession {
    fileprivate func codableTask<T: Codable>(with url: URL, completionHandler: @escaping (T?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        return self.dataTask(with: url) { data, response, error in
            guard let data = data, error == nil else {
                completionHandler(nil, response, error)
                return
            }
            completionHandler(try? newJSONDecoder().decode(T.self, from: data), response, nil)
        }
    }

    func topHeadlinesTask(with url: URL, completionHandler: @escaping (TopHeadlines?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        return self.codableTask(with: url, completionHandler: completionHandler)
    }
}

We have the model, and a URLSession task that would make the retrieve data easier for the purpose of this guide, in the case you would use more services of the API, I recommend to have a networking/API layer.

Widget Entry

We add to the existing Entry, an array of Article

struct HeadlinesWidgetEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
    let articles: [Article]
}
Widget Provider

Now we have to update our timeline provider to add an array of article in the HeadlinesWidgetEntry , first of all we will update the placeholder

func placeholder(in context: Context) -> Entry {
        HeadlinesWidgetEntry(date: Date(), configuration: ConfigurationIntent(), articles: [])
    }

and the snapshot

func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Entry) -> ()) {
        let entry = HeadlinesWidgetEntry(date: Date(), configuration: configuration, articles: [])
        completion(entry)
    }

finally we have the timeline

func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        // Generate a timeline consisting of the latest news now and setting a update policy after 15 minutes
        let currentDate = Date()
        // The next date when the widget should reload the view with a new timeline
        let nextWidgetUpdateDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
        // The date of the only widget entry that will be part of the timeline.
        let currentHeadlinesEntry = HeadlinesWidgetEntry(date: currentDate, configuration: configuration, articles: [])
        // Timeline instance without articles in case that the retrieve from the network fails we return no stories
        let timeline = Timeline(entries: [currentHeadlinesEntry], policy: .after(nextWidgetUpdateDate))

        // Retrieve data from the network
        // Use country code selected by the user to retrieve the news
        var countryCode = "ar"
        switch configuration.country {
        case .ar:
            countryCode = "ar"
        case .us:
            countryCode = "us"
        case .unknown:
            break
        }

        let newsApiKey = ""
        guard let headlinesUrl = URL(string: "https://newsapi.org/v2/top-headlines?country=\(countryCode)&apiKey=\(newsApiKey)") else {
            return completion(timeline)
        }
        let topHeadlinesTask = URLSession.shared.topHeadlinesTask(with: headlinesUrl) { topHeadlines, response, error in
            if let topHeadlines = topHeadlines {
                let entry = HeadlinesWidgetEntry(date: nextWidgetUpdateDate, configuration: configuration, articles: topHeadlines.articles)
                completion(Timeline(entries: [entry], policy: .after(nextWidgetUpdateDate)))
            } else {
                completion(timeline)
            }
        }
        topHeadlinesTask.resume()
    }
Widget View

The view of the widget is HeadlinesWidgetEntryView where we will use the environment variable .widgetFamily to return different views for each widget size:

struct HeadlinesWidgetEntryView : View {
    var entry: HeadlinesWidgetEntry
    @Environment(\.widgetFamily) var family: WidgetFamily

    @ViewBuilder
    var body: some View {
        switch family {
        case .systemSmall: SmallWidgetView(entry: entry)
        case .systemLarge: MediumLargeWidgetView(entry: entry, articlesCount: 5)
        case .systemMedium: MediumLargeWidgetView(entry: entry, articlesCount: 2)
        default: Text(entry.date, style: .time)
        }
    }
}

For the .systemSmall widget we will create the SmallWidgetView and for .systemLarge and .systemMedium we will use the same view using a parameter to set the number of stories to show in each family:

SmallWidgetView

The small widget will show the most recent story of the response, using a ZStack we can put the image of the article in the background with a overlay gradient, and on top of the image the headline of the story.

struct SmallWidgetView: View {
    var entry: HeadlinesWidgetEntry
    var body: some View {
        if let item = entry.articles.first {
            ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
                VStack {
                    URLImage(url: item.urlToImage)
                        .scaledToFit()
                        .overlay(imageGradientOverlay, alignment: .bottom)
                    Spacer()
                }
                .background(Color.black)

                Text(item.title)
                    .font(.headline)
                    .foregroundColor(Color.white)
                    .multilineTextAlignment(.leading)
                    .lineLimit(5)
                    .padding([.leading, .bottom, .trailing], 10.0)

            }
            .background(Color.black)
        }
    }

    let imageGradientOverlay: some View = LinearGradient(
        gradient: Gradient(
            colors: [Color.black, Color.black.opacity(0.0)]),
        startPoint: .bottom,
        endPoint: .top)
        .frame(height: 48)
}
MediumLargeWidgetView

For the medium and large widget family we will reuse the same view, and just showing different amount of articles depending of the family. Using a VStack we'll render a list of stories and at the top of the widget a Text as widget header. I extracted each story view using ArticleRow item in a different struct

Medium Large
struct MediumLargeWidgetView: View {
    var entry: HeadlinesWidgetEntry
    var articlesCount: Int

    var body: some View {
        VStack(alignment: .leading, spacing: 7.0) {
            widgetHeaderView
            ForEach(0..<articlesCount) { index in
                if entry.articles.count > index,
                   let item = entry.articles[index] {
                    ArticleRow(item: item)
                }
            }
            Spacer()
        }
        .padding(.horizontal, 15.0)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    let widgetHeaderView: some View = Text("🔥 TOP STORIES")
        .font(.headline)
				.foregroundColor(Color.storyTitle)
        .padding(.top)
}

ArticleRow item:

struct ArticleRow: View {
    let item: Article
    var body: some View {
        HStack(alignment: .top) {
            URLImage(url: item.urlToImage)
                .clipShape(RoundedRectangle(cornerRadius: 8.0))
                .frame(width: 75.0, height: 50.0)
            Text(item.title)
                .font(.system(size: 14, weight: .semibold, design: .rounded))
								.foregroundColor(Color.storyTitle)
                .lineLimit(3)
            Spacer()
        }
    }
}

Open the app from the widget

The user can long press the widget if you support an IntentConfiguration to configure the widget with customized values, and the use can tap in the widget to open the app. You can specify .widgetURL modifier with a deep link to the app, for example in the .small widget we can use that modifier because it's there is only one article, in case you have to support different deep links, you can use the Link view.

To add the url to the small widget we can add the widgetURL view modifier to the ZStack of the view

ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
  //...
}
.background(Color.black)
.widgetURL(URL(string: item.url)!)

Finally for each story of the medium and large widget we will wrap the ArticleRow in a Link

Link(destination: url) {
  ArticleRow(item: item)
}

Challenge💡: If you are interested on continuing this tutorial and I'll ask to handle the link tap in the app and create a pull request in this project to discuss and merge your solution to open the news url in the app using a webview

Reviewing the widget in the Widget gallery

Light mode
Dark mode

Editing the widget

As a user I can long press in the widget and select the country from I wanna see the top stories, this allow the user to have two widget of the same app with different source.

Where to go from here?

I recommend watch the WWDC20 videos about designing and developing widgets, and something that it's important to mention in terms of eficienty of the widget, is read about Keeping a Widget Up to Date to understand how the widget refresh works and what you can do from the app using WidgetCenter because Apple says that widget receives a limited number of refreshes every day. so choosing the correct refresh strategy for your widget is a game-changer.

The project is available in Github, and I eager to see pull request contributing to this initial idea to share with the community.

Ideas: Show a empty state view when there is no articles to show, load url image using and ObservableObject, handle widgetURL in app opening a webview and more...! 🙌🏾